feat(sf): producer side of progressive planning — plan-milestone emits sketches, insertSlice persists is_sketch+sketch_scope

Closes the producer half of the ADR-011 rollout. With this commit, the
end-to-end progressive planning path is complete and runnable:
plan-milestone → insertSlice writes is_sketch=1 → dispatch reads it →
refine-slice expands → clearSliceSketch zeros the flag.

Changes:

sf-db.ts insertSlice: extends the typed payload with isSketch and
sketchScope (3-valued: true/false/undefined). The INSERT INTO and ON
CONFLICT clauses gain is_sketch + sketch_scope columns with the same
NULL-sentinel pattern (raw_is_sketch / raw_sketch_scope) used by every
other field — so a re-plan that omits these flags preserves any
existing sketch state rather than blanking it.

sf-db.ts clearSliceSketch: new exported helper for refine-slice to
call after persisting the full plan. Idempotent.

tools/plan-milestone.ts validateSlices: handles 3-valued isSketch
semantics. When isSketch=true, sketchScope is required (non-empty)
and the heavyweight planning fields (successCriteria, proofLevel,
integrationClosure, observabilityImpact) are optional. Non-sketches
keep current strict validation (no regression for existing callers).

tools/plan-milestone.ts persist loop: passes isSketch/sketchScope
through to insertSlice; skips upsertSlicePlanning entirely when
isSketch=true (the planning fields belong to refine-slice's output).

End-to-end DB test verified all four behaviors:
 isSketch=true + sketchScope writes is_sketch=1 + scope text
 Explicit isSketch=false writes is_sketch=0
 Omitted isSketch defaults to 0 on insert
 clearSliceSketch zeros the flag while preserving sketch_scope
 ON CONFLICT with omitted isSketch preserves existing row state

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 19:26:08 +02:00
parent c11595cf22
commit d4be9afe15
2 changed files with 111 additions and 40 deletions

View file

@ -2039,6 +2039,10 @@ export function insertSlice(s: {
demo?: string;
sequence?: number;
planning?: Partial<SlicePlanningRecord>;
/** ADR-011: 3-valued — true marks sketch, false marks non-sketch, undefined leaves the row's existing flag intact. */
isSketch?: boolean;
/** ADR-011: 23 sentence scope hint. Same 3-valued semantics as isSketch. */
sketchScope?: string;
}): void {
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
currentDb
@ -2046,11 +2050,13 @@ export function insertSlice(s: {
`INSERT INTO slices (
milestone_id, id, title, status, risk, depends, demo, created_at,
goal, success_criteria, proof_level, integration_closure, observability_impact,
adversarial_partner, adversarial_combatant, adversarial_architect, planning_meeting_json, sequence
adversarial_partner, adversarial_combatant, adversarial_architect, planning_meeting_json, sequence,
is_sketch, sketch_scope
) VALUES (
:milestone_id, :id, :title, :status, :risk, :depends, :demo, :created_at,
:goal, :success_criteria, :proof_level, :integration_closure, :observability_impact,
:adversarial_partner, :adversarial_combatant, :adversarial_architect, :planning_meeting_json, :sequence
:adversarial_partner, :adversarial_combatant, :adversarial_architect, :planning_meeting_json, :sequence,
:is_sketch, :sketch_scope
)
ON CONFLICT (milestone_id, id) DO UPDATE SET
title = CASE WHEN :raw_title IS NOT NULL THEN excluded.title ELSE slices.title END,
@ -2067,7 +2073,9 @@ export function insertSlice(s: {
adversarial_combatant = CASE WHEN :raw_adversarial_combatant IS NOT NULL THEN excluded.adversarial_combatant ELSE slices.adversarial_combatant END,
adversarial_architect = CASE WHEN :raw_adversarial_architect IS NOT NULL THEN excluded.adversarial_architect ELSE slices.adversarial_architect END,
planning_meeting_json = CASE WHEN :raw_planning_meeting_json IS NOT NULL THEN excluded.planning_meeting_json ELSE slices.planning_meeting_json END,
sequence = CASE WHEN :raw_sequence IS NOT NULL THEN excluded.sequence ELSE slices.sequence END`,
sequence = CASE WHEN :raw_sequence IS NOT NULL THEN excluded.sequence ELSE slices.sequence END,
is_sketch = CASE WHEN :raw_is_sketch IS NOT NULL THEN excluded.is_sketch ELSE slices.is_sketch END,
sketch_scope = CASE WHEN :raw_sketch_scope IS NOT NULL THEN excluded.sketch_scope ELSE slices.sketch_scope END`,
)
.run({
":milestone_id": s.milestoneId,
@ -2090,6 +2098,8 @@ export function insertSlice(s: {
? JSON.stringify(s.planning.planningMeeting)
: "",
":sequence": s.sequence ?? 0,
":is_sketch": s.isSketch === true ? 1 : 0,
":sketch_scope": s.sketchScope ?? "",
// Raw sentinel params: NULL when caller omitted the field, used in ON CONFLICT guards
":raw_title": s.title ?? null,
":raw_risk": s.risk ?? null,
@ -2109,9 +2119,26 @@ export function insertSlice(s: {
? JSON.stringify(s.planning.planningMeeting)
: null,
":raw_sequence": s.sequence ?? null,
":raw_is_sketch":
s.isSketch === undefined ? null : s.isSketch ? 1 : 0,
":raw_sketch_scope":
s.sketchScope === undefined ? null : s.sketchScope,
});
}
/**
* ADR-011: clear the is_sketch flag after refine-slice fills in the full plan.
* Idempotent safe to call on already-refined slices.
*/
export function clearSliceSketch(milestoneId: string, sliceId: string): void {
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
currentDb
.prepare(
`UPDATE slices SET is_sketch = 0 WHERE milestone_id = :mid AND id = :sid`,
)
.run({ ":mid": milestoneId, ":sid": sliceId });
}
export function upsertSlicePlanning(
milestoneId: string,
sliceId: string,

View file

@ -34,10 +34,18 @@ export interface PlanMilestoneSliceInput {
depends: string[];
demo: string;
goal: string;
/** Required when isSketch is false/absent; may be empty for sketch slices (ADR-011). */
successCriteria: string;
/** Required when isSketch is false/absent; may be empty for sketch slices (ADR-011). */
proofLevel: string;
/** Required when isSketch is false/absent; may be empty for sketch slices (ADR-011). */
integrationClosure: string;
/** Required when isSketch is false/absent; may be empty for sketch slices (ADR-011). */
observabilityImpact: string;
/** ADR-011: when true, this slice is a sketch awaiting refine-slice expansion. */
isSketch?: boolean;
/** ADR-011: 23 sentence scope boundary, required when isSketch is true. */
sketchScope?: string;
}
export interface PlanMilestoneParams {
@ -179,6 +187,17 @@ function validateSlices(value: unknown): PlanMilestoneSliceInput[] {
const proofLevel = obj.proofLevel;
const integrationClosure = obj.integrationClosure;
const observabilityImpact = obj.observabilityImpact;
const isSketchRaw = obj.isSketch;
const sketchScopeRaw = obj.sketchScope;
// ADR-011: preserve 3-valued isSketch semantics (true/false/absent).
// Absent must round-trip as undefined so DB upsert ON CONFLICT preserves
// any existing is_sketch row state rather than silently overwriting.
const isSketch: boolean | undefined =
isSketchRaw === true
? true
: isSketchRaw === false
? false
: undefined;
if (!isNonEmptyString(sliceId))
throw new Error(`slices[${index}].sliceId must be a non-empty string`);
@ -201,20 +220,31 @@ function validateSlices(value: unknown): PlanMilestoneSliceInput[] {
throw new Error(`slices[${index}].demo must be a non-empty string`);
if (!isNonEmptyString(goal))
throw new Error(`slices[${index}].goal must be a non-empty string`);
if (!isNonEmptyString(successCriteria))
throw new Error(
`slices[${index}].successCriteria must be a non-empty string`,
);
if (!isNonEmptyString(proofLevel))
throw new Error(`slices[${index}].proofLevel must be a non-empty string`);
if (!isNonEmptyString(integrationClosure))
throw new Error(
`slices[${index}].integrationClosure must be a non-empty string`,
);
if (!isNonEmptyString(observabilityImpact))
throw new Error(
`slices[${index}].observabilityImpact must be a non-empty string`,
);
// ADR-011: sketch slices defer the heavyweight planning fields to
// refine-slice. Non-sketch slices must populate them up front.
if (isSketch === true) {
if (!isNonEmptyString(sketchScopeRaw)) {
throw new Error(
`slices[${index}].sketchScope must be a non-empty string when isSketch is true`,
);
}
} else {
if (!isNonEmptyString(successCriteria))
throw new Error(
`slices[${index}].successCriteria must be a non-empty string`,
);
if (!isNonEmptyString(proofLevel))
throw new Error(`slices[${index}].proofLevel must be a non-empty string`);
if (!isNonEmptyString(integrationClosure))
throw new Error(
`slices[${index}].integrationClosure must be a non-empty string`,
);
if (!isNonEmptyString(observabilityImpact))
throw new Error(
`slices[${index}].observabilityImpact must be a non-empty string`,
);
}
return {
sliceId: normalizePlanningText(sliceId, `slices[${index}].sliceId`),
@ -225,22 +255,30 @@ function validateSlices(value: unknown): PlanMilestoneSliceInput[] {
),
demo: normalizePlanningText(demo, `slices[${index}].demo`),
goal: normalizePlanningText(goal, `slices[${index}].goal`),
successCriteria: normalizePlanningText(
successCriteria,
`slices[${index}].successCriteria`,
),
proofLevel: normalizePlanningText(
proofLevel,
`slices[${index}].proofLevel`,
),
integrationClosure: normalizePlanningText(
integrationClosure,
`slices[${index}].integrationClosure`,
),
observabilityImpact: normalizePlanningText(
observabilityImpact,
`slices[${index}].observabilityImpact`,
),
successCriteria: isNonEmptyString(successCriteria)
? normalizePlanningText(successCriteria, `slices[${index}].successCriteria`)
: "",
proofLevel: isNonEmptyString(proofLevel)
? normalizePlanningText(proofLevel, `slices[${index}].proofLevel`)
: "",
integrationClosure: isNonEmptyString(integrationClosure)
? normalizePlanningText(integrationClosure, `slices[${index}].integrationClosure`)
: "",
observabilityImpact: isNonEmptyString(observabilityImpact)
? normalizePlanningText(observabilityImpact, `slices[${index}].observabilityImpact`)
: "",
isSketch,
// Only carry sketch scope through if caller explicitly provided it —
// preserves ON CONFLICT semantics for re-plans that omit the field.
sketchScope:
sketchScopeRaw === undefined
? undefined
: isNonEmptyString(sketchScopeRaw)
? normalizePlanningText(
sketchScopeRaw,
`slices[${index}].sketchScope`,
)
: "",
};
});
}
@ -464,14 +502,20 @@ export async function handlePlanMilestone(
depends: slice.depends,
demo: slice.demo,
sequence: i + 1, // Preserve agent-ordered sequence (#3356)
isSketch: slice.isSketch,
sketchScope: slice.sketchScope,
});
upsertSlicePlanning(params.milestoneId, slice.sliceId, {
goal: slice.goal,
successCriteria: slice.successCriteria,
proofLevel: slice.proofLevel,
integrationClosure: slice.integrationClosure,
observabilityImpact: slice.observabilityImpact,
});
// ADR-011: sketches defer planning fields to refine-slice — only
// upsert when we actually have content to write.
if (slice.isSketch !== true) {
upsertSlicePlanning(params.milestoneId, slice.sliceId, {
goal: slice.goal,
successCriteria: slice.successCriteria,
proofLevel: slice.proofLevel,
integrationClosure: slice.integrationClosure,
observabilityImpact: slice.observabilityImpact,
});
}
}
});
} catch (err) {