diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 000000000..65bd1a3a0 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,12 @@ +# Third-Party Notices + +This project incorporates material from third-party sources. Each entry below +identifies the source, license, and the scope of the included material. + +## OpenSpec — Fission AI + +- Source: https://github.com/Fission-AI/OpenSpec +- License: MIT +- Scope: Spec template structure (RFC 2119 Requirement + Given/When/Then + Scenario format) used by `htmlgraph spec generate`. The template fragment is + copied verbatim with attribution into `plugin/templates/spec-template.md`. diff --git a/cmd/htmlgraph/compliance.go b/cmd/htmlgraph/compliance.go index dbd0f33fb..8c2fa835b 100644 --- a/cmd/htmlgraph/compliance.go +++ b/cmd/htmlgraph/compliance.go @@ -21,11 +21,17 @@ var atomicWriteCounter atomic.Int64 // writeComplianceSection replaces (or inserts) a
block // in the feature HTML file. The replacement is idempotent: any existing section is replaced -// by class match. The write is atomic via atomicWriteFile in internal/workitem. +// by class match. The write is atomic via writeFileAtomicRaw. +// +// The whole read-modify-write window runs inside workitem.LockFeatureForWrite +// (in-process mutex + cross-process flock) so racing writers — including +// separate `htmlgraph` CLI processes — cannot lose updates. // // attrs is a map of data-* attribute names (without the "data-" prefix) → values. // body is the inner HTML content for the section. func writeComplianceSection(featurePath string, attrs map[string]string, body string) error { + defer workitem.LockFeatureForWrite(featurePath)() + content, err := os.ReadFile(featurePath) if err != nil { return fmt.Errorf("read feature file: %w", err) diff --git a/cmd/htmlgraph/plan_cmds.go b/cmd/htmlgraph/plan_cmds.go index 74d74b9bb..fa59df170 100644 --- a/cmd/htmlgraph/plan_cmds.go +++ b/cmd/htmlgraph/plan_cmds.go @@ -67,6 +67,8 @@ func planCmdWithExtras() *cobra.Command { cmd.AddCommand(planSetSliceStatusCmd()) // slice-5: incremental slice promotion cmd.AddCommand(planPromoteSliceCmd()) + // CRISPI: cross-harness decisions elicitation (feat-0fd7c8bc) + cmd.AddCommand(planElicitDecisionsCmd()) return cmd } diff --git a/cmd/htmlgraph/plan_elicit_decisions.go b/cmd/htmlgraph/plan_elicit_decisions.go new file mode 100644 index 000000000..0f147ef8e --- /dev/null +++ b/cmd/htmlgraph/plan_elicit_decisions.go @@ -0,0 +1,177 @@ +// Register in plan_cmds.go: cmd.AddCommand(planElicitDecisionsCmd()) +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/shakestzd/htmlgraph/internal/planyaml" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// planElicitDecisionsCmd adds the cobra sub-command `plan elicit-decisions`. +// +// Cross-harness: this command works on Claude Code, Codex CLI, and Gemini CLI +// without modification. The Claude-only convenience wrapper at +// `plugin/skills/spec-from-slice/SKILL.md` calls into this same command. +func planElicitDecisionsCmd() *cobra.Command { + var scope, decisions, contextStr string + var fromStdin bool + + cmd := &cobra.Command{ + Use: "elicit-decisions ", + Short: "Capture Scope/Decisions/Context for a plan slice", + Long: `Write the three-question Scope/Decisions/Context interview answers +into the slice's decisions_notes field as a single Markdown blob. + +Two input forms: + + Flags (programmatic / non-interactive): + htmlgraph plan elicit-decisions \ + --scope "..." --decisions "..." --context "..." + + Stdin (YAML payload): + cat < --from-stdin + scope: | + + decisions: | + + context: | + + EOF + +The command writes to plan YAML atomically. Re-runs replace previous content +with a stderr warning. The slice's decisions_notes field is consumed by +'htmlgraph spec generate --insert' to populate the spec's '## Decisions' section.`, + Args: cobra.ExactArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + htmlgraphDir, err := findHtmlgraphDir() + if err != nil { + return err + } + sliceNum, err := parseSliceNum(args[1]) + if err != nil { + return err + } + return elicitDecisionsForSlice(htmlgraphDir, args[0], sliceNum, + elicitInput{ + scope: scope, + decisions: decisions, + context: contextStr, + fromStdin: fromStdin, + stdin: os.Stdin, + }) + }, + } + cmd.Flags().StringVar(&scope, "scope", "", "Scope answer (boundaries of this slice)") + cmd.Flags().StringVar(&decisions, "decisions", "", "Decisions answer (design choices made)") + cmd.Flags().StringVar(&contextStr, "context", "", "Context answer (constraints, related work)") + cmd.Flags().BoolVar(&fromStdin, "from-stdin", false, "Read scope/decisions/context as YAML from stdin") + return cmd +} + +// elicitInput bundles the three answers plus the stdin source. +type elicitInput struct { + scope string + decisions string + context string + fromStdin bool + stdin io.Reader +} + +// stdinPayload mirrors the YAML keys accepted on stdin. +type stdinPayload struct { + Scope string `yaml:"scope"` + Decisions string `yaml:"decisions"` + Context string `yaml:"context"` +} + +// elicitDecisionsForSlice is the testable implementation. It loads the plan, +// derives the combined Markdown blob from flags or stdin, writes the blob to +// the slice's decisions_notes, and saves the plan atomically. +// +// The load → modify → save window runs inside planyaml.LockPlanForWrite so +// concurrent in-process elicitations on different slices of the same plan +// can't lose each other's writes. +func elicitDecisionsForSlice(htmlgraphDir, planID string, sliceNum int, in elicitInput) error { + planPath := filepath.Join(htmlgraphDir, "plans", planID+".yaml") + + defer planyaml.LockPlanForWrite(planPath)() + + plan, err := planyaml.Load(planPath) + if err != nil { + return fmt.Errorf("load plan: %w", err) + } + + sliceIdx, _, err := findPlanSlice(plan, sliceNum) + if err != nil { + return err + } + + scope, decisions, contextStr, err := resolveElicitInputs(in) + if err != nil { + return err + } + if strings.TrimSpace(scope) == "" && strings.TrimSpace(decisions) == "" && strings.TrimSpace(contextStr) == "" { + return errors.New("at least one of --scope, --decisions, --context (or stdin) must be non-empty") + } + + if existing := strings.TrimSpace(plan.Slices[sliceIdx].DecisionsNotes); existing != "" { + fmt.Fprintln(stderr, "elicit-decisions: replacing previous decisions_notes (use --from-stdin or flags to overwrite intentionally)") + } + + plan.Slices[sliceIdx].DecisionsNotes = combineDecisionsMarkdown(scope, decisions, contextStr) + + if err := planyaml.SaveLocked(planPath, plan); err != nil { + return fmt.Errorf("save plan: %w", err) + } + + fmt.Printf("Decisions written to slice %d of %s\n", sliceNum, planID) + return nil +} + +// resolveElicitInputs picks between flag-form and stdin-form input. Stdin form +// is used when --from-stdin is set; otherwise the flag values are returned +// verbatim. +func resolveElicitInputs(in elicitInput) (scope, decisions, contextStr string, err error) { + if in.fromStdin { + raw, rerr := io.ReadAll(in.stdin) + if rerr != nil { + return "", "", "", fmt.Errorf("read stdin: %w", rerr) + } + var p stdinPayload + if uerr := yaml.Unmarshal(raw, &p); uerr != nil { + return "", "", "", fmt.Errorf("parse stdin YAML: %w", uerr) + } + return p.Scope, p.Decisions, p.Context, nil + } + return in.scope, in.decisions, in.context, nil +} + +// combineDecisionsMarkdown produces the canonical Markdown blob written into +// slice.decisions_notes. Empty subsections are omitted (no empty headings). +func combineDecisionsMarkdown(scope, decisions, contextStr string) string { + var sb strings.Builder + add := func(label, body string) { + body = strings.TrimSpace(body) + if body == "" { + return + } + if sb.Len() > 0 { + sb.WriteString("\n\n") + } + sb.WriteString("### ") + sb.WriteString(label) + sb.WriteString("\n") + sb.WriteString(body) + } + add("Scope", scope) + add("Decisions", decisions) + add("Context", contextStr) + return sb.String() +} diff --git a/cmd/htmlgraph/plan_elicit_decisions_test.go b/cmd/htmlgraph/plan_elicit_decisions_test.go new file mode 100644 index 000000000..acaa01345 --- /dev/null +++ b/cmd/htmlgraph/plan_elicit_decisions_test.go @@ -0,0 +1,204 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/shakestzd/htmlgraph/internal/planyaml" + "github.com/shakestzd/htmlgraph/internal/workitem" +) + +// seedPlanForElicit creates a temp project dir with a single-slice plan ready +// for elicitation tests. +func seedPlanForElicit(t *testing.T) (dir, planID string, sliceNum int) { + t.Helper() + dir = t.TempDir() + for _, sub := range []string{"plans", "features", "tracks"} { + if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", sub, err) + } + } + planID = workitem.GenerateID("plan", "elicit test") + plan := planyaml.NewPlan(planID, "Elicit Test", "") + plan.Meta.Status = "active" + plan.Design.Problem = "test" + plan.Slices = append(plan.Slices, planyaml.PlanSlice{ + ID: workitem.GenerateID("slice", "one"), + Num: 1, + Title: "Slice One", + What: "Do the thing", + Why: "Because", + }) + planPath := filepath.Join(dir, "plans", planID+".yaml") + if err := planyaml.Save(planPath, plan); err != nil { + t.Fatalf("save plan: %v", err) + } + return dir, planID, 1 +} + +// TestSliceSchema_DecisionsNotesField — round-trip preserves the field. +func TestSliceSchema_DecisionsNotesField(t *testing.T) { + dir, planID, _ := seedPlanForElicit(t) + planPath := filepath.Join(dir, "plans", planID+".yaml") + plan, err := planyaml.Load(planPath) + if err != nil { + t.Fatalf("load: %v", err) + } + plan.Slices[0].DecisionsNotes = "### Scope\nA, B" + if err := planyaml.Save(planPath, plan); err != nil { + t.Fatalf("save: %v", err) + } + reloaded, err := planyaml.Load(planPath) + if err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.Slices[0].DecisionsNotes != "### Scope\nA, B" { + t.Errorf("DecisionsNotes = %q, want round-trip preserved", reloaded.Slices[0].DecisionsNotes) + } +} + +// TestSliceSchema_NoDecisionsNotes — legacy slice without the field validates +// and round-trips with the field empty. +func TestSliceSchema_NoDecisionsNotes(t *testing.T) { + dir, planID, _ := seedPlanForElicit(t) + planPath := filepath.Join(dir, "plans", planID+".yaml") + plan, err := planyaml.Load(planPath) + if err != nil { + t.Fatalf("load: %v", err) + } + if plan.Slices[0].DecisionsNotes != "" { + t.Errorf("expected empty DecisionsNotes by default, got %q", plan.Slices[0].DecisionsNotes) + } + // Re-save and reload to confirm omitempty behavior. + if err := planyaml.Save(planPath, plan); err != nil { + t.Fatalf("save: %v", err) + } + body, _ := os.ReadFile(planPath) + if strings.Contains(string(body), "decisions_notes:") { + t.Errorf("YAML should not contain decisions_notes when empty:\n%s", body) + } +} + +// TestElicitDecisions_FlagsForm — --scope/--decisions/--context flags write +// the expected combined Markdown blob. +func TestElicitDecisions_FlagsForm(t *testing.T) { + dir, planID, sliceNum := seedPlanForElicit(t) + + err := elicitDecisionsForSlice(dir, planID, sliceNum, elicitInput{ + scope: "what's in", + decisions: "we picked X", + context: "see plan-foo", + }) + if err != nil { + t.Fatalf("elicit: %v", err) + } + + plan, err := planyaml.Load(filepath.Join(dir, "plans", planID+".yaml")) + if err != nil { + t.Fatalf("reload plan: %v", err) + } + got := plan.Slices[0].DecisionsNotes + for _, want := range []string{"### Scope", "what's in", "### Decisions", "we picked X", "### Context", "see plan-foo"} { + if !strings.Contains(got, want) { + t.Errorf("DecisionsNotes missing %q\n--- got ---\n%s", want, got) + } + } +} + +// TestElicitDecisions_StdinForm — --from-stdin reads YAML and writes the same +// blob structure. +func TestElicitDecisions_StdinForm(t *testing.T) { + dir, planID, sliceNum := seedPlanForElicit(t) + + stdin := strings.NewReader("scope: what's in\ndecisions: we picked X\ncontext: see plan-foo\n") + err := elicitDecisionsForSlice(dir, planID, sliceNum, elicitInput{ + fromStdin: true, + stdin: stdin, + }) + if err != nil { + t.Fatalf("elicit: %v", err) + } + + plan, _ := planyaml.Load(filepath.Join(dir, "plans", planID+".yaml")) + got := plan.Slices[0].DecisionsNotes + for _, want := range []string{"### Scope", "what's in", "### Decisions", "we picked X", "### Context", "see plan-foo"} { + if !strings.Contains(got, want) { + t.Errorf("DecisionsNotes missing %q\n--- got ---\n%s", want, got) + } + } +} + +// TestElicitDecisions_AllEmpty — all three answers empty is rejected so we +// don't blow away decisions_notes by accident. +func TestElicitDecisions_AllEmpty(t *testing.T) { + dir, planID, sliceNum := seedPlanForElicit(t) + + err := elicitDecisionsForSlice(dir, planID, sliceNum, elicitInput{}) + if err == nil { + t.Fatal("expected error when all three answers empty, got nil") + } +} + +// TestElicitDecisions_AtomicWrite — concurrent elicitations on different +// slices in the same plan don't corrupt the YAML. +func TestElicitDecisions_AtomicWrite(t *testing.T) { + dir := t.TempDir() + for _, sub := range []string{"plans", "features", "tracks"} { + _ = os.MkdirAll(filepath.Join(dir, sub), 0o755) + } + planID := workitem.GenerateID("plan", "atomic") + plan := planyaml.NewPlan(planID, "Atomic", "") + plan.Meta.Status = "active" + plan.Design.Problem = "x" + for i := 1; i <= 4; i++ { + plan.Slices = append(plan.Slices, planyaml.PlanSlice{ + ID: workitem.GenerateID("slice", "s"), + Num: i, + Title: "s", + What: "w", + Why: "y", + }) + } + planPath := filepath.Join(dir, "plans", planID+".yaml") + if err := planyaml.Save(planPath, plan); err != nil { + t.Fatalf("save: %v", err) + } + + var wg sync.WaitGroup + wg.Add(4) + for i := 1; i <= 4; i++ { + go func(n int) { + defer wg.Done() + _ = elicitDecisionsForSlice(dir, planID, n, elicitInput{ + decisions: "slice " + string(rune('A'+n-1)), + }) + }(i) + } + wg.Wait() + + reloaded, err := planyaml.Load(planPath) + if err != nil { + t.Fatalf("reload after concurrent writes: %v (likely corrupted YAML)", err) + } + if len(reloaded.Slices) != 4 { + t.Errorf("slice count = %d, want 4 (lost slice from race)", len(reloaded.Slices)) + } +} + +// TestCombineDecisionsMarkdown_OmitsEmpty — empty subsections are dropped so +// the rendered blob never contains an empty heading. +func TestCombineDecisionsMarkdown_OmitsEmpty(t *testing.T) { + out := combineDecisionsMarkdown("", "we picked X", "") + if strings.Contains(out, "### Scope") { + t.Errorf("empty Scope should be omitted: %q", out) + } + if strings.Contains(out, "### Context") { + t.Errorf("empty Context should be omitted: %q", out) + } + if !strings.Contains(out, "### Decisions") || !strings.Contains(out, "we picked X") { + t.Errorf("Decisions should be present: %q", out) + } +} diff --git a/cmd/htmlgraph/plan_promote_slice.go b/cmd/htmlgraph/plan_promote_slice.go index 00ce6ce68..1ba43f588 100644 --- a/cmd/htmlgraph/plan_promote_slice.go +++ b/cmd/htmlgraph/plan_promote_slice.go @@ -8,6 +8,7 @@ import ( "time" dbpkg "github.com/shakestzd/htmlgraph/internal/db" + "github.com/shakestzd/htmlgraph/internal/hooks" "github.com/shakestzd/htmlgraph/internal/models" "github.com/shakestzd/htmlgraph/internal/planyaml" "github.com/shakestzd/htmlgraph/internal/workitem" @@ -16,7 +17,7 @@ import ( // planPromoteSliceCmd adds the cobra sub-command `plan promote-slice`. func planPromoteSliceCmd() *cobra.Command { - var waiveDeps bool + var waiveDeps, allowSpecSkip bool cmd := &cobra.Command{ Use: "promote-slice ", Short: "Promote an approved plan slice to a feature work item", @@ -45,7 +46,7 @@ Example: if err != nil { return err } - featID, err := promoteSliceFromYAML(htmlgraphDir, args[0], sliceNum, waiveDeps) + featID, err := promoteSliceFromYAML(htmlgraphDir, args[0], sliceNum, waiveDeps, allowSpecSkip) if err != nil { return err } @@ -54,12 +55,20 @@ Example: }, } cmd.Flags().BoolVar(&waiveDeps, "waive-deps", false, "skip dependency readiness check") + cmd.Flags().BoolVar(&allowSpecSkip, "allow-spec-skip", false, "bypass spec_enforcement.promote_slice gate; logs an audit comment on the slice") return cmd } // promoteSliceFromYAML is the testable implementation of plan promote-slice. // It promotes exactly one approved slice, creating (or reusing) a feature. -func promoteSliceFromYAML(htmlgraphDir, planID string, sliceNum int, waiveDeps bool) (string, error) { +// +// When config.spec_enforcement.promote_slice is true and allowSpecSkip is +// false, the call refuses if the slice's DecisionsNotes is empty — pointing +// the user at `htmlgraph plan elicit-decisions` (or the Claude skill) to +// capture decisions before promotion. When allowSpecSkip is true and the +// gate would have fired, an audit line is appended to slice.Comment so the +// override is visible in the plan history. +func promoteSliceFromYAML(htmlgraphDir, planID string, sliceNum int, waiveDeps, allowSpecSkip bool) (string, error) { planPath := filepath.Join(htmlgraphDir, "plans", planID+".yaml") plan, err := planyaml.Load(planPath) if err != nil { @@ -93,6 +102,25 @@ func promoteSliceFromYAML(htmlgraphDir, planID string, sliceNum int, waiveDeps b sliceNum, approvals[sectionKey], slice.ApprovalStatus, planID, sliceNum) } + // CRISPI spec-enforcement gate: refuse if config opts in and slice has no + // decisions captured. Audited override via --allow-spec-skip writes a + // comment to the slice so deliberate skips are visible in plan history. + enforcement := hooks.ReadSpecEnforcement(filepath.Dir(htmlgraphDir)) + if enforcement.PromoteSlice && strings.TrimSpace(slice.DecisionsNotes) == "" { + if !allowSpecSkip { + return "", fmt.Errorf("slice %d has no decisions; run `htmlgraph plan elicit-decisions %s %d` first (or invoke /htmlgraph:spec-from-slice on Claude). Override with --allow-spec-skip if intentional.", + sliceNum, planID, sliceNum) + } + auditNote := fmt.Sprintf("[%s] promote-slice --allow-spec-skip: promoted without decisions_notes", + time.Now().UTC().Format(time.RFC3339)) + if existing := strings.TrimSpace(plan.Slices[sliceIdx].Comment); existing == "" { + plan.Slices[sliceIdx].Comment = auditNote + } else { + plan.Slices[sliceIdx].Comment = existing + "\n" + auditNote + } + fmt.Fprintln(stderr, "promote-slice: --allow-spec-skip set; bypassing spec_enforcement.promote_slice gate") + } + // Validate dependency readiness. if !waiveDeps && len(slice.Deps) > 0 { if err := checkDepReadiness(db, plan, planID, slice.Deps); err != nil { diff --git a/cmd/htmlgraph/plan_promote_slice_test.go b/cmd/htmlgraph/plan_promote_slice_test.go index a5482e1f8..50fb9663f 100644 --- a/cmd/htmlgraph/plan_promote_slice_test.go +++ b/cmd/htmlgraph/plan_promote_slice_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "path/filepath" "strings" @@ -63,7 +64,7 @@ func TestPromoteSlice_Approved_CreatesFeature(t *testing.T) { db.Close() // Promote slice 1. - featID, err := promoteSliceFromYAML(dir, pID, 1, false) + featID, err := promoteSliceFromYAML(dir, pID, 1, false, false) if err != nil { t.Fatalf("promoteSliceFromYAML: %v", err) } @@ -142,7 +143,7 @@ func TestPromoteSlice_NotApproved_Refuses(t *testing.T) { os.WriteFile(filepath.Join(dir, "plans", pID+".html"), []byte(""), 0o644) // No approval row — must refuse. - _, err := promoteSliceFromYAML(dir, pID, 1, false) + _, err := promoteSliceFromYAML(dir, pID, 1, false, false) if err == nil { t.Fatal("expected error for unapproved slice") } @@ -187,7 +188,7 @@ func TestPromoteSlice_BlockedByPendingDeps_Refuses(t *testing.T) { db.Close() // Without --waive-deps: must refuse. - _, err := promoteSliceFromYAML(dir, pID, 2, false) + _, err := promoteSliceFromYAML(dir, pID, 2, false, false) if err == nil { t.Fatal("expected error when dep not done") } @@ -201,7 +202,7 @@ func TestPromoteSlice_BlockedByPendingDeps_Refuses(t *testing.T) { dbpkg.StorePlanFeedback(db2, pID, "slice-2", "approve", "true", "") db2.Close() - featID, err := promoteSliceFromYAML(dir, pID, 2, true) + featID, err := promoteSliceFromYAML(dir, pID, 2, true, false) if err != nil { t.Fatalf("waive-deps promote: %v", err) } @@ -247,7 +248,7 @@ func TestPromoteSlice_DepDoneViaSetSliceStatus_NoWaiveNeeded(t *testing.T) { } // Without --waive-deps: must succeed because slice 1 is done. - featID, err := promoteSliceFromYAML(dir, pID, 2, false) + featID, err := promoteSliceFromYAML(dir, pID, 2, false, false) if err != nil { t.Fatalf("expected promote to succeed when dep marked done via runSetSliceStatus, got: %v", err) } @@ -282,13 +283,13 @@ func TestPromoteSlice_Idempotent(t *testing.T) { db.Close() // First promote. - featID1, err := promoteSliceFromYAML(dir, pID, 1, false) + featID1, err := promoteSliceFromYAML(dir, pID, 1, false, false) if err != nil { t.Fatalf("first promote: %v", err) } // Second promote — must reuse same feature_id, not create duplicate. - featID2, err := promoteSliceFromYAML(dir, pID, 1, false) + featID2, err := promoteSliceFromYAML(dir, pID, 1, false, false) if err != nil { t.Fatalf("second promote: %v", err) } @@ -329,7 +330,7 @@ func TestPromoteSlice_SetsExecutionStatusPromoted(t *testing.T) { dbpkg.StorePlanFeedback(db, pID, "slice-1", "approve", "true", "") db.Close() - if _, err := promoteSliceFromYAML(dir, pID, 1, false); err != nil { + if _, err := promoteSliceFromYAML(dir, pID, 1, false, false); err != nil { t.Fatalf("promote: %v", err) } @@ -377,7 +378,7 @@ func TestPromoteSlice_PlanRemainsActive(t *testing.T) { dbpkg.StorePlanFeedback(db, pID, "slice-1", "approve", "true", "") db.Close() - if _, err := promoteSliceFromYAML(dir, pID, 1, false); err != nil { + if _, err := promoteSliceFromYAML(dir, pID, 1, false, false); err != nil { t.Fatalf("promote: %v", err) } @@ -439,7 +440,7 @@ func TestExecutePreview_IncludesPromotedSliceFeatures(t *testing.T) { } db.Close() - featID, err := promoteSliceFromYAML(hgDir, pID, 1, false) + featID, err := promoteSliceFromYAML(hgDir, pID, 1, false, false) if err != nil { t.Fatalf("promoteSliceFromYAML: %v", err) } @@ -465,3 +466,141 @@ func TestExecutePreview_IncludesPromotedSliceFeatures(t *testing.T) { t.Errorf("promoted feature %s not found in execute-preview features: %v", featID, ids) } } + +// --- Spec-enforcement gate tests (feat-0fd7c8bc) ---------------------- + +// seedPromoteFixture creates a temp project with a single approved slice and +// returns the htmlgraph dir + plan ID. The layout mirrors production: a +// project root (t.TempDir()) containing a .htmlgraph/ subdirectory. +func seedPromoteFixture(t *testing.T, decisionsNotes string) (hgDir, planID string) { + t.Helper() + projectRoot := t.TempDir() + hgDir = filepath.Join(projectRoot, ".htmlgraph") + for _, sub := range []string{"plans", "features", "tracks"} { + if err := os.MkdirAll(filepath.Join(hgDir, sub), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", sub, err) + } + } + + p, err := workitem.Open(hgDir, "test-agent") + if err != nil { + t.Fatalf("workitem.Open: %v", err) + } + track, err := p.Tracks.Create("Gate Track") + if err != nil { + p.Close() + t.Fatalf("create track: %v", err) + } + p.Close() + + planID = workitem.GenerateID("plan", "gate test") + plan := planyaml.NewPlan(planID, "Gate Test", "") + plan.Meta.TrackID = track.ID + plan.Meta.Status = "active" + plan.Design.Problem = "x" + plan.Slices = append(plan.Slices, planyaml.PlanSlice{ + ID: workitem.GenerateID("slice", "s"), + Num: 1, + Title: "Slice One", + What: "do", + Why: "because", + DecisionsNotes: decisionsNotes, + }) + planPath := filepath.Join(hgDir, "plans", planID+".yaml") + if err := planyaml.Save(planPath, plan); err != nil { + t.Fatalf("save plan: %v", err) + } + if err := os.WriteFile(filepath.Join(hgDir, "plans", planID+".html"), []byte(""), 0o644); err != nil { + t.Fatalf("write html: %v", err) + } + + db, err := openPlanDB(hgDir) + if err != nil { + t.Fatalf("openPlanDB: %v", err) + } + if err := dbpkg.StorePlanFeedback(db, planID, "slice-1", "approve", "true", ""); err != nil { + db.Close() + t.Fatalf("store approval: %v", err) + } + db.Close() + + return hgDir, planID +} + +// writeSpecEnforcementConfig writes a config.json into the .htmlgraph dir. +func writeSpecEnforcementConfig(t *testing.T, hgDir string, promoteSlice, featureComplete bool) { + t.Helper() + body := fmt.Sprintf(`{"spec_enforcement":{"promote_slice":%t,"feature_complete":%t}}`, + promoteSlice, featureComplete) + if err := os.WriteFile(filepath.Join(hgDir, "config.json"), []byte(body), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } +} + +// TestPromoteSliceGate_Disabled — default config, no DecisionsNotes; succeeds. +func TestPromoteSliceGate_Disabled(t *testing.T) { + dir, pID := seedPromoteFixture(t, "") + + featID, err := promoteSliceFromYAML(dir, pID, 1, false, false) + if err != nil { + t.Fatalf("expected success with gate disabled, got error: %v", err) + } + if !strings.HasPrefix(featID, "feat-") { + t.Errorf("featID = %q", featID) + } +} + +// TestPromoteSliceGate_EnabledNoNotes — gate enabled + empty notes refuses. +func TestPromoteSliceGate_EnabledNoNotes(t *testing.T) { + dir, pID := seedPromoteFixture(t, "") + writeSpecEnforcementConfig(t, dir, true, false) + + _, err := promoteSliceFromYAML(dir, pID, 1, false, false) + if err == nil { + t.Fatal("expected gate refusal, got nil") + } + if !strings.Contains(err.Error(), "no decisions") { + t.Errorf("error should mention missing decisions: %v", err) + } + if !strings.Contains(err.Error(), "elicit-decisions") { + t.Errorf("error should point to remediation command: %v", err) + } +} + +// TestPromoteSliceGate_EnabledWithNotes — gate enabled + notes present succeeds. +func TestPromoteSliceGate_EnabledWithNotes(t *testing.T) { + dir, pID := seedPromoteFixture(t, "### Decisions\nWe picked X.") + writeSpecEnforcementConfig(t, dir, true, false) + + featID, err := promoteSliceFromYAML(dir, pID, 1, false, false) + if err != nil { + t.Fatalf("expected success with notes present, got: %v", err) + } + if !strings.HasPrefix(featID, "feat-") { + t.Errorf("featID = %q", featID) + } +} + +// TestPromoteSliceGate_AllowSkip — gate enabled, no notes, --allow-spec-skip +// succeeds and writes an audit line into slice.Comment. +func TestPromoteSliceGate_AllowSkip(t *testing.T) { + dir, pID := seedPromoteFixture(t, "") + writeSpecEnforcementConfig(t, dir, true, false) + + featID, err := promoteSliceFromYAML(dir, pID, 1, false, true) + if err != nil { + t.Fatalf("expected --allow-spec-skip to succeed, got: %v", err) + } + if !strings.HasPrefix(featID, "feat-") { + t.Errorf("featID = %q", featID) + } + + // Audit comment must appear on the slice. + plan, err := planyaml.Load(filepath.Join(dir, "plans", pID+".yaml")) + if err != nil { + t.Fatalf("reload plan: %v", err) + } + if !strings.Contains(plan.Slices[0].Comment, "allow-spec-skip") { + t.Errorf("expected audit comment on slice, got: %q", plan.Slices[0].Comment) + } +} diff --git a/cmd/htmlgraph/spec.go b/cmd/htmlgraph/spec.go index 0514646fe..67221c299 100644 --- a/cmd/htmlgraph/spec.go +++ b/cmd/htmlgraph/spec.go @@ -3,25 +3,51 @@ package main import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" + "reflect" + "regexp" "strings" "github.com/shakestzd/htmlgraph/internal/htmlparse" + "github.com/shakestzd/htmlgraph/internal/models" + "github.com/shakestzd/htmlgraph/internal/planyaml" "github.com/shakestzd/htmlgraph/internal/workitem" "github.com/spf13/cobra" ) +const specAttributionComment = "" + +// specRequirement is one RFC 2119 requirement with at least one Given/When/Then scenario. +type specRequirement struct { + Name string `json:"name"` + SHALL string `json:"shall"` + Scenarios []specScenario `json:"scenarios"` +} + +// specScenario is one Given/When/Then scenario block. +type specScenario struct { + Name string `json:"name"` + When string `json:"when"` + Then string `json:"then"` +} + // specTemplate holds the structured fields of a feature spec. +// +// New (OpenSpec) format consumes Requirements + DecisionsNotes; legacy callers +// that still set AcceptanceCriteria fall through to the legacy renderer. type specTemplate struct { - FeatureID string `json:"feature_id"` - Title string `json:"title"` - Problem string `json:"problem"` - AcceptanceCriteria []string `json:"acceptance_criteria"` - Files []string `json:"files"` - APISurface []string `json:"api_surface"` - Notes []string `json:"notes"` + FeatureID string `json:"feature_id"` + Title string `json:"title"` + Problem string `json:"problem"` + DecisionsNotes string `json:"decisions_notes,omitempty"` + Requirements []specRequirement `json:"requirements,omitempty"` + AcceptanceCriteria []string `json:"acceptance_criteria,omitempty"` + Files []string `json:"files"` + APISurface []string `json:"api_surface,omitempty"` + Notes []string `json:"notes"` } func specCmd() *cobra.Command { @@ -44,17 +70,20 @@ func specCmd() *cobra.Command { func specGenerateCmd() *cobra.Command { var format, output string + var insert, force bool cmd := &cobra.Command{ Use: "generate ", Short: "Generate a spec template for a feature", Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { - return runSpecGenerate(args[0], format, output) + return runSpecGenerate(args[0], format, output, insert, force) }, } cmd.Flags().StringVar(&format, "format", "markdown", "Output format: markdown or json") - cmd.Flags().StringVar(&output, "output", "", "Write output to file instead of stdout") + cmd.Flags().StringVar(&output, "output", "", "Write output to file instead of stdout (mutually exclusive with --insert)") + cmd.Flags().BoolVar(&insert, "insert", false, "Write the generated spec into the feature HTML's
block") + cmd.Flags().BoolVar(&force, "force", false, "With --insert, overwrite an existing non-empty spec section") return cmd } @@ -74,15 +103,51 @@ func specShowCmd() *cobra.Command { return cmd } -// runSpecGenerate reads the feature title and outputs a blank spec template. -func runSpecGenerate(featureID, format, output string) error { - title, err := resolveFeatureTitle(featureID) +// runSpecGenerate builds a spec template for a feature and either prints it, +// writes it to a file, or inserts it into the feature HTML. +func runSpecGenerate(featureID, format, output string, insert, force bool) error { + if insert && output != "" { + return errors.New("--insert and --output are mutually exclusive; pick one") + } + if format != "markdown" && format != "json" { + return fmt.Errorf("unknown format %q (markdown or json)", format) + } + if insert && format == "json" { + return errors.New("--insert writes Markdown into the feature HTML; --format=json is incompatible") + } + + dir, err := findHtmlgraphDir() if err != nil { return err } - spec := buildBlankSpec(featureID, title) - return writeSpec(spec, format, output, featureID) + featurePath := filepath.Join(dir, "features", featureID+".html") + node, err := htmlparse.ParseFile(featurePath) + if err != nil { + if os.IsNotExist(err) { + return workitem.ErrNotFound("feature", featureID) + } + return fmt.Errorf("parse feature %s: %w", featureID, err) + } + + spec := buildSpecFromFeature(featureID, node, dir) + + if insert { + return insertSpecIntoFeature(featurePath, featureID, spec, force) + } + + var content string + switch format { + case "json": + data, jerr := json.MarshalIndent(spec, "", " ") + if jerr != nil { + return fmt.Errorf("marshal spec: %w", jerr) + } + content = string(data) + default: + content = renderSpecMarkdown(spec) + } + return writeOutput(content, output) } // runSpecShow looks for an existing spec section in the feature HTML. @@ -114,68 +179,159 @@ func runSpecShow(featureID, format, output string) error { return writeOutput(specContent, output) } -// resolveFeatureTitle looks up a feature by ID and returns its title. -func resolveFeatureTitle(featureID string) (string, error) { - dir, err := findHtmlgraphDir() - if err != nil { - return "", err +// buildSpecFromFeature assembles a spec template, preferring slice-derived +// fields when the feature is linked to a plan, falling back to a blank +// template otherwise. +func buildSpecFromFeature(featureID string, node *models.Node, htmlgraphDir string) *specTemplate { + spec := &specTemplate{ + FeatureID: featureID, + Title: node.Title, + Problem: "What problem does this solve? Why is it needed?", + Files: []string{"NEW: path/to/new/file.go", "EDIT: path/to/existing/file.go"}, + Notes: []string{"Dependencies, risks, edge cases"}, } - path := filepath.Join(dir, "features", featureID+".html") - if _, err := os.Stat(path); err != nil { - return "", workitem.ErrNotFound("feature", featureID) + slice := lookupSliceForFeature(node, htmlgraphDir, featureID) + if slice == nil { + spec.AcceptanceCriteria = []string{"Criterion 1", "Criterion 2", "Criterion 3"} + spec.APISurface = []string{"Function/command signatures", "Input/output formats"} + return spec } - node, err := htmlparse.ParseFile(path) + if w := strings.TrimSpace(slice.Why); w != "" { + spec.Problem = w + } + if dn := sliceDecisionsNotes(slice); dn != "" { + spec.DecisionsNotes = dn + } + if len(slice.Files) > 0 { + spec.Files = append([]string(nil), slice.Files...) + } + if t := strings.TrimSpace(slice.Tests); t != "" { + spec.Notes = []string{t} + } + spec.Requirements = buildRequirementsFromDoneWhen(slice.DoneWhen, slice.Tests) + return spec +} + +// lookupSliceForFeature finds the PlanSlice corresponding to a feature by +// following the planned_in edge to the plan YAML and matching feature_id. +// Returns nil if the feature has no plan link or the slice is not found. +func lookupSliceForFeature(node *models.Node, htmlgraphDir, featureID string) *planyaml.PlanSlice { + planID := "" + if edges, ok := node.Edges["planned_in"]; ok && len(edges) > 0 { + planID = edges[0].TargetID + } + if planID == "" { + return nil + } + planPath := filepath.Join(htmlgraphDir, "plans", planID+".yaml") + plan, err := planyaml.Load(planPath) if err != nil { - return "", fmt.Errorf("parse feature %s: %w", featureID, err) + return nil } - return node.Title, nil + for i := range plan.Slices { + if plan.Slices[i].FeatureID == featureID { + return &plan.Slices[i] + } + } + return nil } -// buildBlankSpec constructs an empty spec template for the given feature. -func buildBlankSpec(featureID, title string) *specTemplate { - return &specTemplate{ - FeatureID: featureID, - Title: title, - Problem: "What problem does this solve? Why is it needed?", - AcceptanceCriteria: []string{"Criterion 1", "Criterion 2", "Criterion 3"}, - Files: []string{"NEW: path/to/new/file.go", "EDIT: path/to/existing/file.go"}, - APISurface: []string{"Function/command signatures", "Input/output formats"}, - Notes: []string{"Dependencies, risks, edge cases"}, +// sliceDecisionsNotes reads the optional DecisionsNotes field from a PlanSlice. +// Uses reflection so we keep working before slice 3 ships the typed field — +// the renderer treats absence and empty as identical. +func sliceDecisionsNotes(slice *planyaml.PlanSlice) string { + if slice == nil { + return "" + } + v := reflect.ValueOf(slice).Elem() + f := v.FieldByName("DecisionsNotes") + if !f.IsValid() || f.Kind() != reflect.String { + return "" } + return strings.TrimSpace(f.String()) } -// writeSpec serialises and outputs the spec in the requested format. -func writeSpec(spec *specTemplate, format, output, _ string) error { - var content string - switch format { - case "json": - data, err := json.MarshalIndent(spec, "", " ") - if err != nil { - return fmt.Errorf("marshal spec: %w", err) +// buildRequirementsFromDoneWhen turns a slice's done_when entries into +// OpenSpec-formatted requirements with a single placeholder scenario each. +func buildRequirementsFromDoneWhen(doneWhen []string, tests string) []specRequirement { + if len(doneWhen) == 0 { + return nil + } + out := make([]specRequirement, 0, len(doneWhen)) + for i, dw := range doneWhen { + dw = strings.TrimSpace(dw) + if dw == "" { + continue } - content = string(data) - default: - content = renderSpecMarkdown(spec) + req := specRequirement{ + Name: fmt.Sprintf("Requirement %d", i+1), + SHALL: dw, + Scenarios: []specScenario{ + { + Name: "implementation satisfies the criterion", + When: "the implementation is reviewed against this requirement", + Then: "the criterion above is fully satisfied", + }, + }, + } + out = append(out, req) } - return writeOutput(content, output) + if len(out) > 0 && strings.TrimSpace(tests) != "" { + // Attach the slice's tests prose to the first requirement as a hint. + out[0].Scenarios[0].Then = strings.TrimSpace(tests) + } + return out } // renderSpecMarkdown formats the spec as a Markdown document. +// +// Render order (when Requirements present): +// +// # Spec: +// ## Problem +// ## Decisions (only when DecisionsNotes non-empty) +// ## ADDED Requirements +// ### Requirement: ... +// #### Scenario: ... +// ## Files +// ## Notes +// +// When Requirements is empty, falls back to the legacy `## Acceptance Criteria` +// checkbox layout for backward compatibility. func renderSpecMarkdown(s *specTemplate) string { var sb strings.Builder + sb.WriteString(specAttributionComment + "\n\n") sb.WriteString(fmt.Sprintf("# Spec: %s\n\n", s.Title)) sb.WriteString("## Problem\n") sb.WriteString(s.Problem + "\n\n") - sb.WriteString("## Acceptance Criteria\n") - for i, c := range s.AcceptanceCriteria { - sb.WriteString(fmt.Sprintf("%d. [ ] %s\n", i+1, c)) + if dn := strings.TrimSpace(s.DecisionsNotes); dn != "" { + sb.WriteString("## Decisions\n") + sb.WriteString(dn + "\n\n") + } + + if len(s.Requirements) > 0 { + sb.WriteString("## ADDED Requirements\n\n") + for _, r := range s.Requirements { + sb.WriteString(fmt.Sprintf("### Requirement: %s\n", r.Name)) + sb.WriteString("The implementation SHALL ensure: " + r.SHALL + "\n\n") + for _, sc := range r.Scenarios { + sb.WriteString(fmt.Sprintf("#### Scenario: %s\n", sc.Name)) + sb.WriteString(fmt.Sprintf("- **WHEN** %s\n", sc.When)) + sb.WriteString(fmt.Sprintf("- **THEN** %s\n\n", sc.Then)) + } + } + } else { + sb.WriteString("## Acceptance Criteria\n") + for i, c := range s.AcceptanceCriteria { + sb.WriteString(fmt.Sprintf("%d. [ ] %s\n", i+1, c)) + } + sb.WriteString("\n") } - sb.WriteString("\n") sb.WriteString("## Files\n") for _, f := range s.Files { @@ -183,11 +339,13 @@ func renderSpecMarkdown(s *specTemplate) string { } sb.WriteString("\n") - sb.WriteString("## API Surface\n") - for _, a := range s.APISurface { - sb.WriteString(fmt.Sprintf("- %s\n", a)) + if len(s.APISurface) > 0 { + sb.WriteString("## API Surface\n") + for _, a := range s.APISurface { + sb.WriteString(fmt.Sprintf("- %s\n", a)) + } + sb.WriteString("\n") } - sb.WriteString("\n") sb.WriteString("## Notes\n") for _, n := range s.Notes { @@ -197,7 +355,8 @@ func renderSpecMarkdown(s *specTemplate) string { return sb.String() } -// extractSpecSection looks for <section class="spec"> inside feature HTML. +// extractSpecSection looks for <section class="spec"> inside feature HTML and +// returns its inner content trimmed of surrounding whitespace. func extractSpecSection(html string) string { const open = `<section class="spec">` const close = `</section>` @@ -214,6 +373,102 @@ func extractSpecSection(html string) string { return strings.TrimSpace(inner) } +// specSectionIsEmpty returns true when the spec section is missing OR contains +// only the attribution comment + whitespace. This is what `--insert` checks +// before refusing to clobber. +func specSectionIsEmpty(html string) bool { + inner := extractSpecSection(html) + if inner == "" { + return true + } + stripped := strings.TrimSpace(strings.ReplaceAll(inner, specAttributionComment, "")) + return stripped == "" +} + +// insertSpecIntoFeature writes the rendered spec into the feature HTML's +// <section class="spec"> block, refusing to clobber non-empty content unless +// force is set. Acquires workitem.LockFeatureForWrite (in-process mutex + +// cross-process flock) for the entire RMW window so concurrent compliance +// auto writers — including from other `htmlgraph` CLI processes — cannot +// lose updates. +func insertSpecIntoFeature(featurePath, featureID string, spec *specTemplate, force bool) error { + defer workitem.LockFeatureForWrite(featurePath)() + + raw, err := os.ReadFile(featurePath) + if err != nil { + return fmt.Errorf("read feature file: %w", err) + } + html := string(raw) + + rendered := renderSpecMarkdown(spec) + + if !specSectionIsEmpty(html) && !force { + existing := extractSpecSection(html) + fmt.Fprintln(os.Stderr, "spec section already has content; pass --force to overwrite, or edit the section directly") + fmt.Fprintln(os.Stderr, unifiedDiff(existing, rendered)) + return errors.New("spec section already has content; pass --force to overwrite") + } + + updated := replaceOrAppendSpecSection(html, rendered) + if err := writeFileAtomicRaw(featurePath, []byte(updated)); err != nil { + return err + } + fmt.Printf("Spec written to %s\n", featurePath) + return nil +} + +// replaceOrAppendSpecSection replaces an existing <section class="spec"> or +// inserts a new one before </body>. The Markdown body is wrapped in a +// <pre>...</pre> block to round-trip cleanly through htmlparse. +func replaceOrAppendSpecSection(html, rendered string) string { + sectionHTML := `<section class="spec">` + "\n<pre>\n" + + escapeForPre(rendered) + "</pre>\n</section>" + + const openTag = `<section class="spec">` + const closeTag = `</section>` + + start := strings.Index(html, openTag) + if start == -1 { + bodyClose := strings.LastIndex(html, "</body>") + if bodyClose == -1 { + return html + "\n" + sectionHTML + "\n" + } + return html[:bodyClose] + sectionHTML + "\n" + html[bodyClose:] + } + + afterOpen := html[start:] + end := strings.Index(afterOpen, closeTag) + if end == -1 { + return html[:start] + sectionHTML + } + end += start + len(closeTag) + return html[:start] + sectionHTML + html[end:] +} + +// escapeForPre escapes the three characters that must not appear raw inside +// a <pre> element. Keeps the Markdown verbatim otherwise. +func escapeForPre(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">") + return r.Replace(s) +} + +// unifiedDiff returns a tiny line-level diff between old and new. It is not +// the GNU `diff -u` format but is recognisable enough for the user to act on, +// and avoids a runtime dependency. +func unifiedDiff(oldText, newText string) string { + var sb strings.Builder + sb.WriteString("--- existing spec section\n+++ rendered spec\n") + oldLines := strings.Split(strings.TrimRight(oldText, "\n"), "\n") + newLines := strings.Split(strings.TrimRight(newText, "\n"), "\n") + for _, l := range oldLines { + sb.WriteString("- " + l + "\n") + } + for _, l := range newLines { + sb.WriteString("+ " + l + "\n") + } + return sb.String() +} + // buildSpecJSON wraps raw spec content in a minimal JSON envelope. func buildSpecJSON(featureID, content string) string { m := map[string]string{ @@ -236,3 +491,6 @@ func writeOutput(content, path string) error { fmt.Printf("Spec written to %s\n", path) return nil } + +// thirdPartyNoticesPattern matches the OpenSpec entry in the repo-root notices file. +var thirdPartyNoticesPattern = regexp.MustCompile(`(?s)OpenSpec.*MIT`) diff --git a/cmd/htmlgraph/spec_test.go b/cmd/htmlgraph/spec_test.go new file mode 100644 index 000000000..319711b15 --- /dev/null +++ b/cmd/htmlgraph/spec_test.go @@ -0,0 +1,387 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// --- helpers ----------------------------------------------------------- + +// minimalFeatureHTMLForSpec returns a minimal feature HTML document. If +// existingSpec is non-empty, a <section class="spec"> wrapping it is included. +func minimalFeatureHTMLForSpec(featureID, existingSpec string) string { + spec := "" + if existingSpec != "" { + spec = `<section class="spec">` + "\n" + existingSpec + "\n" + `</section>` + } + return fmt.Sprintf(`<!DOCTYPE html> +<html> +<head><title>%s + +
+%s + +`, featureID, featureID, spec) +} + +// writeFeatureFile creates a feature HTML file and returns its path. +func writeFeatureFile(t *testing.T, dir, featureID, body string) string { + t.Helper() + featuresDir := filepath.Join(dir, "features") + if err := os.MkdirAll(featuresDir, 0o755); err != nil { + t.Fatalf("mkdir features: %v", err) + } + path := filepath.Join(featuresDir, featureID+".html") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write feature: %v", err) + } + return path +} + +// fixtureSpec returns a specTemplate populated with new-format Requirements +// for renderer tests. +func fixtureSpec(featureID, title string) *specTemplate { + return &specTemplate{ + FeatureID: featureID, + Title: title, + Problem: "Old auth flow leaks tokens.", + Files: []string{"NEW: cmd/auth/login.go"}, + Notes: []string{"Risks: token rotation timing"}, + Requirements: []specRequirement{ + { + Name: "Requirement 1", + SHALL: "Users authenticate via OAuth2.", + Scenarios: []specScenario{ + { + Name: "valid token", + When: "the token signature verifies", + Then: "the user is logged in", + }, + }, + }, + }, + } +} + +// --- Unit tests -------------------------------------------------------- + +// TestSpecGenerate_OpenSpecFormat — fixture slice produces output containing +// the OpenSpec structural keywords. +func TestSpecGenerate_OpenSpecFormat(t *testing.T) { + out := renderSpecMarkdown(fixtureSpec("feat-x", "Auth")) + for _, want := range []string{ + "### Requirement:", + "#### Scenario:", + "- **WHEN**", + "- **THEN**", + "## ADDED Requirements", + } { + if !strings.Contains(out, want) { + t.Errorf("renderSpecMarkdown missing %q\n--- output ---\n%s", want, out) + } + } +} + +// TestSpecGenerate_Insert_NewSection — feature without spec section gets one +// created by insertSpecIntoFeature. +func TestSpecGenerate_Insert_NewSection(t *testing.T) { + dir := t.TempDir() + featureID := "feat-insertnew" + path := writeFeatureFile(t, dir, featureID, minimalFeatureHTMLForSpec(featureID, "")) + + if err := insertSpecIntoFeature(path, featureID, fixtureSpec(featureID, "T"), false); err != nil { + t.Fatalf("insert: %v", err) + } + + body, _ := os.ReadFile(path) + if !strings.Contains(string(body), `
`) { + t.Errorf("expected spec section, got:\n%s", body) + } + if !strings.Contains(string(body), "### Requirement:") { + t.Errorf("expected Requirement marker, got:\n%s", body) + } +} + +// TestSpecGenerate_Insert_NonClobber — existing non-empty spec section without +// --force refuses with error containing the remediation message. +func TestSpecGenerate_Insert_NonClobber(t *testing.T) { + dir := t.TempDir() + featureID := "feat-clobberguard" + existing := "manual content the user typed" + path := writeFeatureFile(t, dir, featureID, minimalFeatureHTMLForSpec(featureID, existing)) + + err := insertSpecIntoFeature(path, featureID, fixtureSpec(featureID, "T"), false) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "spec section already has content") { + t.Errorf("error missing remediation message: %v", err) + } + + body, _ := os.ReadFile(path) + if !strings.Contains(string(body), existing) { + t.Error("original content should not have been clobbered") + } +} + +// TestSpecGenerate_Insert_Force — --force replaces existing content; idempotent +// under repeated --force runs. +func TestSpecGenerate_Insert_Force(t *testing.T) { + dir := t.TempDir() + featureID := "feat-forcerepl" + path := writeFeatureFile(t, dir, featureID, minimalFeatureHTMLForSpec(featureID, "old content")) + + if err := insertSpecIntoFeature(path, featureID, fixtureSpec(featureID, "T"), true); err != nil { + t.Fatalf("first force insert: %v", err) + } + first, _ := os.ReadFile(path) + + if err := insertSpecIntoFeature(path, featureID, fixtureSpec(featureID, "T"), true); err != nil { + t.Fatalf("second force insert: %v", err) + } + second, _ := os.ReadFile(path) + + if string(first) != string(second) { + t.Error("--force re-run should be idempotent") + } + if strings.Contains(string(first), "old content") { + t.Error("--force should have replaced the old content") + } +} + +// TestSpecGenerate_Insert_AtomicAndLocked — concurrent inserts on the same +// feature serialise via LockFeatureForWrite and produce a single coherent +// final HTML. +func TestSpecGenerate_Insert_AtomicAndLocked(t *testing.T) { + dir := t.TempDir() + featureID := "feat-concurrent" + path := writeFeatureFile(t, dir, featureID, minimalFeatureHTMLForSpec(featureID, "")) + + const N = 10 + var wg sync.WaitGroup + wg.Add(N) + for i := 0; i < N; i++ { + go func(i int) { + defer wg.Done() + spec := fixtureSpec(featureID, fmt.Sprintf("T-%d", i)) + _ = insertSpecIntoFeature(path, featureID, spec, true) + }(i) + } + wg.Wait() + + body, _ := os.ReadFile(path) + openCount := strings.Count(string(body), `
`) + closeCount := strings.Count(string(body), `
`) + if openCount != 1 { + t.Errorf("expected exactly 1 spec opening tag, got %d\n%s", openCount, body) + } + if closeCount < 1 { + t.Errorf("expected at least 1 closing section tag, got %d", closeCount) + } +} + +// TestSpecGenerate_DecisionsRendered — DecisionsNotes set renders ## Decisions +// between ## Problem and ## ADDED Requirements. +func TestSpecGenerate_DecisionsRendered(t *testing.T) { + spec := fixtureSpec("feat-d", "T") + spec.DecisionsNotes = "### Scope\nA, B, C\n\n### Decisions\nChose option X." + + out := renderSpecMarkdown(spec) + + if !strings.Contains(out, "## Decisions") { + t.Errorf("missing ## Decisions heading\n%s", out) + } + probIdx := strings.Index(out, "## Problem") + decIdx := strings.Index(out, "## Decisions") + reqIdx := strings.Index(out, "## ADDED Requirements") + if !(probIdx >= 0 && decIdx > probIdx && reqIdx > decIdx) { + t.Errorf("section order wrong: Problem=%d Decisions=%d Requirements=%d", probIdx, decIdx, reqIdx) + } + if !strings.Contains(out, "Chose option X.") { + t.Errorf("decisions prose not rendered\n%s", out) + } +} + +// TestSpecGenerate_DecisionsAbsent — empty DecisionsNotes renders no heading. +func TestSpecGenerate_DecisionsAbsent(t *testing.T) { + spec := fixtureSpec("feat-d", "T") + spec.DecisionsNotes = "" + + out := renderSpecMarkdown(spec) + + if strings.Contains(out, "## Decisions") { + t.Errorf("Decisions section should not appear when notes empty\n%s", out) + } +} + +// TestSpecGenerate_FlagsMutex — --insert + --output is rejected. +func TestSpecGenerate_FlagsMutex(t *testing.T) { + err := runSpecGenerate("feat-x", "markdown", "/tmp/out.md", true, false) + if err == nil { + t.Fatal("expected error for --insert + --output, got nil") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("error should mention mutual exclusion: %v", err) + } +} + +// TestSpecGenerate_FlagsInsertJSON — --insert + --format=json is rejected. +func TestSpecGenerate_FlagsInsertJSON(t *testing.T) { + err := runSpecGenerate("feat-x", "json", "", true, false) + if err == nil { + t.Fatal("expected error for --insert + --format=json, got nil") + } +} + +// TestThirdPartyNotices_HasOpenSpec — the notices file at repo root contains +// the OpenSpec entry. +func TestThirdPartyNotices_HasOpenSpec(t *testing.T) { + _, file, _, _ := runtime.Caller(0) + repoRoot := filepath.Join(filepath.Dir(file), "..", "..") + noticesPath := filepath.Join(repoRoot, "THIRD-PARTY-NOTICES.md") + + body, err := os.ReadFile(noticesPath) + if err != nil { + t.Fatalf("read notices: %v", err) + } + if !thirdPartyNoticesPattern.Match(body) { + t.Errorf("notices file missing OpenSpec/MIT entry\n%s", body) + } +} + +// TestSpecSectionSurvivesStatusWrite — the spec section inserted by +// `spec generate --insert` must NOT be deleted when the feature is later +// rewritten by a status transition (col.Start / col.Complete). This is the +// regression test for the HIGH finding in roborev job 236. +func TestSpecSectionSurvivesStatusWrite(t *testing.T) { + tmpDir := t.TempDir() + hgDir := filepath.Join(tmpDir, ".htmlgraph") + for _, sub := range []string{"features", "tracks", "plans", "specs", "spikes", "bugs"} { + _ = os.MkdirAll(filepath.Join(hgDir, sub), 0o755) + } + + // Create a feature, then insert a spec, then mark it in-progress (which + // re-renders the HTML via WriteNodeHTML). The spec section must persist. + projectDirFlag = tmpDir + defer func() { projectDirFlag = "" }() + + if err := testCreate("track", "T", "", "medium", false, false); err != nil { + t.Fatalf("create track: %v", err) + } + trackFiles, _ := filepath.Glob(filepath.Join(hgDir, "tracks", "trk-*.html")) + if len(trackFiles) == 0 { + t.Fatal("no track") + } + trackNode, _ := htmlparseParseFileForTest(t, trackFiles[0]) + + if err := testCreate("feature", "Feature With Spec", trackNode.ID, "medium", false, false); err != nil { + t.Fatalf("create feature: %v", err) + } + featFiles, _ := filepath.Glob(filepath.Join(hgDir, "features", "feat-*.html")) + if len(featFiles) == 0 { + t.Fatal("no feature") + } + featNode, _ := htmlparseParseFileForTest(t, featFiles[0]) + + if err := insertSpecIntoFeature(featFiles[0], featNode.ID, fixtureSpec(featNode.ID, "T"), false); err != nil { + t.Fatalf("insert spec: %v", err) + } + + beforeBody, _ := os.ReadFile(featFiles[0]) + if !strings.Contains(string(beforeBody), `
`) { + t.Fatal("setup: spec section should exist before status transition") + } + + if err := runWiSetStatus("feature", featNode.ID, "in-progress"); err != nil { + t.Fatalf("set in-progress: %v", err) + } + + afterBody, _ := os.ReadFile(featFiles[0]) + if !strings.Contains(string(afterBody), `
`) { + t.Errorf("spec section was lost during status transition; content:\n%s", afterBody) + } + if !strings.Contains(string(afterBody), "### Requirement:") { + t.Errorf("spec body content was lost during status transition") + } +} + +// htmlparseParseFileForTest is a thin wrapper that fatals the test rather +// than returning errors. +func htmlparseParseFileForTest(t *testing.T, path string) (*modelsNode, error) { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + // Pull the article id="..." attribute. + const idMarker = `
finding %d

