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:
parent
c11595cf22
commit
d4be9afe15
2 changed files with 111 additions and 40 deletions
|
|
@ -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: 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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue