@kylecheng3146/agent-ops 0.1.5 → 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.
- package/README.md +82 -6
- package/dist/packages/cli/src/args.js +1 -1
- package/dist/packages/cli/src/bin.js +2 -0
- package/dist/packages/cli/src/cli.js +1 -1
- package/dist/packages/cli/src/codex-loop-process.js +70 -0
- package/dist/packages/cli/src/commands/hook.js +16 -1
- package/dist/packages/cli/src/commands/update.js +3 -0
- package/dist/packages/cli/src/context.js +60 -0
- package/dist/packages/cli/src/hook-process.js +128 -15
- package/dist/packages/cli/src/loop-entry.js +8 -0
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/wizard.js +9 -4
- package/dist/runtime/src/adapters/claude/config.js +57 -11
- package/dist/runtime/src/adapters/claude/events.js +7 -0
- package/dist/runtime/src/adapters/claude/output.js +2 -1
- package/dist/runtime/src/adapters/codex/config.js +39 -4
- package/dist/runtime/src/adapters/codex/events.js +7 -0
- package/dist/runtime/src/fs/managed-block.js +35 -18
- package/dist/runtime/src/hooks/codex-loop.js +439 -0
- package/dist/runtime/src/install/codex-loop.js +139 -0
- package/dist/runtime/src/install/doctor.js +66 -8
- package/dist/runtime/src/install/harness.js +8 -10
- package/dist/runtime/src/install/ownership.js +37 -2
- package/dist/runtime/src/install/plan.js +70 -4
- package/dist/runtime/src/install/profiles.js +5 -3
- package/dist/runtime/src/install/uninstall.js +1 -1
- package/dist/runtime/src/install/update.js +5 -1
- package/dist/runtime/src/logging/local-log.js +25 -0
- package/dist/runtime/src/schema/validate.js +8 -1
- package/docs/en/guides/configuration.md +78 -2
- package/docs/en/spec/harness-adapters.md +50 -12
- package/docs/zh-TW/guides/configuration.md +73 -5
- package/docs/zh-TW/spec/harness-adapters.md +44 -12
- package/package.json +1 -1
- package/schemas/config.schema.json +1 -1
- package/schemas/manifest.schema.json +12 -1
|
@@ -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,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
|
+
}
|
|
@@ -6,8 +6,8 @@ import { resolveContainedPath } from "../fs/paths.js";
|
|
|
6
6
|
import { validateConfig } from "../schema/validate.js";
|
|
7
7
|
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
8
8
|
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
9
|
-
import { harnessDescriptor } from "./harness.js";
|
|
10
|
-
import { resolveProfiles } from "./profiles.js";
|
|
9
|
+
import { harnessDescriptor, managedRules } from "./harness.js";
|
|
10
|
+
import { resolveCapabilities, resolveProfiles } from "./profiles.js";
|
|
11
11
|
import { inspectHarnessSurfaces, inspectHarnessRegistrations } from "./surface-inspection.js";
|
|
12
12
|
const CONFIG_PATH = ".agent-ops/config.json";
|
|
13
13
|
const MINIMUM_NODE_VERSION = [22, 14, 0];
|
|
@@ -124,25 +124,81 @@ async function checkConfig(root) {
|
|
|
124
124
|
}
|
|
125
125
|
async function checkArtifacts(root, manifest) {
|
|
126
126
|
if (manifest === undefined) {
|
|
127
|
-
return
|
|
127
|
+
return {
|
|
128
|
+
check: check("artifacts", "FAIL", "Artifacts cannot be verified without a valid manifest."),
|
|
129
|
+
hashesByPath: new Map()
|
|
130
|
+
};
|
|
128
131
|
}
|
|
129
132
|
const failures = [];
|
|
133
|
+
const hashesByPath = new Map();
|
|
130
134
|
for (const artifact of manifest.artifacts) {
|
|
131
135
|
try {
|
|
132
136
|
const content = await readContained(root, artifact.path);
|
|
133
|
-
|
|
137
|
+
const hash = sha256(content);
|
|
138
|
+
if (hash !== artifact.hash ||
|
|
134
139
|
(artifact.id === "opencode-plugin" &&
|
|
135
140
|
!isOpencodeManagedPlugin(content.toString("utf8")))) {
|
|
136
141
|
failures.push(artifact.path);
|
|
137
142
|
}
|
|
143
|
+
else {
|
|
144
|
+
hashesByPath.set(artifact.path, hash);
|
|
145
|
+
}
|
|
138
146
|
}
|
|
139
147
|
catch {
|
|
140
148
|
failures.push(artifact.path);
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
return {
|
|
152
|
+
check: failures.length === 0
|
|
153
|
+
? check("artifacts", "PASS", "All managed artifacts match their hashes.")
|
|
154
|
+
: check("artifacts", "FAIL", `Managed artifacts failed verification: ${failures.join(", ")}.`),
|
|
155
|
+
hashesByPath
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function checkArtifactStaleness(manifest, config, artifacts, toolkitVersion) {
|
|
159
|
+
if (manifest === undefined || config === undefined) {
|
|
160
|
+
return check("artifact-staleness", "UNKNOWN", "Managed artifact staleness cannot be assessed without a valid manifest and configuration.");
|
|
161
|
+
}
|
|
162
|
+
if (artifacts.check.status !== "PASS") {
|
|
163
|
+
return check("artifact-staleness", "UNKNOWN", "Managed artifact staleness cannot be assessed until artifact integrity passes.");
|
|
164
|
+
}
|
|
165
|
+
if (toolkitVersion === undefined) {
|
|
166
|
+
return check("artifact-staleness", "UNKNOWN", "Managed artifact staleness cannot be assessed without the running toolkit version.");
|
|
167
|
+
}
|
|
168
|
+
const expectedHashesByPath = new Map();
|
|
169
|
+
try {
|
|
170
|
+
const resolved = config.profiles.length === 0
|
|
171
|
+
? { profiles: [], capabilities: [] }
|
|
172
|
+
: resolveCapabilities(config);
|
|
173
|
+
for (const id of manifest.harness) {
|
|
174
|
+
const descriptor = harnessDescriptor(id);
|
|
175
|
+
const path = `.agent-ops/${descriptor.control.instructionFile}`;
|
|
176
|
+
if (!expectedHashesByPath.has(path)) {
|
|
177
|
+
expectedHashesByPath.set(path, sha256(managedRules(descriptor, {
|
|
178
|
+
scope: manifest.scope,
|
|
179
|
+
profiles: resolved.profiles,
|
|
180
|
+
capabilities: resolved.capabilities,
|
|
181
|
+
toolkitVersion
|
|
182
|
+
})));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return check("artifact-staleness", "UNKNOWN", "Managed artifact staleness could not be assessed safely.");
|
|
188
|
+
}
|
|
189
|
+
const stalePaths = [];
|
|
190
|
+
for (const [path, expectedHash] of expectedHashesByPath) {
|
|
191
|
+
const actualHash = artifacts.hashesByPath.get(path);
|
|
192
|
+
if (actualHash === undefined) {
|
|
193
|
+
return check("artifact-staleness", "UNKNOWN", "Managed artifact staleness could not be assessed safely.");
|
|
194
|
+
}
|
|
195
|
+
if (actualHash !== expectedHash) {
|
|
196
|
+
stalePaths.push(path);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return stalePaths.length === 0
|
|
200
|
+
? check("artifact-staleness", "PASS", "Managed artifacts match the current toolkit and configuration.")
|
|
201
|
+
: check("artifact-staleness", "DEGRADED", `Managed artifacts need update: ${stalePaths.join(", ")}; run agent-ops update.`, "UPDATE_REQUIRED");
|
|
146
202
|
}
|
|
147
203
|
async function checkMarkers(root, manifest) {
|
|
148
204
|
if (manifest === undefined) {
|
|
@@ -285,12 +341,14 @@ async function checkRegistrationDrift(root, manifest, config) {
|
|
|
285
341
|
export async function doctorInstallation(options) {
|
|
286
342
|
const manifest = await checkManifest(options.root);
|
|
287
343
|
const config = await checkConfig(options.root);
|
|
344
|
+
const artifacts = await checkArtifacts(options.root, manifest.manifest);
|
|
288
345
|
const surfaceInventory = await checkSurfaceInventory(options.root, manifest.manifest, config.config);
|
|
289
346
|
const checks = [
|
|
290
347
|
checkNodeVersion(options.nodeVersion ?? process.versions.node),
|
|
291
348
|
manifest.check,
|
|
292
349
|
config.check,
|
|
293
|
-
|
|
350
|
+
artifacts.check,
|
|
351
|
+
checkArtifactStaleness(manifest.manifest, config.config, artifacts, options.toolkitVersion),
|
|
294
352
|
await checkMarkers(options.root, manifest.manifest),
|
|
295
353
|
surfaceInventory.check,
|
|
296
354
|
await checkRegistrationDrift(options.root, manifest.manifest, config.config),
|