@event4u/agent-config 9.17.0 → 9.18.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +79 -182
- package/dist/agent-src/commands/challenge-me/vision.md +5 -0
- package/dist/agent-src/commands/evals/rule-compliance-audit.json +46 -0
- package/dist/agent-src/commands/feature/plan.md +20 -0
- package/dist/agent-src/commands/feature/roadmap.md +19 -0
- package/dist/agent-src/commands/pr/create.md +29 -0
- package/dist/agent-src/commands/roadmap/create.md +29 -0
- package/dist/agent-src/commands/rule-compliance-audit.md +28 -5
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +133 -0
- package/dist/agent-src/rules/active-remediation.md +2 -0
- package/dist/agent-src/rules/architecture.md +2 -0
- package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
- package/dist/agent-src/rules/brand-consistency.md +10 -62
- package/dist/agent-src/rules/brand-source-of-truth.md +62 -11
- package/dist/agent-src/rules/broken-access-control.md +4 -0
- package/dist/agent-src/rules/cli-output-handling.md +5 -0
- package/dist/agent-src/rules/code-comment-discipline.md +3 -0
- package/dist/agent-src/rules/commit-conventions.md +2 -0
- package/dist/agent-src/rules/cross-source-consistency.md +3 -0
- package/dist/agent-src/rules/design-fidelity.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
- package/dist/agent-src/rules/docker-commands.md +4 -0
- package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
- package/dist/agent-src/rules/domain-safety-disclaimer.md +5 -2
- package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +9 -0
- package/dist/agent-src/rules/image-likeness-and-rights.md +4 -0
- package/dist/agent-src/rules/improve-before-implement.md +4 -0
- package/dist/agent-src/rules/laravel-routing.md +4 -0
- package/dist/agent-src/rules/legal-safety-floor.md +3 -0
- package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
- package/dist/agent-src/rules/media-governance-routing.md +5 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/no-roadmap-references.md +2 -0
- package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
- package/dist/agent-src/rules/php-coding.md +2 -0
- package/dist/agent-src/rules/prefer-enums-over-literals.md +3 -0
- package/dist/agent-src/rules/provider-lifecycle-discipline.md +5 -0
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/rule-type-governance.md +2 -0
- package/dist/agent-src/rules/scale-discipline.md +3 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +9 -2
- package/dist/agent-src/rules/senior-engineering-discipline.md +6 -0
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +4 -0
- package/dist/agent-src/rules/source-discovery-gate.md +3 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
- package/dist/agent-src/rules/think-before-action.md +5 -0
- package/dist/agent-src/rules/ui-audit-gate.md +2 -0
- package/dist/agent-src/skills/design-intelligence/SKILL.md +6 -180
- package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +100 -0
- package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +91 -0
- package/dist/agent-src/skills/design-review/SKILL.md +4 -141
- package/dist/agent-src/skills/design-review/references/review-communication.md +92 -0
- package/dist/agent-src/skills/design-review/references/verification-automation.md +61 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +8 -27
- package/dist/agent-src/skills/existing-ui-audit/references/anti-slop-cross-reference.md +31 -0
- package/dist/agent-src/skills/fe-design/SKILL.md +4 -289
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +227 -0
- package/dist/agent-src/skills/fe-design/references/design-read-and-memory.md +75 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +10 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +12 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +12 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
- package/dist/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-AIIUU73N.js +2040 -0
- package/dist/cli-delegate/{chunk-IMSXPRVH.js → chunk-ELMFOQPW.js} +11 -0
- package/dist/cli-delegate/chunk-LF2KKJ4Q.js +183 -0
- package/dist/cli-delegate/cmd_explain.js +36 -36
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +203 -2228
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +249 -0
- package/dist/cli-delegate/cmd_route_explain.js +285 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +57 -57
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/install/install.mjs +11 -0
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +12 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
- package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
- package/src/config/agent-settings.template.yml +46 -0
- package/src/config/evaluator-budgets.json +3 -3
- package/src/config/gate-coverage.yml +40 -0
- package/src/scripts/_cli/cmd_explain.ts +41 -36
- package/src/scripts/_cli/cmd_route_audit.ts +290 -0
- package/src/scripts/_cli/cmd_route_explain.ts +269 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/git_env.ts +40 -0
- package/src/scripts/_lib/md_table.ts +27 -0
- package/src/scripts/_lib/planning_settings.ts +43 -0
- package/src/scripts/_lib/router_match.ts +301 -0
- package/src/scripts/annotate_r1_outcomes.ts +258 -0
- package/src/scripts/check_completion_review.ts +1140 -0
- package/src/scripts/compile_router.ts +20 -3
- package/src/scripts/dispatch_r2_reviewer.ts +968 -0
- package/src/scripts/eval_ui_triviality.ts +156 -0
- package/src/scripts/lint_abstraction_thresholds.ts +3 -1
- package/src/scripts/lint_plan_risk_register.ts +931 -0
- package/src/scripts/lint_trigger_collisions.ts +257 -0
- package/src/scripts/router_telemetry.ts +16 -290
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/server/schemas/settings.ts +18 -0
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Gate R1 validator — plan-risk Risk Register grammar.
|
|
4
|
+
*
|
|
5
|
+
* Implements exactly `docs/contracts/plan-review-gates.md` § 1 (Risk Register
|
|
6
|
+
* grammar incl. the grandfather clause), § 3 (substantial-change heuristic)
|
|
7
|
+
* and § 6 (exit-code contract). A divergence from that contract is a
|
|
8
|
+
* validator bug, never a contract reinterpretation.
|
|
9
|
+
*
|
|
10
|
+
* Corpus: ready (non-draft) roadmap files directly under `agents/roadmaps/`
|
|
11
|
+
* (top level only — `archive/`, `skipped/`, `later/`, `stubs/` excluded, as
|
|
12
|
+
* are `template.md` and `dashboard*`). `status: draft` frontmatter exempts a
|
|
13
|
+
* file (reported as `draft-exempt`, still counted as scanned).
|
|
14
|
+
*
|
|
15
|
+
* Checks per ready file:
|
|
16
|
+
* 1. `## Risk Register` section exists — missing = fail unless the
|
|
17
|
+
* grandfather clause applies (no substantial change since the gate
|
|
18
|
+
* activation date, measured against the last pre-activation commit).
|
|
19
|
+
* 2. Marker line `<!-- risk-review: v1 | reviewed: YYYY-MM-DD | reviewer: <id> -->`
|
|
20
|
+
* is the first non-blank line after the heading.
|
|
21
|
+
* 3. Body is EITHER the exact honest-null line OR the six-column risk
|
|
22
|
+
* table (Rank ascending from 1, Risk type ∈ {product, implementation},
|
|
23
|
+
* Mitigation non-empty, every `Anchored under` anchor resolving to a
|
|
24
|
+
* heading / `**Step N:**` bullet in the same document).
|
|
25
|
+
* 4. Staleness: `reviewed:` may not predate the last substantial change
|
|
26
|
+
* (feature comparison against the newest commit on/before the reviewed
|
|
27
|
+
* date). Skipped while the file has uncommitted modifications — there is
|
|
28
|
+
* no committed baseline for the current content to compare against.
|
|
29
|
+
*
|
|
30
|
+
* Exit codes (contract § 6): 0 = pass, 1 = policy violation,
|
|
31
|
+
* 2 = internal error (degraded advisory mode for callers).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { execFileSync } from 'node:child_process';
|
|
35
|
+
import * as crypto from 'node:crypto';
|
|
36
|
+
import * as fs from 'node:fs';
|
|
37
|
+
import * as path from 'node:path';
|
|
38
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
39
|
+
|
|
40
|
+
import { parse as parseYaml } from 'yaml';
|
|
41
|
+
|
|
42
|
+
import { gitEnv } from './_lib/git_env.js';
|
|
43
|
+
import { splitMarkdownRow } from './_lib/md_table.js';
|
|
44
|
+
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Gate activation date — committed source:
|
|
48
|
+
* docs/contracts/plan-review-gates.md § 1 (grandfather clause, council
|
|
49
|
+
* 2026-08-04). Ready roadmaps without a register are exempt while their
|
|
50
|
+
* content has had no substantial change since this date.
|
|
51
|
+
*/
|
|
52
|
+
export const RISK_REGISTER_GATE_ACTIVATION = '2026-08-04';
|
|
53
|
+
|
|
54
|
+
const MARKER_RE =
|
|
55
|
+
/^<!--\s*risk-review:\s*v1\s*\|\s*reviewed:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*reviewer:\s*(.+?)\s*-->\s*$/;
|
|
56
|
+
const HONEST_NULL_RE = /^\*\*Honest-null:\*\*(.*)$/;
|
|
57
|
+
const CHECKBOX_RE = /^\s*- \[( |x|~|-)\]/;
|
|
58
|
+
const PHASE_HEADING_RE = /^##\s+(Phase|Milestone)\b.*$/;
|
|
59
|
+
const FENCE_RE = /^\s*```/;
|
|
60
|
+
const STEP_BULLET_RE = /\*\*Step\s+([0-9]+[a-z]?):\*\*/i;
|
|
61
|
+
|
|
62
|
+
export interface Violation {
|
|
63
|
+
file: string;
|
|
64
|
+
line: number;
|
|
65
|
+
kind: string;
|
|
66
|
+
detail: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PlanFeatures {
|
|
70
|
+
/** Sorted `## Phase …` / `## Milestone …` heading texts. */
|
|
71
|
+
phaseHeadings: string[];
|
|
72
|
+
/** Total count of deliverable checkbox lines (state-insensitive). */
|
|
73
|
+
checkboxCount: number;
|
|
74
|
+
/** sha256 of the `## Acceptance Criteria` section body (checkbox state normalized). */
|
|
75
|
+
acHash: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function _splitLines(text: string): string[] {
|
|
79
|
+
return text.split(/\r?\n/);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Lines of the document with an outside-code-fence flag per line. */
|
|
83
|
+
function _linesOutsideFences(text: string): Array<{ line: string; outside: boolean }> {
|
|
84
|
+
const out: Array<{ line: string; outside: boolean }> = [];
|
|
85
|
+
let inFence = false;
|
|
86
|
+
for (const line of _splitLines(text)) {
|
|
87
|
+
if (FENCE_RE.test(line)) {
|
|
88
|
+
inFence = !inFence;
|
|
89
|
+
out.push({ line, outside: false });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
out.push({ line, outside: !inFence });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract the three substantial-change features of contract § 3:
|
|
99
|
+
* (a) phase/milestone heading set, (b) checkbox line count,
|
|
100
|
+
* (c) sha256 of the Acceptance Criteria section body. Checkbox STATE is
|
|
101
|
+
* normalized everywhere (state flips are never substantial).
|
|
102
|
+
*/
|
|
103
|
+
export function extractFeatures(text: string): PlanFeatures {
|
|
104
|
+
const lines = _linesOutsideFences(text);
|
|
105
|
+
const phaseHeadings: string[] = [];
|
|
106
|
+
let checkboxCount = 0;
|
|
107
|
+
let acStart = -1;
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
const entry = lines[i] as { line: string; outside: boolean };
|
|
110
|
+
if (!entry.outside) continue;
|
|
111
|
+
if (PHASE_HEADING_RE.test(entry.line)) {
|
|
112
|
+
phaseHeadings.push(entry.line.trim());
|
|
113
|
+
}
|
|
114
|
+
if (CHECKBOX_RE.test(entry.line)) {
|
|
115
|
+
checkboxCount += 1;
|
|
116
|
+
}
|
|
117
|
+
if (/^##\s+Acceptance Criteria\s*$/.test(entry.line)) {
|
|
118
|
+
acStart = i;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
let acBody = '';
|
|
122
|
+
if (acStart !== -1) {
|
|
123
|
+
const bodyLines: string[] = [];
|
|
124
|
+
for (let i = acStart + 1; i < lines.length; i++) {
|
|
125
|
+
const entry = lines[i] as { line: string; outside: boolean };
|
|
126
|
+
if (entry.outside && /^##\s/.test(entry.line)) break;
|
|
127
|
+
bodyLines.push(entry.line);
|
|
128
|
+
}
|
|
129
|
+
// Checkbox state flips inside AC are never substantial — normalize.
|
|
130
|
+
acBody = bodyLines.join('\n').replace(/^(\s*- )\[( |x|~|-)\]/gm, '$1[ ]');
|
|
131
|
+
}
|
|
132
|
+
const acHash = crypto.createHash('sha256').update(acBody, 'utf-8').digest('hex');
|
|
133
|
+
return { phaseHeadings: [...phaseHeadings].sort(), checkboxCount, acHash };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function featuresEqual(a: PlanFeatures, b: PlanFeatures): boolean {
|
|
137
|
+
return (
|
|
138
|
+
a.checkboxCount === b.checkboxCount &&
|
|
139
|
+
a.acHash === b.acHash &&
|
|
140
|
+
a.phaseHeadings.length === b.phaseHeadings.length &&
|
|
141
|
+
a.phaseHeadings.every((h, i) => h === b.phaseHeadings[i])
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Contract § 3 — a plan diff is substantial iff the feature sets differ. */
|
|
146
|
+
export function isSubstantialChange(oldText: string, newText: string): boolean {
|
|
147
|
+
return !featuresEqual(extractFeatures(oldText), extractFeatures(newText));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface Marker {
|
|
151
|
+
reviewed: string;
|
|
152
|
+
reviewer: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Parse the § 1.1 marker line; null when malformed. */
|
|
156
|
+
export function parseMarker(line: string): Marker | null {
|
|
157
|
+
const m = MARKER_RE.exec(line.trim());
|
|
158
|
+
if (!m) return null;
|
|
159
|
+
const reviewed = m[1] as string;
|
|
160
|
+
const reviewer = (m[2] as string).trim();
|
|
161
|
+
if (reviewer === '') return null;
|
|
162
|
+
return { reviewed, reviewer };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** § 1.3 — exact honest-null grammar: `**Honest-null:** … because: <non-empty>.` */
|
|
166
|
+
export function isValidHonestNull(line: string): boolean {
|
|
167
|
+
const m = HONEST_NULL_RE.exec(line.trim());
|
|
168
|
+
if (!m) return false;
|
|
169
|
+
const rest = m[1] as string;
|
|
170
|
+
const because = /\bbecause:\s*(.*)$/.exec(rest);
|
|
171
|
+
if (!because) return false;
|
|
172
|
+
const reason = (because[1] as string).replace(/\.\s*$/, '').trim();
|
|
173
|
+
return reason !== '';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _escapeRe(s: string): string {
|
|
177
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** § 1.2 — does an `Anchored under` anchor resolve inside the same document? */
|
|
181
|
+
export function anchorResolves(anchor: string, docText: string): boolean {
|
|
182
|
+
const a = anchor.trim();
|
|
183
|
+
if (a === '') return false;
|
|
184
|
+
const aLower = a.toLowerCase();
|
|
185
|
+
const lines = _linesOutsideFences(docText);
|
|
186
|
+
const headingLines: string[] = [];
|
|
187
|
+
const stepBulletLines: string[] = [];
|
|
188
|
+
for (const entry of lines) {
|
|
189
|
+
if (!entry.outside) continue;
|
|
190
|
+
if (/^#{1,6}\s/.test(entry.line)) headingLines.push(entry.line.toLowerCase());
|
|
191
|
+
if (STEP_BULLET_RE.test(entry.line)) stepBulletLines.push(entry.line.toLowerCase());
|
|
192
|
+
}
|
|
193
|
+
if (headingLines.some((h) => h.includes(aLower))) return true;
|
|
194
|
+
if (stepBulletLines.some((b) => b.includes(aLower))) return true;
|
|
195
|
+
// `Phase X Step Y` / `Phase X` compound form: "Phase X" must appear in a
|
|
196
|
+
// heading; a given "Step Y" must appear as a `**Step Y:**` bullet.
|
|
197
|
+
const compound = /^phase\s+(\S+?)(?:\s+step\s+([0-9]+[a-z]?))?$/i.exec(a);
|
|
198
|
+
if (compound) {
|
|
199
|
+
const phaseRe = new RegExp(`\\bphase\\s+${_escapeRe(compound[1] as string)}\\b`, 'i');
|
|
200
|
+
if (headingLines.some((h) => phaseRe.test(h))) {
|
|
201
|
+
const step = compound[2];
|
|
202
|
+
if (step === undefined) return true;
|
|
203
|
+
const stepRe = new RegExp(`\\*\\*step\\s+${_escapeRe(step)}:\\*\\*`, 'i');
|
|
204
|
+
if (stepRe.test(docText)) return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const TABLE_HEADER_CELLS = ['Rank', 'Item', 'Risk type', 'Description', 'Mitigation', 'Anchored under'];
|
|
211
|
+
|
|
212
|
+
function _splitRow(line: string): string[] {
|
|
213
|
+
return splitMarkdownRow(line);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function _isHeaderRow(line: string): boolean {
|
|
217
|
+
const cells = _splitRow(line);
|
|
218
|
+
return (
|
|
219
|
+
cells.length === TABLE_HEADER_CELLS.length &&
|
|
220
|
+
cells.every((c, i) => c === TABLE_HEADER_CELLS[i])
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _isSeparatorRow(line: string): boolean {
|
|
225
|
+
const cells = _splitRow(line);
|
|
226
|
+
return cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c) || /^-+$/.test(c));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* § 1.2 — validate the risk table rows found in `bodyLines` (section body
|
|
231
|
+
* after the marker). `startLine` is the 1-based document line of the first
|
|
232
|
+
* body line, used for violation line numbers. Returns violations; the caller
|
|
233
|
+
* decides emptiness handling.
|
|
234
|
+
*/
|
|
235
|
+
export function validateTable(
|
|
236
|
+
file: string,
|
|
237
|
+
docText: string,
|
|
238
|
+
bodyLines: readonly string[],
|
|
239
|
+
startLine: number,
|
|
240
|
+
): { headerFound: boolean; rowCount: number; violations: Violation[] } {
|
|
241
|
+
const violations: Violation[] = [];
|
|
242
|
+
let headerFound = false;
|
|
243
|
+
let rowCount = 0;
|
|
244
|
+
let prevRank = 0;
|
|
245
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
246
|
+
const line = bodyLines[i] as string;
|
|
247
|
+
const lineNo = startLine + i;
|
|
248
|
+
if (!line.trim().startsWith('|')) {
|
|
249
|
+
if (headerFound && line.trim() !== '') break; // table ended
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!headerFound) {
|
|
253
|
+
if (_isHeaderRow(line)) headerFound = true;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (_isSeparatorRow(line)) continue;
|
|
257
|
+
const cells = _splitRow(line);
|
|
258
|
+
if (cells.length < TABLE_HEADER_CELLS.length) {
|
|
259
|
+
violations.push({
|
|
260
|
+
file,
|
|
261
|
+
line: lineNo,
|
|
262
|
+
kind: 'malformed_row',
|
|
263
|
+
detail: `risk row has ${cells.length} cell(s), expected ${TABLE_HEADER_CELLS.length}`,
|
|
264
|
+
});
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
rowCount += 1;
|
|
268
|
+
const [rankCell, , riskType, , mitigation, anchoredUnder] = cells as [
|
|
269
|
+
string,
|
|
270
|
+
string,
|
|
271
|
+
string,
|
|
272
|
+
string,
|
|
273
|
+
string,
|
|
274
|
+
string,
|
|
275
|
+
];
|
|
276
|
+
const rank = /^\d+$/.test(rankCell) ? Number(rankCell) : NaN;
|
|
277
|
+
if (Number.isNaN(rank) || (prevRank === 0 && rank !== 1) || (prevRank !== 0 && rank <= prevRank)) {
|
|
278
|
+
violations.push({
|
|
279
|
+
file,
|
|
280
|
+
line: lineNo,
|
|
281
|
+
kind: 'bad_rank_order',
|
|
282
|
+
detail: `Rank \`${rankCell}\` — ranks must be strictly ascending integers starting at 1 (previous: ${prevRank})`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (!Number.isNaN(rank)) prevRank = rank;
|
|
286
|
+
if (riskType !== 'product' && riskType !== 'implementation') {
|
|
287
|
+
violations.push({
|
|
288
|
+
file,
|
|
289
|
+
line: lineNo,
|
|
290
|
+
kind: 'bad_risk_type',
|
|
291
|
+
detail: `Risk type \`${riskType}\` — must be \`product\` or \`implementation\``,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (mitigation === '') {
|
|
295
|
+
violations.push({
|
|
296
|
+
file,
|
|
297
|
+
line: lineNo,
|
|
298
|
+
kind: 'empty_mitigation',
|
|
299
|
+
detail: 'Mitigation cell is empty — every risk row needs a mitigation',
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (anchoredUnder === '') {
|
|
303
|
+
violations.push({
|
|
304
|
+
file,
|
|
305
|
+
line: lineNo,
|
|
306
|
+
kind: 'empty_anchor',
|
|
307
|
+
detail: '`Anchored under` cell is empty — every risk must anchor to a plan section',
|
|
308
|
+
});
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
for (const anchor of anchoredUnder.split(',')) {
|
|
312
|
+
const a = anchor.trim();
|
|
313
|
+
if (a === '') continue;
|
|
314
|
+
if (!anchorResolves(a, docText)) {
|
|
315
|
+
violations.push({
|
|
316
|
+
file,
|
|
317
|
+
line: lineNo,
|
|
318
|
+
kind: 'dangling_anchor',
|
|
319
|
+
detail: `\`Anchored under\` reference \`${a}\` does not resolve to any heading or \`**Step N:**\` bullet in this document`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { headerFound, rowCount, violations };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** `status: draft` in the YAML frontmatter block exempts a file. */
|
|
328
|
+
export function hasDraftStatus(text: string): boolean {
|
|
329
|
+
const lines = _splitLines(text);
|
|
330
|
+
if ((lines[0] ?? '').trim() !== '---') return false;
|
|
331
|
+
for (let i = 1; i < lines.length; i++) {
|
|
332
|
+
const line = lines[i] as string;
|
|
333
|
+
if (line.trim() === '---') return false;
|
|
334
|
+
if (/^status:\s*draft\s*$/.test(line)) return true;
|
|
335
|
+
}
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface ContentResult {
|
|
340
|
+
draftExempt: boolean;
|
|
341
|
+
registerMissing: boolean;
|
|
342
|
+
/** Parsed `reviewed:` date when the marker is valid; null otherwise. */
|
|
343
|
+
reviewed: string | null;
|
|
344
|
+
violations: Violation[];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Pure content-level Gate-R1 check (no git — grandfather/staleness live in checkFile). */
|
|
348
|
+
export function checkContent(file: string, text: string): ContentResult {
|
|
349
|
+
const result: ContentResult = {
|
|
350
|
+
draftExempt: false,
|
|
351
|
+
registerMissing: false,
|
|
352
|
+
reviewed: null,
|
|
353
|
+
violations: [],
|
|
354
|
+
};
|
|
355
|
+
if (hasDraftStatus(text)) {
|
|
356
|
+
result.draftExempt = true;
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
359
|
+
const lines = _linesOutsideFences(text);
|
|
360
|
+
let regIdx = -1;
|
|
361
|
+
for (let i = 0; i < lines.length; i++) {
|
|
362
|
+
const entry = lines[i] as { line: string; outside: boolean };
|
|
363
|
+
if (entry.outside && /^##\s+Risk Register\s*$/.test(entry.line)) {
|
|
364
|
+
regIdx = i;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (regIdx === -1) {
|
|
369
|
+
result.registerMissing = true;
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
let endIdx = lines.length;
|
|
373
|
+
for (let i = regIdx + 1; i < lines.length; i++) {
|
|
374
|
+
const entry = lines[i] as { line: string; outside: boolean };
|
|
375
|
+
if (entry.outside && /^##\s/.test(entry.line)) {
|
|
376
|
+
endIdx = i;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const sectionLines = lines.slice(regIdx + 1, endIdx).map((e) => e.line);
|
|
381
|
+
// Marker: first non-blank line after the heading.
|
|
382
|
+
let markerOffset = -1;
|
|
383
|
+
for (let i = 0; i < sectionLines.length; i++) {
|
|
384
|
+
if ((sectionLines[i] as string).trim() !== '') {
|
|
385
|
+
markerOffset = i;
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (markerOffset === -1) {
|
|
390
|
+
result.violations.push({
|
|
391
|
+
file,
|
|
392
|
+
line: regIdx + 1,
|
|
393
|
+
kind: 'missing_marker',
|
|
394
|
+
detail: 'Risk Register section is empty — missing `<!-- risk-review: v1 | reviewed: YYYY-MM-DD | reviewer: <id> -->` marker',
|
|
395
|
+
});
|
|
396
|
+
return result;
|
|
397
|
+
}
|
|
398
|
+
const markerLine = sectionLines[markerOffset] as string;
|
|
399
|
+
const marker = parseMarker(markerLine);
|
|
400
|
+
let bodyStartOffset = markerOffset; // body starts after the marker when valid
|
|
401
|
+
if (marker === null) {
|
|
402
|
+
result.violations.push({
|
|
403
|
+
file,
|
|
404
|
+
line: regIdx + 2 + markerOffset,
|
|
405
|
+
kind: 'malformed_marker',
|
|
406
|
+
detail:
|
|
407
|
+
'first non-blank line after `## Risk Register` must be ' +
|
|
408
|
+
'`<!-- risk-review: v1 | reviewed: YYYY-MM-DD | reviewer: <id> -->` ' +
|
|
409
|
+
`(got: \`${markerLine.trim()}\`)`,
|
|
410
|
+
});
|
|
411
|
+
} else {
|
|
412
|
+
result.reviewed = marker.reviewed;
|
|
413
|
+
bodyStartOffset = markerOffset + 1;
|
|
414
|
+
}
|
|
415
|
+
const bodyLines = sectionLines.slice(bodyStartOffset);
|
|
416
|
+
const bodyStartLine = regIdx + 2 + bodyStartOffset;
|
|
417
|
+
// Honest-null branch.
|
|
418
|
+
let honestNullSeen = false;
|
|
419
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
420
|
+
const line = (bodyLines[i] as string).trim();
|
|
421
|
+
if (!HONEST_NULL_RE.test(line)) continue;
|
|
422
|
+
honestNullSeen = true;
|
|
423
|
+
if (!isValidHonestNull(line)) {
|
|
424
|
+
result.violations.push({
|
|
425
|
+
file,
|
|
426
|
+
line: bodyStartLine + i,
|
|
427
|
+
kind: 'malformed_honest_null',
|
|
428
|
+
detail:
|
|
429
|
+
'honest-null line must be exactly ' +
|
|
430
|
+
'`**Honest-null:** no material product or implementation risks identified because: <reason>.` ' +
|
|
431
|
+
'with a non-empty reason after `because:`',
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
// The table is validated whenever one is PRESENT — never suppressed by the
|
|
437
|
+
// honest-null line. § 1.3 makes honest-null valid "only as exactly this
|
|
438
|
+
// shape", so honest-null + table is contradictory: gating validateTable on
|
|
439
|
+
// `!honestNullSeen` let such a register pass with zero row checks (bad
|
|
440
|
+
// ranks, bad risk types, empty mitigations, dangling anchors all
|
|
441
|
+
// unreported) — one honest-null line anywhere in the body disabled the whole
|
|
442
|
+
// table check. Gate R2's `evaluate` already reports the analogous
|
|
443
|
+
// skip-declaration-plus-table combination and still validates the rows; this
|
|
444
|
+
// is the same rule on the R1 side.
|
|
445
|
+
const { headerFound, rowCount, violations } = validateTable(file, text, bodyLines, bodyStartLine);
|
|
446
|
+
if (honestNullSeen) {
|
|
447
|
+
if (headerFound || rowCount > 0) {
|
|
448
|
+
result.violations.push(...violations);
|
|
449
|
+
result.violations.push({
|
|
450
|
+
file,
|
|
451
|
+
line: regIdx + 1,
|
|
452
|
+
kind: 'contradictory_register',
|
|
453
|
+
detail:
|
|
454
|
+
'Risk Register carries BOTH the honest-null line and a risk table — § 1.3 admits ' +
|
|
455
|
+
'honest-null only as the sole body content. Remove one: the table if there are ' +
|
|
456
|
+
'genuinely no risks, the honest-null line otherwise',
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
return result;
|
|
460
|
+
}
|
|
461
|
+
result.violations.push(...violations);
|
|
462
|
+
if (!headerFound || rowCount === 0) {
|
|
463
|
+
result.violations.push({
|
|
464
|
+
file,
|
|
465
|
+
line: regIdx + 1,
|
|
466
|
+
kind: 'empty_register',
|
|
467
|
+
detail:
|
|
468
|
+
'Risk Register is empty or prose-only — needs the six-column risk table ' +
|
|
469
|
+
'or the exact honest-null line (prose like "no risks" does not count)',
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
return result;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// --------------------------------------------------------------------------
|
|
476
|
+
// Git layer — grandfather clause + staleness.
|
|
477
|
+
// --------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Node's default `maxBuffer` is 1 MiB. `_git` reads whole file blobs
|
|
481
|
+
* (`git show <sha>:<path>`), and a plan larger than that would throw ENOBUFS →
|
|
482
|
+
* `null` → **no staleness baseline** → the check silently passes. Same
|
|
483
|
+
* self-disabling shape as the Gate-R2 scope diff, and here it fails OPEN, so the
|
|
484
|
+
* ceiling is raised rather than relied on.
|
|
485
|
+
*/
|
|
486
|
+
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
487
|
+
|
|
488
|
+
function _git(cwd: string, args: readonly string[]): string | null {
|
|
489
|
+
try {
|
|
490
|
+
return execFileSync('git', [...args], {
|
|
491
|
+
cwd,
|
|
492
|
+
encoding: 'utf-8',
|
|
493
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
494
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
495
|
+
// cwd decides, never an inherited GIT_DIR (hook environments).
|
|
496
|
+
env: gitEnv(),
|
|
497
|
+
});
|
|
498
|
+
} catch {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
interface HistoryEntry {
|
|
504
|
+
sha: string;
|
|
505
|
+
date: string; // %cs — YYYY-MM-DD
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Newest-first commit history of a file; empty for untracked / non-repo paths. */
|
|
509
|
+
export function fileHistory(absPath: string): HistoryEntry[] {
|
|
510
|
+
const dir = path.dirname(absPath);
|
|
511
|
+
const top = _git(dir, ['rev-parse', '--show-toplevel']);
|
|
512
|
+
if (top === null) return [];
|
|
513
|
+
const root = top.trim();
|
|
514
|
+
// Normalize separators: git pathspecs use `/` on every platform, so a raw
|
|
515
|
+
// Windows `path.relative()` result matches nothing and would silently yield
|
|
516
|
+
// an empty history — which the grandfather clause reads as "no baseline".
|
|
517
|
+
const rel = path.relative(root, absPath).split(path.sep).join('/');
|
|
518
|
+
const out = _git(root, ['log', '--format=%H %cs', '--', rel]);
|
|
519
|
+
if (out === null) return [];
|
|
520
|
+
const entries: HistoryEntry[] = [];
|
|
521
|
+
for (const line of out.trim().split('\n')) {
|
|
522
|
+
if (line === '') continue;
|
|
523
|
+
const [sha, date] = line.split(' ');
|
|
524
|
+
if (sha !== undefined && date !== undefined) entries.push({ sha, date });
|
|
525
|
+
}
|
|
526
|
+
return entries;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Does `absPath` differ from committed state in the working tree?
|
|
531
|
+
*
|
|
532
|
+
* Staleness (§ 1.1) compares the working-tree text against the blob of the
|
|
533
|
+
* newest commit dated `<= reviewed:`. While a substantial edit AND its freshly
|
|
534
|
+
* written register are both uncommitted, that baseline is the *previous* commit,
|
|
535
|
+
* whose features differ — so a register written the same minute is reported
|
|
536
|
+
* stale. The gate judges committed history; with uncommitted modifications there
|
|
537
|
+
* is no committed history to judge yet, so staleness is not decidable and the
|
|
538
|
+
* check is skipped rather than answered wrongly. Untracked / non-repo paths
|
|
539
|
+
* report `false` (they have no history at all, so staleness never fires there).
|
|
540
|
+
*/
|
|
541
|
+
export function hasUncommittedChanges(absPath: string): boolean {
|
|
542
|
+
const dir = path.dirname(absPath);
|
|
543
|
+
const top = _git(dir, ['rev-parse', '--show-toplevel']);
|
|
544
|
+
if (top === null) return false;
|
|
545
|
+
const root = top.trim();
|
|
546
|
+
const rel = path.relative(root, absPath).split(path.sep).join('/');
|
|
547
|
+
const out = _git(root, ['status', '--porcelain', '--', rel]);
|
|
548
|
+
if (out === null) return false;
|
|
549
|
+
return out.trim() !== '';
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function _blobAt(absPath: string, sha: string): string | null {
|
|
553
|
+
const dir = path.dirname(absPath);
|
|
554
|
+
const top = _git(dir, ['rev-parse', '--show-toplevel']);
|
|
555
|
+
if (top === null) return null;
|
|
556
|
+
const root = top.trim();
|
|
557
|
+
const rel = path.relative(root, absPath).split(path.sep).join('/');
|
|
558
|
+
return _git(root, ['show', `${sha}:${rel}`]);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export type FileStatus = 'ok' | 'draft-exempt' | 'grandfathered' | 'fail';
|
|
562
|
+
|
|
563
|
+
export interface FileResult {
|
|
564
|
+
file: string;
|
|
565
|
+
status: FileStatus;
|
|
566
|
+
violations: Violation[];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Full Gate-R1 check for one ready roadmap file (content + git layers). */
|
|
570
|
+
export function checkFile(absPath: string): FileResult {
|
|
571
|
+
const text = fs.readFileSync(absPath, 'utf-8');
|
|
572
|
+
const content = checkContent(absPath, text);
|
|
573
|
+
if (content.draftExempt) {
|
|
574
|
+
return { file: absPath, status: 'draft-exempt', violations: [] };
|
|
575
|
+
}
|
|
576
|
+
if (content.registerMissing) {
|
|
577
|
+
// Grandfather clause (contract § 1): exempt while feature-equal with
|
|
578
|
+
// the file's last committed state STRICTLY BEFORE the activation date.
|
|
579
|
+
// Strictness is load-bearing: with `<=`, a substantial change committed
|
|
580
|
+
// on the activation day becomes its own baseline and grandfathers
|
|
581
|
+
// itself — the exemption would cover exactly the changes the gate
|
|
582
|
+
// exists to catch. A file whose history starts on or after activation
|
|
583
|
+
// has no baseline and must carry a register.
|
|
584
|
+
const history = fileHistory(absPath);
|
|
585
|
+
const pre = history.find((h) => h.date < RISK_REGISTER_GATE_ACTIVATION);
|
|
586
|
+
if (pre === undefined) {
|
|
587
|
+
return {
|
|
588
|
+
file: absPath,
|
|
589
|
+
status: 'fail',
|
|
590
|
+
violations: [
|
|
591
|
+
{
|
|
592
|
+
file: absPath,
|
|
593
|
+
line: 1,
|
|
594
|
+
kind: 'missing_register',
|
|
595
|
+
detail:
|
|
596
|
+
'no `## Risk Register` section and no committed version before the ' +
|
|
597
|
+
`gate activation date ${RISK_REGISTER_GATE_ACTIVATION} — new plans must carry a register`,
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
const blob = _blobAt(absPath, pre.sha);
|
|
603
|
+
if (blob !== null && featuresEqual(extractFeatures(blob), extractFeatures(text))) {
|
|
604
|
+
return { file: absPath, status: 'grandfathered', violations: [] };
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
file: absPath,
|
|
608
|
+
status: 'fail',
|
|
609
|
+
violations: [
|
|
610
|
+
{
|
|
611
|
+
file: absPath,
|
|
612
|
+
line: 1,
|
|
613
|
+
kind: 'missing_register',
|
|
614
|
+
detail:
|
|
615
|
+
'no `## Risk Register` section and the plan changed substantially since the ' +
|
|
616
|
+
`activation date ${RISK_REGISTER_GATE_ACTIVATION} — the grandfather exemption is lifted`,
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
const violations = [...content.violations];
|
|
622
|
+
// A file with uncommitted modifications has no committed baseline for the
|
|
623
|
+
// current content, so staleness is not judgeable — see
|
|
624
|
+
// hasUncommittedChanges. The committed-history case below is unchanged.
|
|
625
|
+
if (content.reviewed !== null && !hasUncommittedChanges(absPath)) {
|
|
626
|
+
// Staleness (contract §§ 1.1 + 3): compare features of the newest
|
|
627
|
+
// commit on/before the reviewed date against the current content.
|
|
628
|
+
const history = fileHistory(absPath);
|
|
629
|
+
const base = history.find((h) => h.date <= (content.reviewed as string));
|
|
630
|
+
if (base !== undefined) {
|
|
631
|
+
const blob = _blobAt(absPath, base.sha);
|
|
632
|
+
if (blob !== null && !featuresEqual(extractFeatures(blob), extractFeatures(text))) {
|
|
633
|
+
violations.push({
|
|
634
|
+
file: absPath,
|
|
635
|
+
line: 1,
|
|
636
|
+
kind: 'stale_review',
|
|
637
|
+
detail:
|
|
638
|
+
`\`reviewed: ${content.reviewed}\` predates the last substantial change ` +
|
|
639
|
+
'(phase headings, checkbox count, or Acceptance Criteria differ from the ' +
|
|
640
|
+
'version at the review date) — re-review the plan',
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// No commit on/before the reviewed date → the register was just
|
|
645
|
+
// written (brand-new / uncommitted file) → fresh.
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
file: absPath,
|
|
649
|
+
status: violations.length > 0 ? 'fail' : 'ok',
|
|
650
|
+
violations,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// --------------------------------------------------------------------------
|
|
655
|
+
// CLI.
|
|
656
|
+
// --------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
const EXCLUDED_NAMES_RE = /^(template\.md|dashboard.*)$/;
|
|
659
|
+
|
|
660
|
+
/** Ready-roadmap corpus: top-level `*.md` under each root dir, name-filtered. */
|
|
661
|
+
function _resolveTargets(paths: readonly string[]): string[] {
|
|
662
|
+
const out: string[] = [];
|
|
663
|
+
for (const raw of paths) {
|
|
664
|
+
let stat: fs.Stats;
|
|
665
|
+
try {
|
|
666
|
+
stat = fs.statSync(raw);
|
|
667
|
+
} catch {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (stat.isDirectory()) {
|
|
671
|
+
const entries = fs
|
|
672
|
+
.readdirSync(raw, { withFileTypes: true })
|
|
673
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md') && !EXCLUDED_NAMES_RE.test(e.name))
|
|
674
|
+
.map((e) => path.join(raw, e.name));
|
|
675
|
+
entries.sort();
|
|
676
|
+
out.push(...entries);
|
|
677
|
+
} else if (raw.endsWith('.md')) {
|
|
678
|
+
out.push(raw);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** `planning.risk_review === false` in `<dir>/.agent-settings.yml` disables Gate R1. */
|
|
685
|
+
export function riskReviewDisabled(settingsDir: string): boolean {
|
|
686
|
+
const settingsPath = path.join(settingsDir, '.agent-settings.yml');
|
|
687
|
+
let raw: string;
|
|
688
|
+
try {
|
|
689
|
+
raw = fs.readFileSync(settingsPath, 'utf-8');
|
|
690
|
+
} catch {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
const parsed = parseYaml(raw) as unknown;
|
|
695
|
+
if (parsed === null || typeof parsed !== 'object') return false;
|
|
696
|
+
const planning = (parsed as Record<string, unknown>)['planning'];
|
|
697
|
+
if (planning === null || typeof planning !== 'object') return false;
|
|
698
|
+
return (planning as Record<string, unknown>)['risk_review'] === false;
|
|
699
|
+
} catch {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
interface Args {
|
|
705
|
+
paths: string[];
|
|
706
|
+
format: 'text' | 'json';
|
|
707
|
+
quiet: boolean;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function parse_args(argv: readonly string[]): Args {
|
|
711
|
+
const paths: string[] = [];
|
|
712
|
+
let format: 'text' | 'json' = 'text';
|
|
713
|
+
let quiet = false;
|
|
714
|
+
let i = 0;
|
|
715
|
+
while (i < argv.length) {
|
|
716
|
+
const arg = argv[i] as string;
|
|
717
|
+
if (arg === '--format') {
|
|
718
|
+
const v = argv[++i] as string | undefined;
|
|
719
|
+
if (v !== 'text' && v !== 'json') {
|
|
720
|
+
process.stderr.write(
|
|
721
|
+
`lint_plan_risk_register: error: argument --format: invalid choice: '${v ?? ''}' (choose from 'text', 'json')\n`,
|
|
722
|
+
);
|
|
723
|
+
process.exit(2);
|
|
724
|
+
}
|
|
725
|
+
format = v;
|
|
726
|
+
} else if (arg.startsWith('--format=')) {
|
|
727
|
+
const v = arg.slice('--format='.length);
|
|
728
|
+
if (v !== 'text' && v !== 'json') {
|
|
729
|
+
process.stderr.write(
|
|
730
|
+
`lint_plan_risk_register: error: argument --format: invalid choice: '${v}' (choose from 'text', 'json')\n`,
|
|
731
|
+
);
|
|
732
|
+
process.exit(2);
|
|
733
|
+
}
|
|
734
|
+
format = v;
|
|
735
|
+
} else if (arg === '--quiet') {
|
|
736
|
+
quiet = true;
|
|
737
|
+
} else if (arg === '-h' || arg === '--help') {
|
|
738
|
+
process.stdout.write(
|
|
739
|
+
'usage: lint_plan_risk_register [-h] [--format {text,json}] [--quiet] [paths ...]\n',
|
|
740
|
+
);
|
|
741
|
+
process.exit(0);
|
|
742
|
+
} else if (arg.startsWith('-') && arg !== '-') {
|
|
743
|
+
process.stderr.write(`lint_plan_risk_register: error: unrecognized arguments: ${arg}\n`);
|
|
744
|
+
process.exit(2);
|
|
745
|
+
} else {
|
|
746
|
+
paths.push(arg);
|
|
747
|
+
}
|
|
748
|
+
i++;
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
paths: paths.length ? paths : ['agents/roadmaps'],
|
|
752
|
+
format,
|
|
753
|
+
quiet,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export function main(argv?: readonly string[]): number {
|
|
758
|
+
const args = parse_args(argv ?? process.argv.slice(2));
|
|
759
|
+
|
|
760
|
+
// Contract § 6: the `scanned:` line is emitted on EVERY exit path, exit 2
|
|
761
|
+
// included, because the coverage guard reads that number. Emit-once, so the
|
|
762
|
+
// count can be published BEFORE the first failure path without any later
|
|
763
|
+
// path duplicating it — the same shape check_completion_review uses (there
|
|
764
|
+
// an unresolvable base ref used to exit 2 emitting no count at all; here it
|
|
765
|
+
// was the dead-scope return, the unexpected-internal return, and the
|
|
766
|
+
// top-level CLI catch).
|
|
767
|
+
let scannedEmitted = false;
|
|
768
|
+
const emitScanned = (n: number): void => {
|
|
769
|
+
if (scannedEmitted) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
scannedEmitted = true;
|
|
773
|
+
process.stdout.write(`scanned: ${String(n)}\n`);
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
// Settings escape hatch (contract scope note): planning.risk_review: false
|
|
777
|
+
// disables Gate R1. assertScanned is deliberately NOT called here — the
|
|
778
|
+
// zero-scan is an explicit, configured skip, not a dead scope. The
|
|
779
|
+
// gate-coverage guard runs in CI where no .agent-settings.yml exists, so
|
|
780
|
+
// the real scanned floor is still enforced there.
|
|
781
|
+
if (riskReviewDisabled(process.cwd())) {
|
|
782
|
+
process.stdout.write('⚠️ planning.risk_review=false — Gate R1 skipped (settings escape hatch)\n');
|
|
783
|
+
emitScanned(0);
|
|
784
|
+
return 0;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
let targets: string[];
|
|
788
|
+
try {
|
|
789
|
+
targets = _resolveTargets(args.paths);
|
|
790
|
+
} catch (exc) {
|
|
791
|
+
// Inventory resolution itself can fail (an unreadable roots directory —
|
|
792
|
+
// EACCES on readdir). That used to escape main entirely and hit the
|
|
793
|
+
// top-level CLI catch, which printed no count at all.
|
|
794
|
+
emitScanned(0);
|
|
795
|
+
process.stderr.write(
|
|
796
|
+
`❌ Internal error: cannot resolve the roadmap corpus (${args.paths.join(', ')}): ` +
|
|
797
|
+
`${exc instanceof Error ? exc.message : String(exc)}\n`,
|
|
798
|
+
);
|
|
799
|
+
return 2;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// The inventory needs no git and no file reads, so the coverage number is
|
|
803
|
+
// known here — publish it before anything can fail.
|
|
804
|
+
emitScanned(targets.length);
|
|
805
|
+
|
|
806
|
+
// A root that does not exist on disk at all is a legitimately empty
|
|
807
|
+
// corpus (a project with no roadmaps yet), NOT a dead scope. Only a root
|
|
808
|
+
// that IS there while yielding nothing means the scan went blind. Without
|
|
809
|
+
// this discriminator the gate would block every roadmap-less project with
|
|
810
|
+
// a misleading "the root moved" error.
|
|
811
|
+
const noRootOnDisk = args.paths.every((p) => !fs.existsSync(p));
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
assertScanned({
|
|
815
|
+
gate: 'lint_plan_risk_register',
|
|
816
|
+
scanned: targets.length,
|
|
817
|
+
units: 'ready roadmap file(s)',
|
|
818
|
+
roots: args.paths,
|
|
819
|
+
...(noRootOnDisk ? { allowEmpty: 'no roadmaps directory in this project — nothing to gate' } : {}),
|
|
820
|
+
});
|
|
821
|
+
} catch (exc) {
|
|
822
|
+
// A dead scan scope is a POLICY violation (exit 1), never an internal
|
|
823
|
+
// error (exit 2). Exit 2 is warn-and-allow at every call site, so
|
|
824
|
+
// mapping a moved/renamed roadmaps root to 2 would silently degrade
|
|
825
|
+
// this gate to advisory — the exact blind-gate failure the
|
|
826
|
+
// scanned-floor machinery exists to prevent. A gate that read nothing
|
|
827
|
+
// has not passed. Contract carve-out:
|
|
828
|
+
// docs/contracts/plan-review-gates.md § 6.
|
|
829
|
+
// Both returns below rely on the emitScanned above: a dead scope reports
|
|
830
|
+
// `scanned: 0` AND exit 1, so the coverage guard sees the zero instead
|
|
831
|
+
// of a missing line (contract § 6).
|
|
832
|
+
if (exc instanceof DeadScopeError) {
|
|
833
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
834
|
+
return 1;
|
|
835
|
+
}
|
|
836
|
+
// Anything else here really is internal (unexpected): degraded advisory.
|
|
837
|
+
process.stderr.write(`❌ Internal error: ${exc instanceof Error ? exc.message : String(exc)}\n`);
|
|
838
|
+
return 2;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const all_violations: Violation[] = [];
|
|
842
|
+
let scanned = 0;
|
|
843
|
+
const statusLines: string[] = [];
|
|
844
|
+
try {
|
|
845
|
+
for (const p of targets) {
|
|
846
|
+
const result = checkFile(p);
|
|
847
|
+
scanned += 1;
|
|
848
|
+
if (result.status === 'draft-exempt') {
|
|
849
|
+
statusLines.push(` draft-exempt: ${p}`);
|
|
850
|
+
} else if (result.status === 'grandfathered') {
|
|
851
|
+
statusLines.push(` grandfathered: ${p}`);
|
|
852
|
+
}
|
|
853
|
+
all_violations.push(...result.violations);
|
|
854
|
+
}
|
|
855
|
+
} catch (exc) {
|
|
856
|
+
// Contract § 6: `scanned:` is emitted on EVERY exit path, exit 2
|
|
857
|
+
// included. A throw mid-loop (unreadable file, permission denied) that
|
|
858
|
+
// exited silently would leave the coverage guard with no count at all —
|
|
859
|
+
// the same blind spot the guard exists to detect. Already published
|
|
860
|
+
// above (the inventory count); this call is the no-op that documents
|
|
861
|
+
// the obligation for this path.
|
|
862
|
+
emitScanned(scanned);
|
|
863
|
+
process.stderr.write(
|
|
864
|
+
`❌ lint_plan_risk_register: internal error after ${String(scanned)} file(s): ` +
|
|
865
|
+
`${exc instanceof Error ? exc.message : String(exc)}\n`,
|
|
866
|
+
);
|
|
867
|
+
return 2;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (args.format === 'json') {
|
|
871
|
+
process.stdout.write(JSON.stringify(all_violations, null, 2) + '\n');
|
|
872
|
+
} else {
|
|
873
|
+
if (!args.quiet && statusLines.length > 0) {
|
|
874
|
+
process.stdout.write(statusLines.join('\n') + '\n');
|
|
875
|
+
}
|
|
876
|
+
if (all_violations.length > 0) {
|
|
877
|
+
process.stdout.write(`❌ ${all_violations.length} Risk-Register violation(s):\n\n`);
|
|
878
|
+
for (const v of all_violations) {
|
|
879
|
+
process.stdout.write(` ${v.file}:${v.line} — ${v.kind}\n`);
|
|
880
|
+
process.stdout.write(` │ ${v.detail}\n`);
|
|
881
|
+
}
|
|
882
|
+
} else if (!args.quiet) {
|
|
883
|
+
process.stdout.write(`✅ Risk Registers clean (${scanned} ready roadmap file(s) scanned).\n`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Gate-coverage contract (src/config/gate-coverage.yml): always emitted,
|
|
888
|
+
// independent of the pass/fail verdict — already published above, so this
|
|
889
|
+
// is a no-op that keeps the obligation visible on the success path too.
|
|
890
|
+
emitScanned(scanned);
|
|
891
|
+
|
|
892
|
+
return all_violations.length > 0 ? 1 : 0;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
896
|
+
function _isCliEntry(): boolean {
|
|
897
|
+
if (process.argv[1] === undefined) {
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
901
|
+
if (import.meta.url === argvUrl) {
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
// A symlinked invocation (e.g. via an installed projection, or macOS
|
|
905
|
+
// /var → /private/var temp dirs) makes the raw URLs differ — compare
|
|
906
|
+
// realpaths so the entry guard still fires.
|
|
907
|
+
try {
|
|
908
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
909
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
910
|
+
return here === argv;
|
|
911
|
+
} catch {
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
917
|
+
try {
|
|
918
|
+
process.exit(main());
|
|
919
|
+
} catch (exc) {
|
|
920
|
+
// Exit-code contract (docs/contracts/plan-review-gates.md § 6):
|
|
921
|
+
// internal error = 2 → degraded advisory mode for hook/CI callers.
|
|
922
|
+
// Last-resort `scanned:` emission: main() publishes its own count before
|
|
923
|
+
// any failure path, and the coverage guard reads the FIRST match, so a
|
|
924
|
+
// real count always wins over this zero — but a throw that escapes main
|
|
925
|
+
// before it emitted must still leave the guard a number, not silence.
|
|
926
|
+
process.stdout.write('scanned: 0\n');
|
|
927
|
+
const msg = exc instanceof Error ? exc.message : String(exc);
|
|
928
|
+
process.stderr.write(`❌ Internal error: ${msg}\n`);
|
|
929
|
+
process.exit(2);
|
|
930
|
+
}
|
|
931
|
+
}
|