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.
- package/README.md +53 -8
- package/bin/batch-runner.mjs +339 -0
- package/bin/demo.mjs +92 -7
- package/bin/engine/phases.mjs +24 -2
- package/bin/engine/state.mjs +8 -3
- package/bin/engine-cli.mjs +177 -5
- package/bin/forgedock.mjs +409 -26
- package/bin/hooks/interactive-engine.mjs +509 -0
- package/bin/hooks/pre-tool-use.mjs +498 -0
- package/bin/hooks/subagent-stop-enforce.mjs +197 -0
- package/bin/journey.mjs +72 -14
- package/bin/registry.mjs +139 -0
- package/bin/report.mjs +447 -0
- package/bin/runner.mjs +70 -20
- package/bin/settings-hook.mjs +235 -1
- package/commands/adopt.md +4 -1
- package/commands/analytics.md +4 -1
- package/commands/audit-agents.md +110 -27
- package/commands/audit.md +20 -1
- package/commands/autopilot.md +518 -361
- package/commands/changelog.md +2 -1
- package/commands/ci-audit.md +2 -1
- package/commands/cleanup.md +161 -1
- package/commands/compat-audit.md +2 -1
- package/commands/deploy-info.md +58 -1
- package/commands/deploy-pr.md +485 -0
- package/commands/design-bench.md +5 -1
- package/commands/design-render-critique-loop.md +1 -1
- package/commands/diagnose.md +2 -1
- package/commands/explain.md +2 -1
- package/commands/fix-ci.md +577 -0
- package/commands/forge-stats.md +121 -2
- package/commands/forgedock-init.md +1 -1
- package/commands/geo-audit.md +3 -0
- package/commands/incident-response.md +2 -1
- package/commands/issue.md +23 -2
- package/commands/milestone.md +77 -12
- package/commands/optimize.md +2 -1
- package/commands/orchestrate/config.md +67 -0
- package/commands/orchestrate/phase-1-resolve.md +159 -0
- package/commands/orchestrate/phase-2-triage.md +126 -0
- package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
- package/commands/orchestrate/phase-3-dependency.md +591 -0
- package/commands/orchestrate/phase-4-execution.md +962 -0
- package/commands/orchestrate/phase-5-cleanup.md +47 -0
- package/commands/orchestrate/phase-6-report.md +198 -0
- package/commands/orchestrate/safety.md +62 -0
- package/commands/orchestrate.md +25 -2045
- package/commands/pipeline-health/config.md +34 -0
- package/commands/pipeline-health/phase-1-context.md +180 -0
- package/commands/pipeline-health/phase-2-metrics.md +1729 -0
- package/commands/pipeline-health/phase-3-analyze.md +204 -0
- package/commands/pipeline-health/phase-4-proposals.md +117 -0
- package/commands/pipeline-health/phase-5-report.md +284 -0
- package/commands/pipeline-health/phase-6-summary.md +52 -0
- package/commands/pipeline-health.md +25 -2401
- package/commands/pipeline-resume.md +24 -1
- package/commands/pipeline-status.md +65 -0
- package/commands/qa-sweep.md +4 -1
- package/commands/quality-gate.md +248 -10
- package/commands/recover-orphans.md +576 -0
- package/commands/replay.md +2 -1
- package/commands/review-pr-agents/api.md +135 -0
- package/commands/review-pr-agents/auth.md +137 -0
- package/commands/review-pr-agents/billing.md +101 -0
- package/commands/review-pr-agents/concurrency.md +145 -0
- package/commands/review-pr-agents/database.md +103 -0
- package/commands/review-pr-agents/frontend.md +89 -0
- package/commands/review-pr-agents/infra.md +385 -0
- package/commands/review-pr-agents/protocols.md +185 -0
- package/commands/review-pr-agents/scraper.md +185 -0
- package/commands/review-pr-agents/security.md +406 -0
- package/commands/review-pr-agents.md +45 -1722
- package/commands/review-pr-staging.md +196 -40
- package/commands/review-pr.md +591 -52
- package/commands/rollback.md +2 -1
- package/commands/scope.md +2 -1
- package/commands/security-audit.md +2 -1
- package/commands/signal-planner.md +623 -0
- package/commands/sync-ecosystem.md +1 -0
- package/commands/test-gate.md +17 -1
- package/commands/validate.md +2 -1
- package/commands/work-on/build/architect.md +28 -0
- package/commands/work-on/build/context.md +2 -0
- package/commands/work-on/build/implement.md +3 -1
- package/commands/work-on/build/validate.md +26 -7
- package/commands/work-on/build.md +163 -24
- package/commands/work-on/close.md +130 -3
- package/commands/work-on/decompose.md +19 -1
- package/commands/work-on/investigate.md +117 -2
- package/commands/work-on/review.md +24 -4
- package/commands/work-on-monolithic.md +7 -2
- package/commands/work-on.md +202 -4
- package/docs/BRAND.md +14 -7
- package/docs/CI.md +16 -2
- package/docs/CODEX.md +36 -1
- package/docs/CONFIG.md +5 -8
- package/docs/FORGE-PROTOCOL.md +15 -1064
- package/docs/anthropic-devrel-outreach.md +2 -2
- package/docs/articles/command-decomposition-token-savings.md +214 -0
- package/docs/articles/model-release-playbook.md +131 -0
- package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
- package/docs/claude-breakpoints.json +75 -5
- package/docs/design/abc-benchmark.md +1 -1
- package/docs/design/design-spec-schema.md +1 -1
- package/docs/design/divergent-generation.md +1 -1
- package/docs/design/eval-gate-runbook.md +178 -0
- package/docs/design/show-hn-draft.md +26 -1
- package/docs/design/token-cost-baseline-1298.md +59 -0
- package/docs/eval/README.md +120 -0
- package/docs/eval/corpus/seed-v1.json +6 -0
- package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
- package/docs/site/annotations-explained.md +24 -0
- package/docs/site/command-learning-path.md +51 -14
- package/docs/site/command-reference.md +386 -111
- package/docs/site/for-companies.md +95 -0
- package/docs/site/forge-annotation-protocol.md +12 -256
- package/docs/site/getting-started.md +43 -4
- package/docs/site/github-is-the-memory.md +113 -0
- package/docs/site/how-it-works.md +3 -13
- package/docs/site/index.md +9 -1
- package/docs/spec/eval-run-result.md +147 -0
- package/docs/spec/forge-protocol-v1.md +441 -18
- package/docs/spec/label-state-machine.md +55 -0
- package/docs/spec/review-protocol.md +158 -0
- package/docs/spec-graph-schema.md +28 -0
- package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
- package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
- package/examples/forgedock-demo/README.md +41 -22
- package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
- package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
- package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
- package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
- package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
- package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
- package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
- package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
- package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
- package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
- package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
- package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
- package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
- package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
- package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
- package/examples/forgedock-demo/scripts/smoke.js +18 -1
- package/examples/forgedock-demo/src/db.js +36 -11
- package/examples/forgedock-demo/src/routes/notes.js +106 -9
- package/examples/forgedock-demo/src/server.js +4 -1
- package/package.json +3 -1
- package/packages/protocol/LICENSE +21 -0
- package/packages/protocol/src/cli.js +135 -0
- package/packages/protocol/src/emit.js +146 -0
- package/packages/protocol/src/index.js +43 -0
- package/packages/protocol/src/parse.js +254 -0
- package/packages/protocol/src/types.js +171 -0
- package/packages/protocol/src/validate.js +122 -0
- package/scripts/bench-topology-cost.mjs +372 -0
- package/scripts/check-command-docs-drift.sh +198 -0
- package/scripts/check-protocol-restatements.sh +176 -0
- package/scripts/check-registry/README.md +72 -0
- package/scripts/check-registry/manifest.json +6 -0
- package/scripts/code-index.sh +454 -0
- package/scripts/conformance-check.mjs +168 -0
- package/scripts/doctor-pipeline-state.sh +484 -0
- package/scripts/eval-gate-baseline.json +17 -0
- package/scripts/eval-gate-scorecard.mjs +242 -0
- package/scripts/eval-scorecard-aggregator.mjs +252 -0
- package/scripts/flaky-quarantine.sh +241 -0
- package/scripts/forge-annotation.sh +567 -0
- package/scripts/issue-dedup.sh +236 -0
- package/scripts/validate-annotation-node.mjs +233 -0
- package/scripts/worktree-lifecycle.sh +3 -3
- package/templates/devdocs/agent/using-forgedock.md +14 -0
- 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
|