@cohortapp/agent-sdk 2.12.0 → 2.13.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 (127) hide show
  1. package/bin/maestro.mjs +6 -2
  2. package/docs/guides/front-door-session.md +49 -0
  3. package/lib/cli/design.mjs +185 -0
  4. package/lib/cli/design.test.mjs +270 -0
  5. package/lib/cli/global-setup-extras.mjs +44 -0
  6. package/lib/cli/global-setup-extras.test.mjs +95 -0
  7. package/lib/cli/session.mjs +11 -1
  8. package/lib/cli/session.test.mjs +17 -6
  9. package/lib/collective/global-config.mjs +5 -0
  10. package/lib/collective/global-config.test.mjs +5 -0
  11. package/lib/collective/vendor-skills.mjs +305 -0
  12. package/lib/collective/vendor-skills.test.mjs +306 -0
  13. package/lib/design/design-md.mjs +793 -0
  14. package/lib/design/design-md.test.mjs +318 -0
  15. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  16. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  17. package/lib/design/fixtures/foundation.json +133 -0
  18. package/lib/design/refresh-gate.mjs +154 -0
  19. package/lib/design/refresh-gate.test.mjs +144 -0
  20. package/lib/design/write.mjs +275 -0
  21. package/lib/design/write.test.mjs +241 -0
  22. package/lib/prompts/parallelism.mjs +79 -0
  23. package/lib/prompts/parallelism.test.mjs +177 -0
  24. package/package.json +1 -1
  25. package/plugins/maestro-skills/plugin.json +4 -0
  26. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  27. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  28. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  29. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  30. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  31. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  32. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  33. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  34. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  35. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  36. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  37. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  38. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  39. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  40. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  41. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  42. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  43. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  44. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  45. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  46. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  47. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  48. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  49. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  50. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  51. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  52. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  53. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  54. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  55. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  56. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  57. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  58. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  59. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  60. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  61. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  62. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  63. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  64. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  65. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  66. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  67. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  68. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  69. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  70. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  71. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  72. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  73. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  74. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  75. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  76. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  77. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  78. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  79. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  80. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  81. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  82. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  83. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  84. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  85. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  86. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  87. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  88. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  89. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  90. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  91. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  92. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  93. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  94. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  95. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  96. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  97. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  98. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  99. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  100. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  101. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  102. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  103. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  104. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  105. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  106. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  107. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  108. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  109. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  110. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  111. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  112. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  113. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  114. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  115. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  116. package/scripts/ci/check-skill-packs.mjs +388 -0
  117. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  118. package/scripts/ci/check.mjs +3 -0
  119. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  120. package/scripts/daemon/agent-daemon.mjs +108 -0
  121. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +61 -2
  122. package/scripts/daemon/cadence-consumer.mjs +46 -22
  123. package/scripts/daemon/prompt-builder.mjs +19 -3
  124. package/scripts/local-triggers/autoupdate.test.mjs +33 -3
  125. package/scripts/vendor/skill-packs.mjs +354 -0
  126. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  127. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
