@kylecheng3146/agent-ops 0.1.4 → 0.1.6

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 (84) hide show
  1. package/README.md +164 -13
  2. package/dist/packages/cli/src/args.js +40 -7
  3. package/dist/packages/cli/src/bin.js +35 -11
  4. package/dist/packages/cli/src/cli.js +7 -2
  5. package/dist/packages/cli/src/codex-loop-process.js +70 -0
  6. package/dist/packages/cli/src/commands/doctor.js +31 -9
  7. package/dist/packages/cli/src/commands/hook.js +33 -16
  8. package/dist/packages/cli/src/commands/init.js +9 -5
  9. package/dist/packages/cli/src/commands/review.js +5 -1
  10. package/dist/packages/cli/src/commands/uninstall.js +6 -5
  11. package/dist/packages/cli/src/commands/update.js +16 -5
  12. package/dist/packages/cli/src/context.js +69 -3
  13. package/dist/packages/cli/src/hook-process.js +231 -16
  14. package/dist/packages/cli/src/loop-entry.js +8 -0
  15. package/dist/packages/cli/src/plan-output.js +9 -4
  16. package/dist/packages/cli/src/public-plan.js +62 -0
  17. package/dist/packages/cli/src/version.js +1 -1
  18. package/dist/packages/cli/src/wizard.js +36 -15
  19. package/dist/runtime/src/adapters/claude/config.js +57 -19
  20. package/dist/runtime/src/adapters/claude/events.js +33 -0
  21. package/dist/runtime/src/adapters/claude/output.js +8 -7
  22. package/dist/runtime/src/adapters/claude/surfaces.js +70 -0
  23. package/dist/runtime/src/adapters/codex/config.js +66 -13
  24. package/dist/runtime/src/adapters/codex/events.js +33 -0
  25. package/dist/runtime/src/adapters/codex/output.js +10 -0
  26. package/dist/runtime/src/adapters/codex/surfaces.js +12 -0
  27. package/dist/runtime/src/adapters/opencode/config.js +170 -0
  28. package/dist/runtime/src/adapters/opencode/events.js +49 -0
  29. package/dist/runtime/src/adapters/opencode/input.js +32 -0
  30. package/dist/runtime/src/adapters/opencode/output.js +23 -0
  31. package/dist/runtime/src/adapters/opencode/surfaces.js +23 -0
  32. package/dist/runtime/src/config/explain.js +7 -0
  33. package/dist/runtime/src/config/hash.js +24 -0
  34. package/dist/runtime/src/config/merge.js +6 -2
  35. package/dist/runtime/src/config/migrate.js +19 -4
  36. package/dist/runtime/src/contracts.js +10 -1
  37. package/dist/runtime/src/fs/managed-block.js +35 -18
  38. package/dist/runtime/src/fs/manifest.js +32 -1
  39. package/dist/runtime/src/fs/transaction.js +14 -2
  40. package/dist/runtime/src/hooks/advisory.js +16 -0
  41. package/dist/runtime/src/hooks/codex-loop.js +439 -0
  42. package/dist/runtime/src/hooks/stop-service.js +70 -0
  43. package/dist/runtime/src/hooks/stop-verify.js +4 -1
  44. package/dist/runtime/src/install/codex-loop.js +139 -0
  45. package/dist/runtime/src/install/doctor.js +182 -14
  46. package/dist/runtime/src/install/harness.js +294 -35
  47. package/dist/runtime/src/install/hooks.js +22 -17
  48. package/dist/runtime/src/install/ownership.js +146 -35
  49. package/dist/runtime/src/install/plan.js +276 -31
  50. package/dist/runtime/src/install/probes.js +9 -43
  51. package/dist/runtime/src/install/profiles.js +12 -3
  52. package/dist/runtime/src/install/surface-inspection.js +296 -0
  53. package/dist/runtime/src/install/surfaces.js +11 -0
  54. package/dist/runtime/src/install/uninstall.js +11 -4
  55. package/dist/runtime/src/install/update.js +13 -3
  56. package/dist/runtime/src/logging/local-log.js +25 -0
  57. package/dist/runtime/src/schema/validate.js +45 -19
  58. package/dist/runtime/src/task/service.js +3 -3
  59. package/dist/runtime/src/task/store.js +12 -3
  60. package/dist/runtime/src/verify/command-executor.js +113 -0
  61. package/dist/runtime/src/verify/evidence.js +4 -24
  62. package/dist/runtime/src/verify/service.js +20 -92
  63. package/dist/runtime/src/verify/spawn.js +6 -1
  64. package/docs/en/guides/configuration.md +159 -0
  65. package/docs/en/guides/quickstart.md +9 -0
  66. package/docs/en/guides/security.md +5 -0
  67. package/docs/en/spec/README.md +9 -0
  68. package/docs/en/spec/harness-adapters.md +104 -2
  69. package/docs/en/spec/maintenance.md +11 -0
  70. package/docs/en/spec/review.md +11 -0
  71. package/docs/zh-TW/guides/configuration.md +145 -0
  72. package/docs/zh-TW/guides/quickstart.md +9 -0
  73. package/docs/zh-TW/guides/security.md +5 -0
  74. package/docs/zh-TW/spec/README.md +9 -0
  75. package/docs/zh-TW/spec/harness-adapters.md +89 -3
  76. package/docs/zh-TW/spec/maintenance.md +11 -1
  77. package/docs/zh-TW/spec/review.md +10 -0
  78. package/package.json +4 -2
  79. package/schemas/config.schema.json +56 -2
  80. package/schemas/manifest.schema.json +19 -3
  81. package/templates/common/AGENTS.block.md +2 -1
  82. package/templates/common/CLAUDE.block.md +2 -1
  83. package/dist/runtime/src/review/claude-runner.js +0 -4
  84. package/dist/runtime/src/review/codex-runner.js +0 -4
