forgedock 1.1.1 → 1.1.3

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 (174) hide show
  1. package/README.md +53 -8
  2. package/bin/batch-runner.mjs +339 -0
  3. package/bin/demo.mjs +92 -7
  4. package/bin/engine/phases.mjs +24 -2
  5. package/bin/engine/state.mjs +8 -3
  6. package/bin/engine-cli.mjs +177 -5
  7. package/bin/forgedock.mjs +409 -26
  8. package/bin/hooks/interactive-engine.mjs +509 -0
  9. package/bin/hooks/pre-tool-use.mjs +498 -0
  10. package/bin/hooks/subagent-stop-enforce.mjs +197 -0
  11. package/bin/journey.mjs +72 -14
  12. package/bin/registry.mjs +139 -0
  13. package/bin/report.mjs +447 -0
  14. package/bin/runner.mjs +70 -20
  15. package/bin/settings-hook.mjs +235 -1
  16. package/commands/adopt.md +4 -1
  17. package/commands/analytics.md +4 -1
  18. package/commands/audit-agents.md +110 -27
  19. package/commands/audit.md +20 -1
  20. package/commands/autopilot.md +518 -361
  21. package/commands/changelog.md +2 -1
  22. package/commands/ci-audit.md +2 -1
  23. package/commands/cleanup.md +161 -1
  24. package/commands/compat-audit.md +2 -1
  25. package/commands/deploy-info.md +58 -1
  26. package/commands/deploy-pr.md +485 -0
  27. package/commands/design-bench.md +5 -1
  28. package/commands/design-render-critique-loop.md +1 -1
  29. package/commands/diagnose.md +2 -1
  30. package/commands/explain.md +2 -1
  31. package/commands/fix-ci.md +577 -0
  32. package/commands/forge-stats.md +121 -2
  33. package/commands/forgedock-init.md +1 -1
  34. package/commands/geo-audit.md +3 -0
  35. package/commands/incident-response.md +2 -1
  36. package/commands/issue.md +23 -2
  37. package/commands/milestone.md +77 -12
  38. package/commands/optimize.md +2 -1
  39. package/commands/orchestrate/config.md +67 -0
  40. package/commands/orchestrate/phase-1-resolve.md +159 -0
  41. package/commands/orchestrate/phase-2-triage.md +126 -0
  42. package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
  43. package/commands/orchestrate/phase-3-dependency.md +591 -0
  44. package/commands/orchestrate/phase-4-execution.md +962 -0
  45. package/commands/orchestrate/phase-5-cleanup.md +47 -0
  46. package/commands/orchestrate/phase-6-report.md +198 -0
  47. package/commands/orchestrate/safety.md +62 -0
  48. package/commands/orchestrate.md +25 -2045
  49. package/commands/pipeline-health/config.md +34 -0
  50. package/commands/pipeline-health/phase-1-context.md +180 -0
  51. package/commands/pipeline-health/phase-2-metrics.md +1729 -0
  52. package/commands/pipeline-health/phase-3-analyze.md +204 -0
  53. package/commands/pipeline-health/phase-4-proposals.md +117 -0
  54. package/commands/pipeline-health/phase-5-report.md +284 -0
  55. package/commands/pipeline-health/phase-6-summary.md +52 -0
  56. package/commands/pipeline-health.md +25 -2401
  57. package/commands/pipeline-resume.md +24 -1
  58. package/commands/pipeline-status.md +65 -0
  59. package/commands/qa-sweep.md +4 -1
  60. package/commands/quality-gate.md +248 -10
  61. package/commands/recover-orphans.md +576 -0
  62. package/commands/replay.md +2 -1
  63. package/commands/review-pr-agents/api.md +135 -0
  64. package/commands/review-pr-agents/auth.md +137 -0
  65. package/commands/review-pr-agents/billing.md +101 -0
  66. package/commands/review-pr-agents/concurrency.md +145 -0
  67. package/commands/review-pr-agents/database.md +103 -0
  68. package/commands/review-pr-agents/frontend.md +89 -0
  69. package/commands/review-pr-agents/infra.md +385 -0
  70. package/commands/review-pr-agents/protocols.md +185 -0
  71. package/commands/review-pr-agents/scraper.md +185 -0
  72. package/commands/review-pr-agents/security.md +406 -0
  73. package/commands/review-pr-agents.md +45 -1722
  74. package/commands/review-pr-staging.md +196 -40
  75. package/commands/review-pr.md +591 -52
  76. package/commands/rollback.md +2 -1
  77. package/commands/scope.md +2 -1
  78. package/commands/security-audit.md +2 -1
  79. package/commands/signal-planner.md +623 -0
  80. package/commands/sync-ecosystem.md +1 -0
  81. package/commands/test-gate.md +17 -1
  82. package/commands/validate.md +2 -1
  83. package/commands/work-on/build/architect.md +28 -0
  84. package/commands/work-on/build/context.md +2 -0
  85. package/commands/work-on/build/implement.md +3 -1
  86. package/commands/work-on/build/validate.md +26 -7
  87. package/commands/work-on/build.md +163 -24
  88. package/commands/work-on/close.md +130 -3
  89. package/commands/work-on/decompose.md +19 -1
  90. package/commands/work-on/investigate.md +117 -2
  91. package/commands/work-on/review.md +24 -4
  92. package/commands/work-on-monolithic.md +7 -2
  93. package/commands/work-on.md +202 -4
  94. package/docs/BRAND.md +14 -7
  95. package/docs/CI.md +16 -2
  96. package/docs/CODEX.md +36 -1
  97. package/docs/CONFIG.md +5 -8
  98. package/docs/FORGE-PROTOCOL.md +15 -1064
  99. package/docs/anthropic-devrel-outreach.md +2 -2
  100. package/docs/articles/command-decomposition-token-savings.md +214 -0
  101. package/docs/articles/model-release-playbook.md +131 -0
  102. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
  103. package/docs/claude-breakpoints.json +75 -5
  104. package/docs/design/abc-benchmark.md +1 -1
  105. package/docs/design/design-spec-schema.md +1 -1
  106. package/docs/design/divergent-generation.md +1 -1
  107. package/docs/design/eval-gate-runbook.md +178 -0
  108. package/docs/design/show-hn-draft.md +26 -1
  109. package/docs/design/token-cost-baseline-1298.md +59 -0
  110. package/docs/eval/README.md +120 -0
  111. package/docs/eval/corpus/seed-v1.json +6 -0
  112. package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
  113. package/docs/site/annotations-explained.md +24 -0
  114. package/docs/site/command-learning-path.md +51 -14
  115. package/docs/site/command-reference.md +386 -111
  116. package/docs/site/for-companies.md +95 -0
  117. package/docs/site/forge-annotation-protocol.md +12 -256
  118. package/docs/site/getting-started.md +43 -4
  119. package/docs/site/github-is-the-memory.md +113 -0
  120. package/docs/site/how-it-works.md +3 -13
  121. package/docs/site/index.md +9 -1
  122. package/docs/spec/eval-run-result.md +147 -0
  123. package/docs/spec/forge-protocol-v1.md +441 -18
  124. package/docs/spec/label-state-machine.md +55 -0
  125. package/docs/spec/review-protocol.md +158 -0
  126. package/docs/spec-graph-schema.md +28 -0
  127. package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
  128. package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
  129. package/examples/forgedock-demo/README.md +41 -22
  130. package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
  131. package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
  132. package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
  133. package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
  134. package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
  135. package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
  136. package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
  137. package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
  138. package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
  139. package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
  140. package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
  141. package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
  142. package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
  143. package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
  144. package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
  145. package/examples/forgedock-demo/scripts/smoke.js +18 -1
  146. package/examples/forgedock-demo/src/db.js +36 -11
  147. package/examples/forgedock-demo/src/routes/notes.js +106 -9
  148. package/examples/forgedock-demo/src/server.js +4 -1
  149. package/package.json +3 -1
  150. package/packages/protocol/LICENSE +21 -0
  151. package/packages/protocol/src/cli.js +135 -0
  152. package/packages/protocol/src/emit.js +146 -0
  153. package/packages/protocol/src/index.js +43 -0
  154. package/packages/protocol/src/parse.js +254 -0
  155. package/packages/protocol/src/types.js +171 -0
  156. package/packages/protocol/src/validate.js +122 -0
  157. package/scripts/bench-topology-cost.mjs +372 -0
  158. package/scripts/check-command-docs-drift.sh +198 -0
  159. package/scripts/check-protocol-restatements.sh +176 -0
  160. package/scripts/check-registry/README.md +72 -0
  161. package/scripts/check-registry/manifest.json +6 -0
  162. package/scripts/code-index.sh +454 -0
  163. package/scripts/conformance-check.mjs +168 -0
  164. package/scripts/doctor-pipeline-state.sh +484 -0
  165. package/scripts/eval-gate-baseline.json +17 -0
  166. package/scripts/eval-gate-scorecard.mjs +242 -0
  167. package/scripts/eval-scorecard-aggregator.mjs +252 -0
  168. package/scripts/flaky-quarantine.sh +241 -0
  169. package/scripts/forge-annotation.sh +567 -0
  170. package/scripts/issue-dedup.sh +236 -0
  171. package/scripts/validate-annotation-node.mjs +233 -0
  172. package/scripts/worktree-lifecycle.sh +3 -3
  173. package/templates/devdocs/agent/using-forgedock.md +14 -0
  174. package/docs/site/.vitepress/config.mjs +0 -85