", i) + _ = writeComplianceSection(path, attrs, body) + }(i) + } + wg.Wait() + + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read result: %v", err) + } + bodyStr := string(body) + specOpens := strings.Count(bodyStr, `
`) + complianceOpens := strings.Count(bodyStr, `
block embedded in the HTML file. +// extractFromSpecSection extracts acceptance criteria from the +//
block. It supports both the legacy numbered +// checkbox format (`1. [ ] ...`) and the OpenSpec Requirement / Scenario +// format (`### Requirement: ` blocks) by routing through the shared +// parseCriteria helper. The `
` wrapper applied by `spec generate
+// --insert` is unwrapped before parsing.
 func extractFromSpecSection(html string) []string {
 	const open = `
` const close = `
` @@ -113,6 +117,24 @@ func extractFromSpecSection(html string) []string { } section := html[start+len(open) : start+end] + // Try the unified parser first (handles both legacy and OpenSpec). + parsed := parseCriteria(unwrapPreBlock(section)) + if len(parsed) > 0 { + out := make([]string, 0, len(parsed)) + for _, c := range parsed { + text := strings.TrimSpace(c.Text) + if text != "" { + out = append(out, text) + } + } + if len(out) > 0 { + return out + } + } + + // Fallback: tolerate the original legacy numbered-checkbox layout in + // case parseCriteria's section-state machine doesn't recognise the + // heading style (e.g. specs that omit `## Acceptance Criteria`). var criteria []string for _, line := range strings.Split(section, "\n") { m := acPattern.FindStringSubmatch(line) diff --git a/cmd/htmlgraph/tdd_test.go b/cmd/htmlgraph/tdd_test.go new file mode 100644 index 000000000..18a045d12 --- /dev/null +++ b/cmd/htmlgraph/tdd_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "strings" + "testing" +) + +// TestTDDExtract_OpenSpecRequirements — `### Requirement:` blocks become +// criteria for the TDD generator. +func TestTDDExtract_OpenSpecRequirements(t *testing.T) { + html := ` +
## ADDED Requirements
+
+### Requirement: Login flow works
+The implementation SHALL ensure: users authenticate via OAuth.
+
+#### Scenario: valid token
+- [x] WHEN the token signature verifies
+- [x] THEN the user is logged in
+
+### Requirement: Logout flow works
+The implementation SHALL ensure: sessions can be terminated.
+
+#### Scenario: clean logout
+- [x] WHEN the user clicks logout
+- [x] THEN the session ends
+
+` + + got := extractFromSpecSection(html) + if len(got) < 2 { + t.Fatalf("expected ≥2 criteria from OpenSpec spec, got %d: %v", len(got), got) + } + joined := strings.Join(got, "|") + if !strings.Contains(joined, "Login") { + t.Errorf("expected Login criterion, got %v", got) + } + if !strings.Contains(joined, "Logout") { + t.Errorf("expected Logout criterion, got %v", got) + } +} + +// TestTDDExtract_LegacyCheckboxes — backward compat with the legacy +// `1. [ ] criterion` format. +func TestTDDExtract_LegacyCheckboxes(t *testing.T) { + html := ` +
+## Acceptance Criteria +1. [ ] First criterion +2. [x] Second criterion +3. [ ] Third criterion +
+` + + got := extractFromSpecSection(html) + if len(got) != 3 { + t.Fatalf("expected 3 criteria from legacy spec, got %d: %v", len(got), got) + } + if got[0] != "First criterion" { + t.Errorf("got[0] = %q, want 'First criterion'", got[0]) + } +} + +// TestTDDExtract_EmptySpec — section exists but has no parseable criteria; +// extractor returns nil so the caller falls through to data-steps. +func TestTDDExtract_EmptySpec(t *testing.T) { + html := ` +
## Problem
+No criteria yet.
+
+` + + got := extractFromSpecSection(html) + if len(got) != 0 { + t.Errorf("expected 0 criteria from empty spec, got %d: %v", len(got), got) + } +} + +// TestTDDExtract_NoSpecSection — no
at all returns +// nil so the caller falls through. +func TestTDDExtract_NoSpecSection(t *testing.T) { + html := `
` + got := extractFromSpecSection(html) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +// TestTDDExtract_HybridSpec — section contains BOTH legacy and OpenSpec +// formats; parseCriteria's section-state machine keeps them separate; the +// extractor returns at least one criterion from each. +func TestTDDExtract_HybridSpec(t *testing.T) { + html := ` +
## Acceptance Criteria
+1. [ ] Legacy criterion text
+
+## ADDED Requirements
+
+### Requirement: Modern criterion text
+The implementation SHALL ensure: it works.
+
+#### Scenario: works
+- [x] WHEN run
+- [x] THEN passes
+
+` + + got := extractFromSpecSection(html) + if len(got) < 2 { + t.Fatalf("expected ≥2 criteria from hybrid spec, got %d: %v", len(got), got) + } + joined := strings.Join(got, "|") + if !strings.Contains(joined, "Legacy criterion") { + t.Errorf("missing legacy criterion: %v", got) + } + if !strings.Contains(joined, "Modern criterion") { + t.Errorf("missing modern criterion: %v", got) + } +} diff --git a/cmd/htmlgraph/workitem.go b/cmd/htmlgraph/workitem.go index eb1c0ce85..71c7afd20 100644 --- a/cmd/htmlgraph/workitem.go +++ b/cmd/htmlgraph/workitem.go @@ -164,8 +164,14 @@ func wiStartCmd(typeName string) *cobra.Command { } } +// wiAllowSpecSkip is set by the `feature complete --allow-spec-skip` flag and +// consumed by the feature-complete spec-enforcement gate below. Package-level +// because wiSetStatusWithAgent has many test callers and we don't want to +// thread a parameter through all of them just for an opt-in override. +var wiAllowSpecSkip bool + func wiCompleteCmd(typeName string) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "complete ", Short: "Mark a " + typeName + " as done", Args: cobra.ExactArgs(1), @@ -173,6 +179,11 @@ func wiCompleteCmd(typeName string) *cobra.Command { return runWiSetStatus(typeName, args[0], "done") }, } + if typeName == "feature" { + cmd.Flags().BoolVar(&wiAllowSpecSkip, "allow-spec-skip", false, + "bypass spec_enforcement.feature_complete gate; intended for emergency overrides only") + } + return cmd } func runWiSetStatus(typeName, id, status string) error { @@ -201,6 +212,16 @@ func wiSetStatusWithAgent(typeName, id, status, sessionID, agentID string) error defer p.Close() col := collectionFor(p, typeName) + + // CRISPI spec-enforcement gate: when completing a feature with the + // gate opted in via config, refuse if the feature HTML has no usable + // spec section. --allow-spec-skip provides an audited bypass. + if typeName == "feature" && status == "done" && !wiAllowSpecSkip { + if err := checkFeatureCompleteSpecGate(dir, id); err != nil { + return err + } + } + var node *models.Node switch status { case "in-progress": @@ -502,3 +523,48 @@ func kindFromPrefix(id string) string { } return "work item" } + +// checkFeatureCompleteSpecGate enforces config.spec_enforcement.feature_complete: +// the feature HTML's
must exist and contain at least one +// usable criterion (either an OpenSpec ### Requirement: with a non-empty SHALL +// line, or a legacy [ ]/[x]/[F] checkbox line under ## Acceptance Criteria). +// +// Returns nil when the gate is disabled, the feature has a non-empty spec, or +// allowSpecSkip is set. Returns a remediation error otherwise. +func checkFeatureCompleteSpecGate(htmlgraphDir, featureID string) error { + enforcement := hooks.ReadSpecEnforcement(filepath.Dir(htmlgraphDir)) + if !enforcement.FeatureComplete { + return nil + } + + featurePath := filepath.Join(htmlgraphDir, "features", featureID+".html") + raw, err := os.ReadFile(featurePath) + if err != nil { + // Feature file unreadable — let the normal Complete path raise the + // canonical error; we do not block on missing files. + return nil + } + specContent := extractSpecSection(string(raw)) + if specContent == "" { + return fmt.Errorf("feature %s has no spec section; run `htmlgraph spec generate %s --insert` first (or invoke /htmlgraph:spec-from-slice on Claude). Override with --allow-spec-skip if intentional.", + featureID, featureID) + } + criteria := parseCriteria(unwrapPreBlock(specContent)) + if len(criteria) == 0 { + return fmt.Errorf("feature %s spec section has 0 criteria; populate Requirements or Acceptance Criteria, or override with --allow-spec-skip", + featureID) + } + return nil +} + +// unwrapPreBlock strips a leading/trailing
...
wrapper plus HTML +// entity escapes that slice 1's `spec --insert` writer applies. Leaves +// non-wrapped content untouched. +func unwrapPreBlock(s string) string { + t := strings.TrimSpace(s) + if strings.HasPrefix(t, "
") && strings.HasSuffix(t, "
") { + t = strings.TrimSuffix(strings.TrimPrefix(t, "
"), "
") + } + r := strings.NewReplacer("<", "<", ">", ">", "&", "&") + return r.Replace(t) +} diff --git a/cmd/htmlgraph/workitem_test.go b/cmd/htmlgraph/workitem_test.go index c913b52d4..5c25082d8 100644 --- a/cmd/htmlgraph/workitem_test.go +++ b/cmd/htmlgraph/workitem_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -1298,3 +1299,134 @@ func TestRunWiSetStatus_SubagentsDoNotStompLegacyColumn(t *testing.T) { t.Errorf("sessions.active_feature_id must be empty when only subagents claim features; got %q", legacy) } } + +// --- Feature-complete spec-enforcement gate (feat-0fd7c8bc) ---------- + +// setupFeatureGateProject creates a project root with a .htmlgraph subdir. +// Returns the htmlgraphDir. +func setupFeatureGateProject(t *testing.T) string { + t.Helper() + projectRoot := t.TempDir() + hgDir := filepath.Join(projectRoot, ".htmlgraph") + for _, sub := range []string{"features", "bugs", "tracks", "plans", "specs", "spikes"} { + if err := os.MkdirAll(filepath.Join(hgDir, sub), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", sub, err) + } + } + return hgDir +} + +// writeFeatureWithSpec writes a minimal feature HTML inside hgDir/features/ +// with optional spec content (raw text inside
). +func writeFeatureWithSpec(t *testing.T, hgDir, featureID, specContent string) { + t.Helper() + body := fmt.Sprintf(`
`, featureID) + if specContent != "" { + body += `
` + specContent + `
` + } + body += "" + path := filepath.Join(hgDir, "features", featureID+".html") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write feature html: %v", err) + } +} + +// writeFeatureCompleteEnforcementConfig writes spec_enforcement.feature_complete=true +// into hgDir/config.json (which lives under the project root). +func writeFeatureCompleteEnforcementConfig(t *testing.T, hgDir string, enabled bool) { + t.Helper() + body := fmt.Sprintf(`{"spec_enforcement":{"feature_complete":%t}}`, enabled) + if err := os.WriteFile(filepath.Join(hgDir, "config.json"), []byte(body), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } +} + +// TestFeatureCompleteGate_Disabled — default config; complete succeeds without +// any spec section. +func TestFeatureCompleteGate_Disabled(t *testing.T) { + wiAllowSpecSkip = false + hgDir := setupFeatureGateProject(t) + writeFeatureWithSpec(t, hgDir, "feat-gatedis", "") + + if err := checkFeatureCompleteSpecGate(hgDir, "feat-gatedis"); err != nil { + t.Errorf("expected gate disabled by default, got error: %v", err) + } +} + +// TestFeatureCompleteGate_EnabledNoSpec — config opted in, no spec section, +// gate refuses. +func TestFeatureCompleteGate_EnabledNoSpec(t *testing.T) { + hgDir := setupFeatureGateProject(t) + writeFeatureCompleteEnforcementConfig(t, hgDir, true) + writeFeatureWithSpec(t, hgDir, "feat-gatenospec", "") + + err := checkFeatureCompleteSpecGate(hgDir, "feat-gatenospec") + if err == nil { + t.Fatal("expected gate refusal, got nil") + } + if !strings.Contains(err.Error(), "no spec section") { + t.Errorf("error should mention missing spec: %v", err) + } + if !strings.Contains(err.Error(), "spec generate") { + t.Errorf("error should point to remediation command: %v", err) + } +} + +// TestFeatureCompleteGate_EnabledEmptySpec — section exists but has no +// requirements/criteria; gate refuses. +func TestFeatureCompleteGate_EnabledEmptySpec(t *testing.T) { + hgDir := setupFeatureGateProject(t) + writeFeatureCompleteEnforcementConfig(t, hgDir, true) + writeFeatureWithSpec(t, hgDir, "feat-gateempty", + `
## Problem
+x
+## Notes
+none
`) + + err := checkFeatureCompleteSpecGate(hgDir, "feat-gateempty") + if err == nil { + t.Fatal("expected gate refusal on empty criteria, got nil") + } + if !strings.Contains(err.Error(), "0 criteria") { + t.Errorf("error should mention 0 criteria: %v", err) + } +} + +// TestFeatureCompleteGate_EnabledWithRequirement — section has a Requirement +// + scenario with checked task line; gate passes. +func TestFeatureCompleteGate_EnabledWithRequirement(t *testing.T) { + hgDir := setupFeatureGateProject(t) + writeFeatureCompleteEnforcementConfig(t, hgDir, true) + + specBody := `
## ADDED Requirements
+
+### Requirement: Login
+The implementation SHALL ensure: users authenticate.
+
+#### Scenario: valid token
+- [x] WHEN the token signature verifies
+- [x] THEN the user is logged in
+
` + writeFeatureWithSpec(t, hgDir, "feat-gateok", specBody) + + if err := checkFeatureCompleteSpecGate(hgDir, "feat-gateok"); err != nil { + t.Errorf("expected gate to pass with requirement present, got: %v", err) + } +} + +// TestFeatureCompleteGate_EnabledWithLegacyCriterion — legacy ## Acceptance +// Criteria with at least one checkbox passes the gate. +func TestFeatureCompleteGate_EnabledWithLegacyCriterion(t *testing.T) { + hgDir := setupFeatureGateProject(t) + writeFeatureCompleteEnforcementConfig(t, hgDir, true) + writeFeatureWithSpec(t, hgDir, "feat-gatelegacy", + `
## Acceptance Criteria
+- [x] First criterion
+- [ ] Second criterion
+
`) + + if err := checkFeatureCompleteSpecGate(hgDir, "feat-gatelegacy"); err != nil { + t.Errorf("expected gate to pass with legacy criterion, got: %v", err) + } +} + diff --git a/internal/hooks/task_completion_gate.go b/internal/hooks/task_completion_gate.go index a7541b69d..c876fcb46 100644 --- a/internal/hooks/task_completion_gate.go +++ b/internal/hooks/task_completion_gate.go @@ -66,9 +66,18 @@ func runTaskCompletionGate(projectDir string) taskCompletionGateResult { } } +// SpecEnforcement holds opt-in spec-presence gate flags. Both default to +// false; existing projects keep their current behavior unchanged until they +// explicitly opt in. +type SpecEnforcement struct { + PromoteSlice bool `json:"promote_slice"` + FeatureComplete bool `json:"feature_complete"` +} + // taskCompletionConfig represents the relevant fields from .htmlgraph/config.json. type taskCompletionConfig struct { - BlockOnQualityFailure bool `json:"block_task_completion_on_quality_failure"` + BlockOnQualityFailure bool `json:"block_task_completion_on_quality_failure"` + SpecEnforcement SpecEnforcement `json:"spec_enforcement"` } // readTaskCompletionConfig reads the opt-in flag from .htmlgraph/config.json. @@ -85,3 +94,19 @@ func readTaskCompletionConfig(projectDir string) bool { } return cfg.BlockOnQualityFailure } + +// ReadSpecEnforcement returns the opt-in spec_enforcement settings from +// .htmlgraph/config.json. Returns the zero value (both gates disabled) when +// the file is missing, unreadable, or the key is absent — preserving +// backward-compatible default-off behavior. +func ReadSpecEnforcement(projectDir string) SpecEnforcement { + data, err := os.ReadFile(filepath.Join(projectDir, ".htmlgraph", "config.json")) + if err != nil { + return SpecEnforcement{} + } + var cfg taskCompletionConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return SpecEnforcement{} + } + return cfg.SpecEnforcement +} diff --git a/internal/planyaml/io.go b/internal/planyaml/io.go index 874356a1a..138b8d9b5 100644 --- a/internal/planyaml/io.go +++ b/internal/planyaml/io.go @@ -3,11 +3,21 @@ package planyaml import ( "fmt" "os" + "path/filepath" + "sync" + "sync/atomic" "time" "gopkg.in/yaml.v3" ) +// planWriteMu serialises in-process Save calls for the same plan path. +// Cross-process safety still requires an advisory file lock at higher layers. +var planWriteMu sync.Map + +// planAtomicSeq makes temp filenames unique across goroutines in the same PID. +var planAtomicSeq atomic.Int64 + // NewPlan creates a PlanYAML with sensible defaults: status "draft", // empty design/slices/questions, nil critique, and CreatedAt set to today. func NewPlan(id, title, description string) *PlanYAML { @@ -45,10 +55,31 @@ func LoadBytes(data []byte) (*PlanYAML, error) { return &plan, nil } -// Save marshals the plan to YAML and writes it to the given path. -// It auto-increments plan.Meta.Version before every write so every +// Save marshals the plan to YAML and writes it to the given path atomically. +// The write goes through a per-path mutex (so concurrent goroutines don't +// race) and then a temp-file + rename (so a crash mid-write can't leave a +// half-written file). Plan.Meta.Version auto-increments every save so every // mutation is tracked as a distinct revision. +// +// In-process locking only — separate `htmlgraph` CLI processes editing the +// same plan still need an advisory file lock at the call site (see +// LockPlanForWrite). func Save(path string, plan *PlanYAML) error { + defer LockPlanForWrite(path)() + return saveLocked(path, plan) +} + +// SaveLocked performs the marshal + atomic write WITHOUT acquiring the +// per-plan mutex. The caller MUST already hold LockPlanForWrite(path) (or +// otherwise guarantee single-writer semantics). Use this when extending the +// load → modify → save window so the lock isn't released between steps. +func SaveLocked(path string, plan *PlanYAML) error { + return saveLocked(path, plan) +} + +// saveLocked performs the marshal + atomic write. The caller MUST hold +// LockPlanForWrite(path) (or otherwise guarantee single-writer semantics). +func saveLocked(path string, plan *PlanYAML) error { plan.Meta.Version++ data, err := yaml.Marshal(plan) @@ -56,9 +87,49 @@ func Save(path string, plan *PlanYAML) error { return fmt.Errorf("marshal plan YAML: %w", err) } - if err := os.WriteFile(path, data, 0o644); err != nil { - return fmt.Errorf("write plan YAML: %w", err) + dir := filepath.Dir(path) + if dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create plan dir: %w", err) + } } + seq := planAtomicSeq.Add(1) + tmp := fmt.Sprintf("%s.tmp.%d.%d", path, os.Getpid(), seq) + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return fmt.Errorf("open temp plan: %w", err) + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + _ = os.Remove(tmp) + return fmt.Errorf("write temp plan: %w", err) + } + if err := f.Sync(); err != nil { + _ = f.Close() + _ = os.Remove(tmp) + return fmt.Errorf("sync temp plan: %w", err) + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("close temp plan: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("rename plan: %w", err) + } return nil } + +// LockPlanForWrite acquires a per-plan in-process mutex so a load → modify → +// save window is atomic against other goroutines doing the same. Callers +// MUST defer the returned release function. +// +// Cross-process safety is NOT provided here; layer an advisory file lock on +// top when multiple `htmlgraph` invocations may edit the same plan. +func LockPlanForWrite(path string) (release func()) { + muVal, _ := planWriteMu.LoadOrStore(path, &sync.Mutex{}) + mu := muVal.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} diff --git a/internal/planyaml/schema.go b/internal/planyaml/schema.go index e6814fff6..521c585a7 100644 --- a/internal/planyaml/schema.go +++ b/internal/planyaml/schema.go @@ -75,6 +75,13 @@ type PlanSlice struct { // V2 slice-local spec fields. Questions []SliceQuestion `yaml:"questions,omitempty"` // slice-local open questions CriticRevisions []CriticRevision `yaml:"critic_revisions,omitempty"` // critic feedback specific to this slice + + // DecisionsNotes is free-text Markdown captured by `htmlgraph plan + // elicit-decisions` (typically Scope/Decisions/Context). Slice 1's + // `htmlgraph spec generate --insert` weaves this prose verbatim into the + // generated spec's `## Decisions` section. Free text — not a typed schema. + // Empty/absent renders no Decisions section. + DecisionsNotes string `yaml:"decisions_notes,omitempty"` } // SliceQuestion is an open question scoped to a single slice. It supports two diff --git a/internal/workitem/htmlwriter.go b/internal/workitem/htmlwriter.go index b6c2c906b..4d5074cb4 100644 --- a/internal/workitem/htmlwriter.go +++ b/internal/workitem/htmlwriter.go @@ -12,17 +12,57 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "time" "github.com/shakestzd/htmlgraph/internal/models" ) -// featureWriteMu serialises concurrent WriteNodeHTML calls for the same feature -// ID in a single process. Keyed by node ID (string) → *sync.Mutex. -// This prevents lost-update races when two goroutines write the same HTML file -// concurrently (e.g. two compliance auto invocations in tests). +// featureWriteMu serialises concurrent writes that touch the same feature HTML +// file in a single process. Keyed by node ID (string) → *sync.Mutex. +// This prevents lost-update races between writers — `WriteNodeHTML`, +// `compliance auto`'s findings writer, and `spec generate --insert`'s spec +// writer all acquire the same per-feature lock via LockFeatureForWrite. var featureWriteMu sync.Map +// LockFeatureForWrite acquires both an in-process mutex AND a cross-process +// advisory file lock so multiple writers cannot race on the same feature +// HTML. The file lock guards a sidecar at `.lock` (created on +// first use, never deleted — flocks survive removal anyway, and an +// always-present sidecar means we never re-create a contested file). +// Callers MUST defer the returned release function. +// +// The acquire-read-modify-write window must be inside the lock; the +// underlying atomic temp+rename keeps single writes safe on its own. This +// closes the lost-update race when `compliance auto`, `spec generate +// --insert`, and `WriteNodeHTML` (status transitions) target the same +// feature concurrently — including from separate `htmlgraph` CLI processes. +// +// On flock acquisition errors, falls back to in-process-only locking and +// logs nothing; this preserves single-process behavior for tests on file +// systems that don't support flock. +func LockFeatureForWrite(featurePath string) (release func()) { + muVal, _ := featureWriteMu.LoadOrStore(featurePath, &sync.Mutex{}) + mu := muVal.(*sync.Mutex) + mu.Lock() + + lockPath := featurePath + ".lock" + f, ferr := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE, 0o644) + if ferr != nil { + // In-process lock only — degrade gracefully on filesystem errors. + return mu.Unlock + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + _ = f.Close() + return mu.Unlock + } + return func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + mu.Unlock() + } +} + // atomicWriteCounter provides a unique sequence number per atomic write call, // used to make temp filenames unique even when called from multiple goroutines // in the same process (which all share the same PID). @@ -88,30 +128,86 @@ var nodeTmpl = template.Must( // serialises concurrent in-process writes for the same node ID to prevent // lost-update races. // +// Supplemental sections (`
` and +// `
`) are preserved across writes: +// callers like `compliance auto` and `spec generate --insert` append these +// outside the templated render, and we don't want a status transition (which +// re-renders the whole file from the Node) to silently delete them. +// // Returns the absolute path of the written file. func WriteNodeHTML(dir string, node *models.Node) (string, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return "", fmt.Errorf("create dir %s: %w", dir, err) } - // Acquire per-node mutex to serialize concurrent writes for the same node. - muVal, _ := featureWriteMu.LoadOrStore(node.ID, &sync.Mutex{}) - mu := muVal.(*sync.Mutex) - mu.Lock() - defer mu.Unlock() - path := filepath.Join(dir, node.ID+".html") + // Acquire per-feature lock (in-process + cross-process) to serialize + // concurrent writes targeting the same HTML file. + defer LockFeatureForWrite(path)() html, err := renderNodeHTML(node) if err != nil { return "", fmt.Errorf("render %s: %w", node.ID, err) } + // Extract supplemental sections from the existing file (if any) and + // splice them back into the freshly-rendered template output. Skip + // silently when the file doesn't exist or has no supplemental sections. + if existing, rerr := os.ReadFile(path); rerr == nil { + if merged, ok := preserveSupplementalSections(string(existing), html); ok { + html = merged + } + } + if err := atomicWriteFile(path, []byte(html), 0o644); err != nil { return "", fmt.Errorf("write %s: %w", path, err) } return path, nil } +// preserveSupplementalSections finds known supplemental sections in the prior +// file content (sections that the node template does not emit — currently +// `
` and `
`) and +// re-inserts them into the freshly-rendered template just before ``. +// Returns the merged HTML and ok=true when at least one section was carried +// over; otherwise returns ("", false) so the caller writes the raw render. +func preserveSupplementalSections(existing, rendered string) (string, bool) { + classes := []string{"spec", "compliance-findings"} + var preserved []string + for _, class := range classes { + if section, ok := extractSection(existing, class); ok { + preserved = append(preserved, section) + } + } + if len(preserved) == 0 { + return "", false + } + insert := "\n" + strings.Join(preserved, "\n") + "\n" + bodyClose := strings.LastIndex(rendered, "") + if bodyClose == -1 { + return rendered + insert, true + } + return rendered[:bodyClose] + insert + rendered[bodyClose:], true +} + +// extractSection returns the first `
` +// block in html along with ok=true. Match is by leading attribute prefix so +// extra attributes (e.g. data-* on the compliance-findings section) are +// retained verbatim. +func extractSection(html, class string) (string, bool) { + openPrefix := `
` + start := strings.Index(html, openPrefix) + if start == -1 { + return "", false + } + end := strings.Index(html[start:], closeTag) + if end == -1 { + return "", false + } + end += start + len(closeTag) + return html[start:end], true +} + // atomicWriteFile writes data to path atomically: it writes to a temp file in // the same directory, calls Sync to flush to storage, then renames the temp // file over the target. POSIX rename is atomic within the same filesystem. diff --git a/packages/codex-marketplace/.agents/plugins/htmlgraph/skills/spec-from-slice/SKILL.md b/packages/codex-marketplace/.agents/plugins/htmlgraph/skills/spec-from-slice/SKILL.md new file mode 100644 index 000000000..82159df30 --- /dev/null +++ b/packages/codex-marketplace/.agents/plugins/htmlgraph/skills/spec-from-slice/SKILL.md @@ -0,0 +1,97 @@ +--- +name: htmlgraph:spec-from-slice +description: Elicit Scope/Decisions/Context for a plan slice and generate its OpenSpec-formatted feature spec. Use after a slice is approved but before promote-slice, or any time a slice needs decisions captured. Wraps the cross-harness `htmlgraph plan elicit-decisions` CLI plus `htmlgraph spec generate --insert`. +user_invocable: true +--- + +# Spec from Slice — interview + generate + +Use this skill to capture Scope, Decisions, and Context for one slice of an +active plan, then materialise the resulting feature's OpenSpec-formatted spec +into its HTML — in a single guided pass. + +This skill is a Claude-only convenience layer. The canonical interface is the +cross-harness CLI command `htmlgraph plan elicit-decisions`, which works on +Codex CLI and Gemini CLI without any of the steps below. + +## When to invoke + +- Plan slice has just been approved and you are about to call + `htmlgraph plan promote-slice`. +- Slice has been promoted and the resulting feature has no + `
` yet, or the section is empty. +- Decisions changed and you want to re-elicit and re-generate the spec. + +## Inputs you need + +- `` — the active plan that owns the slice. +- `` — the integer slice number within that plan. + +If the slice already has `feature_id` set, the skill will reuse it for the +generate step. Otherwise it stops after writing the decisions and points the +user at `htmlgraph plan promote-slice` first. + +## Procedure + +1. **Read the slice card.** Run `htmlgraph plan show ` (or read + `.htmlgraph/plans/.yaml` directly) to find the slice with the given + `num`. Capture `title`, `what`, `why`, `done_when`, `tests`, and current + `decisions_notes` (if any). + +2. **Re-elicitation guard.** If `decisions_notes` is non-empty, ask the user + via `AskUserQuestion`: + - **Re-elicit** — overwrite previous notes with new answers. + - **Edit in place** — print the existing notes for the user to edit, then + re-write verbatim. + - **Skip** — leave notes unchanged; jump to step 5 (generate spec). + +3. **Three-question interview.** Use `AskUserQuestion` with one grouped block + containing three questions: + - **Scope** — what is and is not in this slice? List the boundaries. + - **Decisions** — what design choices were made and why? Reference any + plan questions answered. + - **Context** — what else does the implementer need to know? Pre-existing + constraints, related work, file ownership boundaries. + + The user may answer each in free-form prose. Empty answers are allowed — + the field is free text. + +4. **Write decisions.** Run the cross-harness CLI: + ```bash + htmlgraph plan elicit-decisions \ + --scope "" \ + --decisions "" \ + --context "" + ``` + This combines the three answers into a single Markdown blob and writes it + to `slice.decisions_notes` atomically. + +5. **Generate the spec.** If the slice has a `feature_id` (i.e., it has already + been promoted), run: + ```bash + htmlgraph spec generate --insert + ``` + The spec section is written non-destructively: if the feature already has + non-empty spec content, the command prints a diff and refuses. Pass + `--force` only when the user explicitly accepts the overwrite. + + If the slice has no `feature_id` yet, tell the user to run + `htmlgraph plan promote-slice ` first, then re-invoke + this skill. + +6. **Confirm.** Show a 2-3 line summary: which fields changed, where the spec + was written, what to do next (typically `htmlgraph plan promote-slice` if + not yet promoted, or `htmlgraph compliance ` to verify the + spec parses). + +## Notes + +- The CLI command `htmlgraph plan elicit-decisions` is the source of truth. + This skill exists to make the interview ergonomic on Claude Code via + `AskUserQuestion`. Other harnesses (Codex CLI, Gemini CLI) call the CLI + directly — they do not need this skill. +- `decisions_notes` is free text, not a typed schema. The renderer in + `htmlgraph spec generate` weaves it verbatim into the generated spec's + `## Decisions` section. +- The `--allow-spec-skip` flag on `promote-slice` and `feature complete` is + for emergency overrides only; this skill is the regular path. diff --git a/packages/codex-marketplace/.agents/plugins/htmlgraph/templates/spec-template.md b/packages/codex-marketplace/.agents/plugins/htmlgraph/templates/spec-template.md new file mode 100644 index 000000000..7a5c7d22e --- /dev/null +++ b/packages/codex-marketplace/.agents/plugins/htmlgraph/templates/spec-template.md @@ -0,0 +1,10 @@ + + +## ADDED Requirements + +### Requirement: + + +#### Scenario: +- **WHEN** +- **THEN** diff --git a/packages/gemini-extension/skills/spec-from-slice/SKILL.md b/packages/gemini-extension/skills/spec-from-slice/SKILL.md new file mode 100644 index 000000000..82159df30 --- /dev/null +++ b/packages/gemini-extension/skills/spec-from-slice/SKILL.md @@ -0,0 +1,97 @@ +--- +name: htmlgraph:spec-from-slice +description: Elicit Scope/Decisions/Context for a plan slice and generate its OpenSpec-formatted feature spec. Use after a slice is approved but before promote-slice, or any time a slice needs decisions captured. Wraps the cross-harness `htmlgraph plan elicit-decisions` CLI plus `htmlgraph spec generate --insert`. +user_invocable: true +--- + +# Spec from Slice — interview + generate + +Use this skill to capture Scope, Decisions, and Context for one slice of an +active plan, then materialise the resulting feature's OpenSpec-formatted spec +into its HTML — in a single guided pass. + +This skill is a Claude-only convenience layer. The canonical interface is the +cross-harness CLI command `htmlgraph plan elicit-decisions`, which works on +Codex CLI and Gemini CLI without any of the steps below. + +## When to invoke + +- Plan slice has just been approved and you are about to call + `htmlgraph plan promote-slice`. +- Slice has been promoted and the resulting feature has no + `
` yet, or the section is empty. +- Decisions changed and you want to re-elicit and re-generate the spec. + +## Inputs you need + +- `` — the active plan that owns the slice. +- `` — the integer slice number within that plan. + +If the slice already has `feature_id` set, the skill will reuse it for the +generate step. Otherwise it stops after writing the decisions and points the +user at `htmlgraph plan promote-slice` first. + +## Procedure + +1. **Read the slice card.** Run `htmlgraph plan show ` (or read + `.htmlgraph/plans/.yaml` directly) to find the slice with the given + `num`. Capture `title`, `what`, `why`, `done_when`, `tests`, and current + `decisions_notes` (if any). + +2. **Re-elicitation guard.** If `decisions_notes` is non-empty, ask the user + via `AskUserQuestion`: + - **Re-elicit** — overwrite previous notes with new answers. + - **Edit in place** — print the existing notes for the user to edit, then + re-write verbatim. + - **Skip** — leave notes unchanged; jump to step 5 (generate spec). + +3. **Three-question interview.** Use `AskUserQuestion` with one grouped block + containing three questions: + - **Scope** — what is and is not in this slice? List the boundaries. + - **Decisions** — what design choices were made and why? Reference any + plan questions answered. + - **Context** — what else does the implementer need to know? Pre-existing + constraints, related work, file ownership boundaries. + + The user may answer each in free-form prose. Empty answers are allowed — + the field is free text. + +4. **Write decisions.** Run the cross-harness CLI: + ```bash + htmlgraph plan elicit-decisions \ + --scope "" \ + --decisions "" \ + --context "" + ``` + This combines the three answers into a single Markdown blob and writes it + to `slice.decisions_notes` atomically. + +5. **Generate the spec.** If the slice has a `feature_id` (i.e., it has already + been promoted), run: + ```bash + htmlgraph spec generate --insert + ``` + The spec section is written non-destructively: if the feature already has + non-empty spec content, the command prints a diff and refuses. Pass + `--force` only when the user explicitly accepts the overwrite. + + If the slice has no `feature_id` yet, tell the user to run + `htmlgraph plan promote-slice ` first, then re-invoke + this skill. + +6. **Confirm.** Show a 2-3 line summary: which fields changed, where the spec + was written, what to do next (typically `htmlgraph plan promote-slice` if + not yet promoted, or `htmlgraph compliance ` to verify the + spec parses). + +## Notes + +- The CLI command `htmlgraph plan elicit-decisions` is the source of truth. + This skill exists to make the interview ergonomic on Claude Code via + `AskUserQuestion`. Other harnesses (Codex CLI, Gemini CLI) call the CLI + directly — they do not need this skill. +- `decisions_notes` is free text, not a typed schema. The renderer in + `htmlgraph spec generate` weaves it verbatim into the generated spec's + `## Decisions` section. +- The `--allow-spec-skip` flag on `promote-slice` and `feature complete` is + for emergency overrides only; this skill is the regular path. diff --git a/packages/gemini-extension/templates/spec-template.md b/packages/gemini-extension/templates/spec-template.md new file mode 100644 index 000000000..7a5c7d22e --- /dev/null +++ b/packages/gemini-extension/templates/spec-template.md @@ -0,0 +1,10 @@ + + +## ADDED Requirements + +### Requirement: + + +#### Scenario: +- **WHEN** +- **THEN** diff --git a/plugin/skills/spec-from-slice/SKILL.md b/plugin/skills/spec-from-slice/SKILL.md new file mode 100644 index 000000000..82159df30 --- /dev/null +++ b/plugin/skills/spec-from-slice/SKILL.md @@ -0,0 +1,97 @@ +--- +name: htmlgraph:spec-from-slice +description: Elicit Scope/Decisions/Context for a plan slice and generate its OpenSpec-formatted feature spec. Use after a slice is approved but before promote-slice, or any time a slice needs decisions captured. Wraps the cross-harness `htmlgraph plan elicit-decisions` CLI plus `htmlgraph spec generate --insert`. +user_invocable: true +--- + +# Spec from Slice — interview + generate + +Use this skill to capture Scope, Decisions, and Context for one slice of an +active plan, then materialise the resulting feature's OpenSpec-formatted spec +into its HTML — in a single guided pass. + +This skill is a Claude-only convenience layer. The canonical interface is the +cross-harness CLI command `htmlgraph plan elicit-decisions`, which works on +Codex CLI and Gemini CLI without any of the steps below. + +## When to invoke + +- Plan slice has just been approved and you are about to call + `htmlgraph plan promote-slice`. +- Slice has been promoted and the resulting feature has no + `
` yet, or the section is empty. +- Decisions changed and you want to re-elicit and re-generate the spec. + +## Inputs you need + +- `` — the active plan that owns the slice. +- `` — the integer slice number within that plan. + +If the slice already has `feature_id` set, the skill will reuse it for the +generate step. Otherwise it stops after writing the decisions and points the +user at `htmlgraph plan promote-slice` first. + +## Procedure + +1. **Read the slice card.** Run `htmlgraph plan show ` (or read + `.htmlgraph/plans/.yaml` directly) to find the slice with the given + `num`. Capture `title`, `what`, `why`, `done_when`, `tests`, and current + `decisions_notes` (if any). + +2. **Re-elicitation guard.** If `decisions_notes` is non-empty, ask the user + via `AskUserQuestion`: + - **Re-elicit** — overwrite previous notes with new answers. + - **Edit in place** — print the existing notes for the user to edit, then + re-write verbatim. + - **Skip** — leave notes unchanged; jump to step 5 (generate spec). + +3. **Three-question interview.** Use `AskUserQuestion` with one grouped block + containing three questions: + - **Scope** — what is and is not in this slice? List the boundaries. + - **Decisions** — what design choices were made and why? Reference any + plan questions answered. + - **Context** — what else does the implementer need to know? Pre-existing + constraints, related work, file ownership boundaries. + + The user may answer each in free-form prose. Empty answers are allowed — + the field is free text. + +4. **Write decisions.** Run the cross-harness CLI: + ```bash + htmlgraph plan elicit-decisions \ + --scope "" \ + --decisions "" \ + --context "" + ``` + This combines the three answers into a single Markdown blob and writes it + to `slice.decisions_notes` atomically. + +5. **Generate the spec.** If the slice has a `feature_id` (i.e., it has already + been promoted), run: + ```bash + htmlgraph spec generate --insert + ``` + The spec section is written non-destructively: if the feature already has + non-empty spec content, the command prints a diff and refuses. Pass + `--force` only when the user explicitly accepts the overwrite. + + If the slice has no `feature_id` yet, tell the user to run + `htmlgraph plan promote-slice ` first, then re-invoke + this skill. + +6. **Confirm.** Show a 2-3 line summary: which fields changed, where the spec + was written, what to do next (typically `htmlgraph plan promote-slice` if + not yet promoted, or `htmlgraph compliance ` to verify the + spec parses). + +## Notes + +- The CLI command `htmlgraph plan elicit-decisions` is the source of truth. + This skill exists to make the interview ergonomic on Claude Code via + `AskUserQuestion`. Other harnesses (Codex CLI, Gemini CLI) call the CLI + directly — they do not need this skill. +- `decisions_notes` is free text, not a typed schema. The renderer in + `htmlgraph spec generate` weaves it verbatim into the generated spec's + `## Decisions` section. +- The `--allow-spec-skip` flag on `promote-slice` and `feature complete` is + for emergency overrides only; this skill is the regular path. diff --git a/plugin/templates/spec-template.md b/plugin/templates/spec-template.md new file mode 100644 index 000000000..7a5c7d22e --- /dev/null +++ b/plugin/templates/spec-template.md @@ -0,0 +1,10 @@ + + +## ADDED Requirements + +### Requirement: + + +#### Scenario: +- **WHEN** +- **THEN**