@kylecheng3146/agent-ops 0.0.1

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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/SECURITY.md +22 -0
  4. package/dist/packages/cli/src/args.js +322 -0
  5. package/dist/packages/cli/src/bin.js +290 -0
  6. package/dist/packages/cli/src/cli.js +80 -0
  7. package/dist/packages/cli/src/commands/config.js +8 -0
  8. package/dist/packages/cli/src/commands/doctor.js +32 -0
  9. package/dist/packages/cli/src/commands/index.js +16 -0
  10. package/dist/packages/cli/src/commands/init.js +65 -0
  11. package/dist/packages/cli/src/commands/review.js +71 -0
  12. package/dist/packages/cli/src/commands/task.js +141 -0
  13. package/dist/packages/cli/src/commands/trust.js +48 -0
  14. package/dist/packages/cli/src/commands/uninstall.js +58 -0
  15. package/dist/packages/cli/src/commands/update.js +58 -0
  16. package/dist/packages/cli/src/commands/verify.js +108 -0
  17. package/dist/packages/cli/src/output.js +53 -0
  18. package/dist/packages/cli/src/plan-output.js +28 -0
  19. package/dist/packages/cli/src/wizard.js +75 -0
  20. package/dist/runtime/src/adapters/claude/config.js +109 -0
  21. package/dist/runtime/src/adapters/claude/events.js +8 -0
  22. package/dist/runtime/src/adapters/claude/input.js +38 -0
  23. package/dist/runtime/src/adapters/claude/output.js +30 -0
  24. package/dist/runtime/src/adapters/codex/config.js +93 -0
  25. package/dist/runtime/src/adapters/codex/events.js +8 -0
  26. package/dist/runtime/src/adapters/codex/input.js +41 -0
  27. package/dist/runtime/src/adapters/codex/output.js +24 -0
  28. package/dist/runtime/src/config/explain.js +34 -0
  29. package/dist/runtime/src/config/load.js +25 -0
  30. package/dist/runtime/src/config/merge.js +170 -0
  31. package/dist/runtime/src/config/migrate.js +61 -0
  32. package/dist/runtime/src/contracts.js +1 -0
  33. package/dist/runtime/src/discovery/go.js +145 -0
  34. package/dist/runtime/src/discovery/index.js +40 -0
  35. package/dist/runtime/src/discovery/make.js +162 -0
  36. package/dist/runtime/src/discovery/node.js +175 -0
  37. package/dist/runtime/src/discovery/python.js +163 -0
  38. package/dist/runtime/src/discovery/rust.js +159 -0
  39. package/dist/runtime/src/discovery/types.js +1 -0
  40. package/dist/runtime/src/fs/hash.js +19 -0
  41. package/dist/runtime/src/fs/managed-block.js +90 -0
  42. package/dist/runtime/src/fs/manifest.js +24 -0
  43. package/dist/runtime/src/fs/mutation-worker.js +185 -0
  44. package/dist/runtime/src/fs/paths.js +96 -0
  45. package/dist/runtime/src/fs/transaction.js +498 -0
  46. package/dist/runtime/src/guardrails/destructive.js +207 -0
  47. package/dist/runtime/src/guardrails/evaluate.js +9 -0
  48. package/dist/runtime/src/guardrails/exceptions.js +49 -0
  49. package/dist/runtime/src/guardrails/secrets.js +97 -0
  50. package/dist/runtime/src/guardrails/types.js +9 -0
  51. package/dist/runtime/src/hooks/dispatch.js +78 -0
  52. package/dist/runtime/src/hooks/events.js +1 -0
  53. package/dist/runtime/src/hooks/hook-entry.js +19 -0
  54. package/dist/runtime/src/hooks/normalize.js +59 -0
  55. package/dist/runtime/src/hooks/output.js +12 -0
  56. package/dist/runtime/src/hooks/shell.js +138 -0
  57. package/dist/runtime/src/hooks/stop-verify.js +70 -0
  58. package/dist/runtime/src/install/apply.js +70 -0
  59. package/dist/runtime/src/install/doctor.js +196 -0
  60. package/dist/runtime/src/install/harness.js +87 -0
  61. package/dist/runtime/src/install/ownership.js +84 -0
  62. package/dist/runtime/src/install/plan.js +257 -0
  63. package/dist/runtime/src/install/profiles.js +28 -0
  64. package/dist/runtime/src/install/types.js +1 -0
  65. package/dist/runtime/src/install/uninstall.js +206 -0
  66. package/dist/runtime/src/install/update.js +123 -0
  67. package/dist/runtime/src/logging/local-log.js +158 -0
  68. package/dist/runtime/src/registry/npm.js +141 -0
  69. package/dist/runtime/src/review/claude-runner.js +4 -0
  70. package/dist/runtime/src/review/codex-runner.js +4 -0
  71. package/dist/runtime/src/review/packet.js +10 -0
  72. package/dist/runtime/src/review/result.js +24 -0
  73. package/dist/runtime/src/review/roles.js +3 -0
  74. package/dist/runtime/src/review/runner.js +45 -0
  75. package/dist/runtime/src/schema/validate.js +584 -0
  76. package/dist/runtime/src/security/permissions.js +654 -0
  77. package/dist/runtime/src/security/redact.js +41 -0
  78. package/dist/runtime/src/security/trust.js +209 -0
  79. package/dist/runtime/src/task/render.js +43 -0
  80. package/dist/runtime/src/task/service.js +235 -0
  81. package/dist/runtime/src/task/store.js +265 -0
  82. package/dist/runtime/src/verify/change-surface.js +86 -0
  83. package/dist/runtime/src/verify/evidence.js +89 -0
  84. package/dist/runtime/src/verify/fingerprint.js +67 -0
  85. package/dist/runtime/src/verify/scope.js +69 -0
  86. package/dist/runtime/src/verify/service.js +217 -0
  87. package/dist/runtime/src/verify/spawn.js +326 -0
  88. package/dist/runtime/src/verify/test-count.js +148 -0
  89. package/docs/en/spec/README.md +13 -0
  90. package/docs/en/spec/acceptance-and-evidence.md +21 -0
  91. package/docs/en/spec/delegation.md +21 -0
  92. package/docs/en/spec/guardrails.md +21 -0
  93. package/docs/en/spec/harness-adapters.md +21 -0
  94. package/docs/en/spec/judgment.md +21 -0
  95. package/docs/en/spec/loop-engineering.md +23 -0
  96. package/docs/en/spec/maintenance.md +21 -0
  97. package/docs/en/spec/review.md +21 -0
  98. package/docs/en/spec/troubleshooting.md +21 -0
  99. package/docs/zh-TW/spec/README.md +13 -0
  100. package/docs/zh-TW/spec/acceptance-and-evidence.md +23 -0
  101. package/docs/zh-TW/spec/delegation.md +23 -0
  102. package/docs/zh-TW/spec/guardrails.md +23 -0
  103. package/docs/zh-TW/spec/harness-adapters.md +23 -0
  104. package/docs/zh-TW/spec/judgment.md +23 -0
  105. package/docs/zh-TW/spec/loop-engineering.md +23 -0
  106. package/docs/zh-TW/spec/maintenance.md +23 -0
  107. package/docs/zh-TW/spec/review.md +23 -0
  108. package/docs/zh-TW/spec/troubleshooting.md +23 -0
  109. package/package.json +41 -0
  110. package/schemas/config.schema.json +231 -0
  111. package/schemas/evidence.schema.json +115 -0
  112. package/schemas/manifest.schema.json +116 -0
  113. package/schemas/task.schema.json +59 -0
  114. package/templates/common/AGENTS.block.md +3 -0
  115. package/templates/common/CLAUDE.block.md +3 -0
