@staix/agent-hub 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +11 -3
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +4 -4
  5. package/docs/agent-notes/daemon.md +9 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/tasks.md +7 -0
  8. package/docs/agent-notes/tests.md +7 -5
  9. package/docs/design/adapters.md +76 -0
  10. package/docs/design/agent-hub-architecture.md +67 -0
  11. package/docs/design/agent-hub.md +52 -0
  12. package/docs/design/bus.md +48 -0
  13. package/docs/design/task-board.md +86 -0
  14. package/docs/dogfood.md +21 -3
  15. package/docs/operations.md +370 -12
  16. package/docs/profiles.md +1 -0
  17. package/docs/quickstart.md +19 -3
  18. package/docs/sdlc.md +2 -2
  19. package/docs/security.md +24 -4
  20. package/docs/smoke.md +2 -0
  21. package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
  22. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  24. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  25. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  26. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  27. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  28. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  29. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  30. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  31. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  32. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  33. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  34. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  35. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  36. package/docs/verified.json +14 -14
  37. package/package.json +2 -2
  38. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  39. package/plugins/agent-hub/server.js +857 -125
  40. package/src/adapters/claude-channel.ts +12 -2
  41. package/src/adapters/compat.ts +1 -1
  42. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  43. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  44. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  45. package/src/adapters/jcode.ts +37 -4
  46. package/src/adapters/qwen-dual.ts +3 -2
  47. package/src/cli/agents.ts +4 -2
  48. package/src/cli/bench.ts +4 -2
  49. package/src/cli/board-read.ts +25 -0
  50. package/src/cli/claude-homes.ts +2 -2
  51. package/src/cli/console-state.ts +8 -3
  52. package/src/cli/help.ts +6 -5
  53. package/src/cli/identity.ts +9 -1
  54. package/src/cli/init-prompt.ts +179 -0
  55. package/src/cli/init-settings.ts +229 -0
  56. package/src/cli/init.ts +102 -9
  57. package/src/cli/launch.ts +3 -1
  58. package/src/cli/main.ts +106 -35
  59. package/src/cli/output.ts +19 -0
  60. package/src/cli/recovery-package.ts +9 -0
  61. package/src/cli/setup-homes.ts +18 -3
  62. package/src/cli/specs.ts +150 -0
  63. package/src/cli/terminal-recovery.ts +35 -11
  64. package/src/hub/board.ts +59 -5
  65. package/src/hub/bus.ts +54 -7
  66. package/src/hub/child-process.ts +6 -4
  67. package/src/hub/config-apply.ts +4 -1
  68. package/src/hub/config-edit.ts +1 -1
  69. package/src/hub/config-tier.ts +1 -0
  70. package/src/hub/control-client.ts +43 -4
  71. package/src/hub/control-write-trace.ts +409 -0
  72. package/src/hub/cost-governance.ts +22 -6
  73. package/src/hub/crash.ts +20 -0
  74. package/src/hub/daemon.ts +470 -98
  75. package/src/hub/delivery-hints.ts +6 -3
  76. package/src/hub/delivery-journal.ts +14 -3
  77. package/src/hub/hub-tools.ts +10 -6
  78. package/src/hub/peers.ts +1 -1
  79. package/src/hub/review-brief.ts +18 -0
  80. package/src/hub/routing.ts +14 -3
  81. package/src/hub/runtime-capture-prune.ts +4 -1
  82. package/src/hub/sdlc-release-authorization.ts +13 -4
  83. package/src/hub/sdlc-release.ts +23 -10
  84. package/src/hub/sdlc.ts +15 -2
  85. package/src/hub/settings.ts +2 -0
  86. package/src/hub/specs-config.ts +43 -0
  87. package/src/hub/specs-task.ts +108 -0
  88. package/src/hub/specs.ts +1008 -0
  89. package/src/hub/task-list-page.ts +75 -0
  90. package/src/hub/task-sweep.ts +10 -1
  91. package/src/hub/task-worktree.ts +331 -0
  92. package/src/hub/tasks.ts +606 -53
  93. package/src/hub/terminal-open.ts +10 -5
  94. package/src/hub/tool-request.ts +131 -0
  95. package/src/local/deny.ts +49 -0
  96. package/src/local/tools.ts +4 -2
  97. package/src/memory/brief.ts +5 -0
  98. package/src/memory/client.ts +5 -2
  99. package/src/ui/office-model.ts +17 -1
  100. package/src/ui/office-space.ts +1 -1
  101. package/templates/config.json +1 -0
  102. package/templates/profiles/dev.json +1 -0
  103. package/templates/profiles/general.json +1 -0
  104. package/templates/profiles/research.json +1 -0
  105. package/templates/profiles/writing.json +1 -0
  106. package/templates/specs.block.md +5 -0
