holo-codex 0.1.0 → 0.1.2
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/CHANGELOG.md +21 -0
- package/README.md +141 -75
- package/README.zh-CN.md +141 -75
- package/docs/release-checklist.md +39 -9
- package/package.json +4 -2
- package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +1 -1
- package/plugins/autonomous-pr-loop/.mcp.json +2 -4
- package/plugins/autonomous-pr-loop/core/cli.ts +89 -1
- package/plugins/autonomous-pr-loop/core/doctor.ts +35 -11
- package/plugins/autonomous-pr-loop/core/hook-diagnostics.ts +153 -0
- package/plugins/autonomous-pr-loop/core/hook-policy.ts +230 -23
- package/plugins/autonomous-pr-loop/core/local-install.ts +13 -19
- package/plugins/autonomous-pr-loop/core/storage.ts +5 -5
- package/plugins/autonomous-pr-loop/core/types.ts +2 -0
- package/plugins/autonomous-pr-loop/core/workflow-board.ts +78 -14
- package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +191 -27
- package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/stop.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/hooks.json +1 -104
- package/plugins/autonomous-pr-loop/mcp-server/dist/index.js +10551 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +6 -3
- package/plugins/autonomous-pr-loop/package.json +1 -1
|
@@ -19,6 +19,7 @@ export interface HookPolicyInput {
|
|
|
19
19
|
isWorker?: boolean;
|
|
20
20
|
protectedPaths?: string[];
|
|
21
21
|
storage?: AgentLoopStorage;
|
|
22
|
+
runId?: string;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface HookPolicyDecision {
|
|
@@ -28,8 +29,19 @@ export interface HookPolicyDecision {
|
|
|
28
29
|
blockedCommand: string;
|
|
29
30
|
nextAction: string;
|
|
30
31
|
reason: string;
|
|
32
|
+
auditDetails?: Record<string, unknown>;
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
type MaintainerOverrideScope = "publish" | "merge";
|
|
36
|
+
|
|
37
|
+
interface ActiveMaintainerOverride {
|
|
38
|
+
decisionId: string;
|
|
39
|
+
scope: MaintainerOverrideScope;
|
|
40
|
+
expiresAt: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const REQUIRED_PUBLISH_EVIDENCE_SUBSTAGES = ["lint", "full_tests", "gitnexus_detect"] as const;
|
|
44
|
+
|
|
33
45
|
/** Normalize a Codex PreToolUse hook payload into an argv-like command. */
|
|
34
46
|
export function commandFromHookPayload(payload: unknown): HookCommand | undefined {
|
|
35
47
|
if (!isRecord(payload)) {
|
|
@@ -50,7 +62,12 @@ export function commandFromHookPayload(payload: unknown): HookCommand | undefine
|
|
|
50
62
|
|
|
51
63
|
/** Evaluate a hook command without spawning subprocesses. */
|
|
52
64
|
export function evaluateHookPolicy(input: HookPolicyInput): HookPolicyDecision {
|
|
53
|
-
const
|
|
65
|
+
const normalized = normalizeCommand(input.command);
|
|
66
|
+
const shellControl = shellControlPolicy(normalized);
|
|
67
|
+
if (shellControl) {
|
|
68
|
+
return deny(renderCommand(normalized), shellControl, "policy_violation", "Run one allowlisted command at a time without shell control operators.");
|
|
69
|
+
}
|
|
70
|
+
const command = unwrapCommand(normalized);
|
|
54
71
|
const blockedCommand = renderCommand(command);
|
|
55
72
|
const destructive = destructivePolicy(command);
|
|
56
73
|
if (destructive) {
|
|
@@ -67,10 +84,25 @@ export function evaluateHookPolicy(input: HookPolicyInput): HookPolicyDecision {
|
|
|
67
84
|
if (protectedPath) {
|
|
68
85
|
return deny(blockedCommand, protectedPath, "policy_violation", "Remove protected path changes from the command.");
|
|
69
86
|
}
|
|
70
|
-
const gate = gatedLifecyclePolicy(command, input.storage);
|
|
87
|
+
const gate = gatedLifecyclePolicy(command, input.storage, input.runId);
|
|
71
88
|
if (gate) {
|
|
72
89
|
return deny(blockedCommand, gate.policy, gate.gate, gate.nextAction);
|
|
73
90
|
}
|
|
91
|
+
const override = activeMaintainerOverride(input.storage, lifecycleOverrideScope(command), input.runId);
|
|
92
|
+
if (override && matchesHookAllowlist(command)) {
|
|
93
|
+
return {
|
|
94
|
+
allow: true,
|
|
95
|
+
matchedPolicy: `maintainer_override:${override.scope}`,
|
|
96
|
+
blockedCommand,
|
|
97
|
+
nextAction: "Continue.",
|
|
98
|
+
reason: `Maintainer override ${override.decisionId} allows ${blockedCommand} until ${override.expiresAt}.`,
|
|
99
|
+
auditDetails: {
|
|
100
|
+
overrideDecisionId: override.decisionId,
|
|
101
|
+
overrideScope: override.scope,
|
|
102
|
+
overrideExpiresAt: override.expiresAt
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
74
106
|
if (!matchesHookAllowlist(command)) {
|
|
75
107
|
return deny(blockedCommand, "command_not_in_hook_allowlist", "policy_violation", "Use agent-loop MCP/CLI control surfaces or an allowlisted read/check command.");
|
|
76
108
|
}
|
|
@@ -123,7 +155,13 @@ export function evaluatePreToolUseHook(payload: unknown, repoRoot?: string): Hoo
|
|
|
123
155
|
try {
|
|
124
156
|
const config = loadConfig(route.binding.repoRoot).config;
|
|
125
157
|
storage = new SqliteAgentLoopStorage(statePath(route.binding.repoRoot));
|
|
126
|
-
const decision = evaluateHookPolicy({
|
|
158
|
+
const decision = evaluateHookPolicy({
|
|
159
|
+
repoRoot: route.binding.repoRoot,
|
|
160
|
+
command,
|
|
161
|
+
storage,
|
|
162
|
+
...(route.binding.runId ? { runId: route.binding.runId } : {}),
|
|
163
|
+
protectedPaths: config.protectedPaths
|
|
164
|
+
});
|
|
127
165
|
recordHookDecision(storage, decision, route.binding.runId);
|
|
128
166
|
return decision;
|
|
129
167
|
} catch (error) {
|
|
@@ -147,10 +185,8 @@ export function toCodexHookResponse(decision: HookPolicyDecision): Record<string
|
|
|
147
185
|
return { continue: true };
|
|
148
186
|
}
|
|
149
187
|
return {
|
|
150
|
-
decision: "
|
|
151
|
-
|
|
152
|
-
continue: false,
|
|
153
|
-
stopReason: decision.reason,
|
|
188
|
+
decision: "block",
|
|
189
|
+
reason: decision.reason,
|
|
154
190
|
systemMessage: formatHookMessage(decision)
|
|
155
191
|
};
|
|
156
192
|
}
|
|
@@ -166,6 +202,7 @@ function recordHookDecision(storage: AgentLoopStorage, decision: HookPolicyDecis
|
|
|
166
202
|
allow: decision.allow,
|
|
167
203
|
matchedPolicy: decision.matchedPolicy,
|
|
168
204
|
...(decision.gate ? { gate: decision.gate } : {}),
|
|
205
|
+
...(decision.auditDetails ? { auditDetails: decision.auditDetails } : {}),
|
|
169
206
|
nextAction: decision.nextAction,
|
|
170
207
|
commandLength: command.length,
|
|
171
208
|
commandSha256: createHash("sha256").update(command).digest("hex"),
|
|
@@ -175,9 +212,11 @@ function recordHookDecision(storage: AgentLoopStorage, decision: HookPolicyDecis
|
|
|
175
212
|
}
|
|
176
213
|
|
|
177
214
|
function routeErrorDecision(command: HookCommand, reason: string): HookPolicyDecision {
|
|
178
|
-
const
|
|
215
|
+
const baseCommand = normalizeCommand(command);
|
|
216
|
+
const shellControl = shellControlPolicy(baseCommand);
|
|
217
|
+
const normalized = shellControl ? baseCommand : unwrapCommand(baseCommand);
|
|
179
218
|
const blockedCommand = renderCommand(normalized);
|
|
180
|
-
const destructive = destructivePolicy(normalized);
|
|
219
|
+
const destructive = shellControl ?? destructivePolicy(normalized);
|
|
181
220
|
if (destructive || lifecycleCommand(normalized)) {
|
|
182
221
|
return deny(
|
|
183
222
|
blockedCommand,
|
|
@@ -196,9 +235,11 @@ function routeErrorDecision(command: HookCommand, reason: string): HookPolicyDec
|
|
|
196
235
|
}
|
|
197
236
|
|
|
198
237
|
function routeSessionMismatchDecision(command: HookCommand, reason: string): HookPolicyDecision {
|
|
199
|
-
const
|
|
238
|
+
const baseCommand = normalizeCommand(command);
|
|
239
|
+
const shellControl = shellControlPolicy(baseCommand);
|
|
240
|
+
const normalized = shellControl ? baseCommand : unwrapCommand(baseCommand);
|
|
200
241
|
const blockedCommand = renderCommand(normalized);
|
|
201
|
-
const destructive = destructivePolicy(normalized);
|
|
242
|
+
const destructive = shellControl ?? destructivePolicy(normalized);
|
|
202
243
|
if (destructive || lifecycleCommand(normalized)) {
|
|
203
244
|
return deny(
|
|
204
245
|
blockedCommand,
|
|
@@ -222,7 +263,7 @@ function lifecycleCommand(command: HookCommand): boolean {
|
|
|
222
263
|
command.file === "gh" && command.args[0] === "pr" && ["create", "ready", "merge"].includes(command.args[1] ?? "");
|
|
223
264
|
}
|
|
224
265
|
|
|
225
|
-
function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage): { policy: string; gate: AgentLoopGateKind; nextAction: string } | undefined {
|
|
266
|
+
function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage, runId?: string): { policy: string; gate: AgentLoopGateKind; nextAction: string } | undefined {
|
|
226
267
|
const args = stripGitGlobalOptions(command.args);
|
|
227
268
|
const lifecycleCommand = command.file === "git" && args[0] === "commit" ||
|
|
228
269
|
command.file === "git" && args[0] === "push" ||
|
|
@@ -238,22 +279,24 @@ function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage):
|
|
|
238
279
|
};
|
|
239
280
|
}
|
|
240
281
|
const current = storage.getCurrentStatus();
|
|
241
|
-
const
|
|
242
|
-
|
|
282
|
+
const run = runId ? storage.getRun(runId) : current.run;
|
|
283
|
+
const state = run?.currentState;
|
|
284
|
+
const override = activeMaintainerOverride(storage, lifecycleOverrideScope(command), runId);
|
|
285
|
+
if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && state !== "COMMIT_PUSH_PR" && !override) {
|
|
243
286
|
return {
|
|
244
287
|
policy: "commit_push_state_gate",
|
|
245
288
|
gate: current.gate?.kind ?? "policy_violation",
|
|
246
289
|
nextAction: "Resume agent-loop until COMMIT_PUSH_PR owns publishing."
|
|
247
290
|
};
|
|
248
291
|
}
|
|
249
|
-
if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && !publishPrerequisitesSatisfied(storage)) {
|
|
292
|
+
if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && !publishPrerequisitesSatisfied(storage, runId)) {
|
|
250
293
|
return {
|
|
251
294
|
policy: "commit_push_prerequisite_gate",
|
|
252
295
|
gate: "policy_violation",
|
|
253
296
|
nextAction: "Run SELF_CHECK and GitNexus detect_changes through agent-loop before publishing."
|
|
254
297
|
};
|
|
255
298
|
}
|
|
256
|
-
if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge" && state !== "MERGE") {
|
|
299
|
+
if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge" && state !== "MERGE" && !override) {
|
|
257
300
|
return {
|
|
258
301
|
policy: "merge_state_gate",
|
|
259
302
|
gate: current.gate?.kind ?? "merge_requires_confirmation",
|
|
@@ -263,6 +306,45 @@ function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage):
|
|
|
263
306
|
return undefined;
|
|
264
307
|
}
|
|
265
308
|
|
|
309
|
+
function lifecycleOverrideScope(command: HookCommand): MaintainerOverrideScope | undefined {
|
|
310
|
+
const args = stripGitGlobalOptions(command.args);
|
|
311
|
+
if (command.file === "git" && (args[0] === "commit" || args[0] === "push")) {
|
|
312
|
+
return "publish";
|
|
313
|
+
}
|
|
314
|
+
if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge") {
|
|
315
|
+
return "merge";
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function activeMaintainerOverride(storage: AgentLoopStorage | undefined, scope: MaintainerOverrideScope | undefined, runId?: string): ActiveMaintainerOverride | undefined {
|
|
321
|
+
if (!storage || !scope) {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
const run = runId ? storage.getRun(runId) : storage.getCurrentRun();
|
|
325
|
+
if (!run) {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
return storage.listDecisions(run.id)
|
|
329
|
+
.map((decision) => {
|
|
330
|
+
const details = objectDetails(decision.details);
|
|
331
|
+
const overrideScope = stringValue(details?.scope);
|
|
332
|
+
const expiresAt = stringValue(details?.expiresAt);
|
|
333
|
+
if (decision.kind !== "maintainer_override_approved" || !overrideScope || !expiresAt) {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
if (overrideScope !== scope) {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
340
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
return { decisionId: decision.id, scope, expiresAt };
|
|
344
|
+
})
|
|
345
|
+
.find((override): override is ActiveMaintainerOverride => override !== undefined);
|
|
346
|
+
}
|
|
347
|
+
|
|
266
348
|
function destructivePolicy(command: HookCommand): string | undefined {
|
|
267
349
|
const args = stripGitGlobalOptions(command.args);
|
|
268
350
|
if (command.file === "git" && args[0] === "reset" && args.includes("--hard")) {
|
|
@@ -271,7 +353,11 @@ function destructivePolicy(command: HookCommand): string | undefined {
|
|
|
271
353
|
if (command.file === "git" && args[0] === "clean" && args.some((arg) => /^-.*f/.test(arg))) {
|
|
272
354
|
return "destructive_git_clean";
|
|
273
355
|
}
|
|
274
|
-
if (command.file === "git" && args[0] === "push" && args.some((arg) =>
|
|
356
|
+
if (command.file === "git" && args[0] === "push" && args.some((arg) =>
|
|
357
|
+
["-f", "-d", "--force", "--force-with-lease", "--mirror", "--delete"].includes(arg) ||
|
|
358
|
+
arg.startsWith("+") ||
|
|
359
|
+
/^:[^:]+/.test(arg)
|
|
360
|
+
)) {
|
|
275
361
|
return "destructive_git_force_push";
|
|
276
362
|
}
|
|
277
363
|
if (command.file === "gh" && command.args[0] === "repo" && command.args[1] === "delete") {
|
|
@@ -304,24 +390,33 @@ function protectedPathPolicy(command: HookCommand, protectedPaths: string[]): st
|
|
|
304
390
|
|
|
305
391
|
function matchesHookAllowlist(command: HookCommand): boolean {
|
|
306
392
|
const args = stripGitGlobalOptions(command.args);
|
|
393
|
+
if (command.file === "rg" && matchesRipgrepAllowlist(command.args) || isApplyPatchCommand(command)) {
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
307
396
|
if (command.file === "git") {
|
|
308
397
|
return args[0] === "status" ||
|
|
309
398
|
args[0] === "branch" && args[1] === "--show-current" ||
|
|
310
399
|
args[0] === "rev-parse" ||
|
|
311
400
|
args[0] === "diff" ||
|
|
401
|
+
["log", "show"].includes(args[0] ?? "") ||
|
|
402
|
+
args[0] === "grep" && matchesGitGrepAllowlist(args.slice(1)) ||
|
|
403
|
+
args[0] === "switch" && args.length === 2 && typeof args[1] === "string" && !args[1].startsWith("-") ||
|
|
312
404
|
args[0] === "add" && args[1] === "--" ||
|
|
313
405
|
args[0] === "commit" && args[1] === "-m" ||
|
|
314
|
-
args[0] === "push" && args
|
|
406
|
+
args[0] === "push" && matchesGitPushAllowlist(args.slice(1));
|
|
315
407
|
}
|
|
316
408
|
if (command.file === "gh") {
|
|
317
409
|
return command.args[0] === "auth" && command.args[1] === "status" ||
|
|
318
|
-
command.args[0] === "pr" && ["list", "view"].includes(command.args[1] ?? "") ||
|
|
410
|
+
command.args[0] === "pr" && ["list", "view", "checks"].includes(command.args[1] ?? "") ||
|
|
411
|
+
command.args[0] === "pr" && command.args[1] === "merge" && matchesGhPrMergeAllowlist(command.args.slice(2)) ||
|
|
319
412
|
command.args[0] === "api" && command.args[1] === "graphql";
|
|
320
413
|
}
|
|
321
414
|
if (command.file === "pnpm") {
|
|
322
415
|
return command.args[0] === "test" ||
|
|
323
416
|
command.args[0] === "lint" ||
|
|
324
|
-
command.args[0] === "
|
|
417
|
+
command.args[0] === "build:hooks" ||
|
|
418
|
+
command.args[0] === "build:mcp" ||
|
|
419
|
+
command.args[0] === "agent-loop" && matchesAgentLoopAllowlist(command.args.slice(1));
|
|
325
420
|
}
|
|
326
421
|
if (command.file === "npx") {
|
|
327
422
|
return command.args[0] === "gitnexus" &&
|
|
@@ -333,6 +428,87 @@ function matchesHookAllowlist(command: HookCommand): boolean {
|
|
|
333
428
|
return false;
|
|
334
429
|
}
|
|
335
430
|
|
|
431
|
+
function matchesRipgrepAllowlist(args: string[]): boolean {
|
|
432
|
+
return !args.some((arg) => arg === "--pre" || arg.startsWith("--pre="));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function matchesGitGrepAllowlist(args: string[]): boolean {
|
|
436
|
+
return !args.some((arg) =>
|
|
437
|
+
arg === "-O" ||
|
|
438
|
+
arg.startsWith("-O") ||
|
|
439
|
+
arg === "--open-files-in-pager" ||
|
|
440
|
+
arg.startsWith("--open-files-in-pager=")
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function matchesGitPushAllowlist(args: string[]): boolean {
|
|
445
|
+
return args.length >= 3 &&
|
|
446
|
+
args[0] === "-u" &&
|
|
447
|
+
args.every((arg) => !["-f", "-d", "--force", "--force-with-lease", "--mirror", "--delete"].includes(arg) && !arg.startsWith("+") && !/^:[^:]+/.test(arg));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function matchesGhPrMergeAllowlist(args: string[]): boolean {
|
|
451
|
+
const allowedFlags = new Set(["--merge", "--squash", "--rebase", "--body", "--subject"]);
|
|
452
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
453
|
+
const arg = args[index] ?? "";
|
|
454
|
+
if (["--admin", "--auto", "--delete-branch", "-d"].includes(arg)) {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
if (arg.startsWith("--") && !allowedFlags.has(arg)) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
if ((arg === "--body" || arg === "--subject") && args[index + 1]) {
|
|
461
|
+
index += 1;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return args.some((arg) => ["--merge", "--squash", "--rebase"].includes(arg));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function isApplyPatchCommand(command: HookCommand): boolean {
|
|
468
|
+
return command.file === "apply_patch" || command.raw?.startsWith("*** Begin Patch") === true;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function matchesAgentLoopAllowlist(args: string[]): boolean {
|
|
472
|
+
if (["status", "doctor", "logs", "observe", "timeline", "workers", "stop"].includes(args[0] ?? "")) {
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
if (args[0] === "local") {
|
|
476
|
+
return args[1] === "doctor";
|
|
477
|
+
}
|
|
478
|
+
if (args[0] === "hooks") {
|
|
479
|
+
return ["doctor", "list"].includes(args[1] ?? "");
|
|
480
|
+
}
|
|
481
|
+
if (args[0] === "delivery") {
|
|
482
|
+
return ["bind", "stage"].includes(args[1] ?? "");
|
|
483
|
+
}
|
|
484
|
+
if (args[0] === "evidence") {
|
|
485
|
+
return args[1] === "append";
|
|
486
|
+
}
|
|
487
|
+
if (args[0] === "maintainer-override") {
|
|
488
|
+
return args[1] === "approve";
|
|
489
|
+
}
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function shellControlPolicy(command: HookCommand): string | undefined {
|
|
494
|
+
if (isApplyPatchCommand(command)) {
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
if (command.raw && hasShellControlOperator(command.raw)) {
|
|
498
|
+
return "shell_control_operator_forbidden";
|
|
499
|
+
}
|
|
500
|
+
if (command.file === "env") {
|
|
501
|
+
const index = command.args.findIndex((arg) => !arg.includes("="));
|
|
502
|
+
if (index >= 0) {
|
|
503
|
+
return shellControlPolicy({ file: basename(command.args[index] ?? ""), args: command.args.slice(index + 1) });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if ((command.file === "sh" || command.file === "bash") && command.args[0] === "-c" && command.args[1] && hasShellControlOperator(command.args[1])) {
|
|
507
|
+
return "shell_control_operator_forbidden";
|
|
508
|
+
}
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
|
|
336
512
|
function deny(
|
|
337
513
|
blockedCommand: string,
|
|
338
514
|
matchedPolicy: string,
|
|
@@ -385,6 +561,10 @@ function tokenizeCommand(command: string): HookCommand {
|
|
|
385
561
|
return { file: basename(file), args, raw: command };
|
|
386
562
|
}
|
|
387
563
|
|
|
564
|
+
function hasShellControlOperator(value: string): boolean {
|
|
565
|
+
return /&&|\|\||[;|<>\n\r]/.test(value);
|
|
566
|
+
}
|
|
567
|
+
|
|
388
568
|
function stripGitGlobalOptions(args: string[]): string[] {
|
|
389
569
|
const result = [...args];
|
|
390
570
|
while (result.length > 0) {
|
|
@@ -406,12 +586,35 @@ function stripGitGlobalOptions(args: string[]): string[] {
|
|
|
406
586
|
return result;
|
|
407
587
|
}
|
|
408
588
|
|
|
409
|
-
function publishPrerequisitesSatisfied(storage: AgentLoopStorage): boolean {
|
|
410
|
-
const run = storage.getCurrentRun();
|
|
589
|
+
function publishPrerequisitesSatisfied(storage: AgentLoopStorage, runId?: string): boolean {
|
|
590
|
+
const run = runId ? storage.getRun(runId) : storage.getCurrentRun();
|
|
411
591
|
if (!run) {
|
|
412
592
|
return false;
|
|
413
593
|
}
|
|
414
|
-
|
|
594
|
+
if (storage.hasRunCheck(run.id, "self_check") && storage.hasRunCheck(run.id, "gitnexus_detect_changes")) {
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
return publishWorkflowEvidenceSatisfied(storage, run.id);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function publishWorkflowEvidenceSatisfied(storage: AgentLoopStorage, runId: string): boolean {
|
|
601
|
+
const completed = new Set<string>();
|
|
602
|
+
for (const event of storage.listEvents(200)) {
|
|
603
|
+
const payload = objectDetails(event.payload);
|
|
604
|
+
if (
|
|
605
|
+
event.runId !== runId ||
|
|
606
|
+
event.kind !== "workflow_stage_evidence" ||
|
|
607
|
+
stringValue(payload?.stageId) !== "verify" ||
|
|
608
|
+
stringValue(payload?.status) !== "done"
|
|
609
|
+
) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const substageId = stringValue(payload?.substageId);
|
|
613
|
+
if (substageId) {
|
|
614
|
+
completed.add(substageId);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return REQUIRED_PUBLISH_EVIDENCE_SUBSTAGES.every((substageId) => completed.has(substageId));
|
|
415
618
|
}
|
|
416
619
|
|
|
417
620
|
function basename(value: string): string {
|
|
@@ -421,3 +624,7 @@ function basename(value: string): string {
|
|
|
421
624
|
function stringValue(value: unknown): string | undefined {
|
|
422
625
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
423
626
|
}
|
|
627
|
+
|
|
628
|
+
function objectDetails(value: unknown): Record<string, unknown> | undefined {
|
|
629
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
630
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { execFileSync } from "node:child_process";
|
|
3
2
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
3
|
import { homedir } from "node:os";
|
|
5
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
5
|
import { runCommand } from "./command.js";
|
|
7
6
|
import { isRecord } from "./config.js";
|
|
8
7
|
import { AgentLoopError } from "./errors.js";
|
|
8
|
+
import { commandsReferencingLegacyPrivateRepo, inspectAgentLoopBinary, inspectBundledHooksConfig, redactDiagnosticText, type AgentLoopBinaryInspection, type BundledHooksConfigInspection } from "./hook-diagnostics.js";
|
|
9
9
|
import { agentLoopRouterHookCommand, collectHookCommands, isLegacyAgentLoopHookCommand } from "./hook-installation.js";
|
|
10
10
|
import { CODEX_HOOK_EVENTS, hookScriptName } from "./hook-events.js";
|
|
11
11
|
import { hookRegistryPath, inspectHookRegistryLock, listHookBindings } from "./hook-router.js";
|
|
@@ -90,13 +90,19 @@ export interface LocalDoctorReport {
|
|
|
90
90
|
realPath?: string;
|
|
91
91
|
expectedPackageRoot: string;
|
|
92
92
|
pointsToExpectedPackage: boolean;
|
|
93
|
+
referencesExpectedPackage: boolean;
|
|
94
|
+
legacyPrivateRepoReferences: string[];
|
|
95
|
+
readError?: string;
|
|
96
|
+
readTruncated?: boolean;
|
|
93
97
|
};
|
|
94
98
|
hooks: {
|
|
95
99
|
hooksPath: string;
|
|
96
100
|
hooksJsonError?: string;
|
|
101
|
+
bundledHooksConfig: BundledHooksConfigInspection;
|
|
97
102
|
routerInstalled: boolean;
|
|
98
103
|
missingRouterEvents: string[];
|
|
99
104
|
legacyCommands: string[];
|
|
105
|
+
legacyPrivateRepoCommands: string[];
|
|
100
106
|
routerCommandsPointToExpectedDist: boolean;
|
|
101
107
|
};
|
|
102
108
|
bindings: {
|
|
@@ -353,8 +359,7 @@ export function inspectLocalInstall(options: LocalInstallOptions): LocalDoctorRe
|
|
|
353
359
|
const repoRoot = canonicalPath(options.repoRoot);
|
|
354
360
|
const codexHome = codexHomePath();
|
|
355
361
|
const hooksPath = join(codexHome, "hooks.json");
|
|
356
|
-
const
|
|
357
|
-
const realBinaryPath = binaryPath && existsSync(binaryPath) ? canonicalPath(binaryPath) : undefined;
|
|
362
|
+
const binary = inspectAgentLoopBinary(packageRoot);
|
|
358
363
|
const hooks = inspectHooks(hooksPath, packageRoot);
|
|
359
364
|
const bindings = inspectBindings(codexHome, repoRoot);
|
|
360
365
|
const selfLinkPollution = detectSelfLinkPollution(packageRoot);
|
|
@@ -363,12 +368,7 @@ export function inspectLocalInstall(options: LocalInstallOptions): LocalDoctorRe
|
|
|
363
368
|
packageRoot,
|
|
364
369
|
repoRoot,
|
|
365
370
|
codexHome,
|
|
366
|
-
binary
|
|
367
|
-
...(binaryPath ? { path: binaryPath } : {}),
|
|
368
|
-
...(realBinaryPath ? { realPath: realBinaryPath } : {}),
|
|
369
|
-
expectedPackageRoot: packageRoot,
|
|
370
|
-
pointsToExpectedPackage: realBinaryPath ? realBinaryPath.startsWith(packageRoot) : false
|
|
371
|
-
},
|
|
371
|
+
binary,
|
|
372
372
|
hooks,
|
|
373
373
|
bindings,
|
|
374
374
|
selfLinkPollution
|
|
@@ -599,12 +599,15 @@ function inspectHooks(hooksPath: string, packageRoot: string): LocalDoctorReport
|
|
|
599
599
|
const missingRouterEvents = CODEX_HOOK_EVENTS.filter((event) => !commands.includes(agentLoopRouterHookCommand(event, packageRoot)));
|
|
600
600
|
const expectedDist = hookDistRoot(packageRoot);
|
|
601
601
|
const routerCommands = commands.filter((command) => command.includes("autonomous-pr-loop/hooks/dist/"));
|
|
602
|
+
const bundledHooksConfig = inspectBundledHooksConfig(packageRoot);
|
|
602
603
|
return {
|
|
603
604
|
hooksPath,
|
|
604
605
|
...(hooksJsonError ? { hooksJsonError } : {}),
|
|
606
|
+
bundledHooksConfig,
|
|
605
607
|
routerInstalled: missingRouterEvents.length === 0,
|
|
606
608
|
missingRouterEvents,
|
|
607
|
-
legacyCommands: commands.filter(isLegacyAgentLoopHookCommand),
|
|
609
|
+
legacyCommands: commands.filter(isLegacyAgentLoopHookCommand).map(redactDiagnosticText),
|
|
610
|
+
legacyPrivateRepoCommands: commandsReferencingLegacyPrivateRepo(commands),
|
|
608
611
|
routerCommandsPointToExpectedDist: routerCommands.length > 0 && routerCommands.every((command) => command.includes(expectedDist))
|
|
609
612
|
};
|
|
610
613
|
}
|
|
@@ -735,15 +738,6 @@ function gitStatus(cwd: string): string[] {
|
|
|
735
738
|
return result.ok && result.stdout ? result.stdout.split(/\r?\n/).filter(Boolean) : [];
|
|
736
739
|
}
|
|
737
740
|
|
|
738
|
-
function firstPathBinary(name: string): string | undefined {
|
|
739
|
-
try {
|
|
740
|
-
const output = execFileSync("sh", ["-lc", `command -v ${name} || true`], { encoding: "utf8" }).trim();
|
|
741
|
-
return output || undefined;
|
|
742
|
-
} catch {
|
|
743
|
-
return undefined;
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
|
|
747
741
|
function localInstallBackupsDir(): string {
|
|
748
742
|
return join(codexHomePath(), "agent-loop", "backups");
|
|
749
743
|
}
|
|
@@ -602,6 +602,9 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
|
|
|
602
602
|
}
|
|
603
603
|
|
|
604
604
|
const run = this.getRun(runId);
|
|
605
|
+
if (!run) {
|
|
606
|
+
throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
|
|
607
|
+
}
|
|
605
608
|
this.db
|
|
606
609
|
.prepare(
|
|
607
610
|
`insert into states (run_id, status, state, version, payload_json, created_at)
|
|
@@ -1562,7 +1565,7 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
|
|
|
1562
1565
|
return row.user_version;
|
|
1563
1566
|
}
|
|
1564
1567
|
|
|
1565
|
-
|
|
1568
|
+
getRun(runId: string): AgentLoopRun | undefined {
|
|
1566
1569
|
const row = this.db
|
|
1567
1570
|
.prepare(
|
|
1568
1571
|
`select id, status, current_state, version, branch, worktree_clean, started_at, stopped_at, created_at, updated_at
|
|
@@ -1570,10 +1573,7 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
|
|
|
1570
1573
|
where id = ?`
|
|
1571
1574
|
)
|
|
1572
1575
|
.get(runId) as RunRow | undefined;
|
|
1573
|
-
|
|
1574
|
-
throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
|
|
1575
|
-
}
|
|
1576
|
-
return fromRunRow(row);
|
|
1576
|
+
return row ? fromRunRow(row) : undefined;
|
|
1577
1577
|
}
|
|
1578
1578
|
|
|
1579
1579
|
private getActiveRun(): AgentLoopRun | undefined {
|
|
@@ -474,6 +474,8 @@ export interface AgentLoopStorage {
|
|
|
474
474
|
listRunChecks(runId: string): AgentLoopRunCheck[];
|
|
475
475
|
/** Return the latest run by update time, if any exists. */
|
|
476
476
|
getCurrentRun(): AgentLoopRun | undefined;
|
|
477
|
+
/** Fetch a run by stable id. */
|
|
478
|
+
getRun(runId: string): AgentLoopRun | undefined;
|
|
477
479
|
/** List persisted runs newest-first. */
|
|
478
480
|
listRuns(limit?: number): AgentLoopRun[];
|
|
479
481
|
/** Run a group of read queries against one consistent SQLite snapshot. */
|
|
@@ -771,13 +771,15 @@ function buildStage(input: {
|
|
|
771
771
|
status,
|
|
772
772
|
actorChips: actorChipsForStage(input.definition.id, status, input.profileRoleMapping, input.stageMetadata),
|
|
773
773
|
evidenceCounts: counts,
|
|
774
|
-
substages: input.definition.
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
774
|
+
substages: input.definition.id === "cleanup"
|
|
775
|
+
? cleanupSubstages(input.definition, input.input, input.evidenceRefs, status)
|
|
776
|
+
: input.definition.substages.map((substage, substageIndex) => ({
|
|
777
|
+
...substage,
|
|
778
|
+
status: substageIndex === 0 && status === "active" ? "active" : status === "done" ? "done" : "pending",
|
|
779
|
+
evidenceCounts: counts,
|
|
780
|
+
latestEvidence: stageEvidence.slice(0, 3),
|
|
781
|
+
requiredEvidence: []
|
|
782
|
+
})),
|
|
781
783
|
latestAction: { label: status === "blocked" ? "Resolve blocker" : input.definition.nextAction, safeToRunFromDashboard: false, requiresConfirmation: false },
|
|
782
784
|
blockers: [],
|
|
783
785
|
nextAction: input.definition.nextAction
|
|
@@ -1343,14 +1345,36 @@ function satisfiedReviewEvidence(events: AgentLoopEvent[]): string | undefined {
|
|
|
1343
1345
|
}
|
|
1344
1346
|
|
|
1345
1347
|
function cleanupRows(input: WorkflowBoardInput): WorkflowCheckRow[] {
|
|
1348
|
+
return cleanupSubstageRows(input);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function cleanupSubstages(
|
|
1352
|
+
definition: (typeof WORKFLOW_STAGE_DEFINITIONS)[number],
|
|
1353
|
+
input: WorkflowBoardInput,
|
|
1354
|
+
refs: WorkflowEvidenceRef[],
|
|
1355
|
+
stageStatus: WorkflowStageStatus
|
|
1356
|
+
): WorkflowBoardSubstage[] {
|
|
1357
|
+
const rows = cleanupSubstageRows(input);
|
|
1358
|
+
const firstIncompleteIndex = rows.findIndex((row) => row.status !== "passed" && row.status !== "skipped");
|
|
1359
|
+
return definition.substages.map((substage, index) => {
|
|
1360
|
+
const row = rows.find((item) => item.id === substage.id);
|
|
1361
|
+
const latestEvidence = cleanupEvidenceRefs(input.events, refs, substage.id);
|
|
1362
|
+
return {
|
|
1363
|
+
...substage,
|
|
1364
|
+
status: row ? cleanupSubstageStatus(row, stageStatus, index === firstIncompleteIndex) : "pending",
|
|
1365
|
+
evidenceCounts: evidenceCounts(latestEvidence),
|
|
1366
|
+
latestEvidence,
|
|
1367
|
+
requiredEvidence: []
|
|
1368
|
+
};
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function cleanupSubstageRows(input: WorkflowBoardInput): WorkflowCheckRow[] {
|
|
1346
1373
|
const evidence = cleanupEvidenceBySubstage(input.events);
|
|
1347
|
-
return
|
|
1348
|
-
|
|
1349
|
-
cleanupCheck(
|
|
1350
|
-
|
|
1351
|
-
cleanupCheck("gitnexus_reindexed", "GitNexus index rebuilt", "GitNexus", evidence),
|
|
1352
|
-
cleanupCheck("worktree_clean", "Worktree clean", "Codex", evidence, input.run?.worktreeClean === true, String(input.run?.worktreeClean ?? "unknown"))
|
|
1353
|
-
];
|
|
1374
|
+
return cleanupDefinition().substages.map((substage) => {
|
|
1375
|
+
const fallback = cleanupFallback(input, substage.id);
|
|
1376
|
+
return cleanupCheck(substage.id, substage.label, cleanupOwner(substage.id), evidence, fallback.passed, fallback.evidence);
|
|
1377
|
+
});
|
|
1354
1378
|
}
|
|
1355
1379
|
|
|
1356
1380
|
function cleanupCheck(
|
|
@@ -1368,6 +1392,46 @@ function cleanupCheck(
|
|
|
1368
1392
|
return { id, label, status: fallbackPassed ? "passed" : "pending", evidence: fallbackEvidence, owner };
|
|
1369
1393
|
}
|
|
1370
1394
|
|
|
1395
|
+
function cleanupSubstageStatus(row: WorkflowCheckRow, stageStatus: WorkflowStageStatus, isFirstIncomplete: boolean): WorkflowStageStatus {
|
|
1396
|
+
if (row.status === "passed") return "done";
|
|
1397
|
+
if (row.status === "failed") return "failed";
|
|
1398
|
+
if (row.status === "blocked") return "blocked";
|
|
1399
|
+
if (row.status === "skipped") return "skipped";
|
|
1400
|
+
if (stageStatus === "active" && isFirstIncomplete) return "active";
|
|
1401
|
+
return "pending";
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function cleanupFallback(input: WorkflowBoardInput, substageId: string): { passed: boolean; evidence: string } {
|
|
1405
|
+
if (substageId === "pr_merged") {
|
|
1406
|
+
return { passed: input.pr?.state === "MERGED", evidence: input.pr?.state ?? "no PR link" };
|
|
1407
|
+
}
|
|
1408
|
+
if (substageId === "worktree_clean") {
|
|
1409
|
+
return { passed: input.run?.worktreeClean === true, evidence: String(input.run?.worktreeClean ?? "unknown") };
|
|
1410
|
+
}
|
|
1411
|
+
return { passed: false, evidence: "no appended evidence" };
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function cleanupOwner(substageId: string): string {
|
|
1415
|
+
if (substageId === "pr_merged") return "GitHub";
|
|
1416
|
+
if (substageId === "gitnexus_reindexed") return "GitNexus";
|
|
1417
|
+
return "Codex";
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function cleanupEvidenceRefs(events: AgentLoopEvent[], refs: WorkflowEvidenceRef[], substageId: string): WorkflowEvidenceRef[] {
|
|
1421
|
+
const eventIds = new Set(events
|
|
1422
|
+
.filter((event) => event.kind === WORKFLOW_EVIDENCE_KIND && payloadStage(event) === "cleanup" && payloadString(event, "substageId") === substageId)
|
|
1423
|
+
.map((event) => event.id));
|
|
1424
|
+
return refs.filter((ref) => eventIds.has(ref.id)).slice(0, 3);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function cleanupDefinition(): (typeof WORKFLOW_STAGE_DEFINITIONS)[number] {
|
|
1428
|
+
const definition = STAGE_BY_ID.get("cleanup");
|
|
1429
|
+
if (!definition) {
|
|
1430
|
+
throw new AgentLoopError("invalid_config", "cleanup workflow stage definition is missing.");
|
|
1431
|
+
}
|
|
1432
|
+
return definition;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1371
1435
|
function cleanupEvidenceBySubstage(events: AgentLoopEvent[]): Map<string, AgentLoopEvent> {
|
|
1372
1436
|
const bySubstage = new Map<string, AgentLoopEvent>();
|
|
1373
1437
|
for (const event of [...events].sort((left, right) => right.seq - left.seq)) {
|
|
@@ -923,6 +923,9 @@ var SqliteAgentLoopStorage = class {
|
|
|
923
923
|
);
|
|
924
924
|
}
|
|
925
925
|
const run = this.getRun(runId);
|
|
926
|
+
if (!run) {
|
|
927
|
+
throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
|
|
928
|
+
}
|
|
926
929
|
this.db.prepare(
|
|
927
930
|
`insert into states (run_id, status, state, version, payload_json, created_at)
|
|
928
931
|
values (?, ?, ?, ?, null, ?)`
|
|
@@ -1696,10 +1699,7 @@ var SqliteAgentLoopStorage = class {
|
|
|
1696
1699
|
from runs
|
|
1697
1700
|
where id = ?`
|
|
1698
1701
|
).get(runId);
|
|
1699
|
-
|
|
1700
|
-
throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
|
|
1701
|
-
}
|
|
1702
|
-
return fromRunRow(row);
|
|
1702
|
+
return row ? fromRunRow(row) : void 0;
|
|
1703
1703
|
}
|
|
1704
1704
|
getActiveRun() {
|
|
1705
1705
|
const row = this.db.prepare(
|