@kylecheng3146/agent-ops 0.1.5 → 0.1.7
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 +104 -6
- package/dist/packages/cli/src/args.js +33 -1
- package/dist/packages/cli/src/bin.js +40 -3
- package/dist/packages/cli/src/cli.js +13 -2
- 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/init.js +4 -1
- package/dist/packages/cli/src/commands/review.js +97 -10
- 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 +71 -7
- 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/config/merge.js +17 -2
- 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 +108 -9
- 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 +81 -9
- 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/review/execute.js +120 -0
- package/dist/runtime/src/review/extract.js +71 -0
- package/dist/runtime/src/review/invocation.js +52 -0
- package/dist/runtime/src/review/probe.js +48 -0
- package/dist/runtime/src/review/result.js +2 -2
- package/dist/runtime/src/review/roles.js +35 -0
- package/dist/runtime/src/review/runner.js +38 -4
- package/dist/runtime/src/schema/validate.js +70 -1
- package/dist/runtime/src/task/service.js +40 -0
- package/docs/en/guides/configuration.md +138 -2
- package/docs/en/spec/harness-adapters.md +50 -12
- package/docs/en/spec/review.md +37 -4
- package/docs/zh-TW/guides/configuration.md +126 -5
- package/docs/zh-TW/spec/harness-adapters.md +44 -12
- package/docs/zh-TW/spec/review.md +33 -3
- package/package.json +1 -1
- package/schemas/config.schema.json +30 -1
- package/schemas/manifest.schema.json +12 -1
|
@@ -7,37 +7,54 @@ function assertBlockId(id) {
|
|
|
7
7
|
throw new AgentOpsError("INVALID_BLOCK_ID", `Invalid block ID: ${id}`);
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
|
-
function assertExactMarkerSyntax(source) {
|
|
11
|
-
const validMarker =
|
|
10
|
+
function assertExactMarkerSyntax(source, markerStyle) {
|
|
11
|
+
const validMarker = markerStyle === "html"
|
|
12
|
+
? /^<!-- agent-ops:(?:start [a-z][a-z0-9-]{0,127} v[1-9][0-9]*|end [a-z][a-z0-9-]{0,127}) -->$/
|
|
13
|
+
: /^# agent-ops:(?:start [a-z][a-z0-9-]{0,127} v[1-9][0-9]*|end [a-z][a-z0-9-]{0,127})$/;
|
|
14
|
+
const markerPrefix = markerStyle === "html"
|
|
15
|
+
? /<!--\s*agent-ops:/
|
|
16
|
+
: /#\s*agent-ops:/;
|
|
12
17
|
for (const line of source.split(/\r?\n/)) {
|
|
13
|
-
if (
|
|
18
|
+
if (markerPrefix.test(line) && !validMarker.test(line)) {
|
|
14
19
|
throw new AgentOpsError("MALFORMED_MANAGED_BLOCK", "Managed block markers must use the exact agent-ops marker syntax.");
|
|
15
20
|
}
|
|
16
21
|
}
|
|
17
22
|
}
|
|
18
|
-
export function managedBlockMarkers(id, version) {
|
|
23
|
+
export function managedBlockMarkers(id, version, markerStyle = "html") {
|
|
19
24
|
assertBlockId(id);
|
|
20
25
|
if (!Number.isSafeInteger(version) || version < 1) {
|
|
21
26
|
throw new AgentOpsError("INVALID_BLOCK_VERSION", `Invalid block version: ${version}`);
|
|
22
27
|
}
|
|
23
|
-
return
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
return markerStyle === "html"
|
|
29
|
+
? {
|
|
30
|
+
start: `<!-- agent-ops:start ${id} v${version} -->`,
|
|
31
|
+
end: `<!-- agent-ops:end ${id} -->`
|
|
32
|
+
}
|
|
33
|
+
: {
|
|
34
|
+
start: `# agent-ops:start ${id} v${version}`,
|
|
35
|
+
end: `# agent-ops:end ${id}`
|
|
36
|
+
};
|
|
27
37
|
}
|
|
28
|
-
function locateMarkers(source, id, version) {
|
|
38
|
+
function locateMarkers(source, id, version, markerStyle = "html") {
|
|
29
39
|
assertBlockId(id);
|
|
30
|
-
assertExactMarkerSyntax(source);
|
|
40
|
+
assertExactMarkerSyntax(source, markerStyle);
|
|
31
41
|
const escapedId = escapeRegExp(id);
|
|
42
|
+
const startPattern = markerStyle === "html"
|
|
43
|
+
? `<!-- agent-ops:start ${escapedId} v[0-9]+ -->`
|
|
44
|
+
: `# agent-ops:start ${escapedId} v[0-9]+`;
|
|
45
|
+
const end = markerStyle === "html"
|
|
46
|
+
? `<!-- agent-ops:end ${id} -->`
|
|
47
|
+
: `# agent-ops:end ${id}`;
|
|
32
48
|
const startMatches = [
|
|
33
|
-
...source.matchAll(new RegExp(
|
|
49
|
+
...source.matchAll(new RegExp(startPattern, "g"))
|
|
34
50
|
];
|
|
35
|
-
const end = `<!-- agent-ops:end ${id} -->`;
|
|
36
51
|
const endMatches = [...source.matchAll(new RegExp(escapeRegExp(end), "g"))];
|
|
37
52
|
if (startMatches.length === 0 && endMatches.length === 0) {
|
|
38
53
|
return null;
|
|
39
54
|
}
|
|
40
|
-
const expectedStart = version === undefined
|
|
55
|
+
const expectedStart = version === undefined
|
|
56
|
+
? startMatches[0]?.[0]
|
|
57
|
+
: managedBlockMarkers(id, version, markerStyle).start;
|
|
41
58
|
const start = startMatches[0]?.[0];
|
|
42
59
|
const startIndex = startMatches[0]?.index;
|
|
43
60
|
const endIndex = endMatches[0]?.index;
|
|
@@ -54,16 +71,16 @@ function locateMarkers(source, id, version) {
|
|
|
54
71
|
return { start, end, startIndex, endIndex };
|
|
55
72
|
}
|
|
56
73
|
function renderBlock(options) {
|
|
57
|
-
if (
|
|
74
|
+
if (/(?:<!--|#)\s*agent-ops:/.test(options.content)) {
|
|
58
75
|
throw new AgentOpsError("AMBIGUOUS_MANAGED_CONTENT", "Managed content must not contain agent-ops marker boundaries.");
|
|
59
76
|
}
|
|
60
|
-
const { start, end } = managedBlockMarkers(options.id, options.version);
|
|
77
|
+
const { start, end } = managedBlockMarkers(options.id, options.version, options.markerStyle);
|
|
61
78
|
const content = options.content.replace(/\r\n/g, "\n").replace(/\n+$/g, "");
|
|
62
79
|
return `${start}\n${content}\n${end}`;
|
|
63
80
|
}
|
|
64
81
|
export function applyManagedBlock(source, options) {
|
|
65
82
|
const block = renderBlock(options);
|
|
66
|
-
const located = locateMarkers(source, options.id, options.version);
|
|
83
|
+
const located = locateMarkers(source, options.id, options.version, options.markerStyle);
|
|
67
84
|
if (located === null) {
|
|
68
85
|
if (source.length === 0) {
|
|
69
86
|
return `${block}\n`;
|
|
@@ -72,8 +89,8 @@ export function applyManagedBlock(source, options) {
|
|
|
72
89
|
}
|
|
73
90
|
return `${source.slice(0, located.startIndex)}${block}${source.slice(located.endIndex + located.end.length)}`;
|
|
74
91
|
}
|
|
75
|
-
export function removeManagedBlock(source, id) {
|
|
76
|
-
const located = locateMarkers(source, id);
|
|
92
|
+
export function removeManagedBlock(source, id, markerStyle = "html") {
|
|
93
|
+
const located = locateMarkers(source, id, undefined, markerStyle);
|
|
77
94
|
if (located === null) {
|
|
78
95
|
return source;
|
|
79
96
|
}
|
|
@@ -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
|
+
}
|