@@ -0,0 +1,960 @@
1
+ #!/usr/bin/env node
2
+ // Execute gate oracles, update evidence, coordinate scopes, and manage leases.
3
+ // Zero dependencies. Node 16+.
4
+
5
+ import {
6
+ closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync,
7
+ mkdirSync, openSync, readFileSync, realpathSync, statSync, unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { spawn } from "node:child_process";
11
+ import { Worker } from "node:worker_threads";
12
+ import { randomBytes } from "node:crypto";
13
+ import { delimiter, dirname, basename, isAbsolute, join, relative, resolve, sep } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { fileURLToPath } from "node:url";
16
+ import {
17
+ MAX_AUTOMATIC_EVIDENCE_CHARS, MAX_CHECK_OUTPUT_BYTES, UNLAZY_DIR, appendStatus,
18
+ automaticEvidencePrefix, claimLeases, formatDocument,
19
+ gateDefinitionDigest, gateState,
20
+ hookStatePath, listScopes, parseGates, qualify, readStableRegularFile, releaseLeases,
21
+ resolveTarget, sameFileIdentity, scopeRoot, sha256, sleep, statCurrentNamedFile,
22
+ validateScopeId, withFileLock, writeAtomic,
23
+ } from "./lib/gates.mjs";
24
+ import { terminateProcessTree } from "./lib/process-tree.mjs";
25
+ import { dispatchStatus } from "./lib/dispatch.mjs";
26
+
27
+ const HELP = `usage: gate-check.mjs [options] [file ...]
28
+
29
+ run modes:
30
+ (default) run unmet runnable gates and update their ledgers
31
+ --status report only; never execute, approve, or write
32
+ --reverify re-run every runnable gate and demote stale failures
33
+ --approve approve each exact pending oracle, then run it
34
+ --jobs N rolling concurrency, integer 1..64 (default 1)
35
+ --timeout S per-check timeout, integer seconds 1..86400 (default 120)
36
+ --shell PATH command shell (UNLAZY_SHELL, then platform default)
37
+ --cwd DIR default CHECK directory (explicit: file dir; discovered: --root)
38
+
39
+ pipeline actions:
40
+ --claim --scope ID [--leaf NAME] atomically claim the leaf's OWNS paths
41
+ --release --scope ID [--leaf NAME] release serialized ownership leases
42
+ --log TEXT --scope ID append one status line
43
+ --bind SESSION --scope ID bind a session to one pipeline
44
+ --list-scopes list .unlazy pipelines
45
+
46
+ targeting:
47
+ --scope ID use .unlazy/ID (or UNLAZY_SCOPE)
48
+ --root DIR repository/pipeline root (default current directory)
49
+ file ... explicit regular ledger files; all are honored
50
+
51
+ CHECK execution requires prior approval keyed to the exact CHECK, EXPECT,
52
+ resolved CWD, resolved shell, timeout, output/regex limits, platform, and PATH.
53
+ Approvals live outside the repository under ~/.unlazy/approved by default.
54
+
55
+ exit codes: 0 all met/action succeeded; 1 unmet; 2 usage/parse/infrastructure;
56
+ 3 lease conflict.`;
57
+
58
+ const FLAG_OPTIONS = new Set([
59
+ "--status", "--reverify", "--approve", "--claim", "--release",
60
+ "--list-scopes", "--help", "-h",
61
+ ]);
62
+ const VALUE_OPTIONS = new Set([
63
+ "--scope", "--leaf", "--timeout", "--jobs", "--cwd", "--root",
64
+ "--log", "--bind", "--shell",
65
+ ]);
66
+ const MAX_OUTPUT_BYTES = MAX_CHECK_OUTPUT_BYTES;
67
+ const MAX_APPROVAL_BYTES = 256 * 1024;
68
+ const MAX_GATE_LEDGER_BYTES = 8 * 1024 * 1024;
69
+ const REGEX_TIMEOUT_MS = 250;
70
+ const REGEX_STARTUP_TIMEOUT_MS = 5000;
71
+ const MAX_REGEX_WORKERS = 4;
72
+ const DEFAULT_TIMEOUT_SECONDS = 120;
73
+ const CHECK_SUPERVISOR = fileURLToPath(new URL("./lib/check-supervisor.mjs", import.meta.url));
74
+
75
+ // Repository-controlled titles, paths, commands, and output must not be able
76
+ // to rewrite terminal history, set a window title, or visually reorder text.
77
+ // Strip every C0/C1 control plus Unicode bidi formatting markers at the final
78
+ // sink. Each console call still receives its own trailing newline.
79
+ const UNSAFE_TERMINAL_RE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g;
80
+ const terminalSafe = (value) => String(value).replace(UNSAFE_TERMINAL_RE, " ");
81
+ for (const method of ["log", "error"]) {
82
+ const write = console[method].bind(console);
83
+ console[method] = (...values) => write(...values.map(terminalSafe));
84
+ }
85
+
86
+ function parseArgs(argv) {
87
+ const options = {};
88
+ const files = [];
89
+ let positional = false;
90
+ for (let index = 0; index < argv.length; index++) {
91
+ const arg = argv[index];
92
+ if (arg === "--") { positional = true; continue; }
93
+ if (!positional && FLAG_OPTIONS.has(arg)) {
94
+ const key = arg.replace(/^-+/, "");
95
+ if (options[key] !== undefined) return { error: "duplicate option " + arg };
96
+ options[key] = true;
97
+ continue;
98
+ }
99
+ if (!positional && arg.startsWith("--")) {
100
+ const equals = arg.indexOf("=");
101
+ const name = equals === -1 ? arg : arg.slice(0, equals);
102
+ if (!VALUE_OPTIONS.has(name)) return { error: "unknown option " + name };
103
+ const key = name.slice(2);
104
+ if (options[key] !== undefined) return { error: "duplicate option " + name };
105
+ const value = equals === -1 ? argv[++index] : arg.slice(equals + 1);
106
+ if (value === undefined || value === "") return { error: name + " needs a value" };
107
+ options[key] = value;
108
+ continue;
109
+ }
110
+ if (!positional && arg.startsWith("-")) return { error: "unknown option " + arg };
111
+ files.push(arg);
112
+ }
113
+ return { options, files };
114
+ }
115
+
116
+ function failUsage(message) {
117
+ console.error("gate-check: " + message);
118
+ console.error("run gate-check.mjs --help for usage");
119
+ process.exit(2);
120
+ }
121
+
122
+ function asDirectory(path, label) {
123
+ try {
124
+ if (!statSync(path).isDirectory()) failUsage(label + " is not a directory: " + path);
125
+ } catch (error) {
126
+ if (error.code === "ENOENT") failUsage(label + " does not exist: " + path);
127
+ failUsage("cannot inspect " + label + " " + path + ": " + error.message);
128
+ }
129
+ }
130
+
131
+ function timeoutValue(value) {
132
+ if (value === undefined) return DEFAULT_TIMEOUT_SECONDS;
133
+ const number = Number(value);
134
+ if (!Number.isFinite(number) || !Number.isInteger(number) || number < 1 || number > 86400) {
135
+ failUsage("--timeout needs an integer from 1 through 86400, got " + JSON.stringify(value));
136
+ }
137
+ return number;
138
+ }
139
+
140
+ function jobCount(value) {
141
+ if (value === undefined) return 1;
142
+ const number = Number(value);
143
+ if (!Number.isFinite(number) || !Number.isInteger(number) || number < 1 || number > 64) {
144
+ failUsage("--jobs needs an integer from 1 through 64, got " + JSON.stringify(value));
145
+ }
146
+ return number;
147
+ }
148
+
149
+ const parsedArgs = parseArgs(process.argv.slice(2));
150
+ if (parsedArgs.error) failUsage(parsedArgs.error);
151
+ const { options: opt, files: fileArgs } = parsedArgs;
152
+ if (opt.help || opt.h) {
153
+ // HELP is a fixed local constant, so preserve its intentional layout instead
154
+ // of routing it through the untrusted-value terminal sanitizer.
155
+ process.stdout.write(HELP + "\n");
156
+ process.exit(0);
157
+ }
158
+
159
+ const actionNames = [];
160
+ for (const key of ["claim", "release", "list-scopes"]) if (opt[key]) actionNames.push("--" + key);
161
+ for (const key of ["log", "bind"]) if (opt[key] !== undefined) actionNames.push("--" + key);
162
+ if (actionNames.length > 1) failUsage("pipeline actions are mutually exclusive: " + actionNames.join(", "));
163
+ const action = actionNames[0] || null;
164
+
165
+ if (opt.status && opt.reverify) failUsage("--status and --reverify are mutually exclusive");
166
+ if (opt.status && opt.approve) failUsage("--status never approves commands; remove --approve");
167
+ if (action && (opt.status || opt.reverify || opt.approve)) failUsage(action + " cannot be combined with a run mode");
168
+ if (action && fileArgs.length) failUsage(action + " cannot be combined with explicit files");
169
+ if (fileArgs.length && opt.scope) failUsage("explicit files and --scope are mutually exclusive");
170
+ if (opt.leaf && !opt.claim && !opt.release) failUsage("--leaf is only valid with --claim or --release");
171
+ if ((opt.timeout || opt.jobs || opt.shell || opt.cwd) && (action || opt.status)) {
172
+ failUsage("--timeout, --jobs, --shell, and --cwd are execution options only");
173
+ }
174
+
175
+ const root = resolve(opt.root || process.cwd());
176
+ asDirectory(root, "--root");
177
+ const timeoutSeconds = timeoutValue(opt.timeout);
178
+ const jobs = jobCount(opt.jobs);
179
+ const defaultCwd = resolve(root, opt.cwd || ".");
180
+ if (!action && !opt.status) asDirectory(defaultCwd, "--cwd");
181
+
182
+ if (action === "--list-scopes") {
183
+ const scopes = listScopes(root);
184
+ if (scopes.length) {
185
+ for (const scope of scopes) console.log(scope);
186
+ } else console.log("(no pipelines under " + UNLAZY_DIR + "/)");
187
+ process.exit(0);
188
+ }
189
+
190
+ if (opt.scope) {
191
+ const error = validateScopeId(opt.scope);
192
+ if (error) failUsage(error);
193
+ }
194
+
195
+ let target = resolveTarget({ root, scope: opt.scope, files: fileArgs });
196
+ // A deleted/crashed pipeline can leave coordination leases behind after its
197
+ // ledger directory is gone. An explicit, validated release target must remain
198
+ // usable for that recovery path; claims still require a live exact ledger.
199
+ if (target.error && action === "--release" && opt.scope) {
200
+ target = { mode: "scope", scope: opt.scope, files: [] };
201
+ } else if (target.error) failUsage(target.error);
202
+ const scope = target.scope;
203
+
204
+ if (action === "--log") {
205
+ if (!scope) failUsage("--log needs --scope ID or exactly one discoverable pipeline");
206
+ if (!String(opt.log).trim()) failUsage("--log needs non-blank text");
207
+ try {
208
+ const path = await appendStatus(root, scope, opt.log);
209
+ console.log("appended to " + path);
210
+ process.exit(0);
211
+ } catch (error) {
212
+ console.error("gate-check: cannot append status: " + error.message);
213
+ process.exit(2);
214
+ }
215
+ }
216
+
217
+ if (action === "--bind") {
218
+ if (!scope) failUsage("--bind needs --scope ID or exactly one discoverable pipeline");
219
+ if (!String(opt.bind).trim()) failUsage("--bind needs a non-blank session id");
220
+ const path = join(scopeRoot(root, scope), "session");
221
+ try {
222
+ writeAtomic(path, String(opt.bind).trim() + "\n", { root });
223
+ console.log("bound session " + opt.bind + " to scope " + scope);
224
+ process.exit(0);
225
+ } catch (error) {
226
+ console.error("gate-check: cannot bind session: " + error.message);
227
+ process.exit(2);
228
+ }
229
+ }
230
+
231
+ if (target.discoveryErrors && target.discoveryErrors.length && action !== "--release") {
232
+ failUsage(target.discoveryErrors.join("; "));
233
+ }
234
+
235
+ if (!target.files.length && action !== "--release") {
236
+ failUsage("no gate files found (looked for " + UNLAZY_DIR + "/<scope>/, then GATES.md and gates/*.md under " + root + ")");
237
+ }
238
+
239
+ const readLedgerFile = (file) => readStableRegularFile(file, {
240
+ ...(target.mode === "explicit" ? {} : { root }),
241
+ maxBytes: MAX_GATE_LEDGER_BYTES,
242
+ label: "gate ledger",
243
+ });
244
+
245
+ for (const file of target.files) {
246
+ try { readLedgerFile(file); }
247
+ catch (error) {
248
+ if (error.code === "ENOENT") failUsage("no such gate file: " + file);
249
+ failUsage("cannot inspect gate file " + file + ": " + error.message);
250
+ }
251
+ }
252
+
253
+ function loadLedger(file) {
254
+ let text;
255
+ try { text = readLedgerFile(file); }
256
+ catch (error) { failUsage("cannot read " + file + ": " + error.message); }
257
+ const doc = parseGates(text);
258
+ for (const warning of doc.warnings) console.error("gate-check: " + file + ": warning: " + warning);
259
+ if (doc.errors.length) {
260
+ for (const error of doc.errors) console.error("gate-check: " + file + ": " + error);
261
+ process.exit(2);
262
+ }
263
+ return { file, doc };
264
+ }
265
+
266
+ if (action === "--claim" || action === "--release") {
267
+ if (!scope) failUsage(action + " needs --scope ID or exactly one discoverable pipeline");
268
+ const stems = target.files.map((file) => basename(file).replace(/\.md$/i, ""));
269
+ if (opt.leaf) {
270
+ const error = validateScopeId(opt.leaf, "leaf");
271
+ if (error) failUsage(error);
272
+ if (stems.length && !stems.includes(opt.leaf)) failUsage("unknown --leaf " + opt.leaf + " (have: " + stems.join(", ") + ")");
273
+ }
274
+ if (action === "--release") {
275
+ try {
276
+ const count = await releaseLeases(root, { scope, leaf: opt.leaf || null });
277
+ console.log("released " + count + " lease(s) for " + scope + (opt.leaf ? "/" + opt.leaf : ""));
278
+ process.exit(0);
279
+ } catch (error) {
280
+ console.error("gate-check: cannot release leases: " + error.message);
281
+ process.exit(2);
282
+ }
283
+ }
284
+
285
+ const leaf = opt.leaf || (stems.length === 1 ? stems[0] : null);
286
+ if (!leaf) failUsage("--claim needs --leaf NAME when a scope has several gate files");
287
+ const selectedFile = target.files.find((file) => basename(file).replace(/\.md$/i, "") === leaf);
288
+ const selected = loadLedger(selectedFile);
289
+ if (!selected.doc.owns.length) failUsage(basename(selected.file) + " declares no OWNS paths");
290
+ let result;
291
+ try { result = await claimLeases(root, { scope, leaf, globs: selected.doc.owns }); }
292
+ catch (error) {
293
+ console.error("gate-check: cannot claim leases: " + error.message);
294
+ process.exit(2);
295
+ }
296
+ if (!result.ok) {
297
+ if (result.error) failUsage(result.error);
298
+ for (const conflict of result.conflicts) {
299
+ if (conflict.identity) {
300
+ console.log("CONFLICT " + conflict.with + " already holds a live lease; release it before claiming again");
301
+ } else {
302
+ console.log("CONFLICT " + conflict.glob + " overlaps " + conflict.theirGlob + " held by " + conflict.with);
303
+ }
304
+ }
305
+ console.log("CLAIM REFUSED (" + result.conflicts.length + " conflict(s))");
306
+ process.exit(3);
307
+ }
308
+ console.log("CLAIMED " + result.globs.length + " path(s) for " + scope + "/" + leaf + ": " + result.globs.join(", "));
309
+ process.exit(0);
310
+ }
311
+
312
+ let ledgers = target.files.map(loadLedger);
313
+
314
+ function executableCandidates(name) {
315
+ if (process.platform !== "win32") return [name];
316
+ if (/\.[A-Za-z0-9]+$/.test(name)) return [name];
317
+ const extensions = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
318
+ return [name, ...extensions.map((extension) => name + extension.toLowerCase()), ...extensions.map((extension) => name + extension.toUpperCase())];
319
+ }
320
+
321
+ function resolveShell(raw) {
322
+ const requested = raw || process.env.UNLAZY_SHELL || (process.platform === "win32" ? (process.env.ComSpec || "cmd.exe") : "/bin/sh");
323
+ const containsSeparator = requested.includes("/") || requested.includes("\\") || isAbsolute(requested);
324
+ const candidates = [];
325
+ if (containsSeparator) candidates.push(resolve(process.cwd(), requested));
326
+ else {
327
+ for (const directory of String(process.env.PATH || "").split(delimiter).filter(Boolean)) {
328
+ for (const name of executableCandidates(requested)) candidates.push(join(directory, name));
329
+ }
330
+ }
331
+ for (const candidate of candidates) {
332
+ try { if (statSync(candidate).isFile()) return candidate; } catch { /* keep looking */ }
333
+ }
334
+ failUsage("cannot resolve command shell " + JSON.stringify(requested) + " from PATH");
335
+ }
336
+
337
+ // Status currentness is a pure function of parsed ledger text. Do not resolve
338
+ // a shell, read PATH, or initialize approval storage for that mode.
339
+ const shell = opt.status ? null : resolveShell(opt.shell);
340
+ const pathValue = opt.status ? null : String(process.env.PATH || "");
341
+ const pathHash = opt.status ? null : sha256(pathValue).slice(0, 12);
342
+ const pathCount = opt.status ? null : (pathValue ? pathValue.split(delimiter).length : 0);
343
+ const pathEvidence = opt.status ? null : pathHash + "/" + pathCount + " entries";
344
+ const pathTranscript = opt.status ? null
345
+ : pathValue.replace(/[\r\n]/g, " ").slice(0, 800) + (pathValue.length > 800 ? "..." : "");
346
+
347
+ function resolvedGateCwd(gate, file) {
348
+ // Explicit ledgers are self-contained: absent --cwd, relative commands and
349
+ // CWD attributes anchor beside that ledger instead of wherever the caller
350
+ // happened to launch the checker. Scoped and legacy discovery anchor at root.
351
+ const base = opt.cwd ? defaultCwd : (target.mode === "explicit" ? dirname(resolve(file)) : root);
352
+ return gate.cwd ? resolve(base, gate.cwd) : base;
353
+ }
354
+
355
+ function oracle(file, gate) {
356
+ const cwd = resolvedGateCwd(gate, file);
357
+ return {
358
+ schema: 1,
359
+ check: gate.check,
360
+ expect: gate.expect,
361
+ cwd,
362
+ shell,
363
+ timeoutMs: timeoutSeconds * 1000,
364
+ maxOutputBytes: MAX_OUTPUT_BYTES,
365
+ regexTimeoutMs: REGEX_TIMEOUT_MS,
366
+ regexStartupTimeoutMs: REGEX_STARTUP_TIMEOUT_MS,
367
+ maxRegexWorkers: MAX_REGEX_WORKERS,
368
+ platform: process.platform,
369
+ path: pathValue,
370
+ };
371
+ }
372
+
373
+ function approvalOracleSignature(file, gate) {
374
+ return sha256(JSON.stringify(oracle(file, gate)));
375
+ }
376
+
377
+ function pathIsInside(parent, child) {
378
+ const rel = relative(resolve(parent), resolve(child));
379
+ return rel === "" || (!rel.startsWith(".." + sep) && rel !== ".." && !isAbsolute(rel));
380
+ }
381
+
382
+ const approvalDir = opt.status ? null
383
+ : resolve(process.env.UNLAZY_APPROVAL_DIR || join(homedir(), ".unlazy", "approved"));
384
+ const canonicalRoot = opt.status ? null : realpathSync(root);
385
+ if (!opt.status && pathIsInside(root, approvalDir)) failUsage("UNLAZY_APPROVAL_DIR must be outside the repository root");
386
+
387
+ function approvalPath(file, gate, directory = approvalDir) {
388
+ const identity = resolve(file) + "\0" + gate.id + "\0" + approvalOracleSignature(file, gate);
389
+ return join(directory, sha256(identity) + ".json");
390
+ }
391
+
392
+ function assertPrivateApprovalEntry(path, info, kind) {
393
+ if (info.isSymbolicLink() || (kind === "directory" ? !info.isDirectory() : !info.isFile())) {
394
+ throw new Error(path + " must be a real " + kind);
395
+ }
396
+ const uid = typeof process.geteuid === "function" ? process.geteuid()
397
+ : typeof process.getuid === "function" ? process.getuid() : null;
398
+ const expectedUid = typeof info.uid === "bigint" && uid !== null ? BigInt(uid) : uid;
399
+ if (uid !== null && info.uid !== expectedUid) {
400
+ throw new Error(path + " must be owned by the current user");
401
+ }
402
+ const permissionMask = typeof info.mode === "bigint" ? 0o077n : 0o077;
403
+ const extraPermissions = info.mode & permissionMask;
404
+ if (process.platform !== "win32" && extraPermissions !== 0 && extraPermissions !== 0n) {
405
+ throw new Error(path + " must not grant group or other permissions");
406
+ }
407
+ }
408
+
409
+ function validatedApprovalDir({ create = false } = {}) {
410
+ if (create) mkdirSync(approvalDir, { recursive: true, mode: 0o700 });
411
+ else if (!existsSync(approvalDir)) return null;
412
+ const info = lstatSync(approvalDir, { bigint: true });
413
+ assertPrivateApprovalEntry(approvalDir, info, "directory");
414
+ const canonical = realpathSync(approvalDir);
415
+ if (pathIsInside(canonicalRoot, canonical)) {
416
+ throw new Error("approval directory resolves inside the repository root: " + canonical);
417
+ }
418
+ const canonicalInfo = lstatSync(canonical, { bigint: true });
419
+ assertPrivateApprovalEntry(canonical, canonicalInfo, "directory");
420
+ return { path: canonical, dev: canonicalInfo.dev, ino: canonicalInfo.ino };
421
+ }
422
+
423
+ function assertApprovalDirUnchanged(store) {
424
+ const current = lstatSync(store.path, { bigint: true });
425
+ assertPrivateApprovalEntry(store.path, current, "directory");
426
+ if (!sameFileIdentity(current, store)) {
427
+ throw new Error("approval directory changed during use: " + store.path);
428
+ }
429
+ }
430
+
431
+ function readApprovalFile(path) {
432
+ let fd = null;
433
+ try {
434
+ const noFollow = process.platform === "win32" ? 0 : (fsConstants.O_NOFOLLOW || 0);
435
+ fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK || 0) | noFollow);
436
+ const opened = fstatSync(fd, { bigint: true });
437
+ const named = statCurrentNamedFile(path, {
438
+ maxBytes: MAX_APPROVAL_BYTES,
439
+ label: "approval record",
440
+ });
441
+ assertPrivateApprovalEntry(path, opened, "file");
442
+ if (opened.size > MAX_APPROVAL_BYTES) {
443
+ throw new Error("approval record exceeds " + MAX_APPROVAL_BYTES + " bytes: " + path);
444
+ }
445
+ assertPrivateApprovalEntry(path, named, "file");
446
+ if (opened.nlink !== 1n || !sameFileIdentity(named, opened)) {
447
+ throw new Error("refusing linked or replaced approval record " + path);
448
+ }
449
+ const text = readFileSync(fd, "utf8");
450
+ const after = statCurrentNamedFile(path, {
451
+ maxBytes: MAX_APPROVAL_BYTES,
452
+ label: "approval record",
453
+ });
454
+ assertPrivateApprovalEntry(path, after, "file");
455
+ if (!sameFileIdentity(after, opened)) {
456
+ throw new Error("approval record changed while it was read: " + path);
457
+ }
458
+ return text;
459
+ } finally {
460
+ if (fd !== null) try { closeSync(fd); } catch { /* ignore */ }
461
+ }
462
+ }
463
+
464
+ function approvalExists(file, gate) {
465
+ const store = validatedApprovalDir();
466
+ if (!store) return false;
467
+ const path = approvalPath(file, gate, store.path);
468
+ let text;
469
+ try { text = readApprovalFile(path); }
470
+ catch (error) {
471
+ if (error.code === "ENOENT") return false;
472
+ throw error;
473
+ }
474
+ let value;
475
+ try { value = JSON.parse(text); }
476
+ catch { return false; }
477
+ assertApprovalDirUnchanged(store);
478
+ return value && value.file === resolve(file) && value.gate === gate.id &&
479
+ value.signature === approvalOracleSignature(file, gate);
480
+ }
481
+
482
+ async function recordApproval(file, gate) {
483
+ const store = validatedApprovalDir({ create: true });
484
+ const token = approvalPath(file, gate, store.path);
485
+ const lock = token + ".lock";
486
+ const deadline = Date.now() + 10000;
487
+ const owner = randomBytes(16).toString("hex");
488
+ let fd = null;
489
+ for (;;) {
490
+ try { fd = openSync(lock, "wx", 0o600); break; }
491
+ catch (error) {
492
+ if (error.code !== "EEXIST") throw error;
493
+ // Fail closed instead of trying to steal by path: an owner can release
494
+ // and a successor can acquire between stat and unlink.
495
+ try { statSync(lock); } catch (statError) {
496
+ if (statError.code === "ENOENT") continue;
497
+ throw statError;
498
+ }
499
+ if (Date.now() >= deadline) throw new Error("timed out waiting for approval lock");
500
+ await sleep(20);
501
+ }
502
+ }
503
+ try {
504
+ writeFileSync(fd, JSON.stringify({ owner, pid: process.pid, at: Date.now() }));
505
+ const value = {
506
+ schema: 1, file: resolve(file), gate: gate.id,
507
+ signature: approvalOracleSignature(file, gate),
508
+ oracle: oracle(file, gate), approvedAt: new Date().toISOString(),
509
+ };
510
+ writeAtomic(token, JSON.stringify(value, null, 2) + "\n");
511
+ assertApprovalDirUnchanged(store);
512
+ } finally {
513
+ try { closeSync(fd); } catch { /* ignore */ }
514
+ try {
515
+ const current = JSON.parse(readFileSync(lock, "utf8"));
516
+ if (current.owner === owner) unlinkSync(lock);
517
+ } catch { /* manual cleanup or a successor owns the lock */ }
518
+ }
519
+ }
520
+
521
+ function printOracle(file, gate, prefix) {
522
+ const value = oracle(file, gate);
523
+ console.log(prefix + " " + qualify(file, gate.id));
524
+ console.log(" CHECK: " + value.check);
525
+ console.log(" EXPECT: " + value.expect);
526
+ console.log(" CWD: " + value.cwd);
527
+ console.log(" SHELL: " + value.shell);
528
+ console.log(" PATH: " + pathTranscript);
529
+ }
530
+
531
+ let activeRegexWorkers = 0;
532
+ const regexWaiters = [];
533
+
534
+ function acquireRegexWorker() {
535
+ if (activeRegexWorkers < MAX_REGEX_WORKERS) {
536
+ activeRegexWorkers++;
537
+ return Promise.resolve();
538
+ }
539
+ return new Promise((done) => regexWaiters.push(done));
540
+ }
541
+
542
+ function releaseRegexWorker() {
543
+ const next = regexWaiters.shift();
544
+ if (next) next();
545
+ else activeRegexWorkers--;
546
+ }
547
+
548
+ async function safeRegexMatch(expectation, output) {
549
+ if (expectation.kind === "text") return { matched: output.includes(expectation.value) };
550
+ await acquireRegexWorker();
551
+ try {
552
+ return await new Promise((done) => {
553
+ let worker;
554
+ let settled = false;
555
+ let startupTimer = null;
556
+ let matchTimer = null;
557
+ const finish = (value) => {
558
+ if (settled) return;
559
+ settled = true;
560
+ if (startupTimer) clearTimeout(startupTimer);
561
+ if (matchTimer) clearTimeout(matchTimer);
562
+ if (worker) worker.terminate().catch(() => {});
563
+ done(value);
564
+ };
565
+ try { worker = new Worker(new URL("./lib/regex-worker.mjs", import.meta.url)); }
566
+ catch (error) {
567
+ finish({ matched: false, error: "EXPECT worker could not start: " + error.message });
568
+ return;
569
+ }
570
+ startupTimer = setTimeout(() => finish({
571
+ matched: false,
572
+ error: "EXPECT worker startup exceeded " + REGEX_STARTUP_TIMEOUT_MS + "ms",
573
+ }), REGEX_STARTUP_TIMEOUT_MS);
574
+ worker.once("online", () => {
575
+ if (settled) return;
576
+ clearTimeout(startupTimer);
577
+ startupTimer = null;
578
+ // The catastrophic-backtracking budget starts only after the worker is
579
+ // online; process startup and a busy --jobs queue are not regex time.
580
+ matchTimer = setTimeout(() => finish({
581
+ matched: false,
582
+ error: "EXPECT regex exceeded " + REGEX_TIMEOUT_MS + "ms",
583
+ }), REGEX_TIMEOUT_MS);
584
+ try { worker.postMessage({ source: expectation.source, flags: expectation.flags, output }); }
585
+ catch (error) { finish({ matched: false, error: error.message }); }
586
+ });
587
+ worker.once("message", (message) => finish(message));
588
+ worker.once("error", (error) => finish({ matched: false, error: error.message }));
589
+ worker.once("exit", (code) => {
590
+ finish({ matched: false, error: "EXPECT worker exited " + code + " without a result" });
591
+ });
592
+ });
593
+ } finally {
594
+ releaseRegexWorker();
595
+ }
596
+ }
597
+
598
+ function outputFingerprint(output) {
599
+ const value = String(output);
600
+ return { sha256: sha256(value), bytes: Buffer.byteLength(value, "utf8") };
601
+ }
602
+
603
+ function runCheck(task) {
604
+ return new Promise((done) => {
605
+ const chunks = { stdout: [], stderr: [] };
606
+ let bytes = 0;
607
+ let rawOverflow = false;
608
+ let timedOut = false;
609
+ let spawnError = null;
610
+ let closed = false;
611
+ let closeStreamsTimer = null;
612
+ let forceSettleTimer = null;
613
+ let timeoutTimer = null;
614
+ let cleanupDiagnostic = null;
615
+ let child;
616
+
617
+ const settle = async (exitCode, signal) => {
618
+ if (closed) return;
619
+ closed = true;
620
+ if (timeoutTimer) clearTimeout(timeoutTimer);
621
+ if (closeStreamsTimer) clearTimeout(closeStreamsTimer);
622
+ if (forceSettleTimer) clearTimeout(forceSettleTimer);
623
+ const stdout = Buffer.concat(chunks.stdout).toString("utf8");
624
+ const stderr = Buffer.concat(chunks.stderr).toString("utf8");
625
+ const output = stdout + (stdout && stderr ? "\n" : "") + stderr;
626
+ // EXPECT and persisted fingerprints both use this exact combined string.
627
+ // Keep its UTF-8 representation within the same 1 MiB ceiling as raw
628
+ // capture: invalid byte replacement and the stream separator can expand
629
+ // it even when the child emitted exactly MAX_OUTPUT_BYTES raw bytes.
630
+ const fingerprint = outputFingerprint(output);
631
+ const normalizedOverflow = fingerprint.bytes > MAX_OUTPUT_BYTES;
632
+ const overflow = rawOverflow || normalizedOverflow;
633
+ const match = timedOut || overflow || spawnError
634
+ ? { matched: false }
635
+ : await safeRegexMatch(task.gate.expectation, output);
636
+ const cleanupSuffix = cleanupDiagnostic ? "; cleanup: " + cleanupDiagnostic : "";
637
+ const error = timedOut ? "timed out after " + timeoutSeconds + "s" + cleanupSuffix
638
+ : overflow ? "output exceeded " + MAX_OUTPUT_BYTES + " bytes" +
639
+ (rawOverflow ? "" : " after stdout/stderr UTF-8 combination") + cleanupSuffix
640
+ : spawnError ? spawnError.message
641
+ : match.error || null;
642
+ done({
643
+ ...task, output, outputFingerprint: fingerprint,
644
+ exitCode, signal, matched: Boolean(match.matched), error,
645
+ ok: !error && exitCode === 0 && Boolean(match.matched),
646
+ });
647
+ };
648
+
649
+ const stopChild = () => {
650
+ if (closeStreamsTimer) return;
651
+ const cleanup = terminateProcessTree(child);
652
+ cleanupDiagnostic = cleanup.diagnostic;
653
+ // A descendant that escaped the shell can otherwise keep inherited pipes
654
+ // open forever. We still settle through the child's close event, after
655
+ // giving stdio a short grace period to drain.
656
+ closeStreamsTimer = setTimeout(() => {
657
+ try { child.stdout.destroy(); } catch { /* closed */ }
658
+ try { child.stderr.destroy(); } catch { /* closed */ }
659
+ }, 1000);
660
+ // A successful signal request is not proof that a child closed or that
661
+ // every pipe-holding descendant exited. Settle independently after the
662
+ // bounded helper attempt for every cleanup outcome, while retaining any
663
+ // explicit cleanup failure in the result.
664
+ forceSettleTimer = setTimeout(() => {
665
+ try { child.stdout.destroy(); } catch { /* closed */ }
666
+ try { child.stderr.destroy(); } catch { /* closed */ }
667
+ try { child.unref(); } catch { /* unavailable */ }
668
+ settle(null, null);
669
+ }, 1500);
670
+ };
671
+
672
+ const capture = (stream, chunk) => {
673
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
674
+ const remaining = MAX_OUTPUT_BYTES - bytes;
675
+ if (remaining > 0) chunks[stream].push(buffer.subarray(0, remaining));
676
+ bytes += buffer.length;
677
+ if (bytes > MAX_OUTPUT_BYTES && !rawOverflow) {
678
+ rawOverflow = true;
679
+ stopChild();
680
+ }
681
+ };
682
+
683
+ try {
684
+ child = spawn(process.execPath, [CHECK_SUPERVISOR, shell, task.gate.check], {
685
+ cwd: task.cwd,
686
+ shell: false,
687
+ windowsHide: true,
688
+ detached: process.platform !== "win32",
689
+ env: process.env,
690
+ stdio: ["ignore", "pipe", "pipe"],
691
+ });
692
+ } catch (error) {
693
+ done({
694
+ ...task, ok: false, output: "", outputFingerprint: outputFingerprint(""),
695
+ exitCode: null, signal: null, matched: false, error: error.message,
696
+ });
697
+ return;
698
+ }
699
+ child.stdout.on("data", (chunk) => capture("stdout", chunk));
700
+ child.stderr.on("data", (chunk) => capture("stderr", chunk));
701
+ child.once("error", (error) => { spawnError = error; });
702
+ timeoutTimer = setTimeout(() => {
703
+ timedOut = true;
704
+ stopChild();
705
+ }, timeoutSeconds * 1000);
706
+ child.once("close", settle);
707
+ });
708
+ }
709
+
710
+ async function runRolling(tasks, limit) {
711
+ const results = new Array(tasks.length);
712
+ let next = 0;
713
+ async function worker() {
714
+ for (;;) {
715
+ const index = next++;
716
+ if (index >= tasks.length) return;
717
+ results[index] = await runCheck(tasks[index]);
718
+ }
719
+ }
720
+ const workers = [];
721
+ for (let index = 0; index < Math.min(limit, tasks.length); index++) workers.push(worker());
722
+ await Promise.all(workers);
723
+ return results;
724
+ }
725
+
726
+ const pending = [];
727
+ for (const ledger of ledgers) {
728
+ for (const gate of ledger.doc.gates) {
729
+ if (ledger.doc.abandoned.has(gate.id) || !gate.check) continue;
730
+ const state = gateState(gate, ledger.doc.abandoned);
731
+ if (opt.status || (!opt.reverify && state === "met")) continue;
732
+ const cwd = resolvedGateCwd(gate, ledger.file);
733
+ try {
734
+ if (!statSync(cwd).isDirectory()) failUsage("gate " + qualify(ledger.file, gate.id) + " CWD is not a directory: " + cwd);
735
+ } catch (error) {
736
+ if (error.code === "ENOENT") failUsage("gate " + qualify(ledger.file, gate.id) + " CWD does not exist: " + cwd);
737
+ failUsage("cannot inspect gate CWD " + cwd + ": " + error.message);
738
+ }
739
+ pending.push({
740
+ file: ledger.file,
741
+ gate,
742
+ cwd,
743
+ startingState: state,
744
+ wasMet: state === "met",
745
+ definitionDigest: gateDefinitionDigest(gate),
746
+ approvalSignature: approvalOracleSignature(ledger.file, gate),
747
+ });
748
+ }
749
+ }
750
+
751
+ const runnable = [];
752
+ const notRun = [];
753
+ let approvalInfrastructureFailures = 0;
754
+ for (const task of pending) {
755
+ let approved = false;
756
+ try { approved = approvalExists(task.file, task.gate); }
757
+ catch (error) {
758
+ console.error("gate-check: could not validate approval for " + qualify(task.file, task.gate.id) + ": " + error.message);
759
+ approvalInfrastructureFailures++;
760
+ notRun.push(task);
761
+ continue;
762
+ }
763
+ if (!approved) {
764
+ printOracle(task.file, task.gate, "APPROVAL REQUIRED");
765
+ if (!opt.approve) {
766
+ console.log(" NOT RUN: inspect this oracle, then re-run with --approve");
767
+ notRun.push(task);
768
+ continue;
769
+ }
770
+ try {
771
+ await recordApproval(task.file, task.gate);
772
+ console.log(" APPROVED: " + approvalPath(task.file, task.gate, validatedApprovalDir().path));
773
+ } catch (error) {
774
+ console.error("gate-check: could not record approval for " + qualify(task.file, task.gate.id) + ": " + error.message);
775
+ approvalInfrastructureFailures++;
776
+ notRun.push(task);
777
+ continue;
778
+ }
779
+ }
780
+ runnable.push(task);
781
+ }
782
+
783
+ for (const task of runnable) {
784
+ console.log(" RUN " + qualify(task.file, task.gate.id) + " shell=" + shell + " cwd=" + task.cwd + " PATH=" + pathTranscript);
785
+ }
786
+ const results = opt.status ? [] : await runRolling(runnable, jobs);
787
+ for (const result of results) {
788
+ const fingerprint = result.outputFingerprint;
789
+ const outputSummary = result.ok
790
+ ? "sha256=" + fingerprint.sha256 + "; bytes=" + fingerprint.bytes
791
+ : failureOutput(result.output);
792
+ const outcome = "exit=" + (result.exitCode === null ? "none" : result.exitCode) +
793
+ (result.signal ? " signal=" + result.signal : "") +
794
+ "; EXPECT=" + (result.matched ? "matched" : "not matched") +
795
+ "; output=" + outputSummary;
796
+ if (result.ok) {
797
+ console.log(" PASS " + qualify(result.file, result.gate.id) + ": " + result.gate.title);
798
+ console.log(" " + outcome);
799
+ } else {
800
+ console.log(" FAIL " + qualify(result.file, result.gate.id) + ": " + result.gate.title);
801
+ console.log(" " + (result.error ? result.error + "; " : "") + outcome);
802
+ }
803
+ }
804
+
805
+ function failureOutput(output, max = 480) {
806
+ const lines = String(output).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
807
+ if (lines.length <= 8) return (lines.join(" | ") || "(no output)").slice(0, max);
808
+ const summary = [...lines.slice(0, 6), "...", ...lines.slice(-2)].join(" | ");
809
+ return summary.slice(0, max);
810
+ }
811
+
812
+ function evidenceFor(result) {
813
+ const clean = (value) => terminalSafe(value).replace(/[\r\n\t]+/g, " ");
814
+ const fingerprint = result.outputFingerprint;
815
+ // Keep the definition binding and successful-output fingerprint ahead of
816
+ // machine-specific fields so the evidence cap can truncate only transcript
817
+ // detail, never the structural currentness or deciding output identity.
818
+ return (automaticEvidencePrefix(result.definitionDigest) +
819
+ " exit=0; EXPECT=matched; output-sha256=" + fingerprint.sha256 +
820
+ "; output-bytes=" + fingerprint.bytes + "; shell=" + clean(shell) +
821
+ "; cwd=" + clean(result.cwd) + "; path=" + pathEvidence).slice(0, MAX_AUTOMATIC_EVIDENCE_CHARS);
822
+ }
823
+
824
+ function insertOrUpdateEvidence(doc, gate, value) {
825
+ if (gate.evidenceLine !== -1) {
826
+ const indent = (doc.lines[gate.evidenceLine].match(/^\s*/) || [" "])[0];
827
+ doc.lines[gate.evidenceLine] = indent + "EVIDENCE: " + value;
828
+ return;
829
+ }
830
+ let line = gate.line + 1;
831
+ while (line < doc.lines.length && /^\s+(CHECK|EXPECT|EVIDENCE|CWD):/.test(doc.lines[line])) line++;
832
+ doc.lines.splice(line, 0, " EVIDENCE: " + value);
833
+ }
834
+
835
+ const resultKey = (file, id) => resolve(file) + "\0" + id;
836
+ const staleResults = new Map();
837
+ for (const result of results) {
838
+ try {
839
+ await withFileLock(root, result.file, () => {
840
+ let doc = parseGates(readLedgerFile(result.file));
841
+ if (doc.errors.length) throw new Error("fresh ledger became invalid: " + doc.errors.join("; "));
842
+ const fresh = doc.gates.find((gate) => gate.id === result.gate.id);
843
+ if (!fresh || gateDefinitionDigest(fresh) !== result.definitionDigest ||
844
+ approvalOracleSignature(result.file, fresh) !== result.approvalSignature) {
845
+ staleResults.set(resultKey(result.file, result.gate.id), qualify(result.file, result.gate.id));
846
+ console.log(" STALE " + qualify(result.file, result.gate.id) +
847
+ ": definition or runtime approval oracle changed; result not written");
848
+ return;
849
+ }
850
+ const freshState = gateState(fresh, doc.abandoned);
851
+ if (freshState === "abandoned") return;
852
+ const mustWriteFailure = result.startingState === "stale-unmet" ||
853
+ (opt.reverify && result.wasMet) || fresh.checked;
854
+ if (!result.ok && !mustWriteFailure) return;
855
+ if (result.ok) {
856
+ doc.lines[fresh.line] = doc.lines[fresh.line].replace(/^- \[( |x|X)\]/, "- [x]");
857
+ insertOrUpdateEvidence(doc, fresh, evidenceFor(result));
858
+ } else {
859
+ doc.lines[fresh.line] = doc.lines[fresh.line].replace(/^- \[(x|X)\]/, "- [ ]");
860
+ insertOrUpdateEvidence(doc, fresh, "pending");
861
+ }
862
+ writeAtomic(result.file, formatDocument(doc));
863
+ });
864
+ } catch (error) {
865
+ console.error("gate-check: cannot update " + result.file + ": " + error.message);
866
+ process.exitCode = 2;
867
+ }
868
+ }
869
+ if (process.exitCode === 2) process.exit(2);
870
+
871
+ ledgers = target.files.map(loadLedger);
872
+ let totalMet = 0;
873
+ let totalUnmet = 0;
874
+ let totalAbandoned = 0;
875
+ let reverified = 0;
876
+ const unmetIds = [];
877
+ const abandonedIds = [];
878
+ const finalStates = new Map();
879
+ for (const result of results) {
880
+ if (opt.reverify && result.wasMet && !staleResults.has(resultKey(result.file, result.gate.id))) reverified++;
881
+ }
882
+
883
+ for (const ledger of ledgers) {
884
+ for (const gate of ledger.doc.gates) {
885
+ const state = gateState(gate, ledger.doc.abandoned);
886
+ finalStates.set(resultKey(ledger.file, gate.id), state);
887
+ if (state === "abandoned") {
888
+ totalAbandoned++;
889
+ abandonedIds.push(qualify(ledger.file, gate.id));
890
+ }
891
+ else if (state === "met") totalMet++;
892
+ else {
893
+ totalUnmet++;
894
+ unmetIds.push(qualify(ledger.file, gate.id));
895
+ if (opt.status) {
896
+ const reason = state === "unmet" ? "unchecked"
897
+ : state === "unmet-no-evidence" ? "checked but EVIDENCE pending"
898
+ : "checked but automatic evidence is stale or unbound";
899
+ console.log(" UNMET " + qualify(ledger.file, gate.id) + " (" +
900
+ reason + "): " + gate.title);
901
+ }
902
+ }
903
+ }
904
+ console.log(basename(ledger.file) + ": " + ledger.doc.gates.length + " gates");
905
+ }
906
+
907
+ // A scoped pipeline is complete only when both its ledgers and its native
908
+ // dispatch waves are resolved. Per-wave `dispatch-check status` is useful for
909
+ // inspection, but completion cannot depend on callers remembering a second
910
+ // command (or on the optional Stop hook being installed).
911
+ const aggregateDispatch = dispatchStatus(root, scope);
912
+ if (aggregateDispatch.errors.length) {
913
+ for (const error of aggregateDispatch.errors) console.error("gate-check: " + error);
914
+ process.exit(2);
915
+ }
916
+ totalAbandoned += aggregateDispatch.abandoned.length;
917
+ abandonedIds.push(...aggregateDispatch.abandoned);
918
+ if (opt.status) {
919
+ for (const blocker of aggregateDispatch.blocking) console.log(" UNMET " + blocker);
920
+ }
921
+
922
+ const where = scope ? " [scope " + scope + "]" : "";
923
+ const verifyNote = opt.reverify
924
+ ? ", reran: " + results.length + ", previously met reverified: " + reverified
925
+ : "";
926
+ const unverifiedMet = opt.reverify ? notRun.filter((task) => task.wasMet) : [];
927
+ const extraUnmet = new Map();
928
+ for (const task of unverifiedMet) {
929
+ const key = resultKey(task.file, task.gate.id);
930
+ const state = finalStates.get(key);
931
+ if (state === "met" || state === undefined) extraUnmet.set(key, qualify(task.file, task.gate.id) + " (reverify not run)");
932
+ }
933
+ for (const [key, label] of staleResults) {
934
+ const state = finalStates.get(key);
935
+ if (state === "met" || state === undefined) extraUnmet.set(key, label + " (stale result discarded)");
936
+ }
937
+ const effectiveUnmet = totalUnmet + extraUnmet.size + aggregateDispatch.blocking.length;
938
+ unmetIds.push(...extraUnmet.values());
939
+ unmetIds.push(...aggregateDispatch.blocking);
940
+ if (approvalInfrastructureFailures) {
941
+ console.error("gate-check: infrastructure failure prevented " + approvalInfrastructureFailures + " approval(s)");
942
+ process.exit(2);
943
+ }
944
+ if (effectiveUnmet === 0 && totalAbandoned === 0) {
945
+ console.log("ALL MET (" + totalMet + " met" + verifyNote + ")" + where);
946
+ process.exit(0);
947
+ }
948
+ if (totalAbandoned) {
949
+ console.log("HANDOFF REQUIRED: " + totalAbandoned + " abandoned (met: " +
950
+ Math.max(0, totalMet - extraUnmet.size) + (effectiveUnmet ? ", unmet: " + effectiveUnmet : "") +
951
+ verifyNote + ")" + where);
952
+ console.log(" " + abandonedIds.slice(0, 12).join(", ") +
953
+ (abandonedIds.length > 12 ? ", +" + (abandonedIds.length - 12) + " more" : ""));
954
+ }
955
+ if (effectiveUnmet) {
956
+ console.log("UNMET: " + effectiveUnmet + " (met: " + Math.max(0, totalMet - extraUnmet.size) +
957
+ (totalAbandoned ? ", abandoned: " + totalAbandoned : "") + verifyNote + ")" + where);
958
+ console.log(" " + unmetIds.slice(0, 12).join(", ") + (unmetIds.length > 12 ? ", +" + (unmetIds.length - 12) + " more" : ""));
959
+ }
960
+ process.exit(1);