@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.
Files changed (36) hide show
  1. package/README.md +82 -6
  2. package/dist/packages/cli/src/args.js +1 -1
  3. package/dist/packages/cli/src/bin.js +2 -0
  4. package/dist/packages/cli/src/cli.js +1 -1
  5. package/dist/packages/cli/src/codex-loop-process.js +70 -0
  6. package/dist/packages/cli/src/commands/hook.js +16 -1
  7. package/dist/packages/cli/src/commands/update.js +3 -0
  8. package/dist/packages/cli/src/context.js +60 -0
  9. package/dist/packages/cli/src/hook-process.js +128 -15
  10. package/dist/packages/cli/src/loop-entry.js +8 -0
  11. package/dist/packages/cli/src/version.js +1 -1
  12. package/dist/packages/cli/src/wizard.js +9 -4
  13. package/dist/runtime/src/adapters/claude/config.js +57 -11
  14. package/dist/runtime/src/adapters/claude/events.js +7 -0
  15. package/dist/runtime/src/adapters/claude/output.js +2 -1
  16. package/dist/runtime/src/adapters/codex/config.js +39 -4
  17. package/dist/runtime/src/adapters/codex/events.js +7 -0
  18. package/dist/runtime/src/fs/managed-block.js +35 -18
  19. package/dist/runtime/src/hooks/codex-loop.js +439 -0
  20. package/dist/runtime/src/install/codex-loop.js +139 -0
  21. package/dist/runtime/src/install/doctor.js +66 -8
  22. package/dist/runtime/src/install/harness.js +8 -10
  23. package/dist/runtime/src/install/ownership.js +37 -2
  24. package/dist/runtime/src/install/plan.js +70 -4
  25. package/dist/runtime/src/install/profiles.js +5 -3
  26. package/dist/runtime/src/install/uninstall.js +1 -1
  27. package/dist/runtime/src/install/update.js +5 -1
  28. package/dist/runtime/src/logging/local-log.js +25 -0
  29. package/dist/runtime/src/schema/validate.js +8 -1
  30. package/docs/en/guides/configuration.md +78 -2
  31. package/docs/en/spec/harness-adapters.md +50 -12
  32. package/docs/zh-TW/guides/configuration.md +73 -5
  33. package/docs/zh-TW/spec/harness-adapters.md +44 -12
  34. package/package.json +1 -1
  35. package/schemas/config.schema.json +1 -1
  36. 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 check("artifacts", "FAIL", "Artifacts cannot be verified without a valid manifest.");
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
- if (sha256(content) !== artifact.hash ||
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 failures.length === 0
144
- ? check("artifacts", "PASS", "All managed artifacts match their hashes.")
145
- : check("artifacts", "FAIL", `Managed artifacts failed verification: ${failures.join(", ")}.`);
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
- await checkArtifacts(options.root, manifest.manifest),
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),