@@ -0,0 +1,217 @@
1
+ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
2
+ import { validateTaskAgainstConfig } from "../schema/validate.js";
3
+ import { collectChangeSurface } from "./change-surface.js";
4
+ import { buildVerificationEvidence } from "./evidence.js";
5
+ import { createFailureFingerprint } from "./fingerprint.js";
6
+ import { selectVerificationScope } from "./scope.js";
7
+ import { runVerificationCommand } from "./spawn.js";
8
+ import { evaluateTestCount, parseTestCount } from "./test-count.js";
9
+ function verificationError(code, message) {
10
+ return new AgentOpsError(code, message);
11
+ }
12
+ function classifyTestCountCode(code) {
13
+ const classes = {
14
+ TEST_COUNT_BELOW_MINIMUM: "test-count-below-minimum",
15
+ TEST_COUNT_INVALID: "test-count-invalid",
16
+ TEST_COUNT_OK: "none",
17
+ TEST_COUNT_REQUIREMENT_INVALID: "test-count-requirement-invalid",
18
+ TEST_COUNT_UNPARSEABLE: "test-count-unparseable",
19
+ ZERO_TESTS: "zero-tests"
20
+ };
21
+ return classes[code];
22
+ }
23
+ function classifyResult(command, spawned) {
24
+ if (command.evidence.kind === "file") {
25
+ return {
26
+ status: spawned.status === "PASS" ? "UNKNOWN" : spawned.status,
27
+ failureClass: spawned.status === "PASS"
28
+ ? "file-evidence-unsupported"
29
+ : spawned.failureClass,
30
+ testCount: null
31
+ };
32
+ }
33
+ if (command.evidence.kind !== "test-count") {
34
+ return {
35
+ status: spawned.status,
36
+ failureClass: spawned.failureClass,
37
+ testCount: null
38
+ };
39
+ }
40
+ const testCount = parseTestCount(`${spawned.stdout}\n${spawned.stderr}`);
41
+ if (spawned.status !== "PASS") {
42
+ return {
43
+ status: spawned.status,
44
+ failureClass: spawned.failureClass,
45
+ testCount
46
+ };
47
+ }
48
+ const evaluation = evaluateTestCount(testCount, command.evidence.minimum);
49
+ return {
50
+ status: evaluation.status,
51
+ failureClass: classifyTestCountCode(evaluation.code),
52
+ testCount: evaluation.testCount
53
+ };
54
+ }
55
+ function exitCategory(result) {
56
+ if (result.timedOut) {
57
+ return "timeout";
58
+ }
59
+ if (result.failureClass === "signal-exit") {
60
+ return "signal-exit";
61
+ }
62
+ if (result.exitCode === null) {
63
+ return "no-exit";
64
+ }
65
+ return result.exitCode === 0 ? "exit-zero" : "nonzero-exit";
66
+ }
67
+ function overallStatus(results) {
68
+ const required = results.filter((result) => result.required);
69
+ const gating = required.length > 0 ? required : results;
70
+ if (gating.some((result) => result.status === "FAIL")) {
71
+ return "FAIL";
72
+ }
73
+ if (gating.some((result) => result.status === "UNKNOWN")) {
74
+ return "UNKNOWN";
75
+ }
76
+ return "PASS";
77
+ }
78
+ function relevantCriteria(task, commandId) {
79
+ return task.criteria.filter((criterion) => criterion.verifierIds.includes(commandId));
80
+ }
81
+ function commandById(config, commandId) {
82
+ const command = config.verification.commands.find((candidate) => candidate.id === commandId);
83
+ if (command === undefined) {
84
+ throw verificationError("VERIFICATION_COMMAND_NOT_FOUND", `Verification command not found: ${commandId}`);
85
+ }
86
+ return command;
87
+ }
88
+ function diagnostics(spawned) {
89
+ return spawned.stderr || spawned.stdout || spawned.failureClass;
90
+ }
91
+ export class VerificationService {
92
+ #options;
93
+ constructor(options) {
94
+ this.#options = options;
95
+ }
96
+ async #commandCwd(command) {
97
+ if (command.cwd === ".") {
98
+ return this.#options.root;
99
+ }
100
+ return await resolveContainedPath(this.#options.root, command.cwd);
101
+ }
102
+ async #persistEvidence(task, command, startedAt, finishedAt, result, exitCode) {
103
+ const references = [];
104
+ for (const criterion of relevantCriteria(task, command.id)) {
105
+ const evidence = buildVerificationEvidence({
106
+ taskId: task.id,
107
+ criterionId: criterion.id,
108
+ command,
109
+ scope: this.#options.scope,
110
+ startedAt,
111
+ finishedAt,
112
+ exitCode,
113
+ testCount: result.testCount,
114
+ toolVersions: this.#options.toolVersions ?? {},
115
+ config: this.#options.config
116
+ });
117
+ references.push(await this.#options.evidenceStore.save(evidence));
118
+ }
119
+ return references;
120
+ }
121
+ async #runCommand(task, command) {
122
+ const startedAt = (this.#options.now ?? (() => new Date().toISOString()))();
123
+ const spawned = this.#options.trusted
124
+ ? await runVerificationCommand(command, {
125
+ cwd: await this.#commandCwd(command),
126
+ runner: this.#options.processRunner
127
+ })
128
+ : {
129
+ commandId: command.id,
130
+ status: "UNKNOWN",
131
+ failureClass: "repository-untrusted",
132
+ exitCode: null,
133
+ signal: null,
134
+ timedOut: false,
135
+ durationMs: 0,
136
+ stdout: "",
137
+ stderr: "",
138
+ stdoutTruncated: false,
139
+ stderrTruncated: false
140
+ };
141
+ const classified = classifyResult(command, spawned);
142
+ const fingerprint = classified.status === "PASS"
143
+ ? null
144
+ : createFailureFingerprint({
145
+ commandId: command.id,
146
+ failureClass: classified.failureClass,
147
+ exitCategory: spawned.timedOut
148
+ ? "timeout"
149
+ : spawned.signal !== null
150
+ ? "signal-exit"
151
+ : spawned.exitCode === null
152
+ ? "no-exit"
153
+ : spawned.exitCode === 0
154
+ ? "exit-zero"
155
+ : "nonzero-exit",
156
+ diagnostics: diagnostics(spawned)
157
+ });
158
+ const finishedAt = (this.#options.now ?? (() => new Date().toISOString()))();
159
+ const evidenceReferences = await this.#persistEvidence(task, command, startedAt, finishedAt, classified, spawned.exitCode);
160
+ return {
161
+ commandId: command.id,
162
+ required: command.required,
163
+ status: classified.status,
164
+ failureClass: classified.failureClass,
165
+ exitCode: spawned.exitCode,
166
+ timedOut: spawned.timedOut,
167
+ testCount: classified.testCount,
168
+ diagnostic: fingerprint?.diagnostics ?? "",
169
+ evidenceReferences
170
+ };
171
+ }
172
+ async verify(taskId) {
173
+ const stored = await this.#options.taskService.status({ taskId });
174
+ if (stored.status === "archived") {
175
+ throw verificationError("TASK_NOT_ACTIVE", "An archived task cannot be verified.");
176
+ }
177
+ const validation = validateTaskAgainstConfig(stored.task, this.#options.config);
178
+ if (!validation.ok) {
179
+ throw verificationError("VERIFICATION_INPUT_INVALID", validation.errors[0]?.message ??
180
+ "Task and verification configuration are incompatible.");
181
+ }
182
+ const surface = await collectChangeSurface(this.#options.gitRunner);
183
+ const selection = selectVerificationScope(surface.paths, this.#options.config);
184
+ const results = [];
185
+ for (const commandId of selection.verifierIds) {
186
+ results.push(await this.#runCommand(validation.value, commandById(this.#options.config, commandId)));
187
+ }
188
+ const status = overallStatus(results);
189
+ let signal = null;
190
+ if (status === "PASS") {
191
+ await this.#options.taskService.clearFailure(taskId);
192
+ }
193
+ else {
194
+ const required = results.filter((result) => result.required);
195
+ const gating = required.length > 0 ? required : results;
196
+ const failed = gating.find((result) => result.status !== "PASS");
197
+ if (failed === undefined) {
198
+ throw verificationError("VERIFICATION_RESULT_INVALID", "Verification failed without a gating result.");
199
+ }
200
+ const advanced = await this.#options.taskService.recordFailure(taskId, createFailureFingerprint({
201
+ commandId: failed.commandId,
202
+ failureClass: failed.failureClass,
203
+ exitCategory: exitCategory(failed),
204
+ diagnostics: failed.diagnostic
205
+ }));
206
+ signal = advanced.signal;
207
+ }
208
+ return {
209
+ taskId,
210
+ status,
211
+ surface,
212
+ selection,
213
+ results,
214
+ signal
215
+ };
216
+ }
217
+ }
@@ -0,0 +1,326 @@
1
+ import { execFile as execFileCallback, spawn } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
4
+ const DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024;
5
+ const MAX_OUTPUT_LIMIT_BYTES = 1024 * 1024;
6
+ const DEFAULT_TERMINATION_GRACE_MS = 500;
7
+ const MAX_TERMINATION_GRACE_MS = 5_000;
8
+ const execFile = promisify(execFileCallback);
9
+ function boundedPositiveInteger(value, maximum, name) {
10
+ if (!Number.isSafeInteger(value) ||
11
+ value <= 0 ||
12
+ value > maximum) {
13
+ throw new RangeError(`${name} must be a positive integer no greater than ${maximum}.`);
14
+ }
15
+ return value;
16
+ }
17
+ function errorCode(error) {
18
+ if (typeof error === "object" &&
19
+ error !== null &&
20
+ "code" in error &&
21
+ typeof error.code === "string") {
22
+ return error.code;
23
+ }
24
+ return "UNKNOWN";
25
+ }
26
+ async function* readableBytes(stream) {
27
+ if (stream === null) {
28
+ return;
29
+ }
30
+ for await (const value of stream) {
31
+ if (typeof value === "string") {
32
+ yield Buffer.from(value);
33
+ continue;
34
+ }
35
+ if (value instanceof Uint8Array) {
36
+ yield value;
37
+ continue;
38
+ }
39
+ throw new TypeError("Process output contained an unsupported chunk.");
40
+ }
41
+ }
42
+ async function captureOutput(stream, limit) {
43
+ const chunks = [];
44
+ let storedBytes = 0;
45
+ let truncated = false;
46
+ try {
47
+ for await (const value of stream) {
48
+ const chunk = Buffer.from(value);
49
+ const remaining = limit - storedBytes;
50
+ if (remaining > 0) {
51
+ const retained = chunk.subarray(0, remaining);
52
+ chunks.push(retained);
53
+ storedBytes += retained.length;
54
+ }
55
+ if (chunk.length > remaining) {
56
+ truncated = true;
57
+ }
58
+ }
59
+ }
60
+ catch {
61
+ return {
62
+ text: Buffer.concat(chunks, storedBytes).toString("utf8"),
63
+ truncated,
64
+ failed: true
65
+ };
66
+ }
67
+ return {
68
+ text: Buffer.concat(chunks, storedBytes).toString("utf8"),
69
+ truncated,
70
+ failed: false
71
+ };
72
+ }
73
+ function settleCapturedOutput(capture, graceMs) {
74
+ let timer;
75
+ const timeout = new Promise((resolve) => {
76
+ timer = setTimeout(() => resolve({
77
+ text: "",
78
+ truncated: true,
79
+ failed: true
80
+ }), graceMs);
81
+ });
82
+ return Promise.race([capture, timeout]).finally(() => {
83
+ if (timer !== undefined) {
84
+ clearTimeout(timer);
85
+ }
86
+ });
87
+ }
88
+ function isMissingProcess(errorCodeValue) {
89
+ return errorCodeValue === "ENOENT";
90
+ }
91
+ function waitForCompletion(completion, timeoutMs) {
92
+ let timer;
93
+ const timeout = new Promise((resolve) => {
94
+ timer = setTimeout(() => resolve(null), timeoutMs);
95
+ });
96
+ return Promise.race([completion, timeout]).finally(() => {
97
+ if (timer !== undefined) {
98
+ clearTimeout(timer);
99
+ }
100
+ });
101
+ }
102
+ async function terminatePosixTree(processId, completion, graceMs) {
103
+ try {
104
+ process.kill(-processId, "SIGTERM");
105
+ }
106
+ catch (error) {
107
+ if (errorCode(error) === "ESRCH") {
108
+ return;
109
+ }
110
+ throw error;
111
+ }
112
+ await waitForCompletion(completion, graceMs);
113
+ try {
114
+ process.kill(-processId, "SIGKILL");
115
+ }
116
+ catch (error) {
117
+ if (errorCode(error) !== "ESRCH") {
118
+ throw error;
119
+ }
120
+ }
121
+ }
122
+ async function defaultTerminateWindowsTree(processId, graceMs) {
123
+ await execFile("taskkill", ["/pid", String(processId), "/T", "/F"], {
124
+ encoding: "utf8",
125
+ timeout: graceMs,
126
+ windowsHide: true
127
+ });
128
+ }
129
+ export class NodeVerificationProcessRunner {
130
+ #platform;
131
+ #terminateWindowsTree;
132
+ constructor(options = {}) {
133
+ this.#platform = options.platform ?? process.platform;
134
+ this.#terminateWindowsTree =
135
+ options.terminateWindowsTree ?? defaultTerminateWindowsTree;
136
+ }
137
+ start(request) {
138
+ const child = spawn(request.command, [...request.args], {
139
+ cwd: request.cwd,
140
+ detached: this.#platform !== "win32",
141
+ shell: request.shell,
142
+ stdio: ["ignore", "pipe", "pipe"],
143
+ windowsHide: true
144
+ });
145
+ let settled = false;
146
+ const completion = new Promise((resolve) => {
147
+ child.once("error", (error) => {
148
+ if (!settled) {
149
+ settled = true;
150
+ resolve({
151
+ exitCode: null,
152
+ signal: null,
153
+ errorCode: error.code ?? "UNKNOWN"
154
+ });
155
+ }
156
+ });
157
+ child.once("close", (exitCode, signal) => {
158
+ if (!settled) {
159
+ settled = true;
160
+ resolve({
161
+ exitCode,
162
+ signal
163
+ });
164
+ }
165
+ });
166
+ });
167
+ const processId = child.pid ?? null;
168
+ return {
169
+ pid: processId,
170
+ stdout: readableBytes(child.stdout),
171
+ stderr: readableBytes(child.stderr),
172
+ completion,
173
+ terminateTree: async (graceMs) => {
174
+ if (processId === null) {
175
+ throw new Error("Cannot terminate a process without an ID.");
176
+ }
177
+ if (this.#platform === "win32") {
178
+ await this.#terminateWindowsTree(processId, graceMs);
179
+ return;
180
+ }
181
+ await terminatePosixTree(processId, completion, graceMs);
182
+ }
183
+ };
184
+ }
185
+ }
186
+ function emptyResult(commandId, failureClass, durationMs) {
187
+ return {
188
+ commandId,
189
+ status: "UNKNOWN",
190
+ failureClass,
191
+ exitCode: null,
192
+ signal: null,
193
+ timedOut: false,
194
+ durationMs,
195
+ stdout: "",
196
+ stderr: "",
197
+ stdoutTruncated: false,
198
+ stderrTruncated: false
199
+ };
200
+ }
201
+ function classifyCompletion(completion, outputFailed) {
202
+ if (outputFailed) {
203
+ return {
204
+ status: "UNKNOWN",
205
+ failureClass: "output-read-failed"
206
+ };
207
+ }
208
+ if (isMissingProcess(completion.errorCode)) {
209
+ return {
210
+ status: "UNKNOWN",
211
+ failureClass: "missing-executable"
212
+ };
213
+ }
214
+ if (completion.errorCode !== undefined) {
215
+ return {
216
+ status: "UNKNOWN",
217
+ failureClass: "spawn-failed"
218
+ };
219
+ }
220
+ if (completion.signal !== null) {
221
+ return {
222
+ status: "FAIL",
223
+ failureClass: "signal-exit"
224
+ };
225
+ }
226
+ if (completion.exitCode === 0) {
227
+ return {
228
+ status: "PASS",
229
+ failureClass: "none"
230
+ };
231
+ }
232
+ return {
233
+ status: "FAIL",
234
+ failureClass: "nonzero-exit"
235
+ };
236
+ }
237
+ function elapsedMilliseconds(startedAt, finishedAt) {
238
+ const elapsed = Math.max(0, finishedAt - startedAt);
239
+ return Number.isFinite(elapsed) ? Math.round(elapsed) : 0;
240
+ }
241
+ function hasAcknowledgedShell(command) {
242
+ const value = command;
243
+ return value.shell !== true || value.acknowledgeRisk === true;
244
+ }
245
+ export async function runVerificationCommand(command, options) {
246
+ const now = options.now ?? Date.now;
247
+ const startedAt = now();
248
+ if (!hasAcknowledgedShell(command)) {
249
+ return emptyResult(command.id, "shell-risk-unacknowledged", elapsedMilliseconds(startedAt, now()));
250
+ }
251
+ const outputLimit = boundedPositiveInteger(options.outputLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, MAX_OUTPUT_LIMIT_BYTES, "outputLimitBytes");
252
+ const terminationGrace = boundedPositiveInteger(options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS, MAX_TERMINATION_GRACE_MS, "terminationGraceMs");
253
+ const timeoutMs = boundedPositiveInteger(command.timeoutMs ?? DEFAULT_TIMEOUT_MS, 2_147_483_647, "timeoutMs");
254
+ const runner = options.runner ?? new NodeVerificationProcessRunner();
255
+ let running;
256
+ try {
257
+ running = runner.start({
258
+ command: command.command,
259
+ args: [...command.args],
260
+ cwd: options.cwd,
261
+ shell: command.shell === true
262
+ });
263
+ }
264
+ catch {
265
+ return emptyResult(command.id, "spawn-failed", elapsedMilliseconds(startedAt, now()));
266
+ }
267
+ const stdout = captureOutput(running.stdout, outputLimit);
268
+ const stderr = captureOutput(running.stderr, outputLimit);
269
+ let timer;
270
+ const timeout = new Promise((resolve) => {
271
+ timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
272
+ });
273
+ const outcome = await Promise.race([
274
+ running.completion.then((completion) => ({
275
+ kind: "completion",
276
+ completion
277
+ })),
278
+ timeout
279
+ ]);
280
+ if (timer !== undefined) {
281
+ clearTimeout(timer);
282
+ }
283
+ let completion;
284
+ let timedOut = false;
285
+ let terminationFailed = false;
286
+ if (outcome.kind === "timeout") {
287
+ timedOut = true;
288
+ try {
289
+ await running.terminateTree(terminationGrace);
290
+ }
291
+ catch {
292
+ terminationFailed = true;
293
+ }
294
+ completion =
295
+ await waitForCompletion(running.completion, terminationGrace) ??
296
+ { exitCode: null, signal: null };
297
+ }
298
+ else {
299
+ completion = outcome.completion;
300
+ }
301
+ const [capturedStdout, capturedStderr] = await Promise.all([
302
+ settleCapturedOutput(stdout, terminationGrace),
303
+ settleCapturedOutput(stderr, terminationGrace)
304
+ ]);
305
+ const classified = timedOut
306
+ ? {
307
+ status: terminationFailed ? "UNKNOWN" : "FAIL",
308
+ failureClass: terminationFailed
309
+ ? "termination-failed"
310
+ : "timeout"
311
+ }
312
+ : classifyCompletion(completion, capturedStdout.failed || capturedStderr.failed);
313
+ return {
314
+ commandId: command.id,
315
+ status: classified.status,
316
+ failureClass: classified.failureClass,
317
+ exitCode: completion.exitCode,
318
+ signal: completion.signal,
319
+ timedOut,
320
+ durationMs: elapsedMilliseconds(startedAt, now()),
321
+ stdout: capturedStdout.text,
322
+ stderr: capturedStderr.text,
323
+ stdoutTruncated: capturedStdout.truncated,
324
+ stderrTruncated: capturedStderr.truncated
325
+ };
326
+ }
@@ -0,0 +1,148 @@
1
+ const MAX_SUMMARY_BYTES = 1024 * 1024;
2
+ const NUMBER_SOURCE = String.raw `\d{1,16}`;
3
+ function safeCount(value) {
4
+ const parsed = Number(value);
5
+ return Number.isSafeInteger(parsed) && parsed >= 0
6
+ ? parsed
7
+ : null;
8
+ }
9
+ function addCandidate(candidates, source) {
10
+ const parsed = safeCount(source);
11
+ if (parsed !== null) {
12
+ candidates.push(parsed);
13
+ }
14
+ }
15
+ function parsePytestSummary(line) {
16
+ if (!/\bin \d+(?:\.\d+)?s(?:\s|$)/u.test(line)) {
17
+ return null;
18
+ }
19
+ const statusPattern = new RegExp(`(${NUMBER_SOURCE}) (passed|failed|skipped|error|errors|xfailed|xpassed|deselected)`, "gu");
20
+ let total = 0;
21
+ let matched = false;
22
+ for (const match of line.matchAll(statusPattern)) {
23
+ const source = match[1];
24
+ const status = match[2];
25
+ if (source === undefined || status === undefined) {
26
+ return null;
27
+ }
28
+ const count = safeCount(source);
29
+ if (count === null ||
30
+ (status !== "deselected" &&
31
+ total > Number.MAX_SAFE_INTEGER - count)) {
32
+ return null;
33
+ }
34
+ if (status !== "deselected") {
35
+ total += count;
36
+ }
37
+ matched = true;
38
+ }
39
+ return matched ? total : null;
40
+ }
41
+ function parseRustSummary(line) {
42
+ const match = new RegExp(`^test result: (?:ok|FAILED)\\. (${NUMBER_SOURCE}) passed; (${NUMBER_SOURCE}) failed; ${NUMBER_SOURCE} ignored; ${NUMBER_SOURCE} measured; ${NUMBER_SOURCE} filtered out$`, "u").exec(line);
43
+ if (match === null) {
44
+ return null;
45
+ }
46
+ const passedSource = match[1];
47
+ const failedSource = match[2];
48
+ if (passedSource === undefined || failedSource === undefined) {
49
+ return null;
50
+ }
51
+ const passed = safeCount(passedSource);
52
+ const failed = safeCount(failedSource);
53
+ if (passed === null ||
54
+ failed === null ||
55
+ passed > Number.MAX_SAFE_INTEGER - failed) {
56
+ return null;
57
+ }
58
+ return passed + failed;
59
+ }
60
+ export function parseTestCount(output) {
61
+ if (output.includes("\0") ||
62
+ Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) {
63
+ return null;
64
+ }
65
+ const candidates = [];
66
+ const nodePattern = new RegExp(`^# tests (${NUMBER_SOURCE})$`, "u");
67
+ const collectedPattern = new RegExp(`^collected (${NUMBER_SOURCE}) items?$`, "u");
68
+ const jestPattern = new RegExp(`^Tests:\\s+.*\\b(${NUMBER_SOURCE}) total(?:\\s|$)`, "u");
69
+ const vitestPattern = new RegExp(`^Tests\\s+.*\\((${NUMBER_SOURCE})\\)\\s*$`, "u");
70
+ for (const rawLine of output.split(/\r?\n/u)) {
71
+ const line = rawLine.trim();
72
+ if (line.length === 0) {
73
+ continue;
74
+ }
75
+ const node = nodePattern.exec(line);
76
+ if (node?.[1] !== undefined) {
77
+ addCandidate(candidates, node[1]);
78
+ }
79
+ const collected = collectedPattern.exec(line);
80
+ if (collected?.[1] !== undefined) {
81
+ addCandidate(candidates, collected[1]);
82
+ }
83
+ const jest = jestPattern.exec(line);
84
+ if (jest?.[1] !== undefined) {
85
+ addCandidate(candidates, jest[1]);
86
+ }
87
+ const vitest = vitestPattern.exec(line);
88
+ if (vitest?.[1] !== undefined) {
89
+ addCandidate(candidates, vitest[1]);
90
+ }
91
+ const pytest = parsePytestSummary(line);
92
+ if (pytest !== null) {
93
+ candidates.push(pytest);
94
+ }
95
+ const rust = parseRustSummary(line);
96
+ if (rust !== null) {
97
+ candidates.push(rust);
98
+ }
99
+ }
100
+ const first = candidates[0];
101
+ if (first === undefined ||
102
+ candidates.some((candidate) => candidate !== first)) {
103
+ return null;
104
+ }
105
+ return first;
106
+ }
107
+ export function evaluateTestCount(testCount, minimum = 1) {
108
+ if (!Number.isSafeInteger(minimum) || minimum < 0) {
109
+ return {
110
+ status: "UNKNOWN",
111
+ code: "TEST_COUNT_REQUIREMENT_INVALID",
112
+ testCount
113
+ };
114
+ }
115
+ if (testCount === null) {
116
+ return {
117
+ status: "UNKNOWN",
118
+ code: "TEST_COUNT_UNPARSEABLE",
119
+ testCount: null
120
+ };
121
+ }
122
+ if (!Number.isSafeInteger(testCount) || testCount < 0) {
123
+ return {
124
+ status: "UNKNOWN",
125
+ code: "TEST_COUNT_INVALID",
126
+ testCount: null
127
+ };
128
+ }
129
+ if (testCount === 0) {
130
+ return {
131
+ status: "FAIL",
132
+ code: "ZERO_TESTS",
133
+ testCount
134
+ };
135
+ }
136
+ if (testCount < minimum) {
137
+ return {
138
+ status: "FAIL",
139
+ code: "TEST_COUNT_BELOW_MINIMUM",
140
+ testCount
141
+ };
142
+ }
143
+ return {
144
+ status: "PASS",
145
+ code: "TEST_COUNT_OK",
146
+ testCount
147
+ };
148
+ }
@@ -0,0 +1,13 @@
1
+ # Loop Engineering Specification
2
+
3
+ This is the normative English specification for bounded, evidence-driven work.
4
+
5
+ - [Loop engineering](./loop-engineering.md)
6
+ - [Acceptance and evidence](./acceptance-and-evidence.md)
7
+ - [Judgment](./judgment.md)
8
+ - [Delegation](./delegation.md)
9
+ - [Review](./review.md)
10
+ - [Troubleshooting](./troubleshooting.md)
11
+ - [Guardrails](./guardrails.md)
12
+ - [Maintenance](./maintenance.md)
13
+ - [Harness adapters](./harness-adapters.md)