@namewta/speculo 0.6.0 → 0.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 (116) hide show
  1. package/README.md +11 -11
  2. package/dist/src/cli.js +36 -128
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/index.d.ts +2 -4
  5. package/dist/src/index.js +173 -151
  6. package/dist/src/index.js.map +1 -1
  7. package/package.json +6 -4
  8. package/template/.speculo/README.md +4 -0
  9. package/template/canonical/canonical-specdev-engineering-cognitive-mentor.md +133 -141
  10. package/template/canonical/canonical-specdev-goal-plan.md +394 -377
  11. package/template/canonical/canonical-specdev-grill-with-docs.md +241 -178
  12. package/template/canonical/canonical-specdev-spec.md +132 -122
  13. package/template/canonical/canonical-specdev-tickets.md +176 -156
  14. package/template/canonical/canonical-specdev-wayfinder.md +106 -108
  15. package/template/commands/archive-and-consolidate.md +10 -8
  16. package/template/commands/handoff.md +2 -0
  17. package/template/commands/retro.md +3 -3
  18. package/template/commands/status.md +5 -4
  19. package/template/skills/archive-and-consolidate/SKILL.md +5 -9
  20. package/template/skills/archive-and-consolidate/assets/archive-plan-template.md +1 -1
  21. package/template/skills/archive-and-consolidate/references/archive-rules.md +4 -4
  22. package/template/skills/archive-and-consolidate/references/consolidation-rules.md +7 -8
  23. package/template/skills/archive-and-consolidate/references/knowledge-graduation.md +5 -2
  24. package/template/skills/github-npm-ops/SKILL.md +4 -2
  25. package/template/skills/github-npm-ops/references/issue-transport.md +26 -0
  26. package/template/skills/github-npm-ops/references/preflight-checklist.md +1 -1
  27. package/template/skills/github-npm-ops/scripts/issue-transport.mjs +227 -0
  28. package/template/workflows/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md +31 -145
  29. package/template/workflows/specdev/A-archive-and-consolidate/consolidation-interview.md +4 -6
  30. package/template/workflows/specdev/C-code-review/C-code-review.md +42 -0
  31. package/template/workflows/specdev/C-code-review/code-review-template.md +42 -0
  32. package/template/workflows/specdev/D-diagnose-bugs/D-diagnose-bugs.md +45 -48
  33. package/template/workflows/specdev/D-diagnose-bugs/diagnosis-template.md +44 -40
  34. package/template/workflows/specdev/D-diagnose-bugs/feedback-loop.md +41 -0
  35. package/template/workflows/specdev/D-diagnose-bugs/hypothesis-and-instrumentation.md +32 -0
  36. package/template/workflows/specdev/D-diagnose-bugs/scripts/hitl-loop.template.sh +26 -0
  37. package/template/workflows/specdev/E-engineering-cognitive-mentor/E-engineering-cognitive-mentor.md +3 -3
  38. package/template/workflows/specdev/E-engineering-cognitive-mentor/persistence-and-resume.md +5 -20
  39. package/template/workflows/specdev/G-grill-with-docs/G-grill-with-docs.md +17 -10
  40. package/template/workflows/specdev/G-grill-with-docs/adr-format.md +31 -17
  41. package/template/workflows/specdev/G-grill-with-docs/context-format.md +9 -29
  42. package/template/workflows/specdev/G-grill-with-docs/domain-modeling-rules.md +10 -5
  43. package/template/workflows/specdev/G-grill-with-docs/stakeholder-questionnaire.md +45 -0
  44. package/template/workflows/specdev/I-implement/I-implement.md +29 -14
  45. package/template/workflows/specdev/I-implement/delegated-evidence-template.md +11 -0
  46. package/template/workflows/specdev/I-implement/evidence-template.md +24 -10
  47. package/template/workflows/specdev/I-implement/execution-preflight.md +4 -4
  48. package/template/workflows/specdev/I-implement/merge-conflict-protocol.md +20 -0
  49. package/template/workflows/specdev/I-implement/tdd-mocking.md +19 -0
  50. package/template/workflows/specdev/I-implement/tdd-rules.md +14 -12
  51. package/template/workflows/specdev/I-implement/tdd-test-design.md +25 -0
  52. package/template/workflows/specdev/I-init-setup/I-init-setup.md +12 -9
  53. package/template/workflows/specdev/I-init-setup/config-template.json +1 -1
  54. package/template/workflows/specdev/I-init-setup/domain-layout-template.md +2 -2
  55. package/template/workflows/specdev/I-init-setup/status-template.json +2 -3
  56. package/template/workflows/specdev/I-init-setup/tracking-template.md +3 -0
  57. package/template/workflows/specdev/INDEX.md +64 -26
  58. package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +48 -38
  59. package/template/workflows/specdev/P-goal-plan/completion-control.md +19 -53
  60. package/template/workflows/specdev/P-goal-plan/delegated-execution-template.md +33 -0
  61. package/template/workflows/specdev/P-goal-plan/delegated-execution.md +53 -0
  62. package/template/workflows/specdev/P-goal-plan/goal-plan-template.md +9 -40
  63. package/template/workflows/specdev/P-goal-plan/orchestration-protocol.md +16 -68
  64. package/template/workflows/specdev/P-goal-plan/planning-modes.md +20 -48
  65. package/template/workflows/specdev/P-prototype/P-prototype.md +46 -0
  66. package/template/workflows/specdev/P-prototype/logic-prototype.md +24 -0
  67. package/template/workflows/specdev/P-prototype/prototype-record-template.md +46 -0
  68. package/template/workflows/specdev/P-prototype/ui-prototype.md +21 -0
  69. package/template/workflows/specdev/R-review-architecture/R-review-architecture.md +1 -1
  70. package/template/workflows/specdev/S-spec/S-spec.md +2 -1
  71. package/template/workflows/specdev/T-tickets/T-tickets.md +3 -2
  72. package/template/workflows/specdev/T-tickets/ticket-readiness.md +1 -1
  73. package/template/workflows/specdev/T-tickets/ticket-template.md +1 -1
  74. package/template/workflows/specdev/T-tickets/tickets-map-template.md +1 -1
  75. package/template/workflows/specdev/T-triage/T-triage.md +65 -26
  76. package/template/workflows/specdev/T-triage/intake-protocol.md +32 -0
  77. package/template/workflows/specdev/T-triage/reconcile-protocol.md +34 -0
  78. package/template/workflows/specdev/T-triage/source-template.md +30 -0
  79. package/template/workflows/specdev/T-triage/triage-template.md +38 -24
  80. package/template/workflows/specdev/W-wayfinder/W-wayfinder.md +3 -1
  81. package/template/workflows/specdev/_state/status.json +2 -3
  82. package/template/workflows/specdev/common/README.md +9 -2
  83. package/template/workflows/specdev/common/rules/artifact-contract.md +20 -9
  84. package/template/workflows/specdev/common/rules/change-completion.md +33 -0
  85. package/template/workflows/specdev/common/rules/deviation-control.md +2 -2
  86. package/template/workflows/specdev/common/rules/evidence-and-verification.md +1 -1
  87. package/template/workflows/specdev/common/rules/path-ownership.md +3 -3
  88. package/template/workflows/specdev/common/schemas/change-status.schema.json +43 -0
  89. package/template/workflows/specdev/common/schemas/code-review.schema.json +22 -0
  90. package/template/workflows/specdev/common/schemas/diagnosis.schema.json +19 -0
  91. package/template/workflows/specdev/common/schemas/prototype-record.schema.json +24 -0
  92. package/template/workflows/specdev/common/schemas/source.schema.json +20 -0
  93. package/template/workflows/specdev/common/schemas/status.schema.json +20 -78
  94. package/template/workflows/specdev/common/schemas/triage.schema.json +21 -0
  95. package/template/workflows/specdev/common/skills/code-review/SKILL.md +49 -0
  96. package/template/workflows/specdev/common/skills/code-review/references/fowler-smells.md +18 -0
  97. package/template/workflows/specdev/common/skills/code-review/references/reviewer-contracts.md +13 -0
  98. package/template/workflows/specdev/common/skills/code-review/references/source-discovery.md +22 -0
  99. package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +12 -9
  100. package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +18 -13
  101. package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +8 -6
  102. package/template/workflows/specdev/common/skills/research/SKILL.md +38 -26
  103. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +3 -5
  104. package/template/workflows/specdev/common/tools/README.md +3 -0
  105. package/template/workflows/specdev/common/tools/validate-specdev.mjs +496 -30
  106. package/dist/src/migrate.d.ts +0 -38
  107. package/dist/src/migrate.js +0 -642
  108. package/dist/src/migrate.js.map +0 -1
  109. package/dist/src/skills-mirror.d.ts +0 -38
  110. package/dist/src/skills-mirror.js +0 -160
  111. package/dist/src/skills-mirror.js.map +0 -1
  112. package/template/workflows/specdev/A-archive-and-consolidate/archive-checklist.md +0 -15
  113. package/template/workflows/specdev/A-archive-and-consolidate/knowledge-promotion-rules.md +0 -32
  114. package/template/workflows/specdev/I-implement/code-review-process.md +0 -17
  115. package/template/workflows/specdev/I-implement/tdd-examples.md +0 -14
  116. package/template/workflows/specdev/I-init-setup/status-labels-template.md +0 -55
