cclaw-cli 6.5.0 → 6.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/artifact-linter/brainstorm.js +2 -1
  2. package/dist/artifact-linter/design.js +2 -1
  3. package/dist/artifact-linter/findings-dedup.d.ts +56 -0
  4. package/dist/artifact-linter/findings-dedup.js +232 -0
  5. package/dist/artifact-linter/plan.js +4 -2
  6. package/dist/artifact-linter/review.js +2 -1
  7. package/dist/artifact-linter/scope.js +2 -1
  8. package/dist/artifact-linter/shared.d.ts +103 -0
  9. package/dist/artifact-linter/shared.js +177 -0
  10. package/dist/artifact-linter/tdd.js +2 -1
  11. package/dist/artifact-linter.d.ts +1 -1
  12. package/dist/artifact-linter.js +45 -3
  13. package/dist/content/examples.d.ts +32 -0
  14. package/dist/content/examples.js +74 -0
  15. package/dist/content/hooks.js +36 -1
  16. package/dist/content/node-hooks.js +43 -0
  17. package/dist/content/skills-elicitation.js +3 -6
  18. package/dist/content/skills.d.ts +10 -0
  19. package/dist/content/skills.js +44 -2
  20. package/dist/content/stages/brainstorm.js +7 -5
  21. package/dist/content/stages/design.js +3 -1
  22. package/dist/content/stages/plan.js +3 -1
  23. package/dist/content/stages/review.js +3 -1
  24. package/dist/content/stages/scope.js +5 -3
  25. package/dist/content/stages/ship.js +2 -1
  26. package/dist/content/stages/spec.js +3 -1
  27. package/dist/content/stages/tdd.js +3 -1
  28. package/dist/content/templates.d.ts +9 -0
  29. package/dist/content/templates.js +45 -2
  30. package/dist/delegation.d.ts +9 -0
  31. package/dist/delegation.js +3 -0
  32. package/dist/internal/advance-stage/advance.js +23 -1
  33. package/dist/internal/advance-stage/parsers.d.ts +8 -0
  34. package/dist/internal/advance-stage/parsers.js +7 -0
  35. package/dist/internal/advance-stage/proactive-delegation-trace.d.ts +3 -0
  36. package/dist/internal/advance-stage/proactive-delegation-trace.js +8 -1
  37. package/dist/internal/advance-stage/rewind.js +2 -2
  38. package/dist/internal/advance-stage/start-flow.js +4 -1
  39. package/dist/internal/advance-stage.js +32 -2
  40. package/dist/internal/flow-state-repair.d.ts +13 -0
  41. package/dist/internal/flow-state-repair.js +65 -0
  42. package/dist/internal/waiver-grant.d.ts +62 -0
  43. package/dist/internal/waiver-grant.js +294 -0
  44. package/dist/run-persistence.d.ts +70 -0
  45. package/dist/run-persistence.js +215 -3
  46. package/dist/runs.d.ts +1 -1
  47. package/dist/runs.js +1 -1
  48. package/dist/runtime/run-hook.mjs +43 -0
  49. package/package.json +1 -1
@@ -384,6 +384,41 @@ export function duplicateH2Headings(markdown) {
384
384
  .filter(([, count]) => count > 1)
385
385
  .map(([key]) => displayHeading.get(key) ?? key);
386
386
  }
