@kylecheng3146/agent-ops 0.2.1 → 0.2.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.
@@ -53,6 +53,15 @@ export function claudeHookOutput(event, result) {
53
53
  if (result.action === "continue" && result.status === "PASS") {
54
54
  return { exitCode: 0, stdout: "", stderr: "" };
55
55
  }
56
+ // An advisory PreToolUse result carries nothing the user can act on: the
57
+ // command runs either way. Saying so on every unparsed command — a heredoc,
58
+ // a substitution — trains the reader to ignore the hook that also refuses
59
+ // stops. A result with a remedy still speaks.
60
+ if (event === "PreToolUse" &&
61
+ result.action === "continue" &&
62
+ result.remedy === undefined) {
63
+ return { exitCode: 0, stdout: "", stderr: "" };
64
+ }
56
65
  if (event === "PreToolUse" && result.action === "block") {
57
66
  return json({
58
67
  hookSpecificOutput: {
@@ -3,7 +3,7 @@ import { sha256 } from "../fs/hash.js";
3
3
  import { AgentOpsError } from "../fs/paths.js";
4
4
  import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
5
5
  import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
6
- import { collectChangeSurface } from "../verify/change-surface.js";
6
+ import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
7
7
  import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
8
8
  const FINGERPRINT = /^[a-f0-9]{64}$/u;
9
9
  const SESSION = /^[^\0\r\n]{1,256}$/u;
@@ -76,6 +76,38 @@ export class CompletionGateService {
76
76
  const surface = await collectChangeSurface(this.#options.gitRunner);
77
77
  return await calculateSourceFingerprint(this.#options.root, { mode: "worktree", changedFiles: surface.paths }, this.#options.gitRunner);
78
78
  }
79
+ /**
80
+ * The fingerprint the task's evidence should carry. Committed work leaves an
81
+ * empty worktree surface: there is nothing left to measure there, and the
82
+ * evidence names the `--base` range `task complete` was given instead. That
83
+ * range is recomputed here rather than trusted, so evidence for a range that
84
+ * no longer ends at HEAD still fails.
85
+ */
86
+ async #evidenceFingerprint(completionBase) {
87
+ const worktree = await this.#fingerprint();
88
+ if (completionBase === null) {
89
+ return worktree;
90
+ }
91
+ const surface = await collectChangeSurface(this.#options.gitRunner);
92
+ if (surface.paths.length > 0) {
93
+ return worktree;
94
+ }
95
+ try {
96
+ const changedFiles = await collectBaseChangePaths(this.#options.gitRunner, completionBase);
97
+ if (changedFiles.length === 0) {
98
+ return worktree;
99
+ }
100
+ return await calculateSourceFingerprint(this.#options.root, {
101
+ mode: "base",
102
+ baseRef: completionBase,
103
+ resolvedBase: completionBase,
104
+ changedFiles
105
+ }, this.#options.gitRunner);
106
+ }
107
+ catch {
108
+ return worktree;
109
+ }
110
+ }
79
111
  async initialize(sessionId) {
80
112
  const fingerprint = await this.#fingerprint();
81
113
  const state = await this.#store.mutate(sessionId, (current) => current ?? {
@@ -103,7 +135,7 @@ export class CompletionGateService {
103
135
  event.event === "command-batch" ? event.commands : [];
104
136
  return commands.some(({ command, args }) => [command, ...args].includes("allow-stop"));
105
137
  }
106
- async #validateTask(sessionId, sourceFingerprint) {
138
+ async #validateTask(sessionId) {
107
139
  let stored;
108
140
  try {
109
141
  stored = await this.#options.taskService.status({ sessionId });
@@ -120,7 +152,11 @@ export class CompletionGateService {
120
152
  if (unfinished !== undefined) {
121
153
  return gateResult("block", "FAIL", "COMPLETION_GATE_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent.`);
122
154
  }
123
- const problem = await checkTaskCompletionEvidence(stored, { ...this.#options, sourceFingerprint });
155
+ const sourceFingerprint = await this.#evidenceFingerprint(stored.completionBase);
156
+ const problem = await checkTaskCompletionEvidence(stored, {
157
+ ...this.#options,
158
+ sourceFingerprint
159
+ });
124
160
  if (problem !== null) {
125
161
  return gateResult("block", problem.status, `COMPLETION_GATE_${problem.code}`, problem.remedy);
126
162
  }
@@ -154,7 +190,7 @@ export class CompletionGateService {
154
190
  return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED", "The session baseline is unavailable; continue once so PreInvocation can initialize it.");
155
191
  }
156
192
  if (state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint) {
157
- const failure = await this.#validateTask(sessionId, fingerprint);
193
+ const failure = await this.#validateTask(sessionId);
158
194
  if (failure !== null)
159
195
  return failure;
160
196
  }
@@ -2,19 +2,179 @@ import { normalizeHookEvent } from "./normalize.js";
2
2
  const MAX_COMMAND_LENGTH = 16 * 1024;
3
3
  const MAX_COMMANDS = 64;
4
4
  const MAX_WORDS = 256;
5
- function parseShellWords(input) {
6
- if (input.length === 0 || input.length > MAX_COMMAND_LENGTH) {
7
- return null;
5
+ /** Substitutions nest; a deeper stack is pathological input, not a script. */
6
+ const MAX_SUBSTITUTION_DEPTH = 8;
7
+ /** Stands in for a substitution's value in the word that contained it. */
8
+ const SUBSTITUTION_PLACEHOLDER = "_";
9
+ /**
10
+ * Walks from the opening parenthesis of a `$(...)` to the one that closes it,
11
+ * honouring quotes on the way. A quoted `)` is text, not structure: counting it
12
+ * as structure loses the whole command, and a lost command is one the policy
13
+ * never sees.
14
+ */
15
+ function scanParenthesis(input, open) {
16
+ let depth = 0;
17
+ let index = open;
18
+ while (index < input.length) {
19
+ const character = input[index];
20
+ if (character === "\\") {
21
+ index += 2;
22
+ continue;
23
+ }
24
+ if (character === "'") {
25
+ const end = input.indexOf("'", index + 1);
26
+ if (end < 0)
27
+ return null;
28
+ index = end + 1;
29
+ continue;
30
+ }
31
+ if (character === "\"") {
32
+ const end = scanDoubleQuote(input, index + 1);
33
+ if (end === null)
34
+ return null;
35
+ index = end + 1;
36
+ continue;
37
+ }
38
+ if (character === "`") {
39
+ const end = scanBacktick(input, index + 1);
40
+ if (end === null)
41
+ return null;
42
+ index = end + 1;
43
+ continue;
44
+ }
45
+ if (character === "(") {
46
+ depth += 1;
47
+ }
48
+ else if (character === ")") {
49
+ depth -= 1;
50
+ if (depth === 0)
51
+ return index;
52
+ }
53
+ index += 1;
54
+ }
55
+ return null;
56
+ }
57
+ /** Index of the `"` that closes the one already consumed. */
58
+ function scanDoubleQuote(input, start) {
59
+ let index = start;
60
+ while (index < input.length) {
61
+ const character = input[index];
62
+ if (character === "\\") {
63
+ index += 2;
64
+ continue;
65
+ }
66
+ if (character === "`") {
67
+ const end = scanBacktick(input, index + 1);
68
+ if (end === null)
69
+ return null;
70
+ index = end + 1;
71
+ continue;
72
+ }
73
+ if (character === "$" && input[index + 1] === "(") {
74
+ const end = scanParenthesis(input, index + 1);
75
+ if (end === null)
76
+ return null;
77
+ index = end + 1;
78
+ continue;
79
+ }
80
+ if (character === "\"")
81
+ return index;
82
+ index += 1;
83
+ }
84
+ return null;
85
+ }
86
+ function scanBacktick(input, start) {
87
+ let index = start;
88
+ while (index < input.length) {
89
+ if (input[index] === "\\") {
90
+ index += 2;
91
+ continue;
92
+ }
93
+ if (input[index] === "`")
94
+ return index;
95
+ index += 1;
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * The substitutions in text the shell expands but does not tokenize as a
101
+ * command line — the body of an unquoted heredoc. Quotes there are literal
102
+ * characters; only `$(...)` and backticks run anything.
103
+ */
104
+ function collectExpansions(input) {
105
+ const found = [];
106
+ let index = 0;
107
+ while (index < input.length) {
108
+ const character = input[index];
109
+ if (character === "\\") {
110
+ index += 2;
111
+ continue;
112
+ }
113
+ if (character === "$" && input[index + 1] === "(") {
114
+ const end = scanParenthesis(input, index + 1);
115
+ if (end === null)
116
+ return null;
117
+ found.push(input.slice(index + 2, end));
118
+ index = end + 1;
119
+ continue;
120
+ }
121
+ if (character === "`") {
122
+ const end = scanBacktick(input, index + 1);
123
+ if (end === null)
124
+ return null;
125
+ found.push(input.slice(index + 1, end));
126
+ index = end + 1;
127
+ continue;
128
+ }
129
+ index += 1;
130
+ }
131
+ return found;
132
+ }
133
+ /** Reads the `<<WORD`, `<<-WORD`, `<<'WORD'` that follows a `<<`. */
134
+ function readHeredocHeader(input, start) {
135
+ let index = start;
136
+ if (input[index] === "-")
137
+ index += 1;
138
+ while (input[index] === " " || input[index] === "\t")
139
+ index += 1;
140
+ const quote = input[index];
141
+ if (quote === "'" || quote === "\"") {
142
+ const end = input.indexOf(quote, index + 1);
143
+ if (end < 0)
144
+ return null;
145
+ const delimiter = input.slice(index + 1, end);
146
+ return delimiter.length === 0
147
+ ? null
148
+ : { marker: { delimiter, expands: false }, end: end + 1 };
149
+ }
150
+ let delimiter = "";
151
+ while (index < input.length && /[A-Za-z0-9_.-]/u.test(input[index] ?? "")) {
152
+ delimiter += input[index];
153
+ index += 1;
8
154
  }
155
+ return delimiter.length === 0
156
+ ? null
157
+ : { marker: { delimiter, expands: true }, end: index };
158
+ }
159
+ /**
160
+ * One quote-aware pass over a command line. Everything that decides whether a
161
+ * character is structure or text — quotes, escapes, heredocs, substitutions —
162
+ * is decided here, once, so no later pass can disagree with this one about
163
+ * where a quote begins.
164
+ */
165
+ function parseLine(input) {
9
166
  const commands = [];
167
+ const substitutions = [];
168
+ const pending = [];
10
169
  let words = [];
11
170
  let word = "";
12
- let quote = null;
13
- let escaped = false;
171
+ let started = false;
172
+ let index = 0;
14
173
  const finishWord = () => {
15
- if (word.length > 0) {
174
+ if (started) {
16
175
  words.push(word);
17
176
  word = "";
177
+ started = false;
18
178
  }
19
179
  };
20
180
  const finishCommand = () => {
@@ -24,66 +184,154 @@ function parseShellWords(input) {
24
184
  words = [];
25
185
  }
26
186
  };
27
- for (let index = 0; index < input.length; index += 1) {
187
+ const addText = (text) => {
188
+ word += text;
189
+ started = true;
190
+ };
191
+ while (index < input.length) {
28
192
  const character = input[index];
29
- if (character === undefined) {
193
+ if (character === undefined || character === "\0") {
30
194
  return null;
31
195
  }
32
- if (escaped) {
33
- word += character;
34
- escaped = false;
196
+ if (word.length > MAX_COMMAND_LENGTH) {
197
+ return null;
198
+ }
199
+ if (character === "\\") {
200
+ const next = input[index + 1];
201
+ if (next === undefined)
202
+ return null;
203
+ if (next !== "\n")
204
+ addText(next);
205
+ index += 2;
35
206
  continue;
36
207
  }
37
- if (character === "\\" && quote !== "'") {
38
- escaped = true;
208
+ if (character === "'") {
209
+ const end = input.indexOf("'", index + 1);
210
+ if (end < 0)
211
+ return null;
212
+ addText(input.slice(index + 1, end));
213
+ index = end + 1;
39
214
  continue;
40
215
  }
41
- if (quote !== "'" &&
42
- (character === "$" || character === "`")) {
43
- return null;
216
+ if (character === "\"") {
217
+ const end = scanDoubleQuote(input, index + 1);
218
+ if (end === null)
219
+ return null;
220
+ const quoted = input.slice(index + 1, end);
221
+ const expansions = collectExpansions(quoted);
222
+ if (expansions === null)
223
+ return null;
224
+ substitutions.push(...expansions);
225
+ addText(quoted);
226
+ index = end + 1;
227
+ continue;
44
228
  }
45
- if (quote !== null) {
46
- if (character === quote) {
47
- quote = null;
48
- }
49
- else {
50
- word += character;
51
- }
229
+ if (character === "$" && input[index + 1] === "(") {
230
+ const end = scanParenthesis(input, index + 1);
231
+ if (end === null)
232
+ return null;
233
+ substitutions.push(input.slice(index + 2, end));
234
+ addText(SUBSTITUTION_PLACEHOLDER);
235
+ index = end + 1;
52
236
  continue;
53
237
  }
54
- if (character === "'" || character === "\"") {
55
- quote = character;
238
+ if (character === "`") {
239
+ const end = scanBacktick(input, index + 1);
240
+ if (end === null)
241
+ return null;
242
+ substitutions.push(input.slice(index + 1, end));
243
+ addText(SUBSTITUTION_PLACEHOLDER);
244
+ index = end + 1;
56
245
  continue;
57
246
  }
58
- if (/\s/.test(character)) {
59
- finishWord();
60
- if (character === "\n") {
61
- finishCommand();
247
+ if (character === "<" && input[index + 1] === "<") {
248
+ const header = readHeredocHeader(input, index + 2);
249
+ if (header === null) {
250
+ // `<<` with no delimiter is a here-string or an unfinished line; either
251
+ // way there is no body to skip.
252
+ addText("<<");
253
+ index += 2;
254
+ continue;
62
255
  }
256
+ pending.push(header.marker);
257
+ addText(input.slice(index, header.end));
258
+ index = header.end;
63
259
  continue;
64
260
  }
65
- if (character === ";" || character === "|" || character === "&") {
261
+ if (character === "\n") {
66
262
  finishCommand();
67
- const next = input[index + 1];
68
- if (next === character) {
69
- index += 1;
263
+ index += 1;
264
+ while (pending.length > 0) {
265
+ const marker = pending.shift();
266
+ if (marker === undefined)
267
+ break;
268
+ const body = [];
269
+ let closed = false;
270
+ while (index <= input.length) {
271
+ const lineEnd = input.indexOf("\n", index);
272
+ const line = lineEnd < 0 ? input.slice(index) : input.slice(index, lineEnd);
273
+ index = lineEnd < 0 ? input.length : lineEnd + 1;
274
+ if (line.trim() === marker.delimiter) {
275
+ closed = true;
276
+ break;
277
+ }
278
+ body.push(line);
279
+ if (lineEnd < 0)
280
+ break;
281
+ }
282
+ // An unterminated heredoc means the command is still being written.
283
+ if (!closed)
284
+ return null;
285
+ if (marker.expands) {
286
+ const expansions = collectExpansions(body.join("\n"));
287
+ if (expansions === null)
288
+ return null;
289
+ substitutions.push(...expansions);
290
+ }
70
291
  }
71
292
  continue;
72
293
  }
73
- if (character === "(" ||
74
- character === ")" ||
75
- character === "\0") {
76
- return null;
294
+ if (/\s/u.test(character)) {
295
+ finishWord();
296
+ index += 1;
297
+ continue;
77
298
  }
78
- word += character;
79
- if (word.length > MAX_COMMAND_LENGTH) {
80
- return null;
299
+ if (character === ";" || character === "|" || character === "&") {
300
+ finishCommand();
301
+ index += input[index + 1] === character ? 2 : 1;
302
+ continue;
303
+ }
304
+ // Grouping and subshells separate commands; their contents are commands.
305
+ if (character === "(" || character === ")") {
306
+ finishCommand();
307
+ index += 1;
308
+ continue;
81
309
  }
310
+ addText(character);
311
+ index += 1;
82
312
  }
83
- if (escaped || quote !== null) {
313
+ finishCommand();
314
+ return pending.length > 0 ? null : { commands, substitutions };
315
+ }
316
+ function parseShellWords(input, depth = 0) {
317
+ if (input.length === 0 ||
318
+ input.length > MAX_COMMAND_LENGTH ||
319
+ depth > MAX_SUBSTITUTION_DEPTH) {
84
320
  return null;
85
321
  }
86
- finishCommand();
322
+ const parsed = parseLine(input);
323
+ if (parsed === null) {
324
+ return null;
325
+ }
326
+ const commands = [...parsed.commands];
327
+ for (const substitution of parsed.substitutions) {
328
+ if (substitution.trim().length === 0)
329
+ continue;
330
+ const nested = parseShellWords(substitution, depth + 1);
331
+ if (nested === null)
332
+ return null;
333
+ commands.push(...nested);
334
+ }
87
335
  if (commands.length === 0 ||
88
336
  commands.length > MAX_COMMANDS ||
89
337
  commands.some((command) => command.length > MAX_WORDS)) {
@@ -124,7 +124,8 @@ export class TaskService {
124
124
  completedAt: null,
125
125
  archivedAt: null,
126
126
  failureFingerprint: null,
127
- policyConfigHash: input.policyConfigHash ?? null
127
+ policyConfigHash: input.policyConfigHash ?? null,
128
+ completionBase: null
128
129
  };
129
130
  state.tasks.push(record);
130
131
  if (input.sessionId !== undefined) {
@@ -235,10 +236,14 @@ export class TaskService {
235
236
  }
236
237
  }
237
238
  }
239
+ let completionBase = null;
238
240
  const fingerprint = async () => {
239
241
  try {
240
242
  const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
241
243
  ...(completion.base === undefined ? {} : { base: completion.base }) });
244
+ // Recorded so the completion gate can recompute this exact range once
245
+ // the work is committed and the worktree has nothing left to measure.
246
+ completionBase = scope.mode === "base" ? scope.resolvedBase : null;
242
247
  return await calculateSourceFingerprint(completion.root, scope, completion.gitRunner);
243
248
  }
244
249
  catch (error) {
@@ -282,7 +287,8 @@ export class TaskService {
282
287
  status: "complete",
283
288
  evidence,
284
289
  updatedAt: now,
285
- completedAt: now
290
+ completedAt: now,
291
+ completionBase
286
292
  };
287
293
  replaceTask(state, completed);
288
294
  return cloneRecord(completed);
@@ -64,12 +64,13 @@ function parseTaskRecord(value) {
64
64
  "task",
65
65
  "updatedAt"
66
66
  ];
67
- const allowedKeys = new Set([
68
- baseKeys.join("\0"),
69
- [...baseKeys, "failureFingerprint"].sort().join("\0"),
70
- [...baseKeys, "policyConfigHash"].sort().join("\0"),
71
- [...baseKeys, "failureFingerprint", "policyConfigHash"].sort().join("\0")
72
- ]);
67
+ // Every record written before an optional field existed must still parse, so
68
+ // the accepted shapes are every combination of them.
69
+ const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash"];
70
+ const allowedKeys = new Set(Array.from({ length: 1 << optionalKeys.length }, (_unused, mask) => [
71
+ ...baseKeys,
72
+ ...optionalKeys.filter((_key, position) => (mask & (1 << position)) !== 0)
73
+ ].sort().join("\0")));
73
74
  if (!isRecord(value) || !allowedKeys.has(Object.keys(value).sort().join("\0"))) {
74
75
  return invalidState("Task state contains an invalid task record.");
75
76
  }
@@ -138,6 +139,13 @@ function parseTaskRecord(value) {
138
139
  (typeof policyConfigHash !== "string" || !/^[a-f0-9]{64}$/u.test(policyConfigHash))) {
139
140
  return invalidState("Task state contains an invalid policy config hash.");
140
141
  }
142
+ const completionBase = value.completionBase === undefined
143
+ ? null
144
+ : value.completionBase;
145
+ if (completionBase !== null &&
146
+ (typeof completionBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(completionBase))) {
147
+ return invalidState("Task state contains an invalid completion base.");
148
+ }
141
149
  return {
142
150
  task: task.value,
143
151
  status,
@@ -147,7 +155,8 @@ function parseTaskRecord(value) {
147
155
  completedAt: value.completedAt,
148
156
  archivedAt: value.archivedAt,
149
157
  failureFingerprint,
150
- policyConfigHash
158
+ policyConfigHash,
159
+ completionBase
151
160
  };
152
161
  }
153
162
  function parseSession(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",