@@ -0,0 +1,1008 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readlinkSync, readSync, type Stats } from "node:fs";
3
+ import { StringDecoder } from "node:string_decoder";
4
+ import { join, relative, resolve } from "node:path";
5
+ import { hubGitSync } from "./git.ts";
6
+ import { realPath } from "./project.ts";
7
+ import { parseSpecsConfig, type SpecsConfig } from "./specs-config.ts";
8
+
9
+ export { DEFAULT_SPECS_CONFIG, parseSpecsConfig, type SpecsConfig, type SpecsReview } from "./specs-config.ts";
10
+
11
+ const MAX_FILES = 2000;
12
+ const MAX_FRONTMATTER_BYTES = 8 * 1024;
13
+ const MAX_STAMPS_FILE_BYTES = 4 * 1024 * 1024;
14
+ // ponytail: committed bodies stop at 4 MiB; stream bounded headers/body links when larger specs are needed.
15
+ const MAX_COMMITTED_SPEC_BYTES = 4 * 1024 * 1024;
16
+ const MAX_GIT_OUTPUT_BYTES = 16 * 1024 * 1024;
17
+ const GIT_TIMEOUT_MS = 2000;
18
+ const MAX_PARSED_BLOB_CACHE_BYTES = 16 * 1024 * 1024;
19
+ const MAX_BLOB_BATCH_BYTES = 8 * 1024 * 1024;
20
+ const MAX_TRACKED_PATHS_PER_SCOPE = 4096;
21
+ const MAX_REPORT_ITEMS = 500;
22
+ const GIT_SAFE_OPTIONS = ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "diff.external=", "-c", "core.pager=cat", "-c", "credential.helper=", "-c", "core.askpass="];
23
+ const LIST_KEYS = new Set(["depends-on", "references", "implements", "covers", "tags", "paths"]);
24
+ const TYPES = new Set(["goal", "architecture", "module", "submodule"]);
25
+ const STATUS = new Set(["draft", "active", "deprecated"]);
26
+ const RANK: Readonly<Record<string, number>> = { goal: 0, architecture: 1, module: 2, submodule: 3 };
27
+
28
+ export type SpecsSeverity = "error" | "warning" | "info";
29
+ export type SpecsFreshnessState = "fresh" | "stale" | "unverified" | "nonancestor" | "unknown";
30
+
31
+ export interface SpecsDiagnostic {
32
+ severity: SpecsSeverity;
33
+ code: string;
34
+ message: string;
35
+ cause: string;
36
+ nextSteps: string;
37
+ path?: string;
38
+ id?: string;
39
+ field?: string;
40
+ relatedId?: string;
41
+ }
42
+
43
+ export interface SpecSummary {
44
+ id: string;
45
+ path: string;
46
+ title: string;
47
+ type: string;
48
+ status: string | null;
49
+ paths: string[];
50
+ }
51
+
52
+ export interface SpecsFreshness {
53
+ path: string;
54
+ id?: string;
55
+ state: SpecsFreshnessState;
56
+ commitsSince: number | null;
57
+ dirty: boolean | null;
58
+ }
59
+
60
+ export interface SpecsListReport {
61
+ specs: SpecSummary[];
62
+ errors: number;
63
+ truncated: boolean;
64
+ items: SpecsDiagnostic[];
65
+ }
66
+
67
+ export interface SpecsCoverSummary { id: string; path: string; title: string }
68
+ export interface SpecsCoverReport { specs: SpecsCoverSummary[]; errors: number; truncated: boolean; items: SpecsDiagnostic[] }
69
+
70
+ export interface SpecsCheckReport {
71
+ specCount: number;
72
+ errors: number;
73
+ warnings: number;
74
+ stale: number;
75
+ truncated: boolean;
76
+ freshness: SpecsFreshness[];
77
+ items: SpecsDiagnostic[];
78
+ }
79
+
80
+ interface SpecNode {
81
+ path: string;
82
+ id?: string;
83
+ type?: string;
84
+ title?: string;
85
+ status?: string;
86
+ parent?: string;
87
+ dependsOn: string[];
88
+ references: string[];
89
+ implements: string[];
90
+ supersededBy?: string;
91
+ covers: string[];
92
+ tags: string[];
93
+ paths: string[];
94
+ verifiedAgainst?: string;
95
+ bodyLinks: string[];
96
+ frontmatterIncomplete?: boolean;
97
+ }
98
+
99
+ interface CachedFile {
100
+ size: number;
101
+ mtimeMs: number;
102
+ dev: number;
103
+ ino: number;
104
+ truncated: boolean;
105
+ node?: SpecNode;
106
+ items: SpecsDiagnostic[];
107
+ }
108
+
109
+ // Immutable object ids allow unchanged Markdown (including non-specs) to survive
110
+ // submitted-head/engine rotation. Bound retained parsed data, never whole bodies.
111
+ const committedCandidates = new Map<string, { path: string; read: CachedFile; weight: number }>();
112
+ let committedCandidateBytes = 0;
113
+ function cachedCandidate(oid: string, path: string): CachedFile | undefined {
114
+ const cached = committedCandidates.get(oid);
115
+ if (!cached) return undefined;
116
+ committedCandidates.delete(oid); committedCandidates.set(oid, cached);
117
+ return { ...cached.read, node: cached.read.node ? { ...cached.read.node, path } : undefined,
118
+ items: cached.read.items.map(diagnostic => ({ ...diagnostic, path,
119
+ message: diagnostic.message.startsWith(`${cached.path}:`) ? `${path}:${diagnostic.message.slice(cached.path.length + 1)}` : diagnostic.message })) };
120
+ }
121
+ function cacheCandidate(oid: string, path: string, read: CachedFile): void {
122
+ const weight = Math.max(512, Buffer.byteLength(JSON.stringify(read)));
123
+ if (weight > MAX_PARSED_BLOB_CACHE_BYTES) return;
124
+ const previous = committedCandidates.get(oid);
125
+ if (previous) { committedCandidateBytes -= previous.weight; committedCandidates.delete(oid); }
126
+ committedCandidates.set(oid, { path, read, weight }); committedCandidateBytes += weight;
127
+ while (committedCandidateBytes > MAX_PARSED_BLOB_CACHE_BYTES) {
128
+ const oldest = committedCandidates.keys().next().value!;
129
+ committedCandidateBytes -= committedCandidates.get(oldest)!.weight; committedCandidates.delete(oldest);
130
+ }
131
+ }
132
+
133
+ interface ScanResult {
134
+ files: string[];
135
+ nodes: SpecNode[];
136
+ items: SpecsDiagnostic[];
137
+ truncated: boolean;
138
+ }
139
+
140
+ function item(severity: SpecsSeverity, code: string, message: string, cause: string, nextSteps: string, extra: Partial<SpecsDiagnostic> = {}): SpecsDiagnostic {
141
+ return { severity, code, message, cause, nextSteps, ...extra };
142
+ }
143
+
144
+ function isRecord(value: unknown): value is Record<string, unknown> {
145
+ return !!value && typeof value === "object" && !Array.isArray(value);
146
+ }
147
+
148
+ function isSafeRepoPath(value: string): boolean {
149
+ return value.length > 0 && value.length <= 1024 && !value.startsWith("/") && !value.startsWith("-")
150
+ && !/^[A-Za-z]:/.test(value) && !value.includes("\\") && !/[\u0000-\u001f\u007f]/.test(value)
151
+ && !value.split("/").some(part => part === ".." || part === "" || part === ".");
152
+ }
153
+
154
+ function isSafePathspec(value: string): boolean {
155
+ let pattern = value;
156
+ if (pattern.startsWith(":(")) {
157
+ const end = pattern.indexOf(")");
158
+ if (end < 0) return false;
159
+ const magic = pattern.slice(2, end).split(",");
160
+ if (!magic.length || magic.some(flag => !["glob", "literal", "icase", "top"].includes(flag)) || new Set(magic).size !== magic.length) return false;
161
+ pattern = pattern.slice(end + 1);
162
+ }
163
+ const normalized = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
164
+ return isSafeRepoPath(normalized) && !normalized.includes("\n") && !normalized.includes("\r");
165
+ }
166
+
167
+ function pathspecBody(value: string): { pattern: string; magic: string[] } {
168
+ if (!value.startsWith(":(")) return { pattern: value, magic: [] };
169
+ const end = value.indexOf(")");
170
+ return { pattern: value.slice(end + 1), magic: value.slice(2, end).split(",") };
171
+ }
172
+
173
+ function pathspecMatchesPath(pathspec: string, candidate: string): boolean {
174
+ if (!isSafeRepoPath(candidate) || !isSafePathspec(pathspec)) return false;
175
+ const parsed = pathspecBody(pathspec), magic = parsed.magic;
176
+ const pattern = parsed.pattern.endsWith("/") ? parsed.pattern.slice(0, -1) : parsed.pattern;
177
+ const icase = magic.includes("icase"), globMagic = magic.includes("glob");
178
+ const matchSegment = (glob: string, value: string, slash: boolean): boolean => {
179
+ const p = icase ? glob.toLowerCase() : glob, v = icase ? value.toLowerCase() : value;
180
+ let previous = new Array<boolean>(v.length + 1).fill(false); previous[0] = true;
181
+ for (let index = 0; index < p.length; index++) {
182
+ const char = p[index]!;
183
+ const next = new Array<boolean>(v.length + 1).fill(false);
184
+ if (char === "*") {
185
+ next[0] = previous[0]!;
186
+ for (let at = 1; at <= v.length; at++) next[at] = previous[at]! || next[at - 1]! && (slash || v[at - 1] !== "/");
187
+ } else if (char === "?") {
188
+ for (let at = 1; at <= v.length; at++) next[at] = previous[at - 1]! && (slash || v[at - 1] !== "/");
189
+ } else if (char === "[") {
190
+ const end = p.indexOf("]", index + 1); if (end < 0) return false;
191
+ let range = p.slice(index + 1, end), negate = range.startsWith("!") || range.startsWith("^");
192
+ if (negate) range = range.slice(1);
193
+ for (let at = 1; at <= v.length; at++) {
194
+ const value = v[at - 1]!, match = !(!slash && value === "/") && (() => {
195
+ for (let i = 0; i < range.length; i++) {
196
+ if (range[i + 1] === "-" && range[i + 2] !== undefined) { if (value >= range[i]! && value <= range[i + 2]!) return true; i += 2; }
197
+ else if (value === range[i]) return true;
198
+ }
199
+ return false;
200
+ })();
201
+ next[at] = previous[at - 1]! && (negate ? !match : match);
202
+ }
203
+ index = end;
204
+ } else {
205
+ for (let at = 1; at <= v.length; at++) next[at] = previous[at - 1]! && v[at - 1] === char;
206
+ }
207
+ previous = next;
208
+ }
209
+ return previous[v.length]!;
210
+ };
211
+ if (magic.includes("literal")) {
212
+ const compare = (value: string) => icase ? value.toLowerCase() : value;
213
+ const prefix = pattern.endsWith("/") ? pattern : `${pattern}/`;
214
+ return compare(candidate) === compare(pattern) || compare(candidate).startsWith(compare(prefix));
215
+ }
216
+ if (!globMagic) {
217
+ const parts = candidate.split("/");
218
+ for (let end = parts.length; end > 0; end--) if (matchSegment(pattern, parts.slice(0, end).join("/"), true)) return true;
219
+ return false;
220
+ }
221
+ const patternParts = pattern.split("/"), candidateParts = candidate.split("/");
222
+ const matchParts = (parts: string[]): boolean => {
223
+ const matrix = Array.from({ length: patternParts.length + 1 }, () => new Array<boolean>(parts.length + 1).fill(false));
224
+ matrix[0]![0] = true;
225
+ for (let p = 0; p < patternParts.length; p++) {
226
+ const patternPart = patternParts[p]!;
227
+ if (patternPart === "**") {
228
+ for (let at = 0; at <= parts.length; at++) {
229
+ if (matrix[p]![at]) matrix[p + 1]![at] = true;
230
+ if (at > 0 && matrix[p + 1]![at - 1]) matrix[p + 1]![at] = true;
231
+ }
232
+ } else {
233
+ for (let at = 0; at < parts.length; at++) if (matrix[p]![at] && matchSegment(patternPart, parts[at]!, false)) matrix[p + 1]![at + 1] = true;
234
+ }
235
+ }
236
+ return matrix[patternParts.length]![parts.length]!;
237
+ };
238
+ for (let end = candidateParts.length; end > 0; end--) if (matchParts(candidateParts.slice(0, end))) return true;
239
+ return false;
240
+ }
241
+
242
+ function normalizePath(value: string): string { return value.normalize("NFC"); }
243
+
244
+ function comparePath(a: string, b: string): number {
245
+ const aa = normalizePath(a), bb = normalizePath(b);
246
+ return aa < bb ? -1 : aa > bb ? 1 : 0;
247
+ }
248
+
249
+ function escapeGitGlobLiteral(value: string): string { return value.replace(/[\\*?\[\]]/g, "\\$&"); }
250
+
251
+ function splitNul(text: string): string[] { return text.split("\0").filter(Boolean); }
252
+
253
+ function boundedItems(items: SpecsDiagnostic[]): SpecsDiagnostic[] {
254
+ if (items.length <= MAX_REPORT_ITEMS) return items;
255
+ return [...items.slice(0, MAX_REPORT_ITEMS - 1), item("warning", "items-truncated", `report shows ${MAX_REPORT_ITEMS - 1} of ${items.length} findings`, "the diagnostic list exceeded its display bound", "use roots to narrow the scan")];
256
+ }
257
+
258
+ function errorText(error: unknown): string { return error instanceof Error ? error.message : "unknown error"; }
259
+
260
+ function parseScalar(text: string): string {
261
+ const value = text.trim();
262
+ if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
263
+ const quote = value[0];
264
+ const inner = value.slice(1, -1);
265
+ if (quote === "\"") {
266
+ try { const decoded = JSON.parse(value); return typeof decoded === "string" ? decoded : inner; } catch { return inner; }
267
+ }
268
+ return inner.replaceAll("''", "'");
269
+ }
270
+ const comment = value.search(/\s+#/);
271
+ return (comment >= 0 ? value.slice(0, comment) : value).trim();
272
+ }
273
+
274
+ function splitInlineList(text: string): string[] | undefined {
275
+ const value = text.trim();
276
+ if (!value.startsWith("[") || !value.endsWith("]")) return undefined;
277
+ const body = value.slice(1, -1);
278
+ if (!body.trim()) return [];
279
+ const parts: string[] = [];
280
+ let current = "", quote = "", escaped = false;
281
+ for (const char of body) {
282
+ if (escaped) { current += char; escaped = false; continue; }
283
+ if (quote === "\"" && char === "\\") { current += char; escaped = true; continue; }
284
+ if (quote) { current += char; if (char === quote) quote = ""; continue; }
285
+ if (char === "\"" || char === "'") { quote = char; current += char; continue; }
286
+ if (char === ",") { parts.push(parseScalar(current)); current = ""; continue; }
287
+ current += char;
288
+ }
289
+ if (quote || escaped) return undefined;
290
+ parts.push(parseScalar(current));
291
+ return parts;
292
+ }
293
+
294
+ function parseHeader(text: string, path: string): { fields: Record<string, string | string[]>; bodyOffset: number; closing: boolean; tooLarge: boolean; items: SpecsDiagnostic[] } {
295
+ const items: SpecsDiagnostic[] = [];
296
+ const fields: Record<string, string | string[]> = {};
297
+ const normalized = text.startsWith("\uFEFF") ? text.slice(1) : text;
298
+ const firstEnd = normalized.indexOf("\n");
299
+ const firstLine = (firstEnd < 0 ? normalized : normalized.slice(0, firstEnd)).replace(/\r$/, "");
300
+ if (firstLine !== "---") return { fields, bodyOffset: 0, closing: false, tooLarge: false, items };
301
+
302
+ const lines = normalized.split(/\n/);
303
+ let position = (Buffer.byteLength(firstEnd < 0 ? normalized : normalized.slice(0, firstEnd + 1))) + (text.startsWith("\uFEFF") ? 3 : 0);
304
+ let headerBytes = 0;
305
+ let currentList: string | undefined;
306
+ let closing = false;
307
+ let bodyOffset = 0;
308
+ const addList = (key: string, value: string) => {
309
+ if (!Object.hasOwn(fields, key)) fields[key] = [];
310
+ if (!Array.isArray(fields[key])) { items.push(item("error", "invalid-frontmatter", `frontmatter field ${key} must be a list`, "the field uses more than one value shape", "use an inline or block list", { path, field: key })); return; }
311
+ (fields[key] as string[]).push(value);
312
+ };
313
+ for (let index = 1; index < lines.length; index++) {
314
+ const raw = lines[index]!;
315
+ const line = raw.replace(/\r$/, "");
316
+ const lineBytes = Buffer.byteLength(raw) + (index < lines.length - 1 ? 1 : 0);
317
+ if (line === "---" || line === "...") { closing = true; bodyOffset = position + lineBytes; break; }
318
+ headerBytes += lineBytes;
319
+ position += lineBytes;
320
+ if (headerBytes > MAX_FRONTMATTER_BYTES) {
321
+ return { fields, bodyOffset: 0, closing: false, tooLarge: true, items: [...items, item("warning", "frontmatter-truncated", `${path}: frontmatter scan stopped at 8 KiB`, "the frontmatter is larger than the per-file scan cap", "shorten frontmatter to 8 KiB or set specs.roots to narrow discovery", { path })] };
322
+ }
323
+ if (!line.trim() || line.trimStart().startsWith("#")) continue;
324
+ const listItem = /^\s+-\s*(.*)$/.exec(line);
325
+ if (listItem) {
326
+ if (!currentList) { items.push(item("error", "invalid-frontmatter", `${path}: list item has no field`, "a block-list item appeared outside a list field", "add the item under a supported list key", { path })); continue; }
327
+ addList(currentList, parseScalar(listItem[1]!));
328
+ continue;
329
+ }
330
+ if (/^\s/.test(line)) { currentList = undefined; items.push(item("error", "invalid-frontmatter", `${path}: nested frontmatter is unsupported`, "the frontmatter parser accepts flat scalar and list fields only", "flatten the field or move detail into the Markdown body", { path })); continue; }
331
+ const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
332
+ if (!match) { currentList = undefined; items.push(item("error", "invalid-frontmatter", `${path}: malformed frontmatter line`, "the line is not a flat key/value field", "use key: value or a flat list", { path })); continue; }
333
+ const key = match[1]!, rawValue = match[2]!.trim();
334
+ currentList = undefined;
335
+ if (Object.hasOwn(fields, key)) { items.push(item("error", "duplicate-field", `${path}: duplicate frontmatter field ${key}`, "the field appears more than once", "keep one value for each field", { path, field: key })); continue; }
336
+ // Presence identifies a spec even when a declared value fails flat-schema parsing.
337
+ fields[key] = "";
338
+ if (LIST_KEYS.has(key)) {
339
+ if (!rawValue) { fields[key] = []; currentList = key; continue; }
340
+ const list = splitInlineList(rawValue);
341
+ if (!list) { items.push(item("error", "invalid-frontmatter", `${path}: ${key} must be a list`, "a list field has a scalar value", "use [item] or a block list", { path, field: key })); continue; }
342
+ fields[key] = list;
343
+ } else {
344
+ if (rawValue.startsWith("[") || rawValue.startsWith("{")) { items.push(item("error", "invalid-frontmatter", `${path}: ${key} must be a scalar`, "nested structures are unsupported", "use a scalar or a supported flat list", { path, field: key })); continue; }
345
+ fields[key] = parseScalar(rawValue);
346
+ }
347
+ }
348
+ return { fields, bodyOffset, closing, tooLarge: false, items };
349
+ }
350
+
351
+ function scanBodyLinks(fd: number, offset: number, fileSize: number): string[] {
352
+ const links = new Set<string>();
353
+ let position = offset, tail = "";
354
+ const chunk = Buffer.allocUnsafe(64 * 1024);
355
+ const decoder = new StringDecoder("utf8");
356
+ const consume = (text: string, final: boolean) => {
357
+ const combined = tail + text;
358
+ const boundary = final ? combined.length : Math.max(0, combined.length - 260);
359
+ for (const match of combined.matchAll(/\[\[([^\]\r\n]{1,256})\]\]/g)) {
360
+ if (match.index! + match[0].length > boundary) continue;
361
+ const id = match[1]!.trim(); if (id) links.add(id);
362
+ }
363
+ tail = final ? "" : combined.slice(Math.max(0, boundary - 260));
364
+ };
365
+ while (position < fileSize) {
366
+ const count = readSync(fd, chunk, 0, Math.min(chunk.length, fileSize - position), position);
367
+ if (!count) break;
368
+ consume(decoder.write(chunk.subarray(0, count)), false);
369
+ position += count;
370
+ }
371
+ consume(decoder.end(), true);
372
+ return [...links];
373
+ }
374
+
375
+ function ranks(type: string): number | undefined { return RANK[type]; }
376
+
377
+ function summary(node: SpecNode, includePaths = true): SpecSummary {
378
+ return { id: node.id ?? "", path: node.path, title: node.title ?? "", type: node.type ?? "", status: node.status ?? null, paths: includePaths ? [...node.paths] : [] };
379
+ }
380
+
381
+ function coverSummary(node: SpecNode): SpecsCoverSummary { return { id: node.id ?? "", path: node.path, title: node.title ?? "" }; }
382
+
383
+ /** Cached, read-only graph and freshness engine shared by the daemon and CLI. */
384
+ /** One document `ahub specs stamp` would change: a spec's `verified-against` line, or a stamps-file entry's `verifiedAgainst`. `from` is the stamp it holds now (`null`: none). */
385
+ export interface SpecsStampRow { target: string; path: string; kind: "spec" | "stamps-file"; document?: string; from: string | null; to: string; paths: string[] }
386
+ export interface SpecsStampPlan { head: string; rows: SpecsStampRow[]; refusals: string[] }
387
+
388
+ /**
389
+ * #1064 T3: the text of a spec with its `verified-against` set to `sha`, changing only that one line (or adding it before the closing delimiter) and keeping every other byte, the line endings and a byte order mark.
390
+ * Pass the file's bytes decoded as latin1 and write the result back as latin1, so a body that is not valid UTF-8 is never rewritten. A refusal names the cause and the next step.
391
+ */
392
+ export function stampFrontmatter(text: string, sha: string): { ok: true; text: string; from: string | null } | { ok: false; error: string } {
393
+ const bom = /^(?:\uFEFF|\u00EF\u00BB\u00BF)/.exec(text)?.[0] ?? "";
394
+ const lines = text.slice(bom.length).split("\n");
395
+ if ((lines[0] ?? "").replace(/\r$/, "") !== "---") return { ok: false, error: "the file has no frontmatter; add a --- block with id and type first" };
396
+ let close = -1, found = -1, bytes = 0;
397
+ for (let index = 1; index < lines.length; index++) {
398
+ const raw = lines[index]!, line = raw.replace(/\r$/, "");
399
+ if (line === "---" || line === "...") { close = index; break; }
400
+ bytes += raw.length + 1;
401
+ if (bytes > MAX_FRONTMATTER_BYTES) return { ok: false, error: "the frontmatter is larger than 8 KiB, so the engine would not read a stamp there; shorten it first" };
402
+ if (/^verified-against\s*:/.test(line)) {
403
+ if (found >= 0) return { ok: false, error: "verified-against appears more than once in the frontmatter; keep one and run ahub specs check" };
404
+ found = index;
405
+ }
406
+ }
407
+ if (close < 0) return { ok: false, error: "the frontmatter has no closing --- line; close it first" };
408
+ let from: string | null = null;
409
+ if (found >= 0) {
410
+ const raw = lines[found]!, eol = raw.endsWith("\r") ? "\r" : "";
411
+ const match = /^(verified-against\s*:\s*)(.*?)\r?$/.exec(raw)!;
412
+ from = match[2]!.trim().replace(/^(["'])(.*)\1$/, "$2") || null;
413
+ if (from === sha) return { ok: true, text, from };
414
+ lines[found] = `${match[1]!.endsWith(" ") || match[1]!.endsWith("\t") ? match[1] : `${match[1]} `}${sha}${eol}`;
415
+ } else {
416
+ const eol = (lines[close - 1] ?? "").endsWith("\r") ? "\r" : "";
417
+ lines.splice(close, 0, `verified-against: ${sha}${eol}`);
418
+ }
419
+ return { ok: true, text: bom + lines.join("\n"), from };
420
+ }
421
+
422
+ export class SpecsEngine {
423
+ private readonly root: string;
424
+ private readonly config: SpecsConfig;
425
+ private readonly cache = new Map<string, CachedFile>();
426
+ private readonly pathspecCache = new Map<string, string[]>();
427
+ private readonly commit?: string;
428
+ private treeLoaded = false;
429
+ private tree?: Map<string, { mode: string; oid: string }>;
430
+ private committedCheck?: SpecsCheckReport;
431
+ private coverageUnavailable = false;
432
+
433
+ constructor(root: string, config: SpecsConfig, options: { commit?: string } = {}) {
434
+ this.root = realPath(root);
435
+ this.config = parseSpecsConfig(config);
436
+ if (options.commit !== undefined) {
437
+ if (!/^[0-9a-f]{40}$/.test(options.commit) || this.git(["cat-file", "-e", `${options.commit}^{commit}`]).status !== 0) throw new Error("spec snapshot commit is unavailable; submit an existing full commit SHA");
438
+ this.commit = options.commit;
439
+ }
440
+ }
441
+
442
+ private head(): string { return this.commit ?? "HEAD"; }
443
+
444
+ private snapshotTree(): Map<string, { mode: string; oid: string }> | undefined {
445
+ if (this.treeLoaded) return this.tree;
446
+ const result = this.git(["ls-tree", "-r", "-z", "--full-tree", this.commit!]);
447
+ if (result.status !== 0) return undefined;
448
+ const tree = new Map<string, { mode: string; oid: string }>();
449
+ for (const entry of splitNul(result.stdout)) {
450
+ const match = /^([0-7]{6}) (?:blob|commit) ([0-9a-f]{40})\t([\s\S]+)$/.exec(entry);
451
+ if (!match) return undefined;
452
+ tree.set(match[3]!, { mode: match[1]!, oid: match[2]! });
453
+ }
454
+ this.tree = tree;
455
+ this.treeLoaded = true;
456
+ return tree;
457
+ }
458
+
459
+ private readCommittedCandidate(path: string): CachedFile | undefined {
460
+ const entry = this.snapshotTree()?.get(path);
461
+ if (!entry || !["100644", "100755"].includes(entry.mode) || !isSafeRepoPath(path)) return undefined;
462
+ const cached = this.cache.get(path); if (cached) return cached;
463
+ const shared = cachedCandidate(entry.oid, path);
464
+ if (shared) { this.cache.set(path, shared); return shared; }
465
+ // A batch failure stays unknown; never fall back to one process per file.
466
+ return this.parseCommittedCandidate(path, Buffer.alloc(0), true);
467
+ }
468
+
469
+ private parseCommittedCandidate(path: string, bytes: Buffer, incomplete: boolean): CachedFile {
470
+ const parsed = parseHeader(bytes.subarray(0, MAX_FRONTMATTER_BYTES + 16).toString("utf8"), path);
471
+ const candidate = Object.hasOwn(parsed.fields, "id") && Object.hasOwn(parsed.fields, "type");
472
+ const items = candidate ? [...parsed.items] : [];
473
+ if (incomplete && (!bytes.length || candidate)) items.push(item("error", "snapshot-unavailable", `${path}: committed spec read is incomplete`, "the Git blob exceeded the 4 MiB body cap or could not be read", "shorten the spec or narrow specs.roots and retry", { path }));
474
+ let node: SpecNode | undefined;
475
+ if (candidate) {
476
+ if (!parsed.closing && !parsed.tooLarge) items.push(item("error", "frontmatter-unterminated", `${path}: spec frontmatter is incomplete`, "the opening delimiter has no closing delimiter", "close the frontmatter delimiter within 8 KiB", { path }));
477
+ const scalar = (key: string) => typeof parsed.fields[key] === "string" ? parsed.fields[key] as string : undefined;
478
+ const list = (key: string) => Array.isArray(parsed.fields[key]) ? parsed.fields[key] as string[] : [];
479
+ const bodyLinks = parsed.closing && !incomplete ? [...new Set([...bytes.subarray(parsed.bodyOffset).toString("utf8").matchAll(/\[\[([^\]\r\n]{1,256})\]\]/g)].map(match => Buffer.from(match[1]!.trim()).toString("utf8")).filter(Boolean))] : [];
480
+ node = { path, id: scalar("id"), type: scalar("type"), title: scalar("title"), status: scalar("status"), parent: scalar("parent"), dependsOn: list("depends-on"), references: list("references"), implements: list("implements"), supersededBy: scalar("superseded-by"), covers: list("covers"), tags: list("tags"), paths: list("paths"), verifiedAgainst: scalar("verified-against"), bodyLinks, ...(!parsed.closing ? { frontmatterIncomplete: true } : {}) };
481
+ }
482
+ const read = { size: bytes.length, mtimeMs: 0, dev: 0, ino: 0, node, items, truncated: candidate && (incomplete || parsed.tooLarge) || incomplete && !bytes.length };
483
+ return read;
484
+ }
485
+
486
+ private gitBatch(args: string[], oids: readonly string[]): Buffer | undefined {
487
+ try {
488
+ const result = hubGitSync(["-C", this.root, ...GIT_SAFE_OPTIONS, "cat-file", ...args], {
489
+ input: `${oids.join("\n")}\n`, encoding: "latin1", timeout: GIT_TIMEOUT_MS, maxBuffer: MAX_GIT_OUTPUT_BYTES,
490
+ });
491
+ return result.status === 0 ? Buffer.from(result.stdout ?? "", "latin1") : undefined;
492
+ } catch { return undefined; }
493
+ }
494
+
495
+ private warmCommittedCandidates(files: readonly string[]): void {
496
+ const tree = this.snapshotTree();
497
+ const cold = new Map<string, string[]>();
498
+ for (const path of files) {
499
+ const entry = tree?.get(path);
500
+ if (!entry || !isSafeRepoPath(path) || !["100644", "100755"].includes(entry.mode) || this.cache.has(path)) continue;
501
+ const cached = cachedCandidate(entry.oid, path);
502
+ if (cached) { this.cache.set(path, cached); continue; }
503
+ const paths = cold.get(entry.oid) ?? []; paths.push(path); cold.set(entry.oid, paths);
504
+ }
505
+ if (!cold.size) return;
506
+ // Obtain sizes first so each binary batch fits the output bound, even when a
507
+ // repository has thousands of large Markdown files or adversarial blobs.
508
+ const sizes = this.gitBatch(["--batch-check"], [...cold.keys()]);
509
+ if (!sizes) return;
510
+ const lines = sizes.toString("ascii").trimEnd().split("\n");
511
+ if (lines.length !== cold.size) return;
512
+ const rows: { oid: string; size: number }[] = [];
513
+ let index = 0;
514
+ for (const oid of cold.keys()) {
515
+ const match = /^([0-9a-f]{40}) blob ([0-9]+)$/.exec(lines[index++]!);
516
+ if (!match || match[1] !== oid) return;
517
+ const size = Number(match[2]);
518
+ if (!Number.isSafeInteger(size)) return;
519
+ if (size > MAX_COMMITTED_SPEC_BYTES) continue; // Unknown, without loading an oversized object.
520
+ rows.push({ oid, size });
521
+ }
522
+ let offset = 0;
523
+ while (offset < rows.length) {
524
+ const batch: typeof rows = []; let bytes = 0;
525
+ while (offset < rows.length && bytes + rows[offset]!.size + 128 <= MAX_BLOB_BATCH_BYTES) {
526
+ const row = rows[offset++]!; batch.push(row); bytes += row.size + 128;
527
+ }
528
+ const output: Buffer | undefined = this.gitBatch(["--batch"], batch.map(row => row.oid));
529
+ if (!output) continue;
530
+ let cursor: number = 0;
531
+ for (const row of batch) {
532
+ const end: number = output.indexOf(10, cursor);
533
+ if (end < 0 || output.subarray(cursor, end).toString("ascii") !== `${row.oid} blob ${row.size}`) break;
534
+ const start: number = end + 1, next: number = start + row.size;
535
+ if (next >= output.length || output[next] !== 10) break;
536
+ const paths = cold.get(row.oid)!;
537
+ const parsed = this.parseCommittedCandidate(paths[0]!, output.subarray(start, next), false);
538
+ cacheCandidate(row.oid, paths[0]!, parsed);
539
+ for (const path of paths) this.cache.set(path, path === paths[0] ? parsed : { ...parsed,
540
+ node: parsed.node ? { ...parsed.node, path } : undefined,
541
+ items: parsed.items.map(diagnostic => ({ ...diagnostic, path,
542
+ message: diagnostic.message.startsWith(`${paths[0]}:`) ? `${path}:${diagnostic.message.slice(paths[0]!.length + 1)}` : diagnostic.message })) });
543
+ cursor = next + 1;
544
+ }
545
+ }
546
+ }
547
+
548
+ private git(args: string[], maxBuffer = MAX_GIT_OUTPUT_BYTES): { status: number | null; stdout: string; stderr?: string; error?: { code?: string; message?: string } } {
549
+ const argv = ["-C", this.root, ...GIT_SAFE_OPTIONS, ...args];
550
+ try {
551
+ const result = hubGitSync(argv, { encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer });
552
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr, error: result.error ? { code: (result.error as NodeJS.ErrnoException).code, message: result.error.message } : undefined };
553
+ } catch (error) { return { status: null, stdout: "", error: { message: errorText(error) } }; }
554
+ }
555
+
556
+ private discoveryPathspecs(): string[] {
557
+ if (!this.config.roots.length) return ["*.md"];
558
+ return this.config.roots.flatMap(root => { const literal = escapeGitGlobLiteral(root); return [`:(glob)${literal}/*.md`, `:(glob)${literal}/**/*.md`]; });
559
+ }
560
+
561
+ private readCandidate(path: string): CachedFile | undefined {
562
+ if (this.commit) return this.readCommittedCandidate(path);
563
+ if (!isSafeRepoPath(path) || !path.toLowerCase().endsWith(".md")) return undefined;
564
+ const absolute = resolve(this.root, path);
565
+ const rel = relative(this.root, absolute);
566
+ if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) return undefined;
567
+ let before;
568
+ try {
569
+ const pieces = path.split("/");
570
+ let current = this.root;
571
+ for (let index = 0; index < pieces.length; index++) {
572
+ current = join(current, pieces[index]!);
573
+ const stat = lstatSync(current);
574
+ if (stat.isSymbolicLink() || index < pieces.length - 1 && !stat.isDirectory()) return undefined;
575
+ if (index === pieces.length - 1 && !stat.isFile()) return undefined;
576
+ }
577
+ before = lstatSync(absolute);
578
+ } catch { return undefined; }
579
+ const previous = this.cache.get(path);
580
+ if (previous && previous.size === before.size && previous.mtimeMs === before.mtimeMs && previous.dev === before.dev && previous.ino === before.ino) return previous;
581
+
582
+ let fd: number | undefined;
583
+ try {
584
+ fd = openSync(absolute, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
585
+ const opened = fstatSync(fd);
586
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) return undefined;
587
+ const prefix = Buffer.alloc(Math.min(opened.size, MAX_FRONTMATTER_BYTES + 16));
588
+ let got = 0;
589
+ while (got < prefix.length) { const count = readSync(fd, prefix, got, prefix.length - got, got); if (!count) break; got += count; }
590
+ const parsed = parseHeader(prefix.subarray(0, got).toString("utf8"), path);
591
+ const candidate = Object.hasOwn(parsed.fields, "id") && Object.hasOwn(parsed.fields, "type");
592
+ let node: SpecNode | undefined;
593
+ const diagnostics = candidate ? [...parsed.items] : [];
594
+ if (candidate) {
595
+ if (!parsed.closing && !parsed.tooLarge) diagnostics.push(item("error", "frontmatter-unterminated", `${path}: spec frontmatter is incomplete`, "the opening delimiter has no closing delimiter", "close the frontmatter delimiter within 8 KiB", { path }));
596
+ const scalar = (key: string): string | undefined => typeof parsed.fields[key] === "string" ? parsed.fields[key] as string : undefined;
597
+ const list = (key: string): string[] => Array.isArray(parsed.fields[key]) ? parsed.fields[key] as string[] : [];
598
+ node = { path, id: scalar("id"), type: scalar("type"), title: scalar("title"), status: scalar("status"), parent: scalar("parent"), dependsOn: list("depends-on"), references: list("references"), implements: list("implements"), supersededBy: scalar("superseded-by"), covers: list("covers"), tags: list("tags"), paths: list("paths"), verifiedAgainst: scalar("verified-against"), bodyLinks: parsed.closing ? scanBodyLinks(fd, parsed.bodyOffset, opened.size) : [], ...(!parsed.closing ? { frontmatterIncomplete: true } : {}) };
599
+ }
600
+ const after = lstatSync(absolute);
601
+ if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || after.mtimeMs !== before.mtimeMs) return undefined;
602
+ const cached = { size: before.size, mtimeMs: before.mtimeMs, dev: before.dev, ino: before.ino, node, items: diagnostics, truncated: candidate && parsed.tooLarge };
603
+ this.cache.set(path, cached);
604
+ return cached;
605
+ } catch {
606
+ return undefined;
607
+ } finally { if (fd !== undefined) try { closeSync(fd); } catch { /* closed */ } }
608
+ }
609
+
610
+ private scan(): ScanResult {
611
+ this.coverageUnavailable = false;
612
+ if (!this.commit) this.pathspecCache.clear();
613
+ const tree = this.commit ? this.snapshotTree() : undefined;
614
+ const result = this.commit ? { status: tree ? 0 : 1, stdout: tree ? [...tree].filter(([path, entry]) => ["100644", "100755"].includes(entry.mode) && path.endsWith(".md") && (!this.config.roots.length || this.config.roots.some(root => path.startsWith(`${root}/`)))).map(([path]) => path).join("\0") : "", stderr: "committed spec tree unavailable", error: undefined }
615
+ : this.git(["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", ...this.discoveryPathspecs()]);
616
+ if (result.status !== 0) {
617
+ return { files: [], nodes: [], items: [item("error", "discovery-unavailable", "spec discovery is unavailable", result.error?.message ?? result.stderr ?? "git ls-files failed", "check that this is a readable Git worktree", { path: this.root })], truncated: false };
618
+ }
619
+ const discovered = splitNul(result.stdout).sort(comparePath);
620
+ let truncated = discovered.length > MAX_FILES;
621
+ const files = discovered.slice(0, MAX_FILES);
622
+ const nodes: SpecNode[] = [], items: SpecsDiagnostic[] = [];
623
+ if (this.commit) this.warmCommittedCandidates(files);
624
+ const seen = new Set(files);
625
+ for (const old of this.cache.keys()) if (!seen.has(old)) this.cache.delete(old);
626
+ for (const file of files) {
627
+ const cached = this.readCandidate(file);
628
+ if (!cached) continue; // Symlinks, unsafe names and unreadable entries are never followed.
629
+ items.push(...cached.items);
630
+ if (cached.node) nodes.push(cached.node);
631
+ if (cached.truncated) truncated = true;
632
+ }
633
+ if (discovered.length > MAX_FILES) items.push(item("warning", "scan-truncated", `spec scan stopped at ${MAX_FILES} Markdown files`, `the discovery result has more than ${MAX_FILES} paths`, "set specs.roots to the design areas this project uses", { path: this.root }));
634
+ return { files, nodes, items, truncated };
635
+ }
636
+
637
+ private pathspecMatches(pathspec: string, trackedOnly: boolean): string[] {
638
+ const key = `${this.commit !== undefined || trackedOnly ? "tracked" : "all"}\0${pathspec}`;
639
+ const cached = this.pathspecCache.get(key); if (cached) return cached;
640
+ if (!isSafePathspec(pathspec)) return [];
641
+ const args = this.commit ? ["ls-files", `--with-tree=${this.commit}`, "-z", "--", pathspec] : trackedOnly ? ["ls-files", "-z", "--", pathspec] : ["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", pathspec];
642
+ const result = this.git(args);
643
+ if (result.status !== 0) { this.coverageUnavailable = true; return []; }
644
+ const paths = result.status === 0 ? [...new Set(splitNul(result.stdout))].filter(path => !this.commit || this.snapshotTree()?.has(path)).sort(comparePath).slice(0, MAX_TRACKED_PATHS_PER_SCOPE) : [];
645
+ this.pathspecCache.set(key, paths);
646
+ return paths;
647
+ }
648
+
649
+ private analyze(scan: ScanResult): { items: SpecsDiagnostic[]; byId: Map<string, SpecNode>; counts: { errors: number; warnings: number } } {
650
+ const items = [...scan.items];
651
+ const byId = new Map<string, SpecNode>();
652
+ const ids = new Map<string, SpecNode[]>();
653
+ for (const node of scan.nodes) {
654
+ if (node.frontmatterIncomplete) continue;
655
+ if (!node.id?.trim()) items.push(item("error", "required-field", `${node.path}: spec id is empty or invalid`, "the declared id has no non-empty scalar value", "set id to a unique non-empty value", { path: node.path, field: "id" }));
656
+ if (!node.type) items.push(item("error", "required-field", `${node.path}: spec is missing type`, "the frontmatter has an id but no type", "set type to goal, architecture, module or submodule", { path: node.path, id: node.id, field: "type" }));
657
+ if (!node.title?.trim()) items.push(item("error", "required-field", `${node.path}: spec is missing title`, "the frontmatter has no title", "add a non-empty title", { path: node.path, id: node.id, field: "title" }));
658
+ if (node.id) {
659
+ const group = ids.get(node.id) ?? []; group.push(node); ids.set(node.id, group);
660
+ }
661
+ }
662
+ for (const [id, group] of ids) {
663
+ byId.set(id, group[0]!);
664
+ if (group.length > 1) for (const duplicate of group.slice(1)) items.push(item("error", "duplicate-id", `${duplicate.path}: duplicate spec id ${id}`, `another Markdown file already declares ${id}`, "give each spec a unique id", { path: duplicate.path, id, relatedId: group[0]!.path }));
665
+ }
666
+ let goals = 0;
667
+ const covers = new Map<string, SpecNode[]>();
668
+ for (const node of scan.nodes) {
669
+ if (node.frontmatterIncomplete) continue;
670
+ if (node.type && !TYPES.has(node.type)) items.push(item("error", "unknown-type", `${node.path}: unknown spec type ${node.type}`, "type is outside the fixed spec vocabulary", "use goal, architecture, module or submodule", { path: node.path, id: node.id, field: "type" }));
671
+ if (node.status !== undefined && !STATUS.has(node.status)) items.push(item("error", "unknown-status", `${node.path}: unknown spec status ${node.status}`, "status is outside the fixed spec vocabulary", "use draft, active or deprecated", { path: node.path, id: node.id, field: "status" }));
672
+ if (node.type === "goal") goals++;
673
+ if (node.type && node.type !== "goal" && !node.parent) items.push(item("warning", "orphan", `${node.path}: ${node.type} spec has no parent`, "non-goal specs form the design tree through parent links", "add a parent link if this spec belongs under another design decision", { path: node.path, id: node.id, field: "parent" }));
674
+ if (node.status === "active" && !node.paths.length) items.push(item("warning", "active-without-paths", `${node.path}: active spec has no paths`, "freshness cannot be calculated without paths", "add one or more repository-relative paths", { path: node.path, id: node.id, field: "paths" }));
675
+ if (node.status === "deprecated" && !node.supersededBy) items.push(item("warning", "deprecated-without-replacement", `${node.path}: deprecated spec has no replacement`, "deprecated specs should point to their current replacement", "add superseded-by or keep the spec active", { path: node.path, id: node.id, field: "superseded-by" }));
676
+ if (node.supersededBy && node.status !== "deprecated") items.push(item("error", "superseded-by-misuse", `${node.path}: superseded-by is only valid on a deprecated spec`, "a non-deprecated spec names a replacement", "set status to deprecated or remove superseded-by", { path: node.path, id: node.id, field: "superseded-by" }));
677
+ if (node.supersededBy && node.id === node.supersededBy) items.push(item("error", "superseded-by-self", `${node.path}: spec cannot supersede itself`, "superseded-by points back to the same id", "point to a different replacement spec", { path: node.path, id: node.id, field: "superseded-by" }));
678
+ for (const token of node.covers) { const group = covers.get(token) ?? []; group.push(node); covers.set(token, group); }
679
+ const links = [node.parent, node.supersededBy, ...node.dependsOn, ...node.references, ...node.implements].filter((value): value is string => !!value);
680
+ for (const target of links) if (!byId.has(target) && !ids.has(target)) items.push(item("error", "dangling-link", `${node.path}: link to unknown spec ${target}`, "a frontmatter or body link does not resolve by id", "add the target spec or correct the linked id", { path: node.path, id: node.id, relatedId: target }));
681
+ for (const target of node.bodyLinks) if (!byId.has(target) && !ids.has(target)) items.push(item("warning", "unknown-body-link", `${node.path}: body link refers to unknown spec ${target}`, "a Markdown wiki-link does not resolve by spec id", "correct the link or add the referenced spec", { path: node.path, id: node.id, relatedId: target }));
682
+ if (node.supersededBy && byId.get(node.supersededBy)?.status === "deprecated") items.push(item("warning", "deprecated-replacement", `${node.path}: replacement ${node.supersededBy} is also deprecated`, "the replacement does not point to a current spec", "update superseded-by to a non-deprecated replacement", { path: node.path, id: node.id, relatedId: node.supersededBy }));
683
+ if (node.paths.length) for (const path of node.paths) {
684
+ if (!isSafePathspec(path)) { items.push(item("error", "invalid-paths", `${node.path}: invalid paths entry ${path}`, "paths must be safe repository-relative Git pathspecs", "remove absolute paths, dot segments and pathspec magic", { path: node.path, id: node.id, field: "paths" })); continue; }
685
+ if (!this.pathspecMatches(path, true).length) items.push(item("error", "invalid-paths", `${node.path}: paths entry matches no tracked file: ${path}`, "the pathspec does not select a tracked repository file", "correct the pathspec or add a matching tracked file", { path: node.path, id: node.id, field: "paths" }));
686
+ }
687
+ if (node.verifiedAgainst && !node.paths.length) this.validateStamp(node.path, node.id, node.verifiedAgainst, items);
688
+ }
689
+ if (goals > 1) items.push(item("warning", "multiple-goals", `found ${goals} goal specs`, "a repository usually has one goal root", "keep one root goal or confirm the additional goal is intentional"));
690
+ for (const [token, group] of covers) if (group.length > 1) for (const duplicate of group.slice(1)) items.push(item("warning", "duplicate-covers", `${duplicate.path}: covers token ${token} is also claimed by ${group[0]!.id}`, "a covers token is claimed by multiple specs", "keep each covers token on one spec", { path: duplicate.path, id: duplicate.id, relatedId: group[0]!.id }));
691
+ this.detectParentCycles(scan.nodes, items);
692
+ for (const node of scan.nodes) if (node.parent) {
693
+ const parent = byId.get(node.parent);
694
+ if (parent && node.type && parent.type && ranks(node.type) !== undefined && ranks(parent.type) !== undefined && ranks(node.type)! <= ranks(parent.type)!) items.push(item("error", "rank", `${node.path}: ${node.type} must be below ${parent.type}`, "a child spec rank must be greater than its parent's rank", "move the spec under a lower-ranked parent or change its type", { path: node.path, id: node.id, relatedId: parent.id }));
695
+ }
696
+ if (this.coverageUnavailable) items.push(item("error", "coverage-unavailable", "spec path coverage is unavailable", "Git could not read the submitted pathspec evidence", "check repository state and retry"));
697
+ const counts = { errors: items.filter(entry => entry.severity === "error").length, warnings: items.filter(entry => entry.severity === "warning").length };
698
+ return { items, byId, counts };
699
+ }
700
+
701
+ private detectParentCycles(nodes: SpecNode[], items: SpecsDiagnostic[]): void {
702
+ const byId = new Map(nodes.filter(node => node.id).map(node => [node.id!, node]));
703
+ const color = new Map<string, 0 | 1 | 2>();
704
+ const reported = new Set<string>();
705
+ const visit = (id: string, stack: string[]) => {
706
+ const state = color.get(id) ?? 0;
707
+ if (state === 2) return;
708
+ if (state === 1) {
709
+ const index = stack.indexOf(id), cycle = (index < 0 ? stack : stack.slice(index)).concat(id);
710
+ const key = [...new Set(cycle)].sort().join("\0");
711
+ if (!reported.has(key)) { reported.add(key); const node = byId.get(id); items.push(item("error", "parent-cycle", `${node?.path ?? id}: parent cycle ${cycle.join(" -> ")}`, "parent links must form an acyclic tree", "break one parent link in the cycle", { path: node?.path, id })); }
712
+ return;
713
+ }
714
+ color.set(id, 1);
715
+ const parent = byId.get(id)?.parent;
716
+ if (parent && byId.has(parent)) visit(parent, [...stack, id]);
717
+ color.set(id, 2);
718
+ };
719
+ for (const id of byId.keys()) visit(id, []);
720
+ }
721
+
722
+ /** The canonical project root this engine reads, for a caller that writes a stamp back. */
723
+ get rootPath(): string { return this.root; }
724
+
725
+ /** Whether any of these Git pathspecs has uncommitted changes (`undefined`: it could not be told safely). */
726
+ uncommitted(paths: readonly string[]): boolean | undefined { return this.dirtiness([...paths]); }
727
+
728
+ /**
729
+ * #1064 T3: what `ahub specs stamp <id-or-document>...` would record, without writing anything. The stamp is the full HEAD SHA, so it is an ancestor of HEAD by construction, and
730
+ * it is only offered while the document's covered paths have no uncommitted change: a stamp records a review of what HEAD contains. Every target is checked and all refusals are
731
+ * reported together; one refusal means no row is written by the caller.
732
+ */
733
+ stampPlan(targets: readonly string[]): SpecsStampPlan {
734
+ if (this.commit) throw new Error("stamping reads the working tree, not a committed snapshot");
735
+ const refusals: string[] = [], rows: SpecsStampRow[] = [];
736
+ const head = this.git(["rev-parse", "--verify", "HEAD^{commit}"]), sha = head.stdout.trim();
737
+ if (head.status !== 0 || !/^[0-9a-f]{40}$/.test(sha)) return { head: "", rows, refusals: ["HEAD does not resolve to a commit, so there is nothing to stamp against; make a commit first, then run ahub specs stamp again"] };
738
+ const scan = this.scan();
739
+ const manifest = this.config.stamps_file ? this.stampsManifest(this.config.stamps_file, refusals) : undefined;
740
+ for (const target of [...new Set(targets)]) {
741
+ const nodes = scan.nodes.filter(node => node.id === target || node.path === target);
742
+ const entry = manifest && Object.hasOwn(manifest, target) ? manifest[target] : undefined;
743
+ if (nodes.length + (entry !== undefined ? 1 : 0) > 1) { refusals.push(`"${target}" names more than one document; use the spec's file path, or rename the duplicate id (ahub specs check lists them)`); continue; }
744
+ if (nodes.length === 0 && entry === undefined) {
745
+ refusals.push(`no spec or stamped document is named "${target}"${scan.truncated ? " in the part of the tree that was scanned; narrow specs.roots" : ""}; ahub specs list shows the ids and ahub specs check the stamped documents`);
746
+ continue;
747
+ }
748
+ if (nodes.length === 1) {
749
+ const node = nodes[0]!;
750
+ if (!node.paths.length) { refusals.push(`${node.path} has no paths, so there is nothing a stamp could certify; add paths to its frontmatter first`); continue; }
751
+ const dirty = this.dirtiness(node.paths);
752
+ if (dirty === undefined) { refusals.push(`${node.path}: could not tell whether its covered paths have uncommitted changes; check the repository state and run ahub specs stamp again`); continue; }
753
+ if (dirty) { refusals.push(`${node.path}: its covered paths have uncommitted changes; commit or stash them, then stamp, because a stamp records a review of what HEAD contains`); continue; }
754
+ rows.push({ target, path: node.path, kind: "spec", from: node.verifiedAgainst ?? null, to: sha, paths: node.paths });
755
+ continue;
756
+ }
757
+ const paths = isRecord(entry) && Array.isArray(entry.paths) ? entry.paths : undefined;
758
+ if (!isRecord(entry) || !paths?.length || paths.some(scope => typeof scope !== "string" || !isSafePathspec(scope)) || !this.readableRegularFile(target)) {
759
+ refusals.push(`${target}: its entry in ${this.config.stamps_file} is malformed or its document is missing; repair the entry (ahub specs check names the problem), then stamp`);
760
+ continue;
761
+ }
762
+ const dirty = this.dirtiness(paths as string[]);
763
+ if (dirty === undefined) { refusals.push(`${target}: could not tell whether its covered paths have uncommitted changes; check the repository state and run ahub specs stamp again`); continue; }
764
+ if (dirty) { refusals.push(`${target}: its covered paths have uncommitted changes; commit or stash them, then stamp, because a stamp records a review of what HEAD contains`); continue; }
765
+ rows.push({ target, path: this.config.stamps_file, kind: "stamps-file", document: target, from: typeof entry.verifiedAgainst === "string" ? entry.verifiedAgainst : null, to: sha, paths: paths as string[] });
766
+ }
767
+ return { head: sha, rows, refusals };
768
+ }
769
+
770
+ private stampsManifest(path: string, refusals: string[]): Record<string, unknown> | undefined {
771
+ try {
772
+ const info = this.safeStat(path);
773
+ if (info.size > MAX_STAMPS_FILE_BYTES) throw new Error("the file is larger than 4 MiB");
774
+ const parsed: unknown = JSON.parse(readFileSync(resolve(this.root, path), "utf8"));
775
+ if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !isRecord(parsed.documents)) throw new Error("expected schemaVersion 1 and a documents object");
776
+ return parsed.documents;
777
+ } catch (error) {
778
+ refusals.push(`${path} (specs.stamps_file) cannot be read: ${errorText(error)}; repair it or clear specs.stamps_file, then stamp`);
779
+ return undefined;
780
+ }
781
+ }
782
+
783
+ list(): SpecsListReport {
784
+ const scan = this.scan(), analysis = this.analyze(scan);
785
+ return { specs: scan.nodes.map(node => summary(node)), errors: analysis.counts.errors, truncated: scan.truncated || analysis.items.length > MAX_REPORT_ITEMS, items: boundedItems(analysis.items) };
786
+ }
787
+
788
+ covers(paths: readonly string[]): SpecsCoverReport {
789
+ const scan = this.scan(), analysis = this.analyze(scan);
790
+ const valid = paths.filter(isSafeRepoPath), invalid = paths.filter(path => !isSafeRepoPath(path));
791
+ const items = [...analysis.items, ...invalid.map(path => item("error", "invalid-cover-path", `cannot check unsafe path ${path}`, "covers accepts repository-relative file paths only", "remove absolute paths, dot segments and option-like values", { path }))];
792
+ const requested = [...new Set(valid)];
793
+ const result: SpecsCoverSummary[] = [];
794
+ for (const node of scan.nodes) {
795
+ const matches = node.paths.some(pathspec => {
796
+ const indexed = this.pathspecMatches(pathspec, false);
797
+ return requested.some(path => indexed.includes(path) || pathspecMatchesPath(pathspec, path));
798
+ });
799
+ if (matches) result.push(coverSummary(node));
800
+ }
801
+ if (this.coverageUnavailable && !analysis.items.some(row => row.code === "coverage-unavailable")) {
802
+ analysis.items.push(item("error", "coverage-unavailable", "spec path coverage is unavailable", "Git could not read pathspec evidence", "check repository state and retry"));
803
+ analysis.counts.errors++;
804
+ }
805
+ return { specs: result, errors: analysis.counts.errors + invalid.length, truncated: scan.truncated || items.length > MAX_REPORT_ITEMS, items: boundedItems(items) };
806
+ }
807
+
808
+ check(options: { base?: string; requireFresh?: boolean } = {}): SpecsCheckReport {
809
+ if (this.commit && !options.base && this.committedCheck) return structuredClone(this.committedCheck);
810
+ const scan = this.scan(), analysis = this.analyze(scan), items = [...analysis.items];
811
+ const fresh = this.freshness(scan.nodes, items), freshness = fresh.rows;
812
+ if (options.base) this.baseDrift(scan.nodes, options.base, items);
813
+ const errors = items.filter(entry => entry.severity === "error").length;
814
+ const warnings = items.filter(entry => entry.severity === "warning").length;
815
+ const report = { specCount: scan.nodes.length, errors, warnings, stale: freshness.filter(row => row.state === "stale").length, truncated: scan.truncated || fresh.truncated || items.length > MAX_REPORT_ITEMS, freshness, items: boundedItems(items) };
816
+ if (this.commit && !options.base && !report.errors && !report.truncated) this.committedCheck = structuredClone(report);
817
+ return report;
818
+ }
819
+
820
+ private freshness(nodes: SpecNode[], items: SpecsDiagnostic[]): { rows: SpecsFreshness[]; truncated: boolean } {
821
+ const rows: SpecsFreshness[] = [];
822
+ for (const node of nodes) {
823
+ if (!node.paths.length) continue;
824
+ if (!node.verifiedAgainst) { rows.push({ path: node.path, ...(node.id ? { id: node.id } : {}), state: "unverified", commitsSince: null, dirty: null }); continue; }
825
+ const result = this.measureFreshness(node.path, node.id, node.verifiedAgainst, node.paths, items);
826
+ rows.push(result);
827
+ }
828
+ const stampFile = this.config.stamps_file;
829
+ let truncated = false;
830
+ if (stampFile) { const result = this.readStampFile(stampFile, items); rows.push(...result.rows); truncated = result.truncated; }
831
+ return { rows, truncated };
832
+ }
833
+
834
+ private validateStamp(path: string, id: string | undefined, stamp: string, items: SpecsDiagnostic[]): "valid" | "nonancestor" | "unknown" {
835
+ if (!/^[0-9a-f]{40}$/.test(stamp)) {
836
+ items.push(item("error", "invalid-verified-against", `${path}: verified-against must be a full lowercase commit SHA`, "the stamp is not a full 40-character SHA", "set verified-against to a reviewed commit SHA", { path, id, field: "verified-against" }));
837
+ return "unknown";
838
+ }
839
+ const commit = this.git(["cat-file", "-e", `${stamp}^{commit}`]);
840
+ if (commit.status !== 0) { items.push(item("error", "invalid-verified-against", `${path}: verified commit does not resolve`, "the stamp is missing or is not a commit", "set verified-against to an existing ancestor commit", { path, id, field: "verified-against" })); return "unknown"; }
841
+ const ancestor = this.git(["merge-base", "--is-ancestor", stamp, this.head()]);
842
+ if (ancestor.status !== 0) {
843
+ const nonancestor = ancestor.status === 1;
844
+ items.push(item("error", "nonancestor-stamp", `${path}: verified commit is not an ancestor of HEAD`, nonancestor ? "the stamp belongs to another history" : "Git could not verify stamp ancestry", "stamp from an ancestor commit in this repository", { path, id, field: "verified-against" }));
845
+ return nonancestor ? "nonancestor" : "unknown";
846
+ }
847
+ return "valid";
848
+ }
849
+
850
+ private measureFreshness(path: string, id: string | undefined, stamp: string, paths: string[], items: SpecsDiagnostic[]): SpecsFreshness {
851
+ const row: SpecsFreshness = { path, ...(id ? { id } : {}), state: "unknown", commitsSince: null, dirty: null };
852
+ const stampState = this.validateStamp(path, id, stamp, items);
853
+ if (stampState !== "valid") { row.state = stampState; return row; }
854
+ const count = this.git(["rev-list", "--count", `${stamp}..${this.head()}`, "--", ...paths]);
855
+ const dirty = this.dirtiness(paths);
856
+ if (count.status !== 0 || dirty === undefined) {
857
+ items.push(item("error", "freshness-unavailable", `${path}: cannot compute source freshness`, "Git history or the working tree could not be read safely", "check repository state and retry the specs check", { path, id }));
858
+ return row;
859
+ }
860
+ const commits = Number(count.stdout.trim());
861
+ if (!Number.isSafeInteger(commits) || commits < 0) {
862
+ items.push(item("error", "freshness-unavailable", `${path}: invalid source commit count`, "Git returned a non-numeric revision count", "check repository history and retry the specs check", { path, id }));
863
+ return row;
864
+ }
865
+ row.commitsSince = commits;
866
+ row.dirty = dirty;
867
+ row.state = commits > 0 || dirty ? "stale" : "fresh";
868
+ return row;
869
+ }
870
+
871
+ private dirtiness(paths: string[]): boolean | undefined {
872
+ if (this.commit) return false;
873
+ const staged = this.git(["diff-index", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", "HEAD", "--", ...paths]);
874
+ if (staged.status === 1) return true;
875
+ if (staged.status !== 0) return undefined;
876
+ const untracked = this.git(["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths]);
877
+ if (untracked.status !== 0) return undefined;
878
+ if (untracked.stdout) return true;
879
+ const tracked = this.git(["ls-files", "--stage", "-z", "--", ...paths]);
880
+ if (tracked.status !== 0) return undefined;
881
+ const entries = splitNul(tracked.stdout);
882
+ if (entries.length > MAX_TRACKED_PATHS_PER_SCOPE) return undefined;
883
+ try {
884
+ for (const entry of entries) {
885
+ const match = /^([0-7]{6}) ([0-9a-f]{40}) ([0-3])\t([\s\S]+)$/.exec(entry);
886
+ if (!match) return undefined;
887
+ const [, mode, oid, stage, file] = match;
888
+ if (stage !== "0") return true;
889
+ if (!["100644", "100755", "120000"].includes(mode!)) return undefined;
890
+ const absolute = resolve(this.root, file!);
891
+ const rel = relative(this.root, absolute);
892
+ if (!rel || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) return undefined;
893
+ let info: Stats;
894
+ try { info = this.safeStat(file!, mode === "120000"); } catch (error) { if ((error as { code?: string }).code === "ENOENT") return true; return undefined; }
895
+ let bytes: Buffer;
896
+ if (mode === "120000") { if (!info.isSymbolicLink()) return true; bytes = readlinkSync(absolute, { encoding: "buffer" }); }
897
+ else { if (!info.isFile() || info.isSymbolicLink()) return true; if (info.size > 4 * 1024 * 1024) return undefined; bytes = readFileSync(absolute); }
898
+ const hash = createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
899
+ if (hash !== oid || mode !== "120000" && !!(info.mode & 0o111) !== (mode === "100755")) return true;
900
+ }
901
+ return false;
902
+ } catch { return undefined; }
903
+ }
904
+
905
+ private readStampFile(path: string, items: SpecsDiagnostic[]): { rows: SpecsFreshness[]; truncated: boolean } {
906
+ if (this.commit) {
907
+ const entry = this.snapshotTree()?.get(path);
908
+ if (!entry || !["100644", "100755"].includes(entry.mode)) {
909
+ items.push(item("error", "stamps-unavailable", `${path}: configured committed stamps file is missing or not a regular file`, "the submitted tree has no regular stamps file", "add a schemaVersion 1 documents file or clear specs.stamps_file", { path }));
910
+ return { rows: [], truncated: false };
911
+ }
912
+ const result = this.git(["cat-file", "blob", entry.oid], MAX_STAMPS_FILE_BYTES);
913
+ if (result.status !== 0) {
914
+ items.push(item("error", "stamps-unavailable", `${path}: committed stamps file is unavailable`, "the blob exceeds 4 MiB or cannot be read", "reduce the stamps file or clear specs.stamps_file", { path }));
915
+ return { rows: [], truncated: true };
916
+ }
917
+ return this.parseStampFile(path, result.stdout, items);
918
+ }
919
+ let info: Stats;
920
+ try { info = this.safeStat(path); }
921
+ catch (error) {
922
+ if ((error as { code?: string }).code === "ENOENT") {
923
+ items.push(item("error", "stamps-unavailable", `${path}: configured stamps file is missing`, "specs.stamps_file names no readable file", "create a schemaVersion 1 documents file or clear specs.stamps_file", { path }));
924
+ return { rows: [], truncated: false };
925
+ }
926
+ items.push(item("error", "stamps-unavailable", `${path}: stamps file is not a safe regular file`, errorText(error), "use a regular repository file without symlink components or clear specs.stamps_file", { path }));
927
+ return { rows: [], truncated: false };
928
+ }
929
+ if (info.size > MAX_STAMPS_FILE_BYTES) {
930
+ items.push(item("warning", "stamps-truncated", `${path}: stamps file is too large to scan`, `the file exceeds ${MAX_STAMPS_FILE_BYTES} bytes`, "reduce the stamps file or split its document inventory", { path }));
931
+ return { rows: [], truncated: true };
932
+ }
933
+ const absolute = resolve(this.root, path);
934
+ try { return this.parseStampFile(path, readFileSync(absolute, "utf8"), items); }
935
+ catch (error) { items.push(item("error", "stamps-invalid", `${path}: stamps file cannot be read`, errorText(error), "repair the stamps file or clear specs.stamps_file", { path })); return { rows: [], truncated: false }; }
936
+ }
937
+
938
+ private parseStampFile(path: string, text: string, items: SpecsDiagnostic[]): { rows: SpecsFreshness[]; truncated: boolean } {
939
+ let manifest: unknown;
940
+ try { manifest = JSON.parse(text); }
941
+ catch (error) { items.push(item("error", "stamps-invalid", `${path}: stamps file cannot be parsed`, errorText(error), "repair the schemaVersion 1 documents object", { path })); return { rows: [], truncated: false }; }
942
+ if (!isRecord(manifest) || manifest.schemaVersion !== 1 || !isRecord(manifest.documents)) {
943
+ items.push(item("error", "stamps-invalid", `${path}: stamps file schema is invalid`, "expected schemaVersion 1 and a documents object", "repair the stamps file or clear specs.stamps_file", { path })); return { rows: [], truncated: false };
944
+ }
945
+ const rows: SpecsFreshness[] = [];
946
+ for (const [document, entry] of Object.entries(manifest.documents)) {
947
+ if (!isSafeRepoPath(document) || !isRecord(entry) || !Array.isArray(entry.paths) || !entry.paths.length || entry.paths.some(path => typeof path !== "string" || !isSafePathspec(path))) {
948
+ items.push(item("error", "stamps-invalid-entry", `${path}: invalid stamp entry ${document}`, "entry path or covered paths are unsafe or malformed", "use repository-relative document paths and covered Git pathspecs", { path, id: document }));
949
+ continue;
950
+ }
951
+ const missingScope = (entry.paths as string[]).find(scope => !this.pathspecMatches(scope, true).length);
952
+ if (missingScope) {
953
+ items.push(item("error", "stamps-invalid-entry", `${path}: stamp entry ${document} covers no tracked file: ${missingScope}`, "a covered pathspec selects no tracked file", "correct the pathspec or restore a matching tracked file", { path: document, field: "paths" }));
954
+ continue;
955
+ }
956
+ const stamp = entry.verifiedAgainst;
957
+ const itemPath = document;
958
+ if (!this.readableRegularFile(document)) {
959
+ items.push(item("error", "stamped-document-missing", `${document}: stamped document is missing or unreadable`, "the stamp names no safe regular file", "restore the document or remove its stamp entry", { path: document }));
960
+ continue;
961
+ }
962
+ if (typeof stamp !== "string") {
963
+ items.push(item("error", "invalid-verified-against", `${document}: verified commit is missing`, "stamp entry has no verifiedAgainst SHA", "stamp the document at a reviewed commit", { path: document, field: "verifiedAgainst" }));
964
+ rows.push({ path: itemPath, state: "unknown", commitsSince: null, dirty: null });
965
+ continue;
966
+ }
967
+ rows.push(this.measureFreshness(itemPath, undefined, stamp, entry.paths as string[], items));
968
+ }
969
+ return { rows, truncated: false };
970
+ }
971
+
972
+ private safeStat(path: string, allowFinalSymlink = false): Stats {
973
+ if (!isSafeRepoPath(path)) throw new Error("unsafe repository-relative path");
974
+ let current = this.root;
975
+ const parts = path.split("/");
976
+ for (let index = 0; index < parts.length; index++) {
977
+ current = join(current, parts[index]!);
978
+ const stat = lstatSync(current);
979
+ const final = index === parts.length - 1;
980
+ if (stat.isSymbolicLink() && !(final && allowFinalSymlink) || !final && !stat.isDirectory()) throw new Error("unsafe path component");
981
+ if (final && !stat.isFile() && !(allowFinalSymlink && stat.isSymbolicLink())) throw new Error("not a regular file");
982
+ }
983
+ return lstatSync(current);
984
+ }
985
+
986
+ private readableRegularFile(path: string): boolean {
987
+ if (this.commit) return isSafeRepoPath(path) && ["100644", "100755"].includes(this.snapshotTree()?.get(path)?.mode ?? "");
988
+ try { const stat = this.safeStat(path); return stat.isFile() && !stat.isSymbolicLink(); } catch { return false; }
989
+ }
990
+
991
+ private baseDrift(nodes: SpecNode[], base: string, items: SpecsDiagnostic[]): void {
992
+ if (!base || base.length > 200 || base.startsWith("-") || /[\u0000-\u0020\u007f]/.test(base)) {
993
+ items.push(item("error", "invalid-base", "spec base reference is invalid", "the supplied base is not a safe Git ref", "use a branch or commit name without options or whitespace")); return;
994
+ }
995
+ const verifiedBase = this.git(["rev-parse", "--verify", "--end-of-options", `${base}^{commit}`]);
996
+ if (verifiedBase.status !== 0) { items.push(item("error", "base-unavailable", "cannot compare specs against base", "the requested base does not resolve to a commit", "check the branch or commit name and retry")); return; }
997
+ const diff = this.git(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "-z", `${base}..${this.head()}`, "--"]);
998
+ if (diff.status !== 0) { items.push(item("error", "base-unavailable", "cannot compare specs against base", diff.stderr ?? "Git could not read the requested base", "check that the base exists and is reachable")); return; }
999
+ const changed = new Set(splitNul(diff.stdout));
1000
+ for (const node of nodes) {
1001
+ if (!node.id || !node.paths.length || changed.has(node.path)) continue;
1002
+ const scoped = this.git(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "-z", `${base}..${this.head()}`, "--", ...node.paths]);
1003
+ if (scoped.status !== 0) continue;
1004
+ const touching = splitNul(scoped.stdout).filter(file => file !== node.path);
1005
+ if (touching.length) items.push(item("info", "paths-changed-spec-unchanged", `${node.path}: covered paths changed since ${base}, but the spec did not`, `${touching.slice(0, 20).join(", ")}${touching.length > 20 ? ", ..." : ""}`, "review the spec and update it in the same change when the design changed", { path: node.path, id: node.id }));
1006
+ }
1007
+ }
1008
+ }