From d4be9afe15000072fd56ea18e525a05d5239994c Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 19:26:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(sf):=20producer=20side=20of=20progressive?= =?UTF-8?q?=20planning=20=E2=80=94=20plan-milestone=20emits=20sketches,=20?= =?UTF-8?q?insertSlice=20persists=20is=5Fsketch+sketch=5Fscope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/resources/extensions/sf/sf-db.ts | 33 ++++- .../extensions/sf/tools/plan-milestone.ts | 118 ++++++++++++------ 2 files changed, 111 insertions(+), 40 deletions(-) diff --git a/src/resources/extensions/sf/sf-db.ts b/src/resources/extensions/sf/sf-db.ts index f5ee72d2b..a4ad583f1 100644 --- a/src/resources/extensions/sf/sf-db.ts +++ b/src/resources/extensions/sf/sf-db.ts @@ -2039,6 +2039,10 @@ export function insertSlice(s: { demo?: string; sequence?: number; planning?: Partial; + /** ADR-011: 3-valued — true marks sketch, false marks non-sketch, undefined leaves the row's existing flag intact. */ + isSketch?: boolean; + /** ADR-011: 2–3 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, diff --git a/src/resources/extensions/sf/tools/plan-milestone.ts b/src/resources/extensions/sf/tools/plan-milestone.ts index 4087eedcf..62f594e6e 100644 --- a/src/resources/extensions/sf/tools/plan-milestone.ts +++ b/src/resources/extensions/sf/tools/plan-milestone.ts @@ -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: 2–3 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) {