@@ -19,17 +19,22 @@ import { dirname, basename, extname, join, relative, resolve, sep } from "node:p
19
19
  import { fileURLToPath } from "node:url";
20
20
 
21
21
  const SCHEMA_VERSION = 3;
22
+ const GLOBAL_STATUS_SCHEMA_VERSION = 4;
22
23
  const WORKFLOW_PREFIX = "{roots.workflows}/specdev/";
23
24
  const STATE_PREFIX = "{roots.state}/specdev/";
25
+ const SKILLS_PREFIX = "{roots.skills}/";
26
+ const COMMANDS_PREFIX = "{roots.commands}/";
24
27
 
25
28
  const EXPECTED_WORKS = new Set([
26
29
  "A-archive-and-consolidate",
30
+ "C-code-review",
27
31
  "D-diagnose-bugs",
28
32
  "E-engineering-cognitive-mentor",
29
33
  "G-grill-with-docs",
30
34
  "I-implement",
31
35
  "I-init-setup",
32
36
  "P-goal-plan",
37
+ "P-prototype",
33
38
  "R-review-architecture",
34
39
  "S-spec",
35
40
  "T-tickets",
@@ -72,6 +77,19 @@ const VALID_WAYFINDER_RESOLUTION = new Set([
72
77
  "superseded",
73
78
  "cancelled",
74
79
  ]);
80
+ const VALID_STAGES = new Set([
81
+ "triage",
82
+ "diagnosis",
83
+ "grill",
84
+ "spec",
85
+ "tickets",
86
+ "goal-plan",
87
+ "implement",
88
+ "review",
89
+ "prototype",
90
+ "wayfinder",
91
+ "complete",
92
+ ]);
75
93
  const REQUIRED_TICKET_KEYS = new Set([
76
94
  "schema_version",
77
95
  "artifact",
@@ -109,6 +127,31 @@ const REQUIRED_GOAL_PLAN_SECTIONS = new Set([
109
127
  "## 5. Constraints, Risk and Recovery",
110
128
  "## 6. Progress and Decisions",
111
129
  ]);
130
+ const DELEGATED_GOAL_PLAN_TRIGGERS = [
131
+ "## Delegated Execution Addendum",
132
+ "### Delivery Contract",
133
+ "### Per-Ticket Dispatch Packets",
134
+ "### Candidate Delivery Return and Lead Integration",
135
+ "Lead / Provider",
136
+ "Lead:",
137
+ "Lead:",
138
+ "Worker:",
139
+ "Worker:",
140
+ "Subagent",
141
+ "subagent",
142
+ "execution_model",
143
+ "execution model: direct",
144
+ "max_correction_rounds",
145
+ ];
146
+ const REQUIRED_DELEGATED_GOAL_PLAN_MARKERS = [
147
+ "## Delegated Execution Addendum",
148
+ "### Delivery Contract",
149
+ "### Per-Ticket Dispatch Packets",
150
+ "### Candidate Delivery Return and Lead Integration",
151
+ "native-subagent / external-web-subagent",
152
+ "Lead / Provider",
153
+ "#### Dispatch:",
154
+ ];
112
155
  const STATE_ARTIFACT_BASENAMES = new Set([
113
156
  "spec.md",
114
157
  "tickets-map.md",
@@ -120,13 +163,14 @@ const STATE_ARTIFACT_BASENAMES = new Set([
120
163
  ".status.json",
121
164
  "triage.md",
122
165
  "diagnosis.md",
123
- "source-issue.md",
166
+ "source.md",
124
167
  "architecture-review.md",
125
168
  "architecture-review.html",
126
169
  "wayfinder-map.md",
127
170
  "design-tree.json",
128
171
  ]);
129
172
  const FORBIDDEN_OBSOLETE_BASENAMES = new Set([
173
+ "source-issue.md",
130
174
  "input-validation.md",
131
175
  "vision-sections.md",
132
176
  "execution-sections.md",
@@ -347,8 +391,23 @@ function validatePathValue(value, { allowProject = true } = {}) {
347
391
  }
348
392
  return null;
349
393
  }
394
+ for (const [prefix, name] of [[SKILLS_PREFIX, "skills"], [COMMANDS_PREFIX, "commands"]]) {
395
+ if (value.startsWith(prefix)) {
396
+ const suffix = value.slice(prefix.length);
397
+ if (
398
+ !suffix ||
399
+ suffix.startsWith("/") ||
400
+ suffix.startsWith("./") ||
401
+ suffix.startsWith("../") ||
402
+ suffix.includes("/../")
403
+ ) {
404
+ return `${name} Path is not canonical`;
405
+ }
406
+ return null;
407
+ }
408
+ }
350
409
  if (value.includes("{roots.")) {
351
- return "unknown or incomplete root variable; use roots.workflows or roots.state";
410
+ return "unknown or incomplete root variable";
352
411
  }
353
412
  if (!allowProject) return "this field requires a rooted workflow or state Path";
354
413
  if (
@@ -410,6 +469,17 @@ function validateDocumentReferences(root) {
410
469
  );
411
470
  }
412
471
  }
472
+ } else if (payload.startsWith(SKILLS_PREFIX) || payload.startsWith(COMMANDS_PREFIX)) {
473
+ const isSkill = payload.startsWith(SKILLS_PREFIX);
474
+ const prefix = isSkill ? SKILLS_PREFIX : COMMANDS_PREFIX;
475
+ const referenced = payload.slice(prefix.length);
476
+ if (!PLACEHOLDER_RE.test(referenced)) {
477
+ const templateRoot = resolve(root, "..", "..");
478
+ const target = join(templateRoot, isSkill ? "skills" : "commands", referenced);
479
+ if (!existsSync(target)) {
480
+ errors.push(`${label}:${lineNumber(text, match.index)}: broken public Path ${payload}`);
481
+ }
482
+ }
413
483
  }
414
484
  }
415
485
 
@@ -499,6 +569,54 @@ function validateJsonFiles(root) {
499
569
  return errors;
500
570
  }
501
571
 
572
+ function validateGlobalStatusAssets(root) {
573
+ const errors = [];
574
+ const paths = [join(root, "I-init-setup", "status-template.json")];
575
+ if (isDirectory(join(root, "_state"))) {
576
+ paths.unshift(join(root, "_state", "status.json"));
577
+ }
578
+ for (const path of paths) {
579
+ if (!isFile(path)) {
580
+ errors.push(`missing global status seed ${toPosix(relative(root, path))}`);
581
+ continue;
582
+ }
583
+ const data = JSON.parse(readText(path));
584
+ const keys = Object.keys(data).sort();
585
+ const expected = ["active", "archived", "schema_version", "workflow"];
586
+ if (JSON.stringify(keys) !== JSON.stringify(expected)) {
587
+ errors.push(`${toPosix(relative(root, path))}: global status must contain only ${expected.join(", ")}`);
588
+ }
589
+ if (
590
+ data.schema_version !== GLOBAL_STATUS_SCHEMA_VERSION ||
591
+ data.workflow !== "specdev" ||
592
+ !Array.isArray(data.active) ||
593
+ !Array.isArray(data.archived)
594
+ ) {
595
+ errors.push(`${toPosix(relative(root, path))}: invalid SpecDev global status v4 seed`);
596
+ }
597
+ }
598
+
599
+ const schemaPath = join(root, "common", "schemas", "status.schema.json");
600
+ if (!isFile(schemaPath)) {
601
+ errors.push("missing common/schemas/status.schema.json");
602
+ return errors;
603
+ }
604
+ const schema = JSON.parse(readText(schemaPath));
605
+ if (
606
+ schema.$id !== "urn:speculo:specdev:status:v4" ||
607
+ schema.properties?.schema_version?.const !== GLOBAL_STATUS_SCHEMA_VERSION ||
608
+ schema.additionalProperties !== false
609
+ ) {
610
+ errors.push("status.schema.json must define the strict SpecDev global status v4 contract");
611
+ }
612
+ for (const removed of ["work_history", "completed", "result"]) {
613
+ if (JSON.stringify(schema).includes(`\"${removed}\"`)) {
614
+ errors.push(`status.schema.json still contains removed global field ${removed}`);
615
+ }
616
+ }
617
+ return errors;
618
+ }
619
+
502
620
  function capabilityChecks(root) {
503
621
  const checks = new Map([
504
622
  [
@@ -508,9 +626,9 @@ function capabilityChecks(root) {
508
626
  [
509
627
  "tracking-template",
510
628
  "domain-layout-template",
511
- "status-labels-template",
512
629
  "config-template",
513
630
  "change-status-template",
631
+ "specdev-worktree/",
514
632
  ],
515
633
  ],
516
634
  ],
@@ -518,7 +636,7 @@ function capabilityChecks(root) {
518
636
  "triage",
519
637
  [
520
638
  join(root, "T-triage", "T-triage.md"),
521
- ["source-issue", "不改写原意", "风险", "未知项分类", "推荐路线"],
639
+ ["source.md", "intake", "reconcile", "唯一权威", "远程写入为零"],
522
640
  ],
523
641
  ],
524
642
  [
@@ -545,9 +663,10 @@ function capabilityChecks(root) {
545
663
  "DAG",
546
664
  "Gate",
547
665
  "Wave",
548
- "Lead",
549
- "Dispatch Packet",
550
- "subagent-delivery",
666
+ "普通 Goal Plan",
667
+ "委派 Goal Plan",
668
+ "每次向用户询问",
669
+ "delegated-execution.md",
551
670
  "Definition of Done",
552
671
  ],
553
672
  ],
@@ -559,6 +678,20 @@ function capabilityChecks(root) {
559
678
  ["codebase-design", "design-it-twice", "TDD", "双轴审查", "Evidence", "delegated execution", "subagent-delivery", "提交"],
560
679
  ],
561
680
  ],
681
+ [
682
+ "code-review",
683
+ [
684
+ join(root, "C-code-review", "C-code-review.md"),
685
+ ["git diff <fixed>...<head>", "固定点", "标准轴", "规范轴", "未合并排名", "reviews/CR-###.md"],
686
+ ],
687
+ ],
688
+ [
689
+ "prototype",
690
+ [
691
+ join(root, "P-prototype", "P-prototype.md"),
692
+ ["一个问题", "Logic", "UI", "临时 branch/worktree", "promotion target", "main"],
693
+ ],
694
+ ],
562
695
  [
563
696
  "wayfinder",
564
697
  [
@@ -577,21 +710,21 @@ function capabilityChecks(root) {
577
710
  "archive",
578
711
  [
579
712
  join(root, "A-archive-and-consolidate", "A-archive-and-consolidate.md"),
580
- ["create", "update", "merge", "supersede", "deprecate", "skip"],
713
+ ["archive-single", "dry-run", "external_action", "consolidate-from-code", "archive-and-consolidate"],
581
714
  ],
582
715
  ],
583
716
  [
584
717
  "grill",
585
718
  [
586
719
  join(root, "G-grill-with-docs", "G-grill-with-docs.md"),
587
- ["完整 frontier", "design-tree.json", "LOG.md", "CONTEXT.md", "ADR.md", "推荐答案"],
720
+ ["完整 frontier", "design-tree.json", "LOG.md", "CONTEXT.md", "ADR.md", "推荐答案", "永久 namespace 对 G 只读"],
588
721
  ],
589
722
  ],
590
723
  [
591
724
  "diagnosis",
592
725
  [
593
726
  join(root, "D-diagnose-bugs", "D-diagnose-bugs.md"),
594
- ["最短反馈回路", "可证伪", "最小实验", "根因", "回归测试"],
727
+ ["红灯", "反馈回路", "最小化", "可证伪", "根因", "回归测试"],
595
728
  ],
596
729
  ],
597
730
  ]);
@@ -633,6 +766,46 @@ function capabilityChecks(root) {
633
766
  if (isFile(mapTemplate) && /^## (?:开放 Tickets|调查清单)/m.test(readText(mapTemplate))) {
634
767
  errors.push("Wayfinder map template must query open Tickets instead of caching them");
635
768
  }
769
+
770
+ const ordinaryGoalPlanTemplate = join(root, "P-goal-plan", "goal-plan-template.md");
771
+ if (isFile(ordinaryGoalPlanTemplate)) {
772
+ const text = readText(ordinaryGoalPlanTemplate);
773
+ for (const forbidden of [
774
+ "Lead",
775
+ "Subagent",
776
+ "subagent",
777
+ "Provider",
778
+ "Delivery Contract",
779
+ "Dispatch Packet",
780
+ "Worker",
781
+ "max_correction_rounds",
782
+ "execution_model",
783
+ ]) {
784
+ if (text.includes(forbidden)) {
785
+ errors.push(`ordinary Goal Plan template contains delegated marker ${forbidden}`);
786
+ }
787
+ }
788
+ }
789
+
790
+ const delegatedTemplate = join(root, "P-goal-plan", "delegated-execution-template.md");
791
+ if (!isFile(delegatedTemplate)) {
792
+ errors.push("missing delegated Goal Plan template");
793
+ } else {
794
+ const text = readText(delegatedTemplate);
795
+ const missing = REQUIRED_DELEGATED_GOAL_PLAN_MARKERS.filter((marker) => !text.includes(marker));
796
+ if (missing.length) errors.push(`delegated Goal Plan template lost markers: ${JSON.stringify(missing)}`);
797
+ }
798
+
799
+ const sampleCore = [...REQUIRED_GOAL_PLAN_SECTIONS].join("\n");
800
+ const partialErrors = [];
801
+ validateDelegatedGoalPlan(`${sampleCore}\n## Delegated Execution Addendum\n### Delivery Contract`, "partial delegated self-check", partialErrors);
802
+ if (!partialErrors.length) errors.push("partial delegated Goal Plan self-check did not fail");
803
+ const strayRoleErrors = [];
804
+ validateDelegatedGoalPlan(`${sampleCore}\nLead: owner`, "stray delegated role self-check", strayRoleErrors);
805
+ if (!strayRoleErrors.length) errors.push("stray delegated role self-check did not fail");
806
+ const completeErrors = [];
807
+ validateDelegatedGoalPlan(`${sampleCore}\n${REQUIRED_DELEGATED_GOAL_PLAN_MARKERS.join("\n")}`, "complete delegated self-check", completeErrors);
808
+ if (completeErrors.length) errors.push(...completeErrors);
636
809
  return errors;
637
810
  }
638
811
 
@@ -701,6 +874,7 @@ function selfCheck(root) {
701
874
  }
702
875
 
703
876
  errors.push(...validateJsonFiles(root));
877
+ errors.push(...validateGlobalStatusAssets(root));
704
878
  const references = validateDocumentReferences(root);
705
879
  errors.push(...references.errors);
706
880
  warnings.push(...references.warnings);
@@ -748,6 +922,189 @@ function validatePathList(values, label, key, errors) {
748
922
  }
749
923
  }
750
924
 
925
+ function validateSource(path, expectedChange, errors) {
926
+ if (!isFile(path)) {
927
+ errors.push("missing source artifact");
928
+ return null;
929
+ }
930
+ const { meta, body } = parseFrontmatter(path);
931
+ const required = [
932
+ "schema_version",
933
+ "artifact",
934
+ "change",
935
+ "source_type",
936
+ "canonical_locator",
937
+ "captured_at",
938
+ "content_sha256",
939
+ "remote_state",
940
+ "close_capability",
941
+ ];
942
+ const missing = required.filter((key) => !(key in meta));
943
+ if (missing.length) errors.push(`source.md: missing keys ${JSON.stringify(missing)}`);
944
+ if (meta.schema_version !== 1 || meta.artifact !== "source") {
945
+ errors.push("source.md: artifact/schema_version must be source/1");
946
+ }
947
+ if (meta.change !== expectedChange) errors.push("source.md: change must equal directory name");
948
+ if (!new Set(["conversation", "pasted", "local-file", "url", "github-issue", "github-pr"]).has(meta.source_type)) {
949
+ errors.push(`source.md: invalid source_type ${meta.source_type}`);
950
+ }
951
+ if (!/^[a-f0-9]{64}$/.test(String(meta.content_sha256 ?? ""))) {
952
+ errors.push("source.md: content_sha256 must be a SHA-256 digest");
953
+ }
954
+ if (!new Set(["open", "closed", "unknown", "not-applicable"]).has(meta.remote_state)) {
955
+ errors.push(`source.md: invalid remote_state ${meta.remote_state}`);
956
+ }
957
+ if (!new Set(["supported", "unsupported", "not-applicable"]).has(meta.close_capability)) {
958
+ errors.push(`source.md: invalid close_capability ${meta.close_capability}`);
959
+ }
960
+ if (typeof meta.canonical_locator === "string" && ABSOLUTE_MACHINE_PATH_RE.test(meta.canonical_locator)) {
961
+ errors.push("source.md: canonical_locator must not be a machine absolute path");
962
+ }
963
+ for (const heading of ["## Capture Metadata", "## Original Content", "## Source Comments"]) {
964
+ if (!body.includes(heading)) errors.push(`source.md: missing '${heading}'`);
965
+ }
966
+ return { path, meta, body };
967
+ }
968
+
969
+ function validateTriage(path, expectedChange, errors) {
970
+ if (!isFile(path)) {
971
+ errors.push("missing triage artifact");
972
+ return null;
973
+ }
974
+ const { meta, body } = parseFrontmatter(path);
975
+ const required = [
976
+ "schema_version",
977
+ "artifact",
978
+ "change",
979
+ "mode",
980
+ "source",
981
+ "classification",
982
+ "risk",
983
+ "route",
984
+ "ready_for_implementation",
985
+ "external_action",
986
+ "updated_at",
987
+ ];
988
+ const missing = required.filter((key) => !(key in meta));
989
+ if (missing.length) errors.push(`triage.md: missing keys ${JSON.stringify(missing)}`);
990
+ if (meta.schema_version !== 1 || meta.artifact !== "triage") {
991
+ errors.push("triage.md: artifact/schema_version must be triage/1");
992
+ }
993
+ if (meta.change !== expectedChange) errors.push("triage.md: change must equal directory name");
994
+ if (!new Set(["intake", "reconcile"]).has(meta.mode)) errors.push(`triage.md: invalid mode ${meta.mode}`);
995
+ if (!VALID_RISK.has(meta.risk)) errors.push(`triage.md: invalid risk ${meta.risk}`);
996
+ if (typeof meta.route !== "string" || !meta.route.startsWith("specdev/")) {
997
+ errors.push("triage.md: route must be a specdev work id");
998
+ }
999
+ if (typeof meta.ready_for_implementation !== "boolean") {
1000
+ errors.push("triage.md: ready_for_implementation must be boolean");
1001
+ }
1002
+ if (!new Set(["not-applicable", "pending-close", "closed", "close-failed", "waived"]).has(meta.external_action)) {
1003
+ errors.push(`triage.md: invalid external_action ${meta.external_action}`);
1004
+ }
1005
+ if (!String(meta.source ?? "").includes("/source.md</Path>")) {
1006
+ errors.push("triage.md: source must reference the local source.md artifact");
1007
+ }
1008
+ for (const heading of ["## 当前判定", "## 未知项", "## 路由", "## 外部动作"]) {
1009
+ if (!body.includes(heading)) errors.push(`triage.md: missing '${heading}'`);
1010
+ }
1011
+ return { path, meta, body };
1012
+ }
1013
+
1014
+ function validateDiagnosis(path, expectedChange, errors) {
1015
+ if (!isFile(path)) {
1016
+ errors.push("missing diagnosis artifact");
1017
+ return null;
1018
+ }
1019
+ const { meta, body } = parseFrontmatter(path);
1020
+ if (meta.schema_version !== 1 || meta.artifact !== "diagnosis") {
1021
+ errors.push("diagnosis.md: artifact/schema_version must be diagnosis/1");
1022
+ }
1023
+ if (meta.change !== expectedChange) errors.push("diagnosis.md: change must equal directory name");
1024
+ if (!new Set(["reproducing", "minimizing", "testing-hypotheses", "root-cause-confirmed", "blocked"]).has(meta.status)) {
1025
+ errors.push(`diagnosis.md: invalid status ${meta.status}`);
1026
+ }
1027
+ if (typeof meta.feedback_loop_ready !== "boolean") {
1028
+ errors.push("diagnosis.md: feedback_loop_ready must be boolean");
1029
+ }
1030
+ if (meta.feedback_loop_ready) {
1031
+ if (!String(meta.red_command ?? "").trim() || !String(meta.red_evidence ?? "").trim()) {
1032
+ errors.push("diagnosis.md: ready feedback loop requires red_command and red_evidence");
1033
+ }
1034
+ } else {
1035
+ const hypotheses = sectionBody(body, "## 4. 假设与证伪");
1036
+ const dataRows = hypotheses
1037
+ .split(/\r?\n/)
1038
+ .filter((line) => /^\|/.test(line.trim()))
1039
+ .filter((line) => !/^\|\s*(?:排名|-)/.test(line.trim()));
1040
+ if (dataRows.length) errors.push("diagnosis.md: hypotheses must be empty before red evidence");
1041
+ }
1042
+ if (!new Set(["pending", "clean", "registered"]).has(meta.cleanup_status)) {
1043
+ errors.push(`diagnosis.md: invalid cleanup_status ${meta.cleanup_status}`);
1044
+ }
1045
+ for (const heading of ["## 2. 红灯反馈回路", "## 3. 最小复现", "## 4. 假设与证伪", "## 5. 已确认根因", "## 6. 修复契约", "## 7. 清理"]) {
1046
+ if (!body.includes(heading)) errors.push(`diagnosis.md: missing '${heading}'`);
1047
+ }
1048
+ return { path, meta, body };
1049
+ }
1050
+
1051
+ function validateReviews(change, required, errors) {
1052
+ const root = join(change, "reviews");
1053
+ const paths = isDirectory(root)
1054
+ ? readdirSync(root).filter((name) => /^CR-\d{3,}\.md$/.test(name)).sort().map((name) => join(root, name))
1055
+ : [];
1056
+ if (required && !paths.length) errors.push("review stage requires a CR-###.md report");
1057
+ for (const path of paths) {
1058
+ const { meta, body } = parseFrontmatter(path);
1059
+ if (meta.schema_version !== 1 || meta.artifact !== "code-review") {
1060
+ errors.push(`${basename(path)}: artifact/schema_version must be code-review/1`);
1061
+ }
1062
+ if (meta.change !== basename(change)) errors.push(`${basename(path)}: change must equal directory name`);
1063
+ if (!/^CR-\d{3,}$/.test(String(meta.review_id ?? ""))) errors.push(`${basename(path)}: invalid review_id`);
1064
+ if (!/^[a-f0-9]{7,40}$/i.test(String(meta.fixed_point ?? "")) || !/^[a-f0-9]{7,40}$/i.test(String(meta.head ?? ""))) {
1065
+ errors.push(`${basename(path)}: fixed_point and head must be commit SHAs`);
1066
+ }
1067
+ if (meta.fixed_point === meta.head) errors.push(`${basename(path)}: fixed_point and head must differ`);
1068
+ if (!new Set(["approved", "request-changes", "blocked"]).has(meta.status)) errors.push(`${basename(path)}: invalid status`);
1069
+ if (!new Set(["pass", "request-changes", "skipped"]).has(meta.standards_result)) errors.push(`${basename(path)}: invalid standards_result`);
1070
+ if (!new Set(["pass", "request-changes", "skipped"]).has(meta.specification_result)) errors.push(`${basename(path)}: invalid specification_result`);
1071
+ for (const heading of ["## Fixed Input", "## 标准", "## 规范", "## Summary"]) {
1072
+ if (!body.includes(heading)) errors.push(`${basename(path)}: missing '${heading}'`);
1073
+ }
1074
+ }
1075
+ return paths;
1076
+ }
1077
+
1078
+ function validatePrototypes(change, required, errors) {
1079
+ const root = join(change, "prototypes");
1080
+ const paths = isDirectory(root)
1081
+ ? walk(root).filter((path) => isFile(path) && basename(path) === "record.md").sort()
1082
+ : [];
1083
+ if (required && !paths.length) errors.push("prototype stage requires a prototypes/<id>/record.md artifact");
1084
+ for (const path of paths) {
1085
+ const { meta, body } = parseFrontmatter(path);
1086
+ const label = toPosix(relative(change, path));
1087
+ if (meta.schema_version !== 1 || meta.artifact !== "prototype-record") {
1088
+ errors.push(`${label}: artifact/schema_version must be prototype-record/1`);
1089
+ }
1090
+ if (meta.change !== basename(change)) errors.push(`${label}: change must equal directory name`);
1091
+ if (!/^PROTO-\d{3,}$/.test(String(meta.prototype_id ?? ""))) errors.push(`${label}: invalid prototype_id`);
1092
+ if (!new Set(["logic", "ui"]).has(meta.branch)) errors.push(`${label}: invalid prototype branch`);
1093
+ if (!new Set(["active", "answered", "blocked", "discarded"]).has(meta.status)) errors.push(`${label}: invalid prototype status`);
1094
+ if (typeof meta.workspace_ref !== "string" || ABSOLUTE_MACHINE_PATH_RE.test(meta.workspace_ref)) {
1095
+ errors.push(`${label}: workspace_ref must be a portable locator`);
1096
+ }
1097
+ if (meta.status === "answered" && (!String(meta.winner ?? "").trim() || !String(meta.promotion_target ?? "").trim())) {
1098
+ errors.push(`${label}: answered prototype requires winner and promotion_target`);
1099
+ }
1100
+ if (!new Set(["pending", "clean", "registered"]).has(meta.cleanup_status)) errors.push(`${label}: invalid cleanup_status`);
1101
+ for (const heading of ["## Question and Assumption", "## Run and Assets", "## Evaluation", "## Promotion and Cleanup"]) {
1102
+ if (!body.includes(heading)) errors.push(`${label}: missing '${heading}'`);
1103
+ }
1104
+ }
1105
+ return paths;
1106
+ }
1107
+
751
1108
  function validateSpec(path, errors, warnings) {
752
1109
  if (!isFile(path)) {
753
1110
  warnings.push("Spec is missing; contract traceability cannot be fully checked");
@@ -798,6 +1155,14 @@ function validateMap(path, errors) {
798
1155
  return { path, meta, body };
799
1156
  }
800
1157
 
1158
+ function validateDelegatedGoalPlan(body, label, errors) {
1159
+ if (!DELEGATED_GOAL_PLAN_TRIGGERS.some((marker) => body.includes(marker))) return;
1160
+ const missing = REQUIRED_DELEGATED_GOAL_PLAN_MARKERS.filter((marker) => !body.includes(marker));
1161
+ if (missing.length) {
1162
+ errors.push(`${label}: incomplete delegated execution addendum; missing ${JSON.stringify(missing)}`);
1163
+ }
1164
+ }
1165
+
801
1166
  function validateGoalPlan(path, errors) {
802
1167
  if (!isFile(path)) return null;
803
1168
  const { meta, body } = parseFrontmatter(path);
@@ -809,6 +1174,7 @@ function validateGoalPlan(path, errors) {
809
1174
  if (invalidModes.length) {
810
1175
  errors.push(`${basename(path)}: invalid modes ${JSON.stringify(invalidModes)}`);
811
1176
  }
1177
+ validateDelegatedGoalPlan(body, basename(path), errors);
812
1178
  if (meta.ready_for_execution === true) {
813
1179
  if (!new Set(["ready", "in_progress", "completed"]).has(meta.status)) {
814
1180
  errors.push(`${basename(path)}: ready_for_execution conflicts with status`);
@@ -1126,6 +1492,30 @@ function validateChangeStatus(path, expectedChange, errors) {
1126
1492
  if (!Array.isArray(data.blockers) || !Array.isArray(data.deviations)) {
1127
1493
  errors.push(`${basename(path)}: blockers and deviations must be arrays`);
1128
1494
  }
1495
+ if (data.worktrees !== undefined && !Array.isArray(data.worktrees)) {
1496
+ errors.push(`${basename(path)}: worktrees must be an array when present`);
1497
+ }
1498
+ for (const worktree of Array.isArray(data.worktrees) ? data.worktrees : []) {
1499
+ if (!worktree || typeof worktree !== "object" || Array.isArray(worktree)) {
1500
+ errors.push(`${basename(path)}: invalid worktree entry`);
1501
+ continue;
1502
+ }
1503
+ const ref = worktree.workspace_ref;
1504
+ if (
1505
+ typeof ref !== "string" ||
1506
+ ref.startsWith("/") ||
1507
+ /^[A-Za-z]:[\\/]/.test(ref)
1508
+ ) {
1509
+ errors.push(`${basename(path)}: workspace_ref must be a portable non-absolute locator`);
1510
+ continue;
1511
+ }
1512
+ if (worktree.provider === "git") {
1513
+ const expectedRef = `specdev-worktree/${worktree.ticket_id}`;
1514
+ if (ref !== expectedRef) {
1515
+ errors.push(`${basename(path)}: git workspace_ref must equal ${expectedRef}`);
1516
+ }
1517
+ }
1518
+ }
1129
1519
  if (data.change_status === "archived") {
1130
1520
  if (data.archived !== true) errors.push(`${basename(path)}: archived status requires archived=true`);
1131
1521
  const inner = typeof data.archive_path === "string" ? stripPathTag(data.archive_path) : null;
@@ -1138,7 +1528,7 @@ function validateChangeStatus(path, expectedChange, errors) {
1138
1528
  return data;
1139
1529
  }
1140
1530
 
1141
- function validateChange(change) {
1531
+ function validateChange(change, stage = null) {
1142
1532
  const errors = [];
1143
1533
  const warnings = [];
1144
1534
  if (!isDirectory(change)) {
@@ -1146,11 +1536,48 @@ function validateChange(change) {
1146
1536
  }
1147
1537
 
1148
1538
  const changeStatus = validateChangeStatus(join(change, ".status.json"), basename(change), errors);
1149
- const spec = validateSpec(join(change, "spec.md"), errors, warnings);
1150
- const ticketsMap = validateMap(join(change, "tickets-map.md"), errors);
1151
- const goalPlan = validateGoalPlan(join(change, "goal-plan.md"), errors);
1539
+ if (isFile(join(change, "source-issue.md"))) {
1540
+ errors.push("obsolete source-issue.md is forbidden; use source.md without compatibility fallback");
1541
+ }
1542
+ const sourceRequired = stage === "triage";
1543
+ const sourcePath = join(change, "source.md");
1544
+ const source = isFile(sourcePath) || sourceRequired
1545
+ ? validateSource(sourcePath, basename(change), errors)
1546
+ : null;
1547
+ const triagePath = join(change, "triage.md");
1548
+ const triage = isFile(triagePath) || sourceRequired
1549
+ ? validateTriage(triagePath, basename(change), errors)
1550
+ : null;
1551
+ const diagnosisPath = join(change, "diagnosis.md");
1552
+ if (isFile(diagnosisPath) || stage === "diagnosis") {
1553
+ validateDiagnosis(diagnosisPath, basename(change), errors);
1554
+ }
1555
+ validateReviews(change, stage === "review", errors);
1556
+ validatePrototypes(change, stage === "prototype", errors);
1557
+
1558
+ const specRequired = new Set(["spec", "tickets", "goal-plan", "implement", "complete"]).has(stage);
1559
+ const specPath = join(change, "spec.md");
1560
+ const spec = isFile(specPath) || specRequired
1561
+ ? validateSpec(specPath, errors, warnings)
1562
+ : null;
1563
+ const ticketMode = isDirectory(join(change, "ticket"));
1564
+ const mapRequired = new Set(["tickets", "goal-plan"]).has(stage) || (stage === "implement" && ticketMode);
1565
+ const mapPath = join(change, "tickets-map.md");
1566
+ const ticketsMap = isFile(mapPath) || mapRequired ? validateMap(mapPath, errors) : null;
1567
+ const goalPlanPath = join(change, "goal-plan.md");
1568
+ const goalPlan = isFile(goalPlanPath) || stage === "goal-plan"
1569
+ ? validateGoalPlan(goalPlanPath, errors)
1570
+ : null;
1152
1571
  validateDesignTree(join(change, "design-tree.json"), change, errors);
1153
1572
  validateWayfinder(change, errors);
1573
+ if (stage === "grill") {
1574
+ for (const name of ["design-tree.json", "LOG.md", "CONTEXT.md", "ADR.md"]) {
1575
+ if (!isFile(join(change, name))) errors.push(`grill stage requires ${name}`);
1576
+ }
1577
+ }
1578
+ if (stage === "wayfinder" && !isFile(join(change, "wayfinder-map.md"))) {
1579
+ errors.push("wayfinder stage requires wayfinder-map.md");
1580
+ }
1154
1581
  for (const artifact of [spec, ticketsMap, goalPlan]) {
1155
1582
  if (artifact && artifact.meta.change !== basename(change)) {
1156
1583
  errors.push(`${basename(artifact.path)}: change must equal directory name ${basename(change)}`);
@@ -1158,13 +1585,17 @@ function validateChange(change) {
1158
1585
  }
1159
1586
 
1160
1587
  const ticketDir = join(change, "ticket");
1161
- if (!isDirectory(ticketDir)) return { errors: [...errors, "missing Ticket directory"], warnings };
1162
- const ticketFiles = readdirSync(ticketDir)
1163
- .filter((name) => name.endsWith(".md"))
1164
- .sort()
1165
- .map((name) => join(ticketDir, name));
1166
- if (!ticketFiles.length) {
1167
- return { errors: [...errors, "Ticket directory contains no Markdown tickets"], warnings };
1588
+ const ticketsRequired = new Set(["tickets", "goal-plan"]).has(stage) || (stage === "implement" && ticketMode);
1589
+ const ticketFiles = isDirectory(ticketDir)
1590
+ ? readdirSync(ticketDir)
1591
+ .filter((name) => name.endsWith(".md"))
1592
+ .sort()
1593
+ .map((name) => join(ticketDir, name))
1594
+ : [];
1595
+ if (ticketsRequired && !isDirectory(ticketDir)) errors.push("missing Ticket directory");
1596
+ if (ticketsRequired && !ticketFiles.length) errors.push("Ticket directory contains no Markdown tickets");
1597
+ if (stage === "implement" && !ticketMode && !isFile(join(change, "evidence", "direct-spec.md"))) {
1598
+ errors.push("Direct Spec implement stage requires evidence/direct-spec.md");
1168
1599
  }
1169
1600
 
1170
1601
  const tickets = new Map();
@@ -1263,7 +1694,7 @@ function validateChange(change) {
1263
1694
  );
1264
1695
  } else {
1265
1696
  warnings.push(
1266
- `concurrent Ready Tickets ${leftId}/${rightId} share owned paths; Lead serialization is required`,
1697
+ `concurrent Ready Tickets ${leftId}/${rightId} share owned paths; an explicit owner or serialization is required`,
1267
1698
  );
1268
1699
  }
1269
1700
  }
@@ -1282,9 +1713,10 @@ function validateChange(change) {
1282
1713
  "## 2. 修改范围",
1283
1714
  "## 3. 验收与合同映射",
1284
1715
  "## 4. 验证执行",
1285
- "## 5. 路径所有权审计",
1286
- "## 6. 偏差与决策",
1287
- "## 7. 残余风险与后续",
1716
+ "## 5. 双轴审查",
1717
+ "## 6. 路径所有权审计",
1718
+ "## 7. 偏差与决策",
1719
+ "## 8. 残余风险与后续",
1288
1720
  ]) {
1289
1721
  if (!text.includes(heading)) {
1290
1722
  errors.push(`${toPosix(relative(change, evidence))}: missing '${heading}'`);
@@ -1324,6 +1756,23 @@ function validateChange(change) {
1324
1756
  if (changeStatus.change_status === "archived") {
1325
1757
  warnings.push("validating an archived change in place; normally it lives under the archive root");
1326
1758
  }
1759
+ if (stage === "complete" && changeStatus.change_status !== "completed") {
1760
+ errors.push("complete stage requires change_status=completed");
1761
+ }
1762
+ }
1763
+ if (
1764
+ stage === "complete" &&
1765
+ !ticketFiles.length &&
1766
+ !isFile(join(change, "evidence", "direct-spec.md"))
1767
+ ) {
1768
+ errors.push("complete stage without Tickets requires evidence/direct-spec.md");
1769
+ }
1770
+ if (
1771
+ stage === "complete" &&
1772
+ triage &&
1773
+ new Set(["pending-close", "close-failed"]).has(triage.meta.external_action)
1774
+ ) {
1775
+ errors.push(`complete stage cannot archive external_action=${triage.meta.external_action}`);
1327
1776
  }
1328
1777
 
1329
1778
  for (const path of walk(change).filter((item) => isFile(item) && extname(item) === ".md")) {
@@ -1352,22 +1801,39 @@ function printResults(errors, warnings) {
1352
1801
  }
1353
1802
 
1354
1803
  function usage() {
1355
- console.error("Usage: node validate-specdev.mjs <change-directory> | --self-check");
1804
+ console.error("Usage: node validate-specdev.mjs [--stage <stage>] <change-directory> | --self-check");
1356
1805
  return 2;
1357
1806
  }
1358
1807
 
1359
1808
  function main(argv) {
1360
- const selfCheckRequested = argv.includes("--self-check");
1361
- const positional = argv.filter((arg) => !arg.startsWith("--"));
1809
+ let selfCheckRequested = false;
1810
+ let stage = null;
1811
+ const positional = [];
1812
+ for (let index = 0; index < argv.length; index += 1) {
1813
+ const arg = argv[index];
1814
+ if (arg === "--self-check") {
1815
+ selfCheckRequested = true;
1816
+ } else if (arg === "--stage") {
1817
+ stage = argv[index + 1] ?? null;
1818
+ index += 1;
1819
+ } else if (arg.startsWith("--stage=")) {
1820
+ stage = arg.slice("--stage=".length);
1821
+ } else if (arg.startsWith("--")) {
1822
+ return usage();
1823
+ } else {
1824
+ positional.push(arg);
1825
+ }
1826
+ }
1827
+ if (stage !== null && !VALID_STAGES.has(stage)) return usage();
1362
1828
  if (selfCheckRequested) {
1363
- if (positional.length) return usage();
1829
+ if (positional.length || stage !== null) return usage();
1364
1830
  const scriptDirectory = dirname(fileURLToPath(import.meta.url));
1365
1831
  const root = resolve(scriptDirectory, "..", "..");
1366
1832
  const result = selfCheck(root);
1367
1833
  return printResults(result.errors, result.warnings);
1368
1834
  }
1369
1835
  if (positional.length === 1) {
1370
- const result = validateChange(resolve(positional[0]));
1836
+ const result = validateChange(resolve(positional[0]), stage);
1371
1837
  return printResults(result.errors, result.warnings);
1372
1838
  }
1373
1839
  return usage();