@@ -0,0 +1,439 @@
1
+ import { execFile as execFileCallback } from "node:child_process";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { lstat, realpath } from "node:fs/promises";
4
+ import { promisify } from "node:util";
5
+ import { applyManagedBlock } from "../fs/managed-block.js";
6
+ import { evaluateGuardrail } from "../guardrails/evaluate.js";
7
+ import { appendLocalLog } from "../logging/local-log.js";
8
+ import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
9
+ import { redactSecrets } from "../security/redact.js";
10
+ import { normalizeShellHookEvent } from "./shell.js";
11
+ const execFile = promisify(execFileCallback);
12
+ const MAX_CONTEXT_CHARS = 1_200;
13
+ const MAX_GIT_STATUS_CHARS = 4_096;
14
+ const DEFAULT_TELEMETRY_MAX_BYTES = 64 * 1024;
15
+ const LOOP_SNAPSHOT_ID = "loop-snapshot";
16
+ export const PROJECT_LOOP_EVENTS = [
17
+ "SessionStart",
18
+ "UserPromptSubmit",
19
+ "PreToolUse",
20
+ "PermissionRequest",
21
+ "PostToolUse",
22
+ "PreCompact",
23
+ "PostCompact",
24
+ "SubagentStart",
25
+ "SubagentStop"
26
+ ];
27
+ function isRecord(value) {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ }
30
+ function stringField(value, maximum = 64 * 1024) {
31
+ return (typeof value === "string" &&
32
+ value.length > 0 &&
33
+ value.length <= maximum &&
34
+ !value.includes("\0"))
35
+ ? value
36
+ : null;
37
+ }
38
+ function inputCwd(input) {
39
+ return isRecord(input) ? stringField(input.cwd, 4_096) : null;
40
+ }
41
+ function prompt(input) {
42
+ return isRecord(input) ? stringField(input.prompt) : null;
43
+ }
44
+ function bashCommand(input) {
45
+ if (!isRecord(input) ||
46
+ input.tool_name !== "Bash" ||
47
+ !isRecord(input.tool_input)) {
48
+ return null;
49
+ }
50
+ return stringField(input.tool_input.command, 16 * 1024);
51
+ }
52
+ function requestedSandboxPermission(input) {
53
+ return isRecord(input)
54
+ ? stringField(input.sandbox_permissions, 128)
55
+ : null;
56
+ }
57
+ function noOutput() {
58
+ return { exitCode: 0, stdout: "", stderr: "" };
59
+ }
60
+ function secretDenial(harness, event) {
61
+ const reason = "agent-ops blocked a suspected secret.";
62
+ if (harness === "claude" && event === "UserPromptSubmit") {
63
+ return {
64
+ exitCode: 0,
65
+ stdout: JSON.stringify({ decision: "block", reason }),
66
+ stderr: ""
67
+ };
68
+ }
69
+ if (harness === "claude" && event === "PreToolUse") {
70
+ return {
71
+ exitCode: 0,
72
+ stdout: JSON.stringify({
73
+ hookSpecificOutput: {
74
+ hookEventName: "PreToolUse",
75
+ permissionDecision: "deny",
76
+ permissionDecisionReason: reason
77
+ }
78
+ }),
79
+ stderr: ""
80
+ };
81
+ }
82
+ return { exitCode: 2, stdout: "", stderr: reason };
83
+ }
84
+ function commandDenial(harness, event, code) {
85
+ const reason = "agent-ops blocked a dangerous command.";
86
+ if (harness === "claude" && event === "PreToolUse") {
87
+ return {
88
+ exitCode: 0,
89
+ stdout: JSON.stringify({
90
+ hookSpecificOutput: {
91
+ hookEventName: "PreToolUse",
92
+ permissionDecision: "deny",
93
+ permissionDecisionReason: reason
94
+ }
95
+ }),
96
+ stderr: ""
97
+ };
98
+ }
99
+ return {
100
+ exitCode: 2,
101
+ stdout: "",
102
+ stderr: `${reason} (${code})`
103
+ };
104
+ }
105
+ function evaluatePrompt(input, scope) {
106
+ const value = prompt(input);
107
+ if (value === null) {
108
+ return {
109
+ blocked: false,
110
+ outcome: "observed",
111
+ code: "prompt-unavailable",
112
+ denial: "none"
113
+ };
114
+ }
115
+ const decision = evaluateGuardrail({ kind: "content", content: value, scope });
116
+ return decision.action === "block"
117
+ ? {
118
+ blocked: true,
119
+ outcome: "blocked",
120
+ code: decision.ruleId,
121
+ denial: "secret"
122
+ }
123
+ : {
124
+ blocked: false,
125
+ outcome: "allowed",
126
+ code: "prompt-allowed",
127
+ denial: "none"
128
+ };
129
+ }
130
+ function evaluateBash(input, scope) {
131
+ const rawCommand = bashCommand(input);
132
+ if (rawCommand === null) {
133
+ return {
134
+ blocked: false,
135
+ outcome: "observed",
136
+ code: "command-unavailable",
137
+ denial: "none"
138
+ };
139
+ }
140
+ const secretDecision = evaluateGuardrail({
141
+ kind: "content",
142
+ content: rawCommand,
143
+ scope
144
+ });
145
+ if (secretDecision.action === "block") {
146
+ return {
147
+ blocked: true,
148
+ outcome: "blocked",
149
+ code: secretDecision.ruleId,
150
+ denial: "secret"
151
+ };
152
+ }
153
+ const event = normalizeShellHookEvent(rawCommand, scope);
154
+ const commands = event.event === "command"
155
+ ? [{ command: event.command, args: event.args }]
156
+ : event.event === "command-batch"
157
+ ? event.commands
158
+ : [];
159
+ for (const command of commands) {
160
+ const decision = evaluateGuardrail({
161
+ kind: "command",
162
+ command: command.command,
163
+ args: command.args,
164
+ scope
165
+ });
166
+ if (decision.action === "block") {
167
+ return {
168
+ blocked: true,
169
+ outcome: "blocked",
170
+ code: decision.ruleId,
171
+ denial: "command"
172
+ };
173
+ }
174
+ }
175
+ return {
176
+ blocked: false,
177
+ outcome: commands.length === 0 ? "observed" : "allowed",
178
+ code: commands.length === 0 ? "command-unavailable" : "command-allowed",
179
+ denial: "none"
180
+ };
181
+ }
182
+ function eventLogName(event) {
183
+ const names = {
184
+ SessionStart: "session-start",
185
+ UserPromptSubmit: "user-prompt-submit",
186
+ PreToolUse: "pre-tool-use",
187
+ PermissionRequest: "permission-request",
188
+ PostToolUse: "post-tool-use",
189
+ PreCompact: "pre-compact",
190
+ PostCompact: "post-compact",
191
+ SubagentStart: "subagent-start",
192
+ SubagentStop: "subagent-stop"
193
+ };
194
+ return names[event];
195
+ }
196
+ function boundedContext(value) {
197
+ const normalized = value.replace(/\r\n/g, "\n").trim();
198
+ return normalized.length <= MAX_CONTEXT_CHARS
199
+ ? normalized
200
+ : `${normalized.slice(0, MAX_CONTEXT_CHARS - 14)}\n[truncated]`;
201
+ }
202
+ function safeGoalContext(source) {
203
+ if (source === null || source.trim().length === 0) {
204
+ return "No project loop goal is recorded.";
205
+ }
206
+ const decision = evaluateGuardrail({
207
+ kind: "content",
208
+ content: source,
209
+ scope: "loop-goal.md"
210
+ });
211
+ if (decision.action === "block") {
212
+ return "The project loop goal contains sensitive-looking text and was omitted.";
213
+ }
214
+ return boundedContext(redactSecrets(source));
215
+ }
216
+ function isMissing(error) {
217
+ return (typeof error === "object" &&
218
+ error !== null &&
219
+ "code" in error &&
220
+ error.code === "ENOENT");
221
+ }
222
+ async function findLoopRoot(start, harness) {
223
+ let current;
224
+ try {
225
+ current = await realpath(resolve(start));
226
+ const status = await lstat(current);
227
+ if (!status.isDirectory() || status.isSymbolicLink()) {
228
+ return null;
229
+ }
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ while (true) {
235
+ try {
236
+ const harnessDirectory = await lstat(join(current, `.${harness}`));
237
+ if (harnessDirectory.isDirectory() && !harnessDirectory.isSymbolicLink()) {
238
+ return current;
239
+ }
240
+ }
241
+ catch (error) {
242
+ if (!isMissing(error)) {
243
+ return null;
244
+ }
245
+ }
246
+ const parent = dirname(current);
247
+ if (parent === current) {
248
+ return null;
249
+ }
250
+ current = parent;
251
+ }
252
+ }
253
+ async function resolveLoopRoot(input, fallback, harness) {
254
+ const candidates = [inputCwd(input), fallback].filter((value) => value !== null && value !== undefined);
255
+ for (const candidate of new Set(candidates)) {
256
+ const root = await findLoopRoot(candidate, harness);
257
+ if (root !== null) {
258
+ return root;
259
+ }
260
+ }
261
+ return null;
262
+ }
263
+ function loopPath(root, harness, name) {
264
+ return join(root, `.${harness}`, name);
265
+ }
266
+ async function appendTelemetry(options) {
267
+ const telemetry = loopPath(options.root, options.harness, "loop-telemetry.jsonl");
268
+ await appendLocalLog(telemetry, {
269
+ type: "loop-event",
270
+ event: eventLogName(options.event),
271
+ outcome: options.decision.outcome,
272
+ code: options.decision.code
273
+ }, {
274
+ anchorDirectory: options.root,
275
+ maxBytes: options.maxBytes ?? DEFAULT_TELEMETRY_MAX_BYTES,
276
+ ...(options.now === undefined ? {} : { now: options.now() })
277
+ });
278
+ }
279
+ async function telemetryCount(root, harness) {
280
+ const source = await readPrivateFile(loopPath(root, harness, "loop-telemetry.jsonl"), root);
281
+ if (source === null || Buffer.byteLength(source) > DEFAULT_TELEMETRY_MAX_BYTES) {
282
+ return 0;
283
+ }
284
+ let count = 0;
285
+ for (const line of source.split("\n")) {
286
+ if (line.length === 0) {
287
+ continue;
288
+ }
289
+ try {
290
+ const parsed = JSON.parse(line);
291
+ if (isRecord(parsed) && parsed.type === "loop-event") {
292
+ count += 1;
293
+ }
294
+ }
295
+ catch {
296
+ return 0;
297
+ }
298
+ }
299
+ return count;
300
+ }
301
+ async function defaultGitStatus(root) {
302
+ const result = await execFile("git", ["status", "--short", "--branch"], {
303
+ cwd: root,
304
+ encoding: "utf8",
305
+ maxBuffer: MAX_GIT_STATUS_CHARS + 1,
306
+ timeout: 2_000,
307
+ windowsHide: true
308
+ });
309
+ return result.stdout;
310
+ }
311
+ function boundedSnapshot(status) {
312
+ const decision = evaluateGuardrail({
313
+ kind: "content",
314
+ content: status,
315
+ scope: "git-status"
316
+ });
317
+ if (decision.action === "block") {
318
+ return "Sensitive-looking Git status text was omitted.";
319
+ }
320
+ const redacted = redactSecrets(status).replace(/\r\n/g, "\n").trim();
321
+ return redacted.length <= MAX_GIT_STATUS_CHARS
322
+ ? redacted || "Working tree is clean."
323
+ : `${redacted.slice(0, MAX_GIT_STATUS_CHARS - 14)}\n[truncated]`;
324
+ }
325
+ async function writeCompactSnapshot(options) {
326
+ const path = loopPath(options.root, options.harness, "loop-state.md");
327
+ const status = boundedSnapshot(await options.gitStatus(options.root));
328
+ await withPrivateFileLock(path, options.root, async () => {
329
+ const source = await readPrivateFile(path, options.root);
330
+ const baseline = source ?? "# Loop state\n";
331
+ const content = [
332
+ "Last compaction snapshot (bounded and redacted).",
333
+ `Captured: ${options.now}`,
334
+ "",
335
+ "## Git status",
336
+ status
337
+ ].join("\n");
338
+ await writePrivateFile(path, applyManagedBlock(baseline, {
339
+ id: LOOP_SNAPSHOT_ID,
340
+ version: 1,
341
+ content
342
+ }), options.root);
343
+ });
344
+ }
345
+ function sessionContext(goal, telemetryEntries) {
346
+ return boundedContext([
347
+ "agent-ops project loop is active.",
348
+ "",
349
+ "Current goal:",
350
+ goal,
351
+ "",
352
+ `Telemetry: ${telemetryEntries} recent redacted event(s).`
353
+ ].join("\n"));
354
+ }
355
+ function sessionOutput(harness, context) {
356
+ return {
357
+ exitCode: 0,
358
+ stdout: JSON.stringify({
359
+ hookSpecificOutput: {
360
+ hookEventName: "SessionStart",
361
+ additionalContext: context
362
+ }
363
+ }),
364
+ stderr: ""
365
+ };
366
+ }
367
+ /**
368
+ * Generic project-local loop policy. It deliberately reads only documented
369
+ * hook fields and records outcome identifiers, never prompts or command text.
370
+ */
371
+ export async function runProjectLoop(options) {
372
+ let decision = {
373
+ blocked: false,
374
+ outcome: "observed",
375
+ code: "loop-observed",
376
+ denial: "none"
377
+ };
378
+ const scope = inputCwd(options.input) ?? options.root ?? ".";
379
+ try {
380
+ if (options.event === "UserPromptSubmit") {
381
+ decision = evaluatePrompt(options.input, scope);
382
+ }
383
+ else if (options.event === "PreToolUse") {
384
+ decision = evaluateBash(options.input, scope);
385
+ }
386
+ else if (options.event === "PermissionRequest") {
387
+ decision = {
388
+ blocked: false,
389
+ outcome: "observed",
390
+ code: requestedSandboxPermission(options.input) === "require_escalated"
391
+ ? "permission-escalated"
392
+ : "permission-pending",
393
+ denial: "none"
394
+ };
395
+ }
396
+ }
397
+ catch {
398
+ return noOutput();
399
+ }
400
+ const root = await resolveLoopRoot(options.input, options.root, options.harness).catch(() => null);
401
+ if (root !== null) {
402
+ await appendTelemetry({
403
+ root,
404
+ harness: options.harness,
405
+ event: options.event,
406
+ decision,
407
+ ...(options.now === undefined ? {} : { now: options.now }),
408
+ ...(options.telemetryMaxBytes === undefined
409
+ ? {}
410
+ : { maxBytes: options.telemetryMaxBytes })
411
+ }).catch(() => undefined);
412
+ }
413
+ if (decision.blocked) {
414
+ return decision.denial === "secret"
415
+ ? secretDenial(options.harness, options.event)
416
+ : commandDenial(options.harness, options.event, decision.code);
417
+ }
418
+ if (options.event === "PreCompact" && root !== null) {
419
+ await writeCompactSnapshot({
420
+ root,
421
+ harness: options.harness,
422
+ now: options.now?.() ?? new Date().toISOString(),
423
+ gitStatus: options.gitStatus ?? defaultGitStatus
424
+ }).catch(() => undefined);
425
+ }
426
+ if (options.event === "SessionStart" && root !== null) {
427
+ try {
428
+ const [goal, telemetryEntries] = await Promise.all([
429
+ readPrivateFile(loopPath(root, options.harness, "loop-goal.md"), root),
430
+ telemetryCount(root, options.harness)
431
+ ]);
432
+ return sessionOutput(options.harness, sessionContext(safeGoalContext(goal), telemetryEntries));
433
+ }
434
+ catch {
435
+ return noOutput();
436
+ }
437
+ }
438
+ return noOutput();
439
+ }
@@ -0,0 +1,70 @@
1
+ import { calculateConfigHash } from "../config/hash.js";
2
+ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
3
+ import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
4
+ import { collectChangeSurface } from "../verify/change-surface.js";
5
+ import { selectVerificationScope } from "../verify/scope.js";
6
+ export const STOP_VERIFICATION_ENV = "AGENT_OPS_STOP_VERIFY_ACTIVE";
7
+ const HASH_PATTERN = /^[a-f0-9]{64}$/;
8
+ function stopError(code, message) {
9
+ return new AgentOpsError(code, message);
10
+ }
11
+ function commandById(config, commandId) {
12
+ const command = config.verification.commands.find((candidate) => candidate.id === commandId);
13
+ if (command === undefined) {
14
+ throw stopError("STOP_VERIFICATION_COMMAND_NOT_FOUND", `Stop verification command not found: ${commandId}`);
15
+ }
16
+ return command;
17
+ }
18
+ export class StopVerificationService {
19
+ #options;
20
+ constructor(options) {
21
+ this.#options = options;
22
+ }
23
+ #assertReady() {
24
+ if (!this.#options.config.features.stopVerification.enabled) {
25
+ throw stopError("STOP_VERIFICATION_DISABLED", "Stop verification is not enabled in configuration.");
26
+ }
27
+ if (this.#options.config.verification.commands.length === 0) {
28
+ throw stopError("STOP_VERIFICATION_COMMANDS_REQUIRED", "Stop verification requires at least one configured command.");
29
+ }
30
+ if (!this.#options.trusted) {
31
+ throw stopError("STOP_VERIFICATION_UNTRUSTED", "Stop verification requires a trusted repository.");
32
+ }
33
+ if (this.#options.recursionActive ||
34
+ process.env[STOP_VERIFICATION_ENV] === "1") {
35
+ throw stopError("STOP_VERIFICATION_RECURSION", "Stop verification recursion is active.");
36
+ }
37
+ if (!HASH_PATTERN.test(this.#options.configHash) ||
38
+ calculateConfigHash(this.#options.config) !== this.#options.configHash) {
39
+ throw stopError("STOP_VERIFICATION_UNCONFIRMED", "Stop verification configuration is stale or unconfirmed.");
40
+ }
41
+ }
42
+ async verify() {
43
+ this.#assertReady();
44
+ const surface = await collectChangeSurface(this.#options.gitRunner);
45
+ const selection = selectVerificationScope(surface.paths, this.#options.config);
46
+ const executions = [];
47
+ for (const commandId of selection.verifierIds) {
48
+ const command = commandById(this.#options.config, commandId);
49
+ const cwd = command.cwd === "."
50
+ ? this.#options.root
51
+ : await resolveContainedPath(this.#options.root, command.cwd);
52
+ const result = await executeConfiguredCommand(command, {
53
+ cwd,
54
+ runner: this.#options.processRunner,
55
+ trusted: true,
56
+ env: { [STOP_VERIFICATION_ENV]: "1" }
57
+ });
58
+ executions.push(result);
59
+ }
60
+ const results = executions.map(({ commandId, exitCode, testCount }) => ({
61
+ commandId,
62
+ exitCode,
63
+ testCount
64
+ }));
65
+ return {
66
+ status: aggregateVerificationStatus(executions),
67
+ results
68
+ };
69
+ }
70
+ }
@@ -16,11 +16,14 @@ function boundedEvidence(results) {
16
16
  (!Number.isSafeInteger(result.testCount) || result.testCount < 0)))) {
17
17
  return null;
18
18
  }
19
- return results.map(({ commandId, exitCode, testCount }) => ({
19
+ const bounded = results.map(({ commandId, exitCode, testCount }) => ({
20
20
  commandId,
21
21
  exitCode,
22
22
  testCount
23
23
  }));
24
+ return Buffer.byteLength(JSON.stringify(bounded), "utf8") <= 4096
25
+ ? bounded
26
+ : null;
24
27
  }
25
28
  function evidenceSupportsStatus(status, results) {
26
29
  if (status !== "PASS") {
@@ -0,0 +1,139 @@
1
+ import { AgentOpsError } from "../fs/paths.js";
2
+ export const LOOP_MARKER_ID = "loop-state";
3
+ export const LOOP_MARKER_VERSION = 1;
4
+ const LOOP_HARNESSES = new Set(["claude", "codex"]);
5
+ const CODEX_CONFIG_SEED = [
6
+ "# Created by agent-ops for the project-local loop.",
7
+ "# This file remains user-owned after installation.",
8
+ "[features]",
9
+ "hooks = true",
10
+ ""
11
+ ].join("\n");
12
+ const GOAL_SEED = [
13
+ "# Current goal",
14
+ "",
15
+ "Describe the current objective, acceptance criteria, and important constraints.",
16
+ ""
17
+ ].join("\n");
18
+ const STATE_SEED = [
19
+ "# Loop state",
20
+ "",
21
+ "Status: idle",
22
+ ""
23
+ ].join("\n");
24
+ function isLoopHarness(value) {
25
+ return LOOP_HARNESSES.has(value);
26
+ }
27
+ export function selectedLoopHarnesses(harnesses) {
28
+ return harnesses.filter(isLoopHarness);
29
+ }
30
+ function loopRoot(harness) {
31
+ return `.${harness}`;
32
+ }
33
+ export function loopLauncherPath(harness) {
34
+ return `${loopRoot(harness)}/hooks/agent-ops-loop.sh`;
35
+ }
36
+ export function loopLauncherArtifactId(harness) {
37
+ return `${harness}-loop-launcher`;
38
+ }
39
+ function assertRuntimePath(runtimePath) {
40
+ if (runtimePath.length === 0 ||
41
+ runtimePath.length > 4096 ||
42
+ /[\0\r\n]/u.test(runtimePath) ||
43
+ !runtimePath.endsWith("hook-entry.js")) {
44
+ throw new AgentOpsError("LOOP_RUNTIME_PATH_INVALID", "The loop runtime path must name a safe hook-entry.js file.");
45
+ }
46
+ }
47
+ function shellQuote(value) {
48
+ return `'${value.replaceAll("'", "'\"'\"'")}'`;
49
+ }
50
+ function loopEntryPath(hookRuntimePath) {
51
+ assertRuntimePath(hookRuntimePath);
52
+ return `${hookRuntimePath.slice(0, -"hook-entry.js".length)}loop-entry.js`;
53
+ }
54
+ export function buildLoopLauncher(harness, hookRuntimePath) {
55
+ const runtimePath = loopEntryPath(hookRuntimePath);
56
+ return [
57
+ "#!/usr/bin/env bash",
58
+ `# agent-ops: generated ${harness} loop v1`,
59
+ "set -uo pipefail",
60
+ `exec node ${shellQuote(runtimePath)} ${harness} "$@"`,
61
+ ""
62
+ ].join("\n");
63
+ }
64
+ function statePaths(harness) {
65
+ const root = loopRoot(harness);
66
+ return [
67
+ `${root}/loop-goal.md`,
68
+ `${root}/loop-state.md`,
69
+ `${root}/loop-telemetry.jsonl`
70
+ ];
71
+ }
72
+ export function loopSeeds(harnesses) {
73
+ const seeds = [];
74
+ for (const harness of selectedLoopHarnesses(harnesses)) {
75
+ const root = loopRoot(harness);
76
+ if (harness === "codex") {
77
+ seeds.push({ path: `${root}/config.toml`, content: CODEX_CONFIG_SEED });
78
+ }
79
+ seeds.push({ path: `${root}/loop-goal.md`, content: GOAL_SEED }, { path: `${root}/loop-state.md`, content: STATE_SEED }, { path: `${root}/loop-telemetry.jsonl`, content: "" });
80
+ }
81
+ return seeds;
82
+ }
83
+ export function loopIgnoreContent(harnesses) {
84
+ return selectedLoopHarnesses(harnesses)
85
+ .flatMap((harness) => statePaths(harness))
86
+ .join("\n");
87
+ }
88
+ /**
89
+ * This is intentionally narrower than a TOML parser: only an unambiguous
90
+ * boolean assignment inside the exact [features] table is a conflict. Other
91
+ * configuration remains user-owned and is never normalized or rewritten.
92
+ */
93
+ export function codexHooksExplicitlyDisabled(source) {
94
+ let inFeatures = false;
95
+ for (const rawLine of source.split(/\r?\n/u)) {
96
+ const line = rawLine.trim();
97
+ if (line.length === 0 || line.startsWith("#")) {
98
+ continue;
99
+ }
100
+ const section = /^\[([^\]]+)\]\s*(?:#.*)?$/u.exec(line);
101
+ if (section !== null) {
102
+ inFeatures = section[1] === "features";
103
+ continue;
104
+ }
105
+ if (inFeatures &&
106
+ /^hooks\s*=\s*false\s*(?:#.*)?$/u.test(line)) {
107
+ return true;
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+ export function planLoopContribution(options) {
113
+ if (!options.capabilities.includes("project-loop")) {
114
+ return { artifacts: [], blocks: [] };
115
+ }
116
+ const harnesses = selectedLoopHarnesses(options.harnesses);
117
+ if (options.scope !== "project" || harnesses.length === 0) {
118
+ throw new AgentOpsError("LOOP_PROFILE_UNSUPPORTED", "The loop profile requires project scope and the Codex or Claude harness.");
119
+ }
120
+ if (options.hookRuntimePath === undefined) {
121
+ throw new AgentOpsError("LOOP_RUNTIME_REQUIRED", "The loop profile requires the installed hook runtime path.");
122
+ }
123
+ return {
124
+ artifacts: harnesses.map((harness) => ({
125
+ id: loopLauncherArtifactId(harness),
126
+ path: loopLauncherPath(harness),
127
+ content: buildLoopLauncher(harness, options.hookRuntimePath ?? "")
128
+ })),
129
+ blocks: [
130
+ {
131
+ id: LOOP_MARKER_ID,
132
+ path: ".gitignore",
133
+ version: LOOP_MARKER_VERSION,
134
+ markerStyle: "hash",
135
+ content: loopIgnoreContent(harnesses)
136
+ }
137
+ ]
138
+ };
139
+ }