@@ -0,0 +1,122 @@
1
+ /**
2
+ * FORGE Annotation Protocol v1.0 — validator.
3
+ *
4
+ * validate(annotation) → { valid: boolean, errors: string[] }
5
+ *
6
+ * Checks:
7
+ * - Completion sentinel presence (§3.2) — for types that define one
8
+ * - Required field presence (§4.1 per-type field tables)
9
+ * - Required field enum values (Verdict, Confidence, Severity, Task Type)
10
+ * - Inline-value non-empty (§3.4)
11
+ * - Partial sentinel marking (§3.3) — generates a warning, not an error
12
+ *
13
+ * @license MIT
14
+ */
15
+
16
+ import { RESERVED_TYPES, SentinelState } from './types.js';
17
+
18
+ /**
19
+ * Validate a parsed annotation against the spec.
20
+ *
21
+ * @param {import('./index.js').ParsedAnnotation} annotation
22
+ * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
23
+ */
24
+ export function validate(annotation) {
25
+ if (!annotation || typeof annotation !== 'object') {
26
+ throw new TypeError('validate() expects a ParsedAnnotation object');
27
+ }
28
+
29
+ const errors = [];
30
+ const warnings = [];
31
+ const { type, sentinelState, inlineValue, fields, isReserved, isControl } = annotation;
32
+
33
+ // Unknown types: tolerate per §7.2.4 — no validation errors
34
+ if (!isReserved) {
35
+ return { valid: true, errors: [], warnings: [`Type "${type}" is not a reserved type; skipping field validation (§7.2.4)`] };
36
+ }
37
+
38
+ const typeDef = RESERVED_TYPES[type];
39
+
40
+ // Control markers: presence is the signal; no body or fields to validate
41
+ if (isControl) {
42
+ return { valid: true, errors: [], warnings: [] };
43
+ }
44
+
45
+ // Inline-value form (§3.4): value must be non-empty
46
+ if (typeDef.inlineValue) {
47
+ if (inlineValue === null || inlineValue === '') {
48
+ errors.push(`Inline-value annotation "${type}" must carry a non-empty value (§3.4)`);
49
+ }
50
+ return { valid: errors.length === 0, errors, warnings };
51
+ }
52
+
53
+ // Completion sentinel check (§3.2): annotations with a defined sentinel must be complete.
54
+ // Both PARTIAL and INTERRUPTED states mean the annotation is not safely consumable.
55
+ if (typeDef.completionSentinel) {
56
+ if (sentinelState === SentinelState.PARTIAL) {
57
+ // Partial: producer was interrupted and marked it — consumer SHOULD request re-run (§3.3).
58
+ // Treat as invalid since consumers MUST NOT treat it as complete (§3.2).
59
+ errors.push(
60
+ `Annotation "${type}" is marked PARTIAL (${typeDef.partialSentinel ?? 'PARTIAL sentinel'}) — not safe to consume (§3.2/§3.3)`,
61
+ );
62
+ warnings.push(
63
+ `Annotation "${type}" is marked PARTIAL — consumer SHOULD request re-run (§3.3)`,
64
+ );
65
+ } else if (sentinelState === SentinelState.INTERRUPTED) {
66
+ errors.push(
67
+ `Annotation "${type}" is missing its completion sentinel "<!-- ${typeDef.completionSentinel} -->" (§3.2)`,
68
+ );
69
+ }
70
+ }
71
+
72
+ // Required field checks per type
73
+ for (const fieldName of typeDef.requiredFields) {
74
+ if (!(fieldName in fields) || fields[fieldName] === '') {
75
+ errors.push(`Required field "${fieldName}" is missing or empty in "${type}" annotation`);
76
+ }
77
+ }
78
+
79
+ // INVESTIGATOR-specific enum validation
80
+ if (type === 'INVESTIGATOR') {
81
+ if (fields['Verdict'] && !typeDef.verdictValues.includes(fields['Verdict'])) {
82
+ errors.push(
83
+ `Invalid Verdict "${fields['Verdict']}" — must be one of: ${typeDef.verdictValues.join(', ')}`,
84
+ );
85
+ }
86
+ if (fields['Confidence'] && !typeDef.confidenceValues.includes(fields['Confidence'])) {
87
+ errors.push(
88
+ `Invalid Confidence "${fields['Confidence']}" — must be one of: ${typeDef.confidenceValues.join(', ')}`,
89
+ );
90
+ }
91
+ if (fields['Severity'] && !typeDef.severityValues.includes(fields['Severity'])) {
92
+ errors.push(
93
+ `Invalid Severity "${fields['Severity']}" — must be one of: ${typeDef.severityValues.join(', ')}`,
94
+ );
95
+ }
96
+ if (fields['Task Type'] && !typeDef.taskTypeValues.includes(fields['Task Type'])) {
97
+ errors.push(
98
+ `Invalid Task Type "${fields['Task Type']}" — must be one of: ${typeDef.taskTypeValues.join(', ')}`,
99
+ );
100
+ }
101
+ // Decomposition Assessment must start with YES or NO
102
+ if (fields['Decomposition Assessment']) {
103
+ const da = fields['Decomposition Assessment'];
104
+ if (!/^(YES|NO)\b/.test(da)) {
105
+ errors.push(
106
+ `Decomposition Assessment must start with "YES" or "NO", got: "${da}"`,
107
+ );
108
+ }
109
+ }
110
+ }
111
+
112
+ // REVIEWER-specific enum validation
113
+ if (type === 'REVIEWER') {
114
+ if (fields['Verdict'] && !typeDef.verdictValues.includes(fields['Verdict'])) {
115
+ errors.push(
116
+ `Invalid Verdict "${fields['Verdict']}" — must be one of: ${typeDef.verdictValues.join(', ')}`,
117
+ );
118
+ }
119
+ }
120
+
121
+ return { valid: errors.length === 0, errors, warnings };
122
+ }
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/bench-topology-cost.mjs — end-to-end token-cost benchmark for pipeline topologies (#1279)
4
+ *
5
+ * Deterministic, zero-dependency aggregator for comparing per-issue token cost across
6
+ * ForgeDock pipeline topologies (e.g. "spawned" vs "inline-sequential").
7
+ *
8
+ * Measures the cost reduction claim from the agent topology refactor (#1254):
9
+ * eliminating 6-8 fresh-context establishments per standard run reduces token cost.
10
+ *
11
+ * Benchmark corpus: the 5 seeded issues in examples/forgedock-demo/
12
+ * Issue 1 — Bug / security: DELETE is missing an auth check
13
+ * Issue 2 — Feature / security: Safe filtering for GET /notes
14
+ * Issue 3 — Refactor: Extract the router module
15
+ * Issue 4 — Performance: O(1) findById
16
+ * Issue 5 — Docs: Add an API reference
17
+ *
18
+ * Input schema (runs.json):
19
+ * {
20
+ * "runs": [
21
+ * {
22
+ * "topology": "spawned | inline-sequential",
23
+ * "date": "ISO-8601",
24
+ * "claude_model": "claude-sonnet-4-5 | ...",
25
+ * "issues": [
26
+ * {
27
+ * "number": 1,
28
+ * "title": "string",
29
+ * "input_tokens": 12345,
30
+ * "output_tokens": 2345,
31
+ * "cache_read_tokens": 1000,
32
+ * "cache_write_tokens": 500,
33
+ * "quality_gate_passed": true
34
+ * }
35
+ * ]
36
+ * }
37
+ * ]
38
+ * }
39
+ *
40
+ * Measurements can be populated from:
41
+ * 1. Session log parsing (when bin/runner.mjs emits usage metadata — see #1295)
42
+ * 2. Manual capture from Claude Code session summaries
43
+ *
44
+ * Usage:
45
+ * node scripts/bench-topology-cost.mjs <runs.json> # read file, print scorecard JSON
46
+ * node scripts/bench-topology-cost.mjs - # read runs JSON from stdin
47
+ * cat runs.json | node scripts/bench-topology-cost.mjs # (no arg) read from stdin
48
+ *
49
+ * Exit codes:
50
+ * 0 = scorecard emitted successfully
51
+ * 1 = invalid input (bad JSON, missing required fields, unknown topology)
52
+ * 2 = quality gate regression detected (scorecard still emitted to stdout)
53
+ */
54
+
55
+ const CORPUS_ISSUES = [1, 2, 3, 4, 5];
56
+ const KNOWN_TOPOLOGIES = new Set(["spawned", "inline-sequential"]);
57
+ const TOKEN_FIELDS = ["input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens"];
58
+
59
+ /**
60
+ * Total tokens billed for a single issue run.
61
+ * Cache reads are billed at a lower rate but still count against context budget.
62
+ * cache_write_tokens are not billed but indicate context establishment cost.
63
+ */
64
+ export function totalTokens(issue) {
65
+ return (issue.input_tokens || 0) + (issue.output_tokens || 0);
66
+ }
67
+
68
+ /**
69
+ * Total context establishment cost: input + cache_write (fresh-context proxy).
70
+ * Reducing cache_write_tokens is the primary signal for the topology refactor —
71
+ * fewer spawned agents means fewer fresh context establishments.
72
+ */
73
+ export function contextEstablishmentTokens(issue) {
74
+ return (issue.input_tokens || 0) + (issue.cache_write_tokens || 0);
75
+ }
76
+
77
+ /**
78
+ * Per-run aggregate: sum and per-issue breakdown.
79
+ */
80
+ export function aggregateRun(run) {
81
+ const issues = run.issues || [];
82
+ const perIssue = issues.map((iss) => ({
83
+ number: iss.number,
84
+ title: iss.title || `Issue #${iss.number}`,
85
+ total_tokens: totalTokens(iss),
86
+ context_tokens: contextEstablishmentTokens(iss),
87
+ input_tokens: iss.input_tokens || 0,
88
+ output_tokens: iss.output_tokens || 0,
89
+ cache_read_tokens: iss.cache_read_tokens || 0,
90
+ cache_write_tokens: iss.cache_write_tokens || 0,
91
+ quality_gate_passed: iss.quality_gate_passed ?? null,
92
+ }));
93
+
94
+ const totals = perIssue.reduce(
95
+ (acc, iss) => {
96
+ acc.total_tokens += iss.total_tokens;
97
+ acc.context_tokens += iss.context_tokens;
98
+ acc.input_tokens += iss.input_tokens;
99
+ acc.output_tokens += iss.output_tokens;
100
+ acc.cache_read_tokens += iss.cache_read_tokens;
101
+ acc.cache_write_tokens += iss.cache_write_tokens;
102
+ return acc;
103
+ },
104
+ {
105
+ total_tokens: 0,
106
+ context_tokens: 0,
107
+ input_tokens: 0,
108
+ output_tokens: 0,
109
+ cache_read_tokens: 0,
110
+ cache_write_tokens: 0,
111
+ },
112
+ );
113
+
114
+ const issueCount = perIssue.length;
115
+ const qualityGatePassed = perIssue.every((iss) => iss.quality_gate_passed !== false);
116
+ const qualityGateRegressions = perIssue.filter((iss) => iss.quality_gate_passed === false);
117
+
118
+ return {
119
+ topology: run.topology,
120
+ date: run.date || null,
121
+ claude_model: run.claude_model || null,
122
+ issue_count: issueCount,
123
+ per_issue: perIssue,
124
+ totals,
125
+ averages:
126
+ issueCount > 0
127
+ ? {
128
+ total_tokens: round(totals.total_tokens / issueCount),
129
+ context_tokens: round(totals.context_tokens / issueCount),
130
+ input_tokens: round(totals.input_tokens / issueCount),
131
+ output_tokens: round(totals.output_tokens / issueCount),
132
+ cache_read_tokens: round(totals.cache_read_tokens / issueCount),
133
+ cache_write_tokens: round(totals.cache_write_tokens / issueCount),
134
+ }
135
+ : null,
136
+ quality_gates: {
137
+ all_passed: qualityGatePassed,
138
+ regressions: qualityGateRegressions.map((iss) => iss.number),
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Compute delta between two aggregated runs (topology comparison).
145
+ * Positive delta means `after` uses MORE tokens than `before` (regression).
146
+ * Negative delta means `after` uses FEWER tokens (improvement).
147
+ */
148
+ export function computeDelta(before, after) {
149
+ if (!before || !after) return null;
150
+
151
+ const pct = (b, a) => (b === 0 ? null : round(((a - b) / b) * 100));
152
+
153
+ return {
154
+ total_tokens: {
155
+ before: before.totals.total_tokens,
156
+ after: after.totals.total_tokens,
157
+ delta: after.totals.total_tokens - before.totals.total_tokens,
158
+ delta_pct: pct(before.totals.total_tokens, after.totals.total_tokens),
159
+ },
160
+ context_tokens: {
161
+ before: before.totals.context_tokens,
162
+ after: after.totals.context_tokens,
163
+ delta: after.totals.context_tokens - before.totals.context_tokens,
164
+ delta_pct: pct(before.totals.context_tokens, after.totals.context_tokens),
165
+ },
166
+ cache_write_tokens: {
167
+ before: before.totals.cache_write_tokens,
168
+ after: after.totals.cache_write_tokens,
169
+ delta: after.totals.cache_write_tokens - before.totals.cache_write_tokens,
170
+ delta_pct: pct(before.totals.cache_write_tokens, after.totals.cache_write_tokens),
171
+ },
172
+ quality_gates_unchanged:
173
+ before.quality_gates.all_passed && after.quality_gates.all_passed,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Validate the runs file shape. Throws on any violation.
179
+ */
180
+ export function validate(data) {
181
+ if (!data || typeof data !== "object") throw new Error("runs file must be a JSON object");
182
+ if (!Array.isArray(data.runs) || data.runs.length === 0) {
183
+ throw new Error("runs file must contain a non-empty `runs` array");
184
+ }
185
+
186
+ for (let i = 0; i < data.runs.length; i++) {
187
+ const run = data.runs[i];
188
+ if (!run.topology) {
189
+ throw new Error(`runs[${i}] must have a "topology" field`);
190
+ }
191
+ if (!KNOWN_TOPOLOGIES.has(run.topology)) {
192
+ throw new Error(
193
+ `runs[${i}].topology "${run.topology}" is unknown. Valid values: ${[...KNOWN_TOPOLOGIES].join(", ")}`,
194
+ );
195
+ }
196
+ if (!Array.isArray(run.issues) || run.issues.length === 0) {
197
+ throw new Error(`runs[${i}] (topology: ${run.topology}) must have a non-empty "issues" array`);
198
+ }
199
+ if (run.issues.length !== CORPUS_ISSUES.length) {
200
+ process.stderr.write(
201
+ `WARNING: runs[${i}] (topology: ${run.topology}) has ${run.issues.length} issue(s) ` +
202
+ `but the standard corpus is ${CORPUS_ISSUES.length} issues. ` +
203
+ `Partial runs produce non-comparable deltas.\n`,
204
+ );
205
+ }
206
+ for (let j = 0; j < run.issues.length; j++) {
207
+ const iss = run.issues[j];
208
+ if (typeof iss.number !== "number") {
209
+ throw new Error(`runs[${i}].issues[${j}] must have a numeric "number" field`);
210
+ }
211
+ for (const field of TOKEN_FIELDS) {
212
+ if (iss[field] !== undefined && typeof iss[field] !== "number") {
213
+ throw new Error(
214
+ `runs[${i}].issues[${j}].${field} must be a number (got ${typeof iss[field]})`,
215
+ );
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Aggregate all runs and produce the scorecard.
224
+ * Throws on validation failure.
225
+ */
226
+ export function aggregate(data) {
227
+ validate(data);
228
+
229
+ const aggregated = data.runs.map(aggregateRun);
230
+
231
+ // Group by topology for comparison
232
+ const byTopology = {};
233
+ for (const run of aggregated) {
234
+ if (!byTopology[run.topology]) byTopology[run.topology] = [];
235
+ byTopology[run.topology].push(run);
236
+ }
237
+
238
+ // Pick the most recent run per topology for comparison
239
+ const latestByTopology = {};
240
+ for (const [topology, runs] of Object.entries(byTopology)) {
241
+ latestByTopology[topology] = runs[runs.length - 1];
242
+ }
243
+
244
+ // Compute delta: spawned (baseline) vs inline-sequential (after refactor)
245
+ const baseline = latestByTopology["spawned"] || null;
246
+ const refactored = latestByTopology["inline-sequential"] || null;
247
+ const delta = computeDelta(baseline, refactored);
248
+
249
+ // Quality gate regression check
250
+ const qualityRegressions = aggregated
251
+ .filter((r) => !r.quality_gates.all_passed)
252
+ .map((r) => ({ topology: r.topology, date: r.date, regressions: r.quality_gates.regressions }));
253
+
254
+ return {
255
+ corpus: {
256
+ description: "ForgeDock demo repo — 5 seeded issues (examples/forgedock-demo/)",
257
+ issue_numbers: CORPUS_ISSUES,
258
+ },
259
+ runs: aggregated,
260
+ topology_comparison: delta
261
+ ? {
262
+ baseline_topology: "spawned",
263
+ refactored_topology: "inline-sequential",
264
+ delta,
265
+ verdict:
266
+ delta.total_tokens.delta < 0
267
+ ? "IMPROVEMENT"
268
+ : delta.total_tokens.delta === 0
269
+ ? "NEUTRAL"
270
+ : "REGRESSION",
271
+ quality_gates_confirmed: delta.quality_gates_unchanged,
272
+ }
273
+ : null,
274
+ quality_gate_summary: {
275
+ all_runs_passed: qualityRegressions.length === 0,
276
+ regressions: qualityRegressions,
277
+ },
278
+ };
279
+ }
280
+
281
+ function round(x) {
282
+ return Math.round(x * 100) / 100;
283
+ }
284
+
285
+ async function readInput(arg) {
286
+ const { readFileSync } = await import("node:fs");
287
+ if (!arg || arg === "-") {
288
+ return readFileSync(0, "utf8"); // fd 0 = stdin
289
+ }
290
+ return readFileSync(arg, "utf8");
291
+ }
292
+
293
+ function printUsage() {
294
+ process.stdout.write(
295
+ [
296
+ "Usage:",
297
+ " node scripts/bench-topology-cost.mjs <runs.json>",
298
+ " node scripts/bench-topology-cost.mjs - (read from stdin)",
299
+ "",
300
+ "Input schema (runs.json):",
301
+ ' { "runs": [{ "topology": "spawned|inline-sequential", "date": "ISO-8601",',
302
+ ' "claude_model": "string", "issues": [{ "number": 1, "title": "string",',
303
+ ' "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0,',
304
+ ' "cache_write_tokens": 0, "quality_gate_passed": true }] }] }',
305
+ "",
306
+ "Benchmark corpus: examples/forgedock-demo/ issues #1–#5",
307
+ "Topologies measured: spawned (baseline) vs inline-sequential (refactored)",
308
+ "",
309
+ "Output: JSON scorecard to stdout",
310
+ "Exit 0 = OK, Exit 1 = invalid input, Exit 2 = quality gate regression",
311
+ ].join("\n") + "\n",
312
+ );
313
+ }
314
+
315
+ async function main() {
316
+ const arg = process.argv[2];
317
+
318
+ if (arg === "--help" || arg === "-h") {
319
+ printUsage();
320
+ process.exit(0);
321
+ }
322
+
323
+ if (!arg) {
324
+ // No argument and no piped input — print usage
325
+ const isTTY = process.stdin.isTTY;
326
+ if (isTTY) {
327
+ printUsage();
328
+ process.exit(0);
329
+ }
330
+ }
331
+
332
+ let raw;
333
+ try {
334
+ raw = await readInput(arg);
335
+ } catch (e) {
336
+ process.stderr.write(`ERROR: cannot read input: ${e.message}\n`);
337
+ process.exit(1);
338
+ }
339
+
340
+ let data;
341
+ try {
342
+ data = JSON.parse(raw);
343
+ } catch (e) {
344
+ process.stderr.write(`ERROR: invalid JSON: ${e.message}\n`);
345
+ process.exit(1);
346
+ }
347
+
348
+ let scorecard;
349
+ try {
350
+ scorecard = aggregate(data);
351
+ } catch (e) {
352
+ process.stderr.write(`ERROR: ${e.message}\n`);
353
+ process.exit(1);
354
+ }
355
+
356
+ process.stdout.write(JSON.stringify(scorecard, null, 2) + "\n");
357
+
358
+ if (!scorecard.quality_gate_summary.all_runs_passed) {
359
+ const regressions = scorecard.quality_gate_summary.regressions;
360
+ process.stderr.write(
361
+ `WARNING: quality gate regressions detected in ${regressions.length} run(s). ` +
362
+ `Issues with failures: ${regressions.map((r) => `[${r.topology}] #${r.regressions.join(", #")}`).join("; ")}\n`,
363
+ );
364
+ process.exit(2);
365
+ }
366
+ }
367
+
368
+ // Run as CLI only when invoked directly, not when imported by tests.
369
+ import { fileURLToPath } from "node:url";
370
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
371
+ main();
372
+ }
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env bash
2
+ # check-command-docs-drift.sh
3
+ #
4
+ # Drift check: every installable command spec in commands/ must have a section
5
+ # in docs/site/command-reference.md, and every command section in the reference
6
+ # must have a corresponding spec file.
7
+ #
8
+ # "Installable" means: install: core (or no install: key) in YAML frontmatter.
9
+ # Commands with install: internal or install: extras are excluded.
10
+ #
11
+ # Exit codes:
12
+ # 0 — no drift
13
+ # 1 — drift detected (missing docs or orphaned reference sections)
14
+ #
15
+ # Usage:
16
+ # bash scripts/check-command-docs-drift.sh [commands-dir] [reference-file]
17
+ #
18
+ # Defaults:
19
+ # commands-dir = commands/
20
+ # reference-file = docs/site/command-reference.md
21
+
22
+ set -euo pipefail
23
+
24
+ # Normalise COMMANDS_DIR: strip trailing slash so path arithmetic is consistent
25
+ # regardless of whether the caller passes "commands" or "commands/".
26
+ COMMANDS_DIR="${1:-commands}"
27
+ COMMANDS_DIR="${COMMANDS_DIR%/}"
28
+ REFERENCE="${2:-docs/site/command-reference.md}"
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Helpers
32
+ # ---------------------------------------------------------------------------
33
+
34
+ # get_install_tier <file>
35
+ # Reads the first `install:` key from YAML frontmatter. Echoes: core | extras | internal
36
+ get_install_tier() {
37
+ local file="$1"
38
+ # Read up to 20 lines looking for frontmatter and the install key.
39
+ local in_fm=0
40
+ local line_no=0
41
+ while IFS= read -r line; do
42
+ line_no=$(( line_no + 1 ))
43
+ if [ $line_no -eq 1 ]; then
44
+ if [[ "$line" == "---"* ]]; then in_fm=1; continue; fi
45
+ # No frontmatter — default to core
46
+ echo "core"; return
47
+ fi
48
+ if [ $in_fm -eq 1 ]; then
49
+ if [[ "$line" == "---"* ]]; then
50
+ # End of frontmatter — key not found, default to core
51
+ echo "core"; return
52
+ fi
53
+ if [[ "$line" =~ ^install:[[:space:]]*(.*) ]]; then
54
+ local val="${BASH_REMATCH[1]}"
55
+ # Strip surrounding quotes
56
+ val="${val#\"}" ; val="${val%\"}"
57
+ val="${val#\'}" ; val="${val%\'}"
58
+ val="${val// /}" # trim spaces
59
+ case "$val" in
60
+ internal|extras) echo "$val"; return ;;
61
+ core) echo "core"; return ;;
62
+ *) echo "core"; return ;;
63
+ esac
64
+ fi
65
+ fi
66
+ done < <(head -25 "$file")
67
+ echo "core"
68
+ }
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # 1. Collect installable command names from commands/
72
+ # ---------------------------------------------------------------------------
73
+
74
+ declare -a INSTALLABLE_COMMANDS=()
75
+
76
+ while IFS= read -r -d '' spec; do
77
+ tier="$(get_install_tier "$spec")"
78
+ if [[ "$tier" == "core" ]]; then
79
+ # Derive command name from path relative to COMMANDS_DIR.
80
+ # e.g. commands/work-on.md → /work-on
81
+ # commands/work-on/build.md → /work-on/build
82
+ # commands/work-on/build/impl.md → /work-on/build/impl
83
+ rel="${spec#${COMMANDS_DIR}/}"
84
+ cmd="/${rel%.md}"
85
+ INSTALLABLE_COMMANDS+=("$cmd")
86
+ fi
87
+ done < <(find "$COMMANDS_DIR" -name '*.md' -print0 | sort -z)
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # 2. Extract documented command names from command-reference.md
91
+ # ---------------------------------------------------------------------------
92
+ # We look for lines of the form: ### `/some-command`
93
+ # The reference uses backtick-wrapped command names under H3 headings.
94
+
95
+ declare -a DOCUMENTED_COMMANDS=()
96
+
97
+ while IFS= read -r line; do
98
+ # Match H3 heading: ### `/command-name` or ### `/command/sub`
99
+ if [[ "$line" =~ ^###[[:space:]]+\`(/[a-zA-Z0-9_/-]+)\` ]]; then
100
+ cmd="${BASH_REMATCH[1]}"
101
+ DOCUMENTED_COMMANDS+=("$cmd")
102
+ # Match table cell containing a bare command path: | `/command-name` | ...
103
+ # This covers the Sub-Phase Commands table which lists commands without H3 headings.
104
+ # We require the line to look like a markdown table row (starts with |) to avoid
105
+ # matching inline code examples in prose or fix-instruction blocks.
106
+ elif [[ "$line" =~ ^\| ]]; then
107
+ # Extract all backtick-wrapped tokens that look like command paths
108
+ while [[ "$line" =~ \`(/[a-zA-Z0-9_/-]+)\` ]]; do
109
+ cmd="${BASH_REMATCH[1]}"
110
+ DOCUMENTED_COMMANDS+=("$cmd")
111
+ # Remove the matched portion so the loop can find multiple tokens per line
112
+ line="${line#*\`${cmd}\`}"
113
+ done
114
+ fi
115
+ done < "$REFERENCE"
116
+
117
+ # Deduplicate DOCUMENTED_COMMANDS (table rows may repeat H3-listed commands)
118
+ declare -a _DEDUPED=()
119
+ declare -A _SEEN=()
120
+ for cmd in "${DOCUMENTED_COMMANDS[@]}"; do
121
+ if [[ -z "${_SEEN[$cmd]+x}" ]]; then
122
+ _DEDUPED+=("$cmd")
123
+ _SEEN[$cmd]=1
124
+ fi
125
+ done
126
+ DOCUMENTED_COMMANDS=("${_DEDUPED[@]}")
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # 3. Compare
130
+ # ---------------------------------------------------------------------------
131
+
132
+ FAIL=0
133
+
134
+ echo "=== ForgeDock command docs drift check ==="
135
+ echo ""
136
+ echo "Installable specs : ${#INSTALLABLE_COMMANDS[@]}"
137
+ echo "Documented commands: ${#DOCUMENTED_COMMANDS[@]}"
138
+ echo ""
139
+
140
+ # 3a. Installable commands missing from the reference
141
+ echo "--- Installable commands not documented in command-reference.md ---"
142
+ MISSING_DOCS=0
143
+ for cmd in "${INSTALLABLE_COMMANDS[@]}"; do
144
+ found=0
145
+ for doc_cmd in "${DOCUMENTED_COMMANDS[@]}"; do
146
+ if [[ "$doc_cmd" == "$cmd" ]]; then
147
+ found=1
148
+ break
149
+ fi
150
+ done
151
+ if [[ $found -eq 0 ]]; then
152
+ echo " MISSING DOC: $cmd"
153
+ MISSING_DOCS=$(( MISSING_DOCS + 1 ))
154
+ FAIL=1
155
+ fi
156
+ done
157
+ if [[ $MISSING_DOCS -eq 0 ]]; then
158
+ echo " OK — all installable commands are documented"
159
+ fi
160
+ echo ""
161
+
162
+ # 3b. Reference sections whose spec no longer exists (orphaned docs)
163
+ echo "--- Reference sections with no matching installable spec ---"
164
+ ORPHANED=0
165
+ for doc_cmd in "${DOCUMENTED_COMMANDS[@]}"; do
166
+ found=0
167
+ for cmd in "${INSTALLABLE_COMMANDS[@]}"; do
168
+ if [[ "$cmd" == "$doc_cmd" ]]; then
169
+ found=1
170
+ break
171
+ fi
172
+ done
173
+ if [[ $found -eq 0 ]]; then
174
+ echo " ORPHANED REF: $doc_cmd (no matching installable spec)"
175
+ ORPHANED=$(( ORPHANED + 1 ))
176
+ FAIL=1
177
+ fi
178
+ done
179
+ if [[ $ORPHANED -eq 0 ]]; then
180
+ echo " OK — all reference sections have a matching spec"
181
+ fi
182
+ echo ""
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # 4. Result
186
+ # ---------------------------------------------------------------------------
187
+
188
+ if [[ $FAIL -eq 1 ]]; then
189
+ echo "FAIL: command/docs drift detected."
190
+ echo ""
191
+ echo "Fix:"
192
+ echo " - MISSING DOC → add a section for the command to docs/site/command-reference.md"
193
+ echo " - ORPHANED REF → remove the section or restore the spec under commands/"
194
+ exit 1
195
+ else
196
+ echo "OK: no drift between commands/ and docs/site/command-reference.md"
197
+ exit 0
198
+ fi