387
+ /**
388
+ * Return the author-authored prose of an artifact, stripping linter meta
389
+ * regions so free-text scans (placeholder tokens, scope-reduction phrases,
390
+ * investigation trigger words) don't self-cannibalize by matching the
391
+ * linter's own templated meta-phrases.
392
+ *
393
+ * Stripping rules (in order):
394
+ * 1. `<!-- linter-meta --> ... <!-- /linter-meta -->` paired blocks.
395
+ * Both markers must appear on their own line; unterminated openings
396
+ * are left as-is so a malformed artifact cannot hide arbitrary
397
+ * content by omitting the closing marker.
398
+ * 2. Every other HTML comment (`<!-- ... -->`, possibly multi-line).
399
+ * 3. Fenced code blocks that are tagged `linter-rule` (e.g.
400
+ * ```` ```linter-rule ````). Plain fenced code blocks are preserved
401
+ * because many stages quote code samples that the linter should
402
+ * still see.
403
+ *
404
+ * The function guarantees the returned string is a strict subset of the
405
+ * original: no characters are synthesized, and line offsets are
406
+ * preserved for any surviving line (blank lines stand in for stripped
407
+ * regions). This keeps regex-based linter checks stable when authors
408
+ * add or remove linter-meta blocks between runs.
409
+ */
410
+ export function extractAuthoredBody(rawArtifact) {
411
+ if (typeof rawArtifact !== "string" || rawArtifact.length === 0) {
412
+ return "";
413
+ }
414
+ const linterMetaBlock = /^[ \t]*<!--\s*linter-meta\s*-->[\s\S]*?^[ \t]*<!--\s*\/linter-meta\s*-->[ \t]*$/gmu;
415
+ let body = rawArtifact.replace(linterMetaBlock, (match) => match.replace(/[^\n]/gu, ""));
416
+ const htmlComment = /<!--[\s\S]*?-->/gu;
417
+ body = body.replace(htmlComment, (match) => match.replace(/[^\n]/gu, ""));
418
+ const linterRuleFence = /^([ \t]*)(`{3,}|~{3,})\s*linter-rule\b[^\n]*\n[\s\S]*?\n\1\2[ \t]*$/gmu;
419
+ body = body.replace(linterRuleFence, (match) => match.replace(/[^\n]/gu, ""));
420
+ return body;
421
+ }
387
422
  export function headingPresent(sections, section) {
388
423
  const want = normalizeHeadingTitle(section).toLowerCase();
389
424
  for (const h of sections.keys()) {
@@ -1715,6 +1750,148 @@ export function parseLearningsSection(sectionBody) {
1715
1750
  details: `Parsed ${entries.length} learning bullet(s) as knowledge-compatible JSON entries.`
1716
1751
  };
1717
1752
  }
1753
+ /**
1754
+ * Round 5 (v6.6.0) — file-path / reference detector for the
1755
+ * `investigation_path_first_missing` advisory rule.
1756
+ *
1757
+ * The detector is intentionally permissive: it only needs to recognize
1758
+ * "the author wrote down a path or ref" — the linter does NOT validate
1759
+ * the path resolves on disk. Patterns matched (any one is enough):
1760
+ * - TS/JS/MD/JSON/YAML path with extension
1761
+ * (`src/foo/bar.ts`, `tests/spec.test.ts`, `docs/quality-gates.md`).
1762
+ * - Slash-bearing path under a known repo root prefix
1763
+ * (`src/...`, `tests/...`, `docs/...`, `scripts/...`,
1764
+ * `.cclaw/...`, `.cursor/...`, `node_modules/...`,
1765
+ * `examples/...`, `e2e/...`).
1766
+ * - GitHub-style ref (`owner/repo#123`, `org/repo@sha`,
1767
+ * `path:line`, `path:line-line`).
1768
+ * - Explicit `path:` / `paths:` / `ref:` / `refs:` marker.
1769
+ * - Stable cclaw IDs (`R1`, `D-12`, `AC-3`, `T-4`, `S-2`, `DD-5`,
1770
+ * `ADR-1`, `R-1`, `F-1`, `CR-1`, `I-1`, `QS-1`).
1771
+ * - Backticked path-like token containing a slash.
1772
+ *
1773
+ * Exposed for unit tests (`tests/unit/investigation-trace-evaluator.test.ts`).
1774
+ */
1775
+ export const INVESTIGATION_TRACE_PATH_PATTERNS = [
1776
+ /(?:^|[\s`(\[])(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|mdx|json|yaml|yml|toml|sh|py|rs|go|java|kt|swift|rb|css|scss|html)\b/iu,
1777
+ /(?:^|[\s`(\[])(?:src|tests?|docs?|scripts?|e2e|examples?|packages?|apps?|cmd|internal|pkg|lib|app|server|client|backend|frontend|\.cclaw|\.cursor|\.github|node_modules)\/[A-Za-z0-9_./-]+/iu,
1778
+ /\b[A-Za-z0-9_./-]+(?:\.[A-Za-z0-9]+)?:\d+(?:[-:]\d+)?\b/u,
1779
+ /\b[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#\d+|@[0-9a-f]{6,40})\b/iu,
1780
+ /(?:^|\s)(?:paths?|refs?|file|files|cite|citation)\s*:\s*\S/iu,
1781
+ /\b(?:R|D|AC|T|S|DD|ADR|F|CR|I|QS)-?\d+\b/u,
1782
+ /`[^`]*\/[^`]+`/u
1783
+ ];
1784
+ const INVESTIGATION_TRACE_PLACEHOLDER_PATTERN = /^(?:none|none\.|n\/a|tbd|todo|fixme|placeholder|optional|fill[\s-]?in)\b/u;
1785
+ const INVESTIGATION_TRACE_ID_ONLY_CELL = /^[A-Z]{1,4}-?\d+$/u;
1786
+ function isInvestigationTracePlaceholderCell(cell) {
1787
+ const stripped = cell.replace(/[`*_>#]/gu, "").trim();
1788
+ if (stripped.length === 0)
1789
+ return true;
1790
+ if (INVESTIGATION_TRACE_PLACEHOLDER_PATTERN.test(stripped.toLowerCase()))
1791
+ return true;
1792
+ return false;
1793
+ }
1794
+ function isInvestigationTracePlaceholderProseLine(line) {
1795
+ const stripped = line.replace(/[`*_>#-]/gu, "").trim();
1796
+ if (stripped.length === 0)
1797
+ return true;
1798
+ const lower = stripped.toLowerCase();
1799
+ if (INVESTIGATION_TRACE_PLACEHOLDER_PATTERN.test(lower))
1800
+ return true;
1801
+ if (/^\(\s*(?:none|n\/a|tbd|todo|fixme|placeholder|optional|fill[\s-]?in)\b/u.test(lower)) {
1802
+ return true;
1803
+ }
1804
+ return false;
1805
+ }
1806
+ /**
1807
+ * Internal core that does NOT depend on `StageLintContext`. Returned
1808
+ * shape is consumed by `evaluateInvestigationTrace` (which pushes a
1809
+ * finding into the context) and by unit tests that exercise the
1810
+ * detector directly.
1811
+ *
1812
+ * Returns `null` for sections that are missing, empty, or contain only
1813
+ * template scaffolding (table headers, separators, placeholder rows
1814
+ * with empty cells, lone `- None.` lines). Callers treat `null` as
1815
+ * silent — no finding is emitted.
1816
+ */
1817
+ export function checkInvestigationTrace(sectionBody) {
1818
+ if (sectionBody === null)
1819
+ return null;
1820
+ const lines = sectionBody.split(/\r?\n/u);
1821
+ const candidates = [];
1822
+ for (let index = 0; index < lines.length; index += 1) {
1823
+ const raw = lines[index] ?? "";
1824
+ const trimmed = raw.trim();
1825
+ if (trimmed.length === 0)
1826
+ continue;
1827
+ if (trimmed.startsWith("<!--"))
1828
+ continue;
1829
+ const isTableLine = /^\|.*\|$/u.test(trimmed);
1830
+ if (isTableLine) {
1831
+ if (/^\|[-:| ]+\|$/u.test(trimmed))
1832
+ continue; // separator row
1833
+ const next = (lines[index + 1] ?? "").trim();
1834
+ if (/^\|[-:| ]+\|$/u.test(next))
1835
+ continue; // header row (followed by separator)
1836
+ const cells = trimmed
1837
+ .split("|")
1838
+ .slice(1, -1)
1839
+ .map((cell) => cell.trim());
1840
+ const substantive = cells.filter((cell) => !isInvestigationTracePlaceholderCell(cell));
1841
+ if (substantive.length === 0)
1842
+ continue;
1843
+ if (substantive.length === 1 && INVESTIGATION_TRACE_ID_ONLY_CELL.test(substantive[0])) {
1844
+ continue;
1845
+ }
1846
+ candidates.push(substantive.join(" "));
1847
+ continue;
1848
+ }
1849
+ if (isInvestigationTracePlaceholderProseLine(trimmed))
1850
+ continue;
1851
+ candidates.push(trimmed);
1852
+ }
1853
+ if (candidates.length === 0)
1854
+ return null;
1855
+ const sample = candidates.slice(0, Math.min(5, candidates.length));
1856
+ const detectorMatched = sample.some((line) => INVESTIGATION_TRACE_PATH_PATTERNS.some((pattern) => pattern.test(line)));
1857
+ if (detectorMatched) {
1858
+ return {
1859
+ ok: true,
1860
+ details: "Investigation trace cites file paths or refs in the first non-empty row(s)."
1861
+ };
1862
+ }
1863
+ return {
1864
+ ok: false,
1865
+ details: "Investigation trace has prose-only content in its first row(s). Pass paths and refs, not pasted file contents (e.g. `src/foo/bar.ts:42`, `D-12`, `AC-3`)."
1866
+ };
1867
+ }
1868
+ /**
1869
+ * Round 5 (v6.6.0) — advisory rule wired into the brainstorm / scope /
1870
+ * design / tdd / plan / review linters.
1871
+ *
1872
+ * Behavior contract:
1873
+ * - Section missing or empty / placeholder-only: silent (no finding).
1874
+ * - Section has substantive content with a recognizable file path /
1875
+ * ref / explicit `path:`-style marker in the first non-empty rows:
1876
+ * advisory pass (no finding).
1877
+ * - Section has substantive content but no path/ref signal: advisory
1878
+ * FAIL finding with ruleId `investigation_path_first_missing`.
1879
+ *
1880
+ * The rule is `required: false` so it never blocks `stage-complete`.
1881
+ */
1882
+ export function evaluateInvestigationTrace(ctx, sectionName) {
1883
+ const body = sectionBodyByName(ctx.sections, sectionName);
1884
+ const result = checkInvestigationTrace(body);
1885
+ if (result === null)
1886
+ return;
1887
+ ctx.findings.push({
1888
+ section: "investigation_path_first_missing",
1889
+ required: false,
1890
+ rule: `[P3] investigation_path_first_missing — \`## ${sectionName}\` should cite paths and refs in the first non-empty row(s); pass paths and refs, not content.`,
1891
+ found: result.ok,
1892
+ details: result.details
1893
+ });
1894
+ }
1718
1895
  export function lineContainsVagueAdjective(text) {
1719
1896
  const lower = text.toLowerCase();
1720
1897
  for (const adjective of VAGUE_AC_ADJECTIVES) {
@@ -1,9 +1,10 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { readDelegationLedger } from "../delegation.js";
4
- import { sectionBodyByName } from "./shared.js";
4
+ import { evaluateInvestigationTrace, sectionBodyByName } from "./shared.js";
5
5
  export async function lintTddStage(ctx) {
6
6
  const { projectRoot, track, raw, absFile, sections, findings, parsedFrontmatter, brainstormShortCircuitBody, brainstormShortCircuitActivated, staleDiagramAuditEnabled, isTrivialOverride } = ctx;
7
+ evaluateInvestigationTrace(ctx, "Watched-RED Proof");
7
8
  // Universal Layer 2.6 structural checks (superpowers TDD + evanflow vertical slices).
8
9
  const ironLawBody = sectionBodyByName(sections, "Iron Law Acknowledgement");
9
10
  if (ironLawBody === null) {
@@ -1,7 +1,7 @@
1
1
  import type { FlowStage, FlowTrack } from "./types.js";
2
2
  import { type LintResult } from "./artifact-linter/shared.js";
3
3
  export { validateReviewArmy, checkReviewVerdictConsistency, checkReviewSecurityNoChangeAttestation, checkReviewTddNoCrossArtifactDuplication, type ReviewVerdictConsistencyResult, type ReviewSecurityNoChangeAttestationResult, type ReviewTddDuplicationConflict, type ReviewTddDuplicationResult } from "./artifact-linter/review-army.js";
4
- export { type LintFinding, type LintResult, type LearningEntryType, type LearningConfidence, type LearningSeverity, type LearningSource, type LearningSeedEntry, type LearningsParseResult, formatLearningsErrorsBullets, learningsParseFailureHumanSummary, extractMarkdownSectionBody, parseLearningsSection } from "./artifact-linter/shared.js";
4
+ export { type LintFinding, type LintResult, type LearningEntryType, type LearningConfidence, type LearningSeverity, type LearningSource, type LearningSeedEntry, type LearningsParseResult, extractAuthoredBody, formatLearningsErrorsBullets, learningsParseFailureHumanSummary, extractMarkdownSectionBody, parseLearningsSection } from "./artifact-linter/shared.js";
5
5
  export interface LintArtifactOptions {
6
6
  /**
7
7
  * Stage-level flags supplied by the caller (typically `advance-stage`)
@@ -5,7 +5,8 @@ import { stageSchema } from "./content/stage-schema.js";
5
5
  import { readFlowState } from "./run-persistence.js";
6
6
  import { duplicateH2Headings, extractH2Sections, extractRequirementIdsFromMarkdown, isShortCircuitActivated, normalizeHeadingTitle, parseFrontmatter, parseLearningsSection, sectionBodyByAnyName, sectionBodyByHeadingPrefix, sectionBodyByName, validateSectionBody, formatLearningsErrorsBullets } from "./artifact-linter/shared.js";
7
7
  import { shouldDemoteArtifactValidationByTrack } from "./content/stage-schema.js";
8
- import { recordArtifactValidationDemotedByTrack } from "./delegation.js";
8
+ import { readDelegationLedger, recordArtifactValidationDemotedByTrack } from "./delegation.js";
9
+ import { classifyAndPersistFindings } from "./artifact-linter/findings-dedup.js";
9
10
  import { lintBrainstormStage } from "./artifact-linter/brainstorm.js";
10
11
  import { lintDesignStage } from "./artifact-linter/design.js";
11
12
  import { lintPlanStage } from "./artifact-linter/plan.js";
@@ -15,7 +16,7 @@ import { lintTddStage } from "./artifact-linter/tdd.js";
15
16
  import { lintReviewStage } from "./artifact-linter/review.js";
16
17
  import { lintShipStage } from "./artifact-linter/ship.js";
17
18
  export { validateReviewArmy, checkReviewVerdictConsistency, checkReviewSecurityNoChangeAttestation, checkReviewTddNoCrossArtifactDuplication } from "./artifact-linter/review-army.js";
18
- export { formatLearningsErrorsBullets, learningsParseFailureHumanSummary, extractMarkdownSectionBody, parseLearningsSection } from "./artifact-linter/shared.js";
19
+ export { extractAuthoredBody, formatLearningsErrorsBullets, learningsParseFailureHumanSummary, extractMarkdownSectionBody, parseLearningsSection } from "./artifact-linter/shared.js";
19
20
  const FRONTMATTER_REQUIRED_KEYS = [
20
21
  "stage",
21
22
  "schema_version",
@@ -328,6 +329,30 @@ export async function lintArtifact(projectRoot, stage, track = "standard", optio
328
329
  });
329
330
  }
330
331
  }
332
+ try {
333
+ const delegationLedger = await readDelegationLedger(projectRoot);
334
+ const legacyWaivers = delegationLedger.entries.filter((entry) => entry.status === "waived" &&
335
+ entry.mode === "proactive" &&
336
+ entry.stage === stage &&
337
+ (typeof entry.approvalToken !== "string" || entry.approvalToken.trim().length === 0));
338
+ if (legacyWaivers.length > 0) {
339
+ const descriptors = legacyWaivers
340
+ .map((entry) => [entry.agent, entry.spanId].filter((value) => typeof value === "string").join("@"))
341
+ .filter((value) => value.length > 0);
342
+ findings.push({
343
+ section: "waiver_legacy_provenance",
344
+ required: false,
345
+ rule: "waiver_legacy_provenance — proactive waiver(s) without approvalToken. Issue new waivers via `cclaw-cli internal waiver-grant --stage <stage> --reason <slug>` so the provenance trail is signed. Legacy waivers remain valid (advisory).",
346
+ found: false,
347
+ details: `Found ${legacyWaivers.length} proactive waiver(s) on stage="${stage}" without approvalToken` +
348
+ (descriptors.length > 0 ? ` (${descriptors.join(", ")})` : "") +
349
+ ". Next waiver should be issued with `cclaw-cli internal waiver-grant` and consumed via `--accept-proactive-waiver=<token>`."
350
+ });
351
+ }
352
+ }
353
+ catch {
354
+ // Ledger absent or unreadable: no advisory to emit.
355
+ }
331
356
  const demote = shouldDemoteArtifactValidationByTrack(track, taskClass);
332
357
  const demotedSections = [];
333
358
  if (demote) {
@@ -356,7 +381,24 @@ export async function lintArtifact(projectRoot, stage, track = "standard", optio
356
381
  }
357
382
  }
358
383
  const passed = findings.every((f) => !f.required || f.found);
359
- return { stage, file: relFile, passed, findings };
384
+ let dedup;
385
+ try {
386
+ const dedupResult = await classifyAndPersistFindings(projectRoot, stage, findings);
387
+ const statusByFingerprint = new Map(dedupResult.classified.map(({ fingerprint, status }) => [fingerprint, status]));
388
+ const statuses = dedupResult.classified.map(({ status }) => status);
389
+ void statusByFingerprint;
390
+ dedup = {
391
+ newCount: dedupResult.summary.newCount,
392
+ repeatCount: dedupResult.summary.repeatCount,
393
+ resolvedCount: dedupResult.summary.resolvedCount,
394
+ header: dedupResult.header,
395
+ statuses
396
+ };
397
+ }
398
+ catch {
399
+ dedup = undefined;
400
+ }
401
+ return { stage, file: relFile, passed, findings, ...(dedup ? { dedup } : {}) };
360
402
  }
361
403
  /**
362
404
  * Wave 25 (v6.1.0) — section names whose required-finding outcome is
@@ -1,4 +1,36 @@
1
1
  import type { FlowStage } from "../types.js";
2
+ /**
3
+ * Round 5 (v6.6.0) — short bad → good behavior anchor per stage.
4
+ *
5
+ * Each entry is rendered exactly once in the corresponding stage skill md
6
+ * (via `behaviorAnchorBlock` in `skills.ts`) and exactly once in the stage's
7
+ * artifact template (via `renderBehaviorAnchorTemplateLine`). Anchors are
8
+ * deliberately attached to a real artifact section name so the cross-check
9
+ * test in `tests/unit/behavior-anchors.test.ts` can verify the section
10
+ * exists in the stage's schema.
11
+ *
12
+ * Constraints enforced by the unit test:
13
+ * - Exactly one entry per FlowStage (8 total).
14
+ * - `bad` and `good` must be distinct across stages and ≤ 40 words each.
15
+ * - `section` must match a section name present in
16
+ * `stageSchema(stage).artifactRules.artifactValidation`.
17
+ */
18
+ export interface BehaviorAnchor {
19
+ stage: FlowStage;
20
+ section: string;
21
+ bad: string;
22
+ good: string;
23
+ ruleHint?: string;
24
+ }
25
+ export declare const BEHAVIOR_ANCHORS: ReadonlyArray<BehaviorAnchor>;
26
+ export declare function behaviorAnchorFor(stage: FlowStage): BehaviorAnchor | null;
27
+ /**
28
+ * Render the one-line "Behavior anchor (bad → good)" pointer used at the top
29
+ * of each artifact template (01..08). Templates carry the anchor inline so
30
+ * agents see it before they start filling sections; the prose itself lives
31
+ * only in `BEHAVIOR_ANCHORS` to avoid duplication.
32
+ */
33
+ export declare function renderBehaviorAnchorTemplateLine(stage: FlowStage): string;
2
34
  export declare function stageGoodBadExamples(stage: FlowStage): string;
3
35
  /**
4
36
  * Returns the full example artifact body for tests and internal quality checks.
@@ -1,3 +1,77 @@
1
+ export const BEHAVIOR_ANCHORS = [
2
+ {
3
+ stage: "brainstorm",
4
+ section: "Problem Decision Record",
5
+ bad: "Frame the problem broadly and quietly add a second outcome (\"and while we're at it, refresh the dashboard\") that no Q&A row sanctioned.",
6
+ good: "Name one affected user, one current failure mode, and one observable outcome; record any extra outcome as a separate row in `## Not Doing`.",
7
+ ruleHint: "Scope creep starts in framing — keep the Problem Decision Record single-target."
8
+ },
9
+ {
10
+ stage: "scope",
11
+ section: "Scope Contract",
12
+ bad: "Invent a contract from a hunch: \"I'll let the user choose 3 templates\" with no Q&A row, no user feedback citation, no upstream decision.",
13
+ good: "Cite the Q&A row or upstream decision (`brainstorm > Selected Direction`) that produced each in/out boundary; refuse to lock without that citation.",
14
+ ruleHint: "Every scope contract row must trace to a recorded user signal or carried-forward decision."
15
+ },
16
+ {
17
+ stage: "design",
18
+ section: "Codebase Investigation",
19
+ bad: "Open with \"Use a queue + worker pool\" before reading any file; the architecture choice precedes the trace and the diagram has no concrete node.",
20
+ good: "List 1-3 blast-radius files in `Codebase Investigation` with current responsibility and reuse candidate first; only then propose architecture in `ADR`.",
21
+ ruleHint: "Trace before lock — no architecture decision lands without a codebase citation."
22
+ },
23
+ {
24
+ stage: "spec",
25
+ section: "Acceptance Criteria",
26
+ bad: "AC: \"System should be fast and reliable\" — no measurable predicate, no verification approach, no design-decision ref.",
27
+ good: "AC: \"GET /feed returns ≤ 50 items in < 200 ms p95; verified via integration test `tests/feed.spec.ts` against scope `R-2`.\"",
28
+ ruleHint: "Every AC carries an observable predicate plus the exact evidence command or path that proves it."
29
+ },
30
+ {
31
+ stage: "plan",
32
+ section: "Execution Posture",
33
+ bad: "Posture: \"parallel-safe\" with three units that all edit the same `src/api/router.ts`; no shared interface contract, no boundary map.",
34
+ good: "Posture: \"parallel-safe\" only when each Implementation Unit owns disjoint files and the shared types live in one cited interface contract entry.",
35
+ ruleHint: "Parallelization needs disjoint units AND a single shared interface contract — claim otherwise and the next batch deadlocks."
36
+ },
37
+ {
38
+ stage: "tdd",
39
+ section: "RED Evidence",
40
+ bad: "RED: `expect(true).toBe(true)` then \"failing test observed\" — the assertion can never have caught the bug it claims to prove.",
41
+ good: "RED: `expect(api.fetchFeed()).rejects.toThrow(AuthError)`; the failure output names the missing guard and ties to AC-3.",
42
+ ruleHint: "Mental mutation test: name a plausible bug that would still pass the assertion. If you can, the assertion is too coarse."
43
+ },
44
+ {
45
+ stage: "review",
46
+ section: "Layer 2 Findings",
47
+ bad: "Slip in a rename of `userSvc` → `userService` and a folder reorg under \"Layer 2: cleanup\"; no acceptance criterion or finding ID demanded the change.",
48
+ good: "Findings name observed defects with `file:line`; refactors land as a separate slice with their own RED/GREEN, not bundled into the review pass.",
49
+ ruleHint: "Review surfaces findings; it does not refactor. Drive-by edits go back through TDD."
50
+ },
51
+ {
52
+ stage: "ship",
53
+ section: "Preflight Results",
54
+ bad: "Preflight: \"Looks good, tests passed last night\"; no fresh command output, no commit SHA, no exit code.",
55
+ good: "Preflight: paste the command, the exit code, and the commit SHA from this turn; if the suite was not re-run after the last edit, mark BLOCKED.",
56
+ ruleHint: "Victory-by-confidence is not a preflight. Re-run, capture, cite SHA — or stay BLOCKED."
57
+ }
58
+ ];
59
+ const BEHAVIOR_ANCHOR_BY_STAGE = new Map(BEHAVIOR_ANCHORS.map((entry) => [entry.stage, entry]));
60
+ export function behaviorAnchorFor(stage) {
61
+ return BEHAVIOR_ANCHOR_BY_STAGE.get(stage) ?? null;
62
+ }
63
+ /**
64
+ * Render the one-line "Behavior anchor (bad → good)" pointer used at the top
65
+ * of each artifact template (01..08). Templates carry the anchor inline so
66
+ * agents see it before they start filling sections; the prose itself lives
67
+ * only in `BEHAVIOR_ANCHORS` to avoid duplication.
68
+ */
69
+ export function renderBehaviorAnchorTemplateLine(stage) {
70
+ const anchor = behaviorAnchorFor(stage);
71
+ if (!anchor)
72
+ return "";
73
+ return `> Behavior anchor (bad -> good) — ${anchor.section}: bad: ${anchor.bad} good: ${anchor.good}`;
74
+ }
1
75
  const STAGE_EXAMPLES = {
2
76
  brainstorm: `## Context
3
77
 
@@ -191,7 +191,7 @@ export function cancelRunScript() {
191
191
  return internalHelperScript("cancel-run", "cancel-run", "Usage: node " + RUNTIME_ROOT + "/hooks/cancel-run.mjs --reason=<text> [--disposition=<cancelled|abandoned>] [--name=<slug>]");
192
192
  }
193
193
  export function stageCompleteScript() {
194
- return internalHelperScript("stage-complete", "advance-stage", "Usage: node " + RUNTIME_ROOT + "/hooks/stage-complete.mjs <stage> [--passed=...] [--evidence-json=...] [--waive-delegation=...] [--waiver-reason=...] [--accept-proactive-waiver] [--accept-proactive-waiver-reason=\"<why safe>\"] [--skip-questions] [--json]", {
194
+ return internalHelperScript("stage-complete", "advance-stage", "Usage: node " + RUNTIME_ROOT + "/hooks/stage-complete.mjs <stage> [--passed=...] [--evidence-json=...] [--waive-delegation=...] [--waiver-reason=...] [--accept-proactive-waiver=<token>] [--accept-proactive-waiver-reason=\"<why safe>\"] [--skip-questions] [--json]", {
195
195
  positionalArgName: "stage",
196
196
  positionalArgRequired: true,
197
197
  defaultQuietEnvVar: "CCLAW_STAGE_COMPLETE_QUIET"
@@ -199,6 +199,7 @@ export function stageCompleteScript() {
199
199
  }
200
200
  export function delegationRecordScript() {
201
201
  return `#!/usr/bin/env node
202
+ import { createHash } from "node:crypto";
202
203
  import fs from "node:fs/promises";
203
204
  import path from "node:path";
204
205
  import process from "node:process";
@@ -210,6 +211,37 @@ const VALID_DISPATCH_SURFACES = ${JSON.stringify([...DELEGATION_DISPATCH_SURFACE
210
211
  const VALID_DISPATCH_SURFACES_SET = new Set(VALID_DISPATCH_SURFACES);
211
212
  const SURFACE_PATH_PREFIXES = ${JSON.stringify(DELEGATION_DISPATCH_SURFACE_PATH_PREFIXES)};
212
213
  const LEDGER_SCHEMA_VERSION = 3;
214
+ const FLOW_STATE_GUARD_REL_PATH = RUNTIME_ROOT + "/.flow-state.guard.json";
215
+
216
+ async function verifyFlowStateGuardInline(root) {
217
+ const statePath = path.join(root, RUNTIME_ROOT, "state", "flow-state.json");
218
+ const guardPath = path.join(root, FLOW_STATE_GUARD_REL_PATH);
219
+ let raw;
220
+ try {
221
+ raw = await fs.readFile(statePath, "utf8");
222
+ } catch {
223
+ return;
224
+ }
225
+ let guard;
226
+ try {
227
+ const guardRaw = await fs.readFile(guardPath, "utf8");
228
+ guard = JSON.parse(guardRaw);
229
+ } catch {
230
+ return;
231
+ }
232
+ if (!guard || typeof guard !== "object" || typeof guard.sha256 !== "string") return;
233
+ const actual = createHash("sha256").update(raw, "utf8").digest("hex");
234
+ if (actual === guard.sha256) return;
235
+ process.stderr.write(
236
+ "[cclaw] delegation-record: flow-state guard mismatch: " + (guard.runId || "unknown-run") + "\\n" +
237
+ "expected sha: " + guard.sha256 + "\\n" +
238
+ "actual sha: " + actual + "\\n" +
239
+ "last writer: " + (guard.writerSubsystem || "unknown") + "@" + (guard.writtenAt || "unknown") + "\\n" +
240
+ "do not edit flow-state.json by hand. To recover, run:\\n" +
241
+ " cclaw-cli internal flow-state-repair --reason \\"manual_edit_recovery\\"\\n"
242
+ );
243
+ process.exit(2);
244
+ }
213
245
 
214
246
  function parseArgs(argv) {
215
247
  const args = {};
@@ -693,6 +725,9 @@ async function main() {
693
725
  const args = parseArgs(process.argv.slice(2));
694
726
  const json = args.json !== undefined;
695
727
 
728
+ const guardRoot = await detectRoot();
729
+ await verifyFlowStateGuardInline(guardRoot);
730
+
696
731
  if (args.repair) {
697
732
  await runRepair(args, json);
698
733
  return;
@@ -49,12 +49,14 @@ export function nodeHookRuntimeScript(options = {}) {
49
49
  const defaultDisabledHooks = [];
50
50
  const cliRuntime = resolveCliRuntimeForGeneratedHook();
51
51
  return `#!/usr/bin/env node
52
+ import { createHash } from "node:crypto";
52
53
  import fs from "node:fs/promises";
53
54
  import path from "node:path";
54
55
  import process from "node:process";
55
56
  import { spawn } from "node:child_process";
56
57
 
57
58
  const RUNTIME_ROOT = ${JSON.stringify(RUNTIME_ROOT)};
59
+ const FLOW_STATE_GUARD_REL_PATH = RUNTIME_ROOT + "/.flow-state.guard.json";
58
60
  // Single strictness default, derived from config.strictness at install time.
59
61
  // \`CCLAW_STRICTNESS\` env var overrides for the current process. All guards
60
62
  // (prompt, workflow, TDD, iron-laws) route through \`resolveStrictness()\`.
@@ -1017,6 +1019,40 @@ function extractCodePathsFromText(value) {
1017
1019
  return out;
1018
1020
  }
1019
1021
 
1022
+ async function verifyFlowStateGuardInline(root, hookName) {
1023
+ const statePath = path.join(root, RUNTIME_ROOT, "state", "flow-state.json");
1024
+ const guardPath = path.join(root, FLOW_STATE_GUARD_REL_PATH);
1025
+ let raw;
1026
+ try {
1027
+ raw = await fs.readFile(statePath, "utf8");
1028
+ } catch {
1029
+ return true;
1030
+ }
1031
+ let guard;
1032
+ try {
1033
+ const guardRaw = await fs.readFile(guardPath, "utf8");
1034
+ guard = JSON.parse(guardRaw);
1035
+ } catch {
1036
+ return true;
1037
+ }
1038
+ if (!guard || typeof guard !== "object" || typeof guard.sha256 !== "string") {
1039
+ return true;
1040
+ }
1041
+ const actual = createHash("sha256").update(raw, "utf8").digest("hex");
1042
+ if (actual === guard.sha256) return true;
1043
+ const hookLabel = typeof hookName === "string" && hookName.length > 0 ? hookName : "hook";
1044
+ process.stderr.write(
1045
+ "[cclaw] " + hookLabel + ": flow-state guard mismatch: " + (guard.runId || "unknown-run") + "\\n" +
1046
+ "expected sha: " + guard.sha256 + "\\n" +
1047
+ "actual sha: " + actual + "\\n" +
1048
+ "last writer: " + (guard.writerSubsystem || "unknown") + "@" + (guard.writtenAt || "unknown") + "\\n" +
1049
+ "do not edit flow-state.json by hand. To recover, run:\\n" +
1050
+ " cclaw-cli internal flow-state-repair --reason \\"manual_edit_recovery\\"\\n"
1051
+ );
1052
+ await recordHookError(root, hookLabel, "flow-state guard mismatch actual=" + actual + " expected=" + guard.sha256).catch(() => undefined);
1053
+ return false;
1054
+ }
1055
+
1020
1056
  async function readFlowState(root) {
1021
1057
  const statePath = path.join(root, RUNTIME_ROOT, "state", "flow-state.json");
1022
1058
  // Loud-on-corrupt: if flow-state.json exists but fails JSON.parse, log
@@ -2110,6 +2146,13 @@ async function main() {
2110
2146
  };
2111
2147
 
2112
2148
  try {
2149
+ if (hookName === "session-start" || hookName === "stop-handoff") {
2150
+ const guardOk = await verifyFlowStateGuardInline(runtime.root, hookName);
2151
+ if (!guardOk) {
2152
+ process.exitCode = 2;
2153
+ return;
2154
+ }
2155
+ }
2113
2156
  if (hookName === "session-start") {
2114
2157
  process.exitCode = await handleSessionStart(runtime);
2115
2158
  return;
@@ -29,7 +29,7 @@ Pinned anchor: "Don't tell it what to do, give it success criteria and watch it
29
29
  These behaviors are the exact reason this skill exists. The linter will block your stage-complete if you do them.
30
30
 
31
31
  - **Bad**: User asks for a "simple web app" -> agent asks 1 question about stack -> 1 question about auth -> drafts the brainstorm artifact and asks for approval.
32
- - **Good**: User asks for a "simple web app" -> agent asks Q1 (what pain) -> Q2 (direct path) -> Q3 (do-nothing cost) -> Q4 (first operator/user) -> Q5 (no-go boundaries) -> self-eval: clear -> drafts the brainstorm artifact.
32
+ - **Good**: User asks for a "simple web app" -> agent asks Q1 (what pain) -> Q2 (direct path) -> Q3 (first operator/user) -> Q4 (no-go boundaries) -> self-eval: clear -> drafts the brainstorm artifact.
33
33
 
34
34
  - **Bad**: Agent immediately dispatches a subagent (\`product-discovery\`, \`critic\`, \`planner\`) at the start of brainstorm/scope/design to "gather context" before any user dialogue.
35
35
  - **Good**: Agent walks the Q&A loop with the user first; subagent dispatch happens only after the user approves the elicitation outcome.
@@ -121,7 +121,7 @@ Default mapping note: \`lean\` maps to a lightweight specialist tier on early st
121
121
 
122
122
  ### Topic tagging (MANDATORY for forcing-question rows)
123
123
 
124
- Each forcing question has a stable topic id (kebab-case ASCII, e.g. \`pain\`, \`do-nothing\`, \`data-flow\`). Tag the matching Q&A Log row's \`Decision impact\` cell with \`[topic:<id>]\` so the linter can verify coverage in any natural language. This is a **HARD requirement** in Wave 24 (v6.0.0): the linter no longer keyword-matches English question prose, so an un-tagged row does NOT count toward coverage even if the answer fully addresses the topic.
124
+ Each forcing question has a stable topic id (kebab-case ASCII, e.g. \`pain\`, \`direct-path\`, \`data-flow\`). Tag the matching Q&A Log row's \`Decision impact\` cell with \`[topic:<id>]\` so the linter can verify coverage in any natural language. This is a **HARD requirement** in Wave 24 (v6.0.0): the linter no longer keyword-matches English question prose, so an un-tagged row does NOT count toward coverage even if the answer fully addresses the topic.
125
125
 
126
126
  RU example (after asking \`pain\` in Russian):
127
127
 
@@ -131,21 +131,18 @@ RU example (after asking \`pain\` in Russian):
131
131
  | 1 | Какую боль мы решаем? | Регистрация занимает 30 минут. | scope-shaping [topic:pain] |
132
132
  \`\`\`
133
133
 
134
- Multiple tags in one row are allowed when one answer covers several topics: \`[topic:pain] [topic:do-nothing]\`. Stop-signal rows do NOT need a tag.
134
+ Multiple tags in one row are allowed when one answer covers several topics: \`[topic:pain] [topic:direct-path]\`. Stop-signal rows do NOT need a tag.
135
135
 
136
136
  Stage forcing question lists (id → topic):
137
137
 
138
138
  - **Brainstorm**:
139
139
  - \`pain\` — What pain are we solving?
140
140
  - \`direct-path\` — What is the most direct path?
141
- - \`do-nothing\` — What happens if we do nothing?
142
141
  - \`operator\` — Who is the operator/user impacted first?
143
142
  - \`no-go\` — What are non-negotiable no-go boundaries?
144
143
  - **Scope**:
145
144
  - \`in-out\` — What is definitely in and definitely out?
146
145
  - \`locked-upstream\` — Which decisions are already locked upstream?
147
- - \`rollback\` — What is the rollback path if this fails?
148
- - \`failure-modes\` — What are the top failure modes we must design for?
149
146
  - **Design**:
150
147
  - \`data-flow\` — What is the data flow end-to-end?
151
148
  - \`seams\` — Where are the seams/interfaces and ownership boundaries?
@@ -8,6 +8,16 @@ export declare function outsideVoiceSlotBlock(): string;
8
8
  export declare function antiSycophancyBlock(): string;
9
9
  export declare function noPlaceholdersBlock(): string;
10
10
  export declare function watchedFailProofBlock(): string;
11
+ /**
12
+ * Stages that perform real investigation work. The shared
13
+ * `INVESTIGATION_DISCIPLINE_BLOCK` is rendered once per stage skill in this
14
+ * set so the search → graph → narrow-read → draft ladder appears verbatim
15
+ * across the elicitation/spec/plan/tdd/review pipeline. `ship` is excluded:
16
+ * it consumes the upstream trace rather than producing one.
17
+ */
18
+ export declare const INVESTIGATION_DISCIPLINE_STAGES: ReadonlySet<FlowStage>;
19
+ export declare function investigationDisciplineBlock(): string;
20
+ export declare function behaviorAnchorBlock(stage: FlowStage): string;
11
21
  export declare function stageSkillFolder(stage: FlowStage): string;
12
22
  export declare function stageSkillMarkdown(stage: FlowStage, track?: FlowTrack): string;
13
23
  export declare function executingWavesSkillMarkdown(): string;