@lannguyensi/harness 0.59.1 → 0.60.0

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 (88) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/README.md +1 -1
  3. package/dist/cli/apply/apply.d.ts +20 -0
  4. package/dist/cli/apply/apply.js +13 -7
  5. package/dist/cli/apply/apply.js.map +1 -1
  6. package/dist/cli/apply/install-codex-config.d.ts +113 -2
  7. package/dist/cli/apply/install-codex-config.js +651 -69
  8. package/dist/cli/apply/install-codex-config.js.map +1 -1
  9. package/dist/cli/approve/understanding.js +34 -28
  10. package/dist/cli/approve/understanding.js.map +1 -1
  11. package/dist/cli/doctor/format.js +22 -0
  12. package/dist/cli/doctor/format.js.map +1 -1
  13. package/dist/cli/doctor/index.js +60 -31
  14. package/dist/cli/doctor/index.js.map +1 -1
  15. package/dist/cli/doctor/types.d.ts +17 -0
  16. package/dist/cli/dry-run.js +7 -0
  17. package/dist/cli/dry-run.js.map +1 -1
  18. package/dist/cli/index.js +46 -144
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/cli/pack/auto-approve-path.js +25 -11
  21. package/dist/cli/pack/auto-approve-path.js.map +1 -1
  22. package/dist/cli/pack/hook-codex-post-tool-use.js +2 -2
  23. package/dist/cli/pack/hook-codex-post-tool-use.js.map +1 -1
  24. package/dist/cli/pack/hook-codex-pre-tool-use.js +32 -5
  25. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  26. package/dist/cli/pack/hook-post-merge-gate-record.d.ts +7 -0
  27. package/dist/cli/pack/hook-post-merge-gate-record.js +39 -2
  28. package/dist/cli/pack/hook-post-merge-gate-record.js.map +1 -1
  29. package/dist/cli/pack/hook-post-tool-use.js +2 -2
  30. package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
  31. package/dist/cli/pack/hook-pre-tool-use.js +60 -15
  32. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  33. package/dist/cli/policy/intercept.js +5 -1
  34. package/dist/cli/policy/intercept.js.map +1 -1
  35. package/dist/cli/record/index.d.ts +11 -2
  36. package/dist/cli/record/index.js +18 -5
  37. package/dist/cli/record/index.js.map +1 -1
  38. package/dist/cli/session-start/branch-check.d.ts +3 -29
  39. package/dist/cli/session-start/branch-check.js +7 -37
  40. package/dist/cli/session-start/branch-check.js.map +1 -1
  41. package/dist/cli/session-start/index.d.ts +16 -30
  42. package/dist/cli/session-start/index.js +72 -26
  43. package/dist/cli/session-start/index.js.map +1 -1
  44. package/dist/cli/session-start/shared-options.d.ts +92 -0
  45. package/dist/cli/session-start/shared-options.js +109 -0
  46. package/dist/cli/session-start/shared-options.js.map +1 -0
  47. package/dist/cli/session-start/stale-base-check.d.ts +3 -28
  48. package/dist/cli/session-start/stale-base-check.js +7 -37
  49. package/dist/cli/session-start/stale-base-check.js.map +1 -1
  50. package/dist/cli/session-start/toolchain-parity.d.ts +5 -21
  51. package/dist/cli/session-start/toolchain-parity.js +6 -38
  52. package/dist/cli/session-start/toolchain-parity.js.map +1 -1
  53. package/dist/cli/smoke/index.d.ts +1 -1
  54. package/dist/cli/smoke/index.js +8 -11
  55. package/dist/cli/smoke/index.js.map +1 -1
  56. package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +11 -0
  57. package/dist/policy-packs/builtin/post-merge-gate-runtime.js +11 -0
  58. package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -1
  59. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +1 -1
  60. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  61. package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.d.ts +36 -0
  62. package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.js +73 -0
  63. package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.js.map +1 -0
  64. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +3 -2
  65. package/dist/policy-packs/builtin/understanding-before-execution/index.js +5 -2
  66. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
  67. package/dist/policy-packs/builtin/understanding-before-execution/markers.js +1 -0
  68. package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -1
  69. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +205 -1
  70. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +360 -3
  71. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
  72. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +1 -1
  73. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +2 -2
  74. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -1
  75. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +36 -0
  76. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +50 -0
  77. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -1
  78. package/dist/runtime/bash-prefix-parse.js +319 -126
  79. package/dist/runtime/bash-prefix-parse.js.map +1 -1
  80. package/dist/runtime/intercept.d.ts +38 -0
  81. package/dist/runtime/intercept.js +224 -5
  82. package/dist/runtime/intercept.js.map +1 -1
  83. package/dist/runtime/read-only-bash.js +11 -1
  84. package/dist/runtime/read-only-bash.js.map +1 -1
  85. package/dist/runtime/shell-word.d.ts +16 -1
  86. package/dist/runtime/shell-word.js +82 -1
  87. package/dist/runtime/shell-word.js.map +1 -1
  88. package/package.json +1 -1
@@ -25,6 +25,9 @@ export class CodexInstallRefusalError extends HarnessExitError {
25
25
  }
26
26
  }
27
27
  const GENERATED_HEADER = "# Generated by harness apply --runtime codex.";
28
+ /** How the split-block refusal names a legacy generated header as the line
29
+ * its block starts at (`assertNoSplitBlock`, `findManagedRange`). */
30
+ const GENERATED_HEADER_OPENING = `the '${GENERATED_HEADER}' line`;
28
31
  const LEGACY_HOOK_TABLE_RE = /^\[\[hooks\.(pre_tool_use|post_tool_use|user_prompt_submit|session_start|stop)\]\]$/;
29
32
  const CURRENT_HOOK_TABLE_RE = /^\[\[hooks\.[A-Za-z][A-Za-z0-9_]*\]\]$/;
30
33
  // A single, unquoted-or-quoted TOML bare/quoted key: `foo-bar_1`, a basic
@@ -45,7 +48,11 @@ const TOML_KEY = String.raw `(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`;
45
48
  // single key with nothing after it, never as an array element) so a
46
49
  // multi-line array/value sitting inside a still-owned harness hook table
47
50
  // is never misread as a foreign table header ending the owned region
48
- // early (task 6a037359).
51
+ // early (task 6a037359). A lone element on its own line (`["x"]`, `['x']`,
52
+ // `[1]`, `[1979-05-27]`) does have the shape of a header, so this regex is
53
+ // only a candidate filter: every caller also requires `createTopLevelProbe`
54
+ // before treating a match as a real header (task 6b56d735), the scans
55
+ // through `createLineClassifier` (task 01053b27).
49
56
  const NON_HOOK_TABLE_RE = new RegExp(String.raw `^\[(?!\[hooks\.)\[?\s*${TOML_KEY}(?:\s*\.\s*${TOML_KEY})*\s*\]\]?\s*(?:#.*)?$`);
50
57
  // Every emitted hook table is preceded by exactly this comment shape
51
58
  // (generate-codex-config.ts's `emitHook`); used both to name removed hook
@@ -53,6 +60,16 @@ const NON_HOOK_TABLE_RE = new RegExp(String.raw `^\[(?!\[hooks\.)\[?\s*${TOML_KE
53
60
  // between two harness hook tables (see `assertNoSplitBlock`).
54
61
  const HARNESS_HOOK_COMMENT_PREFIX = "# harness hook: ";
55
62
  const HOOK_ID_LINE_RE = /^# harness hook: (.+) \(budget_ms=\d+\)$/;
63
+ // A `[[hooks.<Event>]]` array-of-tables header, harness-written or not.
64
+ // `NON_HOOK_TABLE_RE` deliberately excludes it, so the ownership scan tells
65
+ // the two apart by the `# harness hook:` comment above the header instead
66
+ // (see `scanOwnedContentEnd`).
67
+ const HOOK_ARRAY_HEADER_RE = /^\[\[hooks\./;
68
+ /** True when a run of comment/blank lines holds a line the generator writes
69
+ * above every hook table (`# harness hook: <id> (budget_ms=N)`). */
70
+ function runHoldsHarnessHookComment(run) {
71
+ return run.split("\n").some((line) => line.trim().startsWith(HARNESS_HOOK_COMMENT_PREFIX));
72
+ }
56
73
  export function defaultCodexConfigPath(homeDir) {
57
74
  return path.join(homeDir ?? os.homedir(), ".codex", "config.toml");
58
75
  }
@@ -202,9 +219,72 @@ function findExactLine(text, target, from) {
202
219
  }
203
220
  return -1;
204
221
  }
222
+ /** Answers, for a line start in `text`, whether that line sits at the top
223
+ * level of the document, that is, not inside a multi-line array, a multi-line
224
+ * inline table or a multi-line string opened by an earlier line. The document
225
+ * parser is the oracle: the text before the line parses on its own exactly
226
+ * when nothing is still open there, whereas a prefix that ends inside an
227
+ * array is an unterminated array and fails. A line that merely LOOKS like a
228
+ * table header (an array element on its own line such as `["x"]`, `['x']`,
229
+ * `[1]` or `[1979-05-27]`) is therefore told apart from a real header even
230
+ * when a real table of the same name exists elsewhere in the document, which
231
+ * a lookup of the header's key path in the parsed document could not do.
232
+ * A line inside an open construct never has a prefix that parses, so such a
233
+ * line is never taken for a header.
234
+ *
235
+ * The verdict is the full-prefix verdict: a line is top level when
236
+ * `text.slice(0, lineStart)` parses. The probe first tries a fast path: it
237
+ * remembers the last line start it confirmed as top level and parses only
238
+ * the text from there; when that segment parses, the line is confirmed
239
+ * without parsing the full prefix. When the segment fails, the full prefix
240
+ * decides, and a line it accepts is confirmed too. The segment is not
241
+ * claimed to agree with the full prefix: a segment can fail where the full
242
+ * prefix parses (a sub-table `[hooks.Stop.meta]` confirmed, then the next
243
+ * `[[hooks.Stop]]` element, which is never a candidate and so stays in the
244
+ * segment, redefines `hooks.Stop` as seen from the segment alone), and then
245
+ * the full prefix is what answers. So a line is reported as not top level
246
+ * only when the full prefix fails, and a line whose full prefix parses is
247
+ * always reported as top level. The fast path can only answer "top level"
248
+ * where the full prefix fails if the prefix holds a conflict of its own (a
249
+ * table defined twice); such a document does not parse as a whole, and the
250
+ * install refuses it through the parse check on the config either way. Even
251
+ * then the segment starts at a confirmed line with nothing open, so a line
252
+ * inside an open array, inline table or multi-line string still fails it.
253
+ *
254
+ * Cost: a real table confirmed on the fast path costs a segment parse. Any
255
+ * candidate whose segment fails costs a full-prefix parse too: an array
256
+ * element on its own line, or a sub-table after a later element of the same
257
+ * array of tables. Many of those are quadratic in their number (seconds at
258
+ * thousands; the CHANGELOG entry for this change has the measurements).
259
+ * Both scans query non-decreasing line starts. A query
260
+ * before the last confirmed line start throws instead of answering; a line
261
+ * reported as not top level is never confirmed, so it never moves that
262
+ * bound (task 6b56d735). Exported for direct tests only. */
263
+ export function createTopLevelProbe(text) {
264
+ let confirmed = 0;
265
+ const parses = (content) => {
266
+ try {
267
+ parseToml(content);
268
+ return true;
269
+ }
270
+ catch {
271
+ return false;
272
+ }
273
+ };
274
+ return (lineStart) => {
275
+ if (lineStart < confirmed) {
276
+ throw new Error(`top-level probe queried at offset ${lineStart}, before the last confirmed line start ${confirmed}`);
277
+ }
278
+ const topLevel = parses(text.slice(confirmed, lineStart)) ||
279
+ (confirmed > 0 && parses(text.slice(0, lineStart)));
280
+ if (topLevel)
281
+ confirmed = lineStart;
282
+ return topLevel;
283
+ };
284
+ }
205
285
  /** The first non-blank, non-comment line at or after `from` (bounded by
206
- * `limit`), trimmed. Used to name the offending foreign header in a
207
- * refusal message. */
286
+ * `limit`), trimmed, with the offset its line starts at. Used to name the
287
+ * offending foreign header in a refusal message. */
208
288
  function firstContentLine(text, from, limit) {
209
289
  let pos = Math.max(from, 0);
210
290
  const end = Math.max(limit, from);
@@ -212,11 +292,59 @@ function firstContentLine(text, from, limit) {
212
292
  const lineEnd = Math.min(lineEndAfter(text, pos), end);
213
293
  const trimmed = text.slice(pos, lineEnd).trim();
214
294
  if (trimmed !== "" && !trimmed.startsWith("#"))
215
- return trimmed;
295
+ return { line: trimmed, start: pos };
216
296
  pos = lineEnd;
217
297
  }
218
298
  return null;
219
299
  }
300
+ /**
301
+ * The one line classifier every header scan of the install uses (the
302
+ * managed-range scan, the foreign-section listing, the kept hook table scan
303
+ * and the hook header map), so a line is read the same way wherever it is
304
+ * met (task 01053b27). Called once per line, in order, from a line start at
305
+ * which nothing is open. The rule, applied in this order:
306
+ *
307
+ * 1. A line that starts inside a multi-line string opened by an earlier line
308
+ * (`nextTripleDelim`) is `string`: string content, whatever it looks like.
309
+ * 2. A blank line or a `#` comment line is `comment` (the END marker and the
310
+ * `# harness hook:` lines are comment lines the callers check by text).
311
+ * 3. A line starting `[[hooks.` is `hookHeader`. No parse is needed: a dotted
312
+ * bare key is not a TOML value, so in a config that parses such a line is
313
+ * never inside an array or inline table, and a config that does not parse
314
+ * is refused by the parse checks before anything is written.
315
+ * 4. Any other line with the shape of a table header (`NON_HOOK_TABLE_RE`)
316
+ * is `foreignHeader` only when it starts at the top level of the document
317
+ * (`createTopLevelProbe`); otherwise it is an array element on its own
318
+ * line inside a multi-line value (`["x"]`, `['x']`, `[1]`,
319
+ * `[1979-05-27]`) and is `content`, never a header.
320
+ * 5. Every other line is `content` (a key/value line or a continuation line
321
+ * of a multi-line value).
322
+ *
323
+ * `wanted` narrows rule 4 for a scan that only uses some headers: a
324
+ * header-shaped line it rejects is `content` for that scan without the
325
+ * top-level probe, whose full-prefix parse is the cost of rule 4 (see
326
+ * `createTopLevelProbe`). Only the hook header map passes one, since it
327
+ * resolves nothing but headers whose root key is `hooks`.
328
+ */
329
+ function createLineClassifier(text, wanted = () => true) {
330
+ let delim = null;
331
+ const startsAtTopLevel = createTopLevelProbe(text);
332
+ return (lineStart, rawLine) => {
333
+ const startedInsideString = delim !== null;
334
+ delim = nextTripleDelim(rawLine, delim);
335
+ if (startedInsideString)
336
+ return "string";
337
+ const trimmed = rawLine.trim();
338
+ if (trimmed === "" || trimmed.startsWith("#"))
339
+ return "comment";
340
+ if (HOOK_ARRAY_HEADER_RE.test(trimmed))
341
+ return "hookHeader";
342
+ if (NON_HOOK_TABLE_RE.test(trimmed) && wanted(trimmed) && startsAtTopLevel(lineStart)) {
343
+ return "foreignHeader";
344
+ }
345
+ return "content";
346
+ };
347
+ }
220
348
  /**
221
349
  * Scans forward from `start` classifying each line as either still-owned
222
350
  * harness content or the start of foreign (non-harness) content, WITHOUT
@@ -235,7 +363,24 @@ function firstContentLine(text, from, limit) {
235
363
  * into the replaced block.
236
364
  *
237
365
  * A line is "foreign" exactly when it is a table header that is not one
238
- * of ours (`NON_HOOK_TABLE_RE`, mirroring the pre-existing legacy scan);
366
+ * of ours (`NON_HOOK_TABLE_RE`, mirroring the pre-existing legacy scan) and
367
+ * sits at the top level of the document (`createTopLevelProbe`: an array
368
+ * element on its own line inside a multi-line value has the same shape but
369
+ * is not a header), or
370
+ * a `[[hooks.<Event>]]` header without a `# harness hook:` comment above it
371
+ * that comes after at least one header that has one (task 01053b27).
372
+ * Failure modes of that second rule: a harness table whose comment an
373
+ * operator deleted ends the range there (a later harness table still
374
+ * carrying its comment, before the END marker when one follows, lands in
375
+ * the zone `assertNoSplitBlock` refuses on; a
376
+ * last table stays as an operator table, which the install names in
377
+ * `keptOperatorHookTables` and refuses when its command is one harness
378
+ * writes, see `assertKeptHookTablesAreOperatorOwned`); a
379
+ * block that never carried the comments (older generator output) has no
380
+ * marked table before its first hook table, so it is consumed whole as
381
+ * before. Bare `key =` lines of an operator table follow that table out of
382
+ * the range; bare keys placed directly under a harness table cannot be told
383
+ * apart from the harness table's own fields and are still consumed with it;
239
384
  * every other non-blank, non-comment line (a hook table's `matcher =` /
240
385
  * `hooks =` field, or an older schema's bare `command =` / `match =` /
241
386
  * `timeout_ms =` / `blocking =` field) is treated as still-owned content,
@@ -252,31 +397,43 @@ function scanOwnedContentEnd(text, start) {
252
397
  let pos = start;
253
398
  let commentRunStart = null;
254
399
  let sawOwnedContent = false;
255
- let delim = null;
400
+ let sawMarkedHookTable = false;
401
+ const classify = createLineClassifier(text);
256
402
  while (pos < text.length) {
257
403
  const lineEnd = lineEndAfter(text, pos);
258
404
  const rawLine = text.slice(pos, lineEnd);
259
- const startedInsideString = delim !== null;
260
- delim = nextTripleDelim(rawLine, delim);
261
- if (startedInsideString) {
405
+ const kind = classify(pos, rawLine);
406
+ if (kind === "string") {
262
407
  commentRunStart = null;
263
408
  sawOwnedContent = true;
264
409
  pos = lineEnd;
265
410
  continue;
266
411
  }
267
- const trimmed = rawLine.trim();
268
- if (trimmed === CODEX_MANAGED_END) {
412
+ if (rawLine.trim() === CODEX_MANAGED_END) {
269
413
  return { end: lineEnd, sawEndMarker: true };
270
414
  }
271
- if (trimmed === "" || trimmed.startsWith("#")) {
415
+ if (kind === "comment") {
272
416
  if (commentRunStart === null)
273
417
  commentRunStart = pos;
274
418
  pos = lineEnd;
275
419
  continue;
276
420
  }
277
- if (NON_HOOK_TABLE_RE.test(trimmed)) {
421
+ if (kind === "foreignHeader") {
278
422
  return { end: commentRunStart ?? pos, sawEndMarker: false };
279
423
  }
424
+ if (kind === "hookHeader") {
425
+ const marked = commentRunStart !== null &&
426
+ runHoldsHarnessHookComment(text.slice(commentRunStart, pos));
427
+ if (marked) {
428
+ sawMarkedHookTable = true;
429
+ }
430
+ else if (sawMarkedHookTable) {
431
+ // An operator-authored hook table (no `# harness hook:` comment
432
+ // above it) after the harness tables ends the owned region, like a
433
+ // foreign table, and keeps a comment attached to it (task 01053b27).
434
+ return { end: commentRunStart ?? pos, sawEndMarker: false, endedAtOperatorHookTable: true };
435
+ }
436
+ }
280
437
  commentRunStart = null;
281
438
  sawOwnedContent = true;
282
439
  pos = lineEnd;
@@ -314,7 +471,8 @@ function scanOwnedContentEnd(text, start) {
314
471
  * `scanOwnedContentEnd`; when only blank or comment lines precede the found
315
472
  * marker in the zone -- so the pre-marker lookup finds no content line to
316
473
  * report -- the header is looked up again just past that marker's own
317
- * line, since the wedged table then sits after it, not before; printed
474
+ * line, since the wedged table then sits after it, not before, and the
475
+ * message says the marker line sits above the table (task e8f4fc03); printed
318
476
  * through `describeTableHeader`, so only the header's own bracketed key
319
477
  * path appears, never a trailing comment), and
320
478
  * the line number and marker of the first line beyond it
@@ -328,9 +486,26 @@ function scanOwnedContentEnd(text, start) {
328
486
  * document (`to` is `text.length`), telling the operator to move something
329
487
  * below a marker that does not exist would be nonsensical, so a different
330
488
  * fix (add one, or move the table below the last harness hook table) is
331
- * offered instead (task 6a037359).
489
+ * offered instead (task 6a037359). When the named table is a hook event
490
+ * table spelled without the `[[hooks.` prefix, the guidance offers deleting
491
+ * it if harness wrote it, since the scan reads such a table as foreign with
492
+ * or without a comment line above it (task e8f4fc03). The legacy paths call
493
+ * this too, so the substring matching above applies there as well: a value
494
+ * holding marker text refuses. `blockOpening` names the line the block
495
+ * starts at in the delete-the-lines fix: the BEGIN marker by default; a
496
+ * legacy config (no BEGIN marker) whose END marker survived passes its
497
+ * source-prefix line or generated header instead, since a BEGIN marker it
498
+ * does not have is no line to delete from (task 01053b27). For the generated
499
+ * header the fix deletes that line and the END marker line too: a generated
500
+ * header left alone above END no longer marks a harness block
501
+ * (`findManagedRange` needs a `[[hooks.` line after it), so the install
502
+ * would add a fresh block and leave that pair behind as an orphan. Exported for
503
+ * direct tests only: the scan
504
+ * that feeds it never hands it a line that is not a real header, so its own
505
+ * naming rule is reachable in isolation only by calling it directly (task
506
+ * 6b56d735).
332
507
  */
333
- function assertNoSplitBlock(text, from, to, configPath, hasEndMarker) {
508
+ export function assertNoSplitBlock(text, from, to, configPath, hasEndMarker, blockOpening = `'${CODEX_MANAGED_BEGIN}'`) {
334
509
  const zoneStart = Math.max(from, 0);
335
510
  const zoneEnd = Math.max(to, from);
336
511
  const zone = text.slice(zoneStart, zoneEnd);
@@ -357,17 +532,49 @@ function assertNoSplitBlock(text, from, to, configPath, hasEndMarker) {
357
532
  // the foreign table that split the block is wedged after the marker's own
358
533
  // line instead, so look there.
359
534
  const markerLineEnd = lineEndAfter(text, zoneStart + markerOffset);
360
- const foreignHeaderLine = firstContentLine(text, zoneStart, zoneStart + markerOffset) ??
361
- firstContentLine(text, markerLineEnd, zoneEnd);
362
- const firstForeignHeader = foreignHeaderLine === null ? "(unknown)" : describeTableHeader(foreignHeaderLine);
363
- const guidance = hasEndMarker
364
- ? `Move ${firstForeignHeader} below the '${CODEX_MANAGED_END}' marker, or delete the lines ` +
365
- `between '${CODEX_MANAGED_BEGIN}' and '${CODEX_MANAGED_END}' so only harness-owned tables ` +
366
- "remain between them"
367
- : `Add a '${CODEX_MANAGED_END}' marker line right after the last harness hook table (before ` +
368
- `${firstForeignHeader}), or move ${firstForeignHeader} below the last harness hook table`;
369
- throw new CodexInstallRefusalError(`Codex config ${configPath} has a foreign table (${firstForeignHeader}) sitting before ` +
370
- `more harness-owned content: line ${offendingLineNumber} contains '${marker}' and still ` +
535
+ const headerBeforeMarker = firstContentLine(text, zoneStart, zoneStart + markerOffset);
536
+ const foreignHeaderLine = headerBeforeMarker ?? firstContentLine(text, markerLineEnd, zoneEnd);
537
+ const firstForeignHeader = describeForeignHeaderLine(text, foreignHeaderLine);
538
+ // Where the marker line sits relative to the named table, so the message
539
+ // stays true when the table was found past the marker (a respelled hook
540
+ // table with its own '# harness hook:' comment line above it, say).
541
+ const placement = headerBeforeMarker === null
542
+ ? `below harness-owned content: line ${offendingLineNumber}, above it, contains '${marker}'`
543
+ : `sitting before more harness-owned content: line ${offendingLineNumber} contains '${marker}'`;
544
+ const fixes = hasEndMarker
545
+ ? [
546
+ `move ${firstForeignHeader} below the '${CODEX_MANAGED_END}' marker`,
547
+ blockOpening === GENERATED_HEADER_OPENING
548
+ ? `delete every line from ${blockOpening} through the '${CODEX_MANAGED_END}' marker ` +
549
+ "line, both included, so the install writes a fresh harness block"
550
+ : `delete the lines between ${blockOpening} and '${CODEX_MANAGED_END}' so only ` +
551
+ "harness-owned tables remain between them",
552
+ ]
553
+ : [
554
+ `add a '${CODEX_MANAGED_END}' marker line right after the last harness hook table ` +
555
+ `(before ${firstForeignHeader})`,
556
+ `move ${firstForeignHeader} below the last harness hook table`,
557
+ ];
558
+ // An uncommented hook table in the middle may be a harness table whose
559
+ // comment line was deleted, so moving it (the operator-table fix) is only
560
+ // right for an operator-owned one; name the restore option first (task
561
+ // 01053b27).
562
+ const isHookTable = HOOK_ARRAY_HEADER_RE.test(foreignHeaderLine?.line ?? "");
563
+ // A hook event table spelled another way (`[[ hooks.Stop ]]`) is read as
564
+ // foreign with or without a comment line above it, so restoring one does
565
+ // not help; deleting it does, if harness wrote it (task e8f4fc03).
566
+ const isRespelledHookTable = isHookEventArrayHeader(foreignHeaderLine?.line ?? "");
567
+ const guidance = isHookTable
568
+ ? `If ${firstForeignHeader} is a harness hook table whose '${HARNESS_HOOK_COMMENT_PREFIX}<id> ` +
569
+ "(budget_ms=<n>)' comment line was deleted, restore that comment line directly above it. " +
570
+ `If it is an operator-owned table, ${fixes.join(", or ")}`
571
+ : isRespelledHookTable
572
+ ? `If ${firstForeignHeader} is a harness hook table, delete it together with any ` +
573
+ `'${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line above it (the install ` +
574
+ "replaces only hook tables spelled '[[hooks.<Event>]]' with that comment line above " +
575
+ `them, and writes the fresh block). If it is an operator-owned table, ${fixes.join(", or ")}`
576
+ : fixes.join(", or ").replace(/^./, (c) => c.toUpperCase());
577
+ throw new CodexInstallRefusalError(`Codex config ${configPath} has a foreign table (${firstForeignHeader}) ${placement} and still ` +
371
578
  "looks like part of the harness-managed hook block; refusing to guess the block " +
372
579
  `boundaries. ${guidance}, then re-run \`harness apply --runtime codex --install\`. As a ` +
373
580
  `last resort, restore ${configPath} from the most recent .harness-backup-* file (if one exists), but ` +
@@ -398,6 +605,23 @@ function parseTableHeader(line) {
398
605
  function describeTableHeader(line) {
399
606
  return parseTableHeader(line)?.header ?? "(unknown)";
400
607
  }
608
+ /** How a refusal names the line it found where the foreign table's header
609
+ * should be. A verified header prints as its bracketed key path. A line that
610
+ * has the shape of a header but is not one (an array element on its own line
611
+ * inside a multi-line value) is never printed, since it can hold a config
612
+ * value such as a token; only its line number is. Any other line is
613
+ * `(unknown)`, as before. */
614
+ function describeForeignHeaderLine(text, found) {
615
+ if (found === null)
616
+ return "(unknown)";
617
+ const parsed = parseTableHeader(found.line);
618
+ if (parsed === null)
619
+ return "(unknown)";
620
+ if (!createTopLevelProbe(text)(found.start)) {
621
+ return `line ${lineNumberAt(text, found.start)}`;
622
+ }
623
+ return parsed.header;
624
+ }
401
625
  /** A key segment's bare name: a literal (`'...'`) key's inner text, a basic
402
626
  * (`"..."`) key's inner text when it holds no escape sequence, otherwise
403
627
  * the segment as written. Used only to recognize the `hooks` root however
@@ -434,25 +658,23 @@ function foreignSectionRootKey(headerLine) {
434
658
  * so the operator reads "a whole namespace, N tables" rather than
435
659
  * mistaking the printed header for the only table kept; exactly one table
436
660
  * under a root prints its own header. Headers print through
437
- * `describeTableHeader` (no trailing comment). */
438
- function collectForeignSectionHeaders(text, from, to) {
661
+ * `describeTableHeader` (no trailing comment). A header that is one of the
662
+ * `kept` operator hook tables (a hook event table spelled without the
663
+ * `[[hooks.` prefix, see `collectKeptHookTables`) is named there instead and
664
+ * not listed twice. */
665
+ function collectForeignSectionHeaders(text, from, to, kept = []) {
439
666
  const seenRoots = new Set();
440
667
  const headers = [];
441
668
  const counts = new Map();
669
+ const keptStarts = new Set(kept.map((t) => t.start));
442
670
  let pos = Math.max(from, 0);
443
671
  const limit = Math.max(to, from);
444
- let delim = null;
672
+ const classify = createLineClassifier(text);
445
673
  while (pos < limit) {
446
674
  const lineEnd = Math.min(lineEndAfter(text, pos), limit);
447
675
  const rawLine = text.slice(pos, lineEnd);
448
- const startedInsideString = delim !== null;
449
- delim = nextTripleDelim(rawLine, delim);
450
- if (startedInsideString) {
451
- pos = lineEnd;
452
- continue;
453
- }
454
- const trimmed = rawLine.trim();
455
- if (NON_HOOK_TABLE_RE.test(trimmed)) {
676
+ if (classify(pos, rawLine) === "foreignHeader" && !keptStarts.has(pos)) {
677
+ const trimmed = rawLine.trim();
456
678
  const root = foreignSectionRootKey(trimmed);
457
679
  counts.set(root, (counts.get(root) ?? 0) + 1);
458
680
  if (!seenRoots.has(root)) {
@@ -477,6 +699,54 @@ function extractHookIds(content) {
477
699
  }
478
700
  return ids;
479
701
  }
702
+ /**
703
+ * The operator-authored hook tables directly after a managed range: every
704
+ * hook event table header from `from` up to the first non-hook table header
705
+ * or `to`, each by header, line number and offset. A hook event table is a
706
+ * `[[hooks.*]]` header, or an array-of-tables header spelled another way
707
+ * whose first key resolves to `hooks` and that names an event
708
+ * (`isHookEventArrayHeader`: `[[ hooks.Stop ]]`, `[["hooks".Stop]]`,
709
+ * `[[hooks . Stop]]`). The ownership scan reads such a respelled header as a
710
+ * foreign table, so it ends the managed range there; collected here, it is
711
+ * named and its commands are checked like any kept hook table, instead of
712
+ * being kept as a foreign section whose harness command would then run next
713
+ * to the fresh block's (task e8f4fc03). Lines are read by
714
+ * `createLineClassifier`, so an array element on its own line inside a kept
715
+ * table's multi-line value neither ends the run nor is named, and a hook
716
+ * table after it is still collected (task 01053b27). Empty when the first
717
+ * header at `from` is a non-hook table, or there is none. A hook table after
718
+ * the first non-hook table is not collected: it is kept, not named and not
719
+ * checked, a deliberate boundary (only the run of hook tables directly after
720
+ * the harness tables is examined). The caller refuses a zone that holds a
721
+ * harness marker (`assertNoSplitBlock`) before it uses the tables: over the
722
+ * whole zone on the BEGIN path and when an END marker follows, over the run
723
+ * of hook tables (up to the returned `end`) on the legacy path without one,
724
+ * so no table the install keeps carries a `# harness hook:` comment.
725
+ */
726
+ function collectKeptHookTables(text, from, to) {
727
+ const tables = [];
728
+ let pos = from;
729
+ const classify = createLineClassifier(text);
730
+ while (pos < to) {
731
+ const lineEnd = Math.min(lineEndAfter(text, pos), to);
732
+ const rawLine = text.slice(pos, lineEnd);
733
+ const kind = classify(pos, rawLine);
734
+ const hookTable = kind === "hookHeader" || (kind === "foreignHeader" && isHookEventArrayHeader(rawLine.trim()));
735
+ if (kind === "foreignHeader" && !hookTable)
736
+ break;
737
+ if (hookTable) {
738
+ tables.push({ header: describeTableHeader(rawLine), line: lineNumberAt(text, pos), start: pos });
739
+ }
740
+ pos = lineEnd;
741
+ }
742
+ return { tables, end: pos };
743
+ }
744
+ /** A kept table as the install names it: its header, its 1-based line
745
+ * number in the config before the install and in the config the install
746
+ * writes (`nextLines`, same order as `kept`), never any value text. */
747
+ function describeKeptHookTables(kept, nextLines) {
748
+ return kept.map((t, i) => `${t.header} (line ${t.line} before the install, line ${nextLines[i]} after it)`);
749
+ }
480
750
  const UTF8_BOM = "\uFEFF";
481
751
  /** A leading UTF-8 BOM is not part of any managed range: a range that would
482
752
  * start at offset 0 starts just past the BOM instead, so the splice keeps
@@ -484,6 +754,86 @@ const UTF8_BOM = "\uFEFF";
484
754
  function startPastBom(text, start) {
485
755
  return start === 0 && text.startsWith(UTF8_BOM) ? UTF8_BOM.length : start;
486
756
  }
757
+ /**
758
+ * The range from `start` to `end`, where the ownership scan stopped short of
759
+ * an END marker, plus what it keeps after `end`: when a stray END marker line
760
+ * follows (a drifted END), the zone up to it is checked for a split block
761
+ * (`assertNoSplitBlock`), its foreign sections and kept operator hook tables
762
+ * are collected, and that END line is excised by the splice (`strayEnd`);
763
+ * without one, the same happens up to the end of the document. Content below
764
+ * that END line is never read here and the splice leaves it untouched.
765
+ * `blockOpening` is the line the split-block refusal names as the start of
766
+ * the block (`assertNoSplitBlock`; the BEGIN marker when omitted).
767
+ */
768
+ function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
769
+ const strayIdx = findExactLine(text, CODEX_MANAGED_END, end);
770
+ if (strayIdx === -1) {
771
+ assertNoSplitBlock(text, end, text.length, configPath, false);
772
+ const keptHookTables = collectKeptHookTables(text, end, text.length).tables;
773
+ const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, text.length, keptHookTables);
774
+ return { start, end, foreignSectionsPreserved, keptHookTables };
775
+ }
776
+ const strayLineStart = lineStartAt(text, strayIdx);
777
+ const strayLineEnd = lineEndAfter(text, strayIdx);
778
+ assertNoSplitBlock(text, end, strayLineStart, configPath, true, blockOpening);
779
+ const keptHookTables = collectKeptHookTables(text, end, strayLineStart).tables;
780
+ const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, strayLineStart, keptHookTables);
781
+ return {
782
+ start,
783
+ end,
784
+ strayEnd: { start: strayLineStart, end: strayLineEnd },
785
+ foreignSectionsPreserved,
786
+ keptHookTables,
787
+ };
788
+ }
789
+ /**
790
+ * The range of a legacy (no BEGIN marker) config: the source-prefix or
791
+ * generated-header paths. When the scan ended at an operator hook table,
792
+ * or at a foreign table with an END marker after it, the zone after it is
793
+ * handled like the BEGIN path's (`rangeBeforeStrayEnd`): an END marker that
794
+ * survived its deleted BEGIN line, sitting after the kept tables or the
795
+ * foreign table, is excised like a drifted END, so the install leaves no
796
+ * orphan END line and the kept-table refusal offers moving an operator-owned
797
+ * table below that END; a harness-commented table after the kept tables or
798
+ * the foreign table refuses through the split-block check instead of being
799
+ * kept as if it were operator content (the fresh block would repeat it)
800
+ * when it sits before that END, or, after kept tables, anywhere later when
801
+ * no END follows (task e8f4fc03 extends the END case to a foreign table).
802
+ * Content below that END is untouched, as on the BEGIN path, so a
803
+ * harness-commented table there is kept and not named. The split-block
804
+ * refusal names `blockOpening` (the source-prefix line or the generated
805
+ * header) as the start of the block, since there is no BEGIN marker (task
806
+ * 01053b27). A scan that ended at a foreign table with no END marker
807
+ * anywhere after it keeps the legacy behaviour (no split-block check), except
808
+ * that hook event tables directly after the block are collected, named and
809
+ * checked (`collectKeptHookTables`), and a harness marker in their run
810
+ * refuses through the split-block check. The END marker is looked up as a
811
+ * whole line outside multi-line strings (`findExactLine`), so END text in a
812
+ * foreign table's value does not route a config here. Like the BEGIN path,
813
+ * the split-block check matches its markers as substrings, so a value in the
814
+ * zone that holds marker text refuses too (see `assertNoSplitBlock`).
815
+ */
816
+ function legacyManagedRange(text, start, scan, configPath, blockOpening) {
817
+ if (scan.sawEndMarker) {
818
+ return { start, end: scan.end, foreignSectionsPreserved: [], keptHookTables: [] };
819
+ }
820
+ if (scan.endedAtOperatorHookTable || findExactLine(text, CODEX_MANAGED_END, scan.end) !== -1) {
821
+ return rangeBeforeStrayEnd(text, start, scan.end, configPath, blockOpening);
822
+ }
823
+ const run = collectKeptHookTables(text, scan.end, text.length);
824
+ // The scan ended at a hook table spelled without the `[[hooks.` prefix: a
825
+ // harness marker in the run of hook tables after it (a harness-commented
826
+ // table, or the respelled table's own comment line) is harness content the
827
+ // scan could not read, so refuse through the split-block check, as the
828
+ // BEGIN path does, instead of naming a table with that comment line above it
829
+ // as one without (task e8f4fc03). The run stops at the first non-hook table:
830
+ // past it the legacy reading below stays unchanged.
831
+ if (run.tables.length > 0)
832
+ assertNoSplitBlock(text, scan.end, run.end, configPath, false);
833
+ const keptHookTables = run.tables;
834
+ const foreignSectionsPreserved = collectForeignSectionHeaders(text, scan.end, text.length, keptHookTables);
835
+ return { start, end: scan.end, foreignSectionsPreserved, keptHookTables };
836
+ }
487
837
  function findManagedRange(text, configPath) {
488
838
  const begin = text.indexOf(CODEX_MANAGED_BEGIN);
489
839
  if (begin !== -1) {
@@ -518,34 +868,14 @@ function findManagedRange(text, configPath) {
518
868
  const scan = scanOwnedContentEnd(text, contentStart);
519
869
  const end = scan.end;
520
870
  if (scan.sawEndMarker) {
521
- return { start, end, foreignSectionsPreserved: [] };
522
- }
523
- const strayIdx = findExactLine(text, CODEX_MANAGED_END, end);
524
- if (strayIdx === -1) {
525
- assertNoSplitBlock(text, end, text.length, configPath, false);
526
- const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, text.length);
527
- return { start, end, foreignSectionsPreserved };
528
- }
529
- const strayLineStart = lineStartAt(text, strayIdx);
530
- const strayLineEnd = lineEndAfter(text, strayIdx);
531
- assertNoSplitBlock(text, end, strayLineStart, configPath, true);
532
- const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, strayLineStart);
533
- return {
534
- start,
535
- end,
536
- strayEnd: { start: strayLineStart, end: strayLineEnd },
537
- foreignSectionsPreserved,
538
- };
871
+ return { start, end, foreignSectionsPreserved: [], keptHookTables: [] };
872
+ }
873
+ return rangeBeforeStrayEnd(text, start, end, configPath);
539
874
  }
540
875
  const source = text.indexOf(CODEX_MANAGED_SOURCE_PREFIX);
541
876
  if (source !== -1) {
542
877
  const start = startPastBom(text, lineStartAt(text, source));
543
- const scan = scanOwnedContentEnd(text, start);
544
- const end = scan.end;
545
- const foreignSectionsPreserved = scan.sawEndMarker
546
- ? []
547
- : collectForeignSectionHeaders(text, end, text.length);
548
- return { start, end, foreignSectionsPreserved };
878
+ return legacyManagedRange(text, start, scanOwnedContentEnd(text, start), configPath, `the '${CODEX_MANAGED_SOURCE_PREFIX}' source-prefix line`);
549
879
  }
550
880
  const generated = text.indexOf(GENERATED_HEADER);
551
881
  if (generated !== -1) {
@@ -555,10 +885,7 @@ function findManagedRange(text, configPath) {
555
885
  const candidate = text.slice(start, end);
556
886
  if (candidate.includes("[[hooks.") ||
557
887
  candidate.includes("[[hooks.pre_tool_use]]")) {
558
- const foreignSectionsPreserved = scan.sawEndMarker
559
- ? []
560
- : collectForeignSectionHeaders(text, end, text.length);
561
- return { start, end, foreignSectionsPreserved };
888
+ return legacyManagedRange(text, start, scan, configPath, GENERATED_HEADER_OPENING);
562
889
  }
563
890
  }
564
891
  return null;
@@ -747,8 +1074,12 @@ function firstDifferingKeyPath(a, b, path) {
747
1074
  * from a harness-written one would need the same line-level reading of the
748
1075
  * file this net is meant to be independent of. An operator-authored
749
1076
  * `[[hooks.<Event>]]` entry that the line scan wrongly counts as part of
750
- * the harness-managed block is therefore replaced without a refusal
751
- * (follow-up task 01053b27).
1077
+ * the harness-managed block is therefore replaced without a refusal (task
1078
+ * 01053b27 covers the operator table after the harness tables, which the scan
1079
+ * now keeps and names; still replaced silently are an operator table before
1080
+ * the first harness table inside the block, one under a comment run that
1081
+ * holds a `# harness hook:` line, bare keys directly under a harness table,
1082
+ * and one after an old block that never carried the comments).
752
1083
  *
753
1084
  * Parsing goes through `parseToml` (a leading byte order mark ignored,
754
1085
  * 64-bit integers kept as `bigint`). A document that does not parse refuses
@@ -896,6 +1227,242 @@ function withBackupLocation(linkPath, check) {
896
1227
  `sit beside the link, as ${linkPath}.harness-backup-* files.`, err.configPath);
897
1228
  }
898
1229
  }
1230
+ /** A header key segment's name as TOML reads it: a bare key as written, a
1231
+ * literal (`'...'`) key's inner text, a basic (`"..."`) key's inner text
1232
+ * when it holds no escape sequence. `null` for a basic key with an escape,
1233
+ * which this scan does not decode; a header holding one is not matched to a
1234
+ * parsed entry (see `mapHookArrayHeaders`). */
1235
+ function resolvedKeyName(segment) {
1236
+ const quote = segment[0];
1237
+ if (quote === "'")
1238
+ return segment.slice(1, -1);
1239
+ if (quote === '"') {
1240
+ const inner = segment.slice(1, -1);
1241
+ return inner.includes("\\") ? null : inner;
1242
+ }
1243
+ return segment;
1244
+ }
1245
+ /** Whether a header-shaped line's first key resolves to `hooks`, the only
1246
+ * root `mapHookArrayHeaders` resolves. */
1247
+ function hooksRooted(trimmed) {
1248
+ return resolvedKeyName(parseTableHeader(trimmed)?.keys[0] ?? "") === "hooks";
1249
+ }
1250
+ /** Whether a header line is an array-of-tables header that appends to a
1251
+ * hook event array or below one (`[[hooks.Stop]]`, `[[ hooks.Stop ]]`,
1252
+ * `[["hooks".Stop]]`, `[[hooks . Stop.hooks]]`): double brackets, a first key
1253
+ * that resolves to `hooks` (`hooksRooted`) and at least an event key after
1254
+ * it. A one-key line (`[["hooks"]]`, which may also be an array element on
1255
+ * its own line) is not one. Used by `collectKeptHookTables` for the
1256
+ * spellings the `[[hooks.` prefix does not catch (task e8f4fc03). */
1257
+ function isHookEventArrayHeader(trimmed) {
1258
+ const parsed = parseTableHeader(trimmed);
1259
+ return (parsed !== null && parsed.header.startsWith("[[") && parsed.keys.length >= 2 && hooksRooted(trimmed));
1260
+ }
1261
+ function subKey(event, index) {
1262
+ return `${event}\u0000${index}`;
1263
+ }
1264
+ /**
1265
+ * Every table header of the whole document that appends to a hook
1266
+ * event array (`[[hooks.<Event>]]`, however its keys are spelled) or to such
1267
+ * an entry's own `hooks` array (`[[hooks.<Event>.hooks]]`), with the entry it
1268
+ * appends: TOML appends each header's table to its array in document order,
1269
+ * and a sub-array header to the array's LAST entry so far. Only the lines
1270
+ * `createLineClassifier` reads as a header are resolved: a line inside a
1271
+ * multi-line string, and an array element on its own line, are skipped. A line resolved
1272
+ * to such a header cannot be a value line of a document that parses (a
1273
+ * dotted bare path is not a TOML value), and a header this scan cannot
1274
+ * resolve (an escape in a quoted key) is not counted, so the caller compares
1275
+ * `eventCounts` and `subCounts` with the parsed arrays' lengths and treats a
1276
+ * mismatch as "cannot match exactly". The classifier reads a header-shaped
1277
+ * line as a header here only when its root key resolves to `hooks`
1278
+ * (`hooksRooted`, checked before the top-level probe), so every header this
1279
+ * scan resolves has the root `hooks` (a `[[hooks.` line has it by its
1280
+ * prefix), a table outside `hooks` never shifts the order match, and an
1281
+ * array element on its own line (`["x"]`, `[1]`) costs no parse in this
1282
+ * scan, unless the element line reads as a header whose first key is
1283
+ * `hooks` (`["hooks"]`, `['hooks']`, `[["hooks"]]`, `[[ 'hooks' ]]`): that
1284
+ * line reads as a `hooks` root, so it is probed like the `hooks`-rooted
1285
+ * spellings that `[[hooks.` does not catch (`[[ hooks.Stop ]]`,
1286
+ * `[["hooks".Stop]]`, `[hooks.Stop]`), and the probe finds it inside the
1287
+ * array.
1288
+ */
1289
+ function mapHookArrayHeaders(text) {
1290
+ const map = { slots: new Map(), eventCounts: new Map(), subCounts: new Map() };
1291
+ let pos = 0;
1292
+ const classify = createLineClassifier(text, hooksRooted);
1293
+ while (pos < text.length) {
1294
+ const lineEnd = lineEndAfter(text, pos);
1295
+ const rawLine = text.slice(pos, lineEnd);
1296
+ const kind = classify(pos, rawLine);
1297
+ const parsed = kind === "hookHeader" || kind === "foreignHeader" ? parseTableHeader(rawLine) : null;
1298
+ // The bracket kind is not checked: a single-bracket `[hooks.<Event>]`
1299
+ // cannot coexist with a `[[hooks.<Event>]]` array in a document that
1300
+ // parses, and a single-bracket `[hooks.<Event>.hooks]` makes that entry's
1301
+ // `hooks` a table, whose `command` is not a hook command either way.
1302
+ if (parsed !== null) {
1303
+ const [, event, sub] = parsed.keys.map(resolvedKeyName);
1304
+ if (typeof event === "string") {
1305
+ const count = map.eventCounts.get(event) ?? 0;
1306
+ if (parsed.keys.length === 2) {
1307
+ map.slots.set(pos, { event, index: count });
1308
+ map.eventCounts.set(event, count + 1);
1309
+ }
1310
+ else if (parsed.keys.length === 3 && sub === "hooks") {
1311
+ // Appends to the event's last entry so far; with no entry before
1312
+ // it (`index` -1) TOML makes the event a table, not an array, and
1313
+ // the caller finds no array to match.
1314
+ const key = subKey(event, count - 1);
1315
+ const subIndex = map.subCounts.get(key) ?? 0;
1316
+ map.slots.set(pos, { event, index: count - 1, subIndex });
1317
+ map.subCounts.set(key, subIndex + 1);
1318
+ }
1319
+ }
1320
+ }
1321
+ pos = lineEnd;
1322
+ }
1323
+ return map;
1324
+ }
1325
+ /** The hook command of one parsed hook entry, `{ command = "..." }`, as a
1326
+ * one-element list; empty for anything else. */
1327
+ function hookCommandOf(entry) {
1328
+ return isTomlTable(entry) && typeof entry.command === "string" ? [entry.command] : [];
1329
+ }
1330
+ /** The hook commands of one parsed `hooks.<Event>` entry: its own `command`
1331
+ * plus each `command` of its `hooks` array (inline `hooks = [{ ... }]` or the
1332
+ * expanded `[[hooks.<Event>.hooks]]` form). No other `command` key counts. */
1333
+ function eventEntryCommands(entry) {
1334
+ if (!isTomlTable(entry))
1335
+ return [];
1336
+ const nested = Array.isArray(entry.hooks) ? entry.hooks.flatMap(hookCommandOf) : [];
1337
+ return [...hookCommandOf(entry), ...nested];
1338
+ }
1339
+ /** Every hook command of a parsed document's `hooks.<Event>` arrays. */
1340
+ function documentHookCommands(doc) {
1341
+ if (!isTomlTable(doc) || !isTomlTable(doc.hooks))
1342
+ return [];
1343
+ return Object.values(doc.hooks).flatMap((value) => Array.isArray(value) ? value.flatMap(eventEntryCommands) : []);
1344
+ }
1345
+ /**
1346
+ * The hook commands a kept table defines, read from the parsed whole
1347
+ * document (`doc`) at the entry its header appends (`map`): for
1348
+ * `[[hooks.<Event>]]` the entry's own `command`, plus its `hooks` array's
1349
+ * commands unless `[[hooks.<Event>.hooks]]` headers build that array (each
1350
+ * of those is its own kept table); for `[[hooks.<Event>.hooks]]` that nested
1351
+ * entry's `command`. A header appending nowhere else (`[[hooks.<Event>.x]]`)
1352
+ * defines none. `null` when the header cannot be matched exactly: a header
1353
+ * the scan did not resolve, a count of headers that differs from the parsed
1354
+ * array's length, or a nested `hooks` header with no entry before it.
1355
+ */
1356
+ function keptTableCommands(table, doc, map) {
1357
+ const slot = map.slots.get(table.start);
1358
+ if (slot === undefined) {
1359
+ // Not an append the map resolved: a `[[hooks.<Event>.x]]` table holds no
1360
+ // hook command; a header with a key the map does not resolve cannot be
1361
+ // matched.
1362
+ const resolved = parseTableHeader(table.header)?.keys.every((k) => resolvedKeyName(k) !== null);
1363
+ return resolved === true ? [] : null;
1364
+ }
1365
+ const hooks = isTomlTable(doc) && isTomlTable(doc.hooks) ? doc.hooks : undefined;
1366
+ const entries = hooks?.[slot.event];
1367
+ if (!Array.isArray(entries) || entries.length !== map.eventCounts.get(slot.event))
1368
+ return null;
1369
+ const entry = entries[slot.index];
1370
+ const subCount = map.subCounts.get(subKey(slot.event, slot.index)) ?? 0;
1371
+ if (slot.subIndex === undefined) {
1372
+ return subCount > 0 ? hookCommandOf(entry) : eventEntryCommands(entry);
1373
+ }
1374
+ const nested = isTomlTable(entry) && Array.isArray(entry.hooks) ? entry.hooks : [];
1375
+ if (nested.length !== subCount)
1376
+ return null;
1377
+ return hookCommandOf(nested[slot.subIndex]);
1378
+ }
1379
+ /** The move-below-END option of a kept-table refusal: only when the config
1380
+ * has an END marker after the kept tables, since below it the install never
1381
+ * touches a table (an operator-owned table whose command starts with
1382
+ * `harness `, the operator's own CLI, is kept that way). Every range with
1383
+ * kept tables and an END marker after them records that marker as
1384
+ * `strayEnd`, on the BEGIN path and on the legacy paths alike
1385
+ * (`rangeBeforeStrayEnd`), so the caller keys the option on it. */
1386
+ function moveBelowEndOption(hasEndMarker) {
1387
+ return hasEndMarker
1388
+ ? `, or, if it is an operator-owned table, move it below the '${CODEX_MANAGED_END}' marker, ` +
1389
+ "where the install never touches it"
1390
+ : "";
1391
+ }
1392
+ /** Parses the whole document for the kept-table check, refusing with the
1393
+ * parser's position and one-line reason only (never its code frame). */
1394
+ function parseForKeptTableCheck(content, what, configPath) {
1395
+ try {
1396
+ return parseToml(content);
1397
+ }
1398
+ catch (err) {
1399
+ throw new CodexInstallRefusalError(`Codex config ${configPath}: the TOML parser used by harness could not read ${what} ` +
1400
+ `(${describeTomlParseError(err)}), so harness cannot check the operator hook tables it ` +
1401
+ "would keep after the harness hook tables; refusing to install. The file is untouched.", configPath);
1402
+ }
1403
+ }
1404
+ /**
1405
+ * Refuses (file untouched) when an operator hook table the install keeps
1406
+ * looks like one harness wrote: one of its hook commands equals a command of
1407
+ * the fresh block, or starts with `harness `. The line scan tells a harness
1408
+ * table from an operator one only by the `# harness hook:` comment above it,
1409
+ * so a harness table whose comment an operator deleted, sitting last, would
1410
+ * otherwise be kept next to the fresh block: the hook would run twice, or a
1411
+ * hook the manifest retired would keep running after every later install
1412
+ * (task 01053b27). A retired hook whose command does not start with
1413
+ * `harness ` (an environment-prefixed report-dir wrap, the memory-router
1414
+ * hook, a custom manifest hook) is not recognised: it is kept and named only.
1415
+ *
1416
+ * The commands come from the parsed WHOLE document (the same `parseToml` the
1417
+ * semantic net uses), each kept table matched to its parsed entry by the
1418
+ * order of the `[[hooks.<Event>]]` headers (`mapHookArrayHeaders`); no part
1419
+ * of the file is parsed on its own. Only hook commands count
1420
+ * (`hooks.<Event>[].command` and `hooks.<Event>[].hooks[].command`), not any
1421
+ * other key named `command`. A kept table that cannot be matched exactly
1422
+ * refuses too. Every message names the table's header and line number only,
1423
+ * never a command or any other value text, and never a parser code frame,
1424
+ * since a refusal is printed to stderr and into `--json` output.
1425
+ */
1426
+ function assertKeptHookTablesAreOperatorOwned(kept, currentContent, generatedContent, configPath, hasEndMarker) {
1427
+ if (kept.length === 0)
1428
+ return;
1429
+ const freshCommands = new Set(documentHookCommands(parseForKeptTableCheck(generatedContent, "the generated hook block", configPath)));
1430
+ const doc = parseForKeptTableCheck(currentContent, "this file", configPath);
1431
+ const map = mapHookArrayHeaders(currentContent);
1432
+ for (const table of kept) {
1433
+ const commands = keptTableCommands(table, doc, map);
1434
+ if (commands === null) {
1435
+ throw new CodexInstallRefusalError(`Codex config ${configPath} has a hook table ${table.header} (line ${table.line}) with no ` +
1436
+ `'${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line above it, placed after the ` +
1437
+ "harness hook tables, that harness could not match to its entry in the parsed config " +
1438
+ "(a header it does not resolve: an escape sequence in a quoted key, on this table or on " +
1439
+ "another table of the same hook event, or a nested hooks table it cannot place), so it " +
1440
+ "cannot check whether harness wrote that table. Spell those header keys without escape " +
1441
+ `sequences, restore the '${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line ` +
1442
+ `directly above ${table.header} if harness wrote it${moveBelowEndOption(hasEndMarker)}, or ` +
1443
+ "delete the table, then re-run `harness apply --runtime codex --install`. The file is untouched.", configPath);
1444
+ }
1445
+ const harnessShaped = commands.some((command) => freshCommands.has(command) || command.startsWith("harness "));
1446
+ if (!harnessShaped)
1447
+ continue;
1448
+ // harness writes every hook table as `[[hooks.<Event>]]`; a table spelled
1449
+ // another way (`[[ hooks.Stop ]]`) never was one, and with the comment
1450
+ // line restored above it the scan would still read it as a foreign
1451
+ // table, so the restore fix is offered only for the harness spelling
1452
+ // (task e8f4fc03).
1453
+ const fix = HOOK_ARRAY_HEADER_RE.test(table.header)
1454
+ ? `Restore the comment line directly above ${table.header}, so the install replaces the table ` +
1455
+ `with the fresh block${moveBelowEndOption(hasEndMarker)}, or delete the table`
1456
+ : "Delete the table (the install replaces only hook tables spelled '[[hooks.<Event>]]' with " +
1457
+ `that comment line above them)${moveBelowEndOption(hasEndMarker)}`;
1458
+ throw new CodexInstallRefusalError(`Codex config ${configPath} has a hook table ${table.header} (line ${table.line}) with no ` +
1459
+ `'${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line above it, placed after the ` +
1460
+ "harness hook tables, whose command is one harness itself writes. harness cannot tell it " +
1461
+ "from a harness hook table whose comment line was deleted, and keeping it next to the " +
1462
+ "fresh harness block would run that hook twice (or keep a retired harness hook running). " +
1463
+ `${fix}, then re-run \`harness apply --runtime codex --install\`. The file is untouched.`, configPath);
1464
+ }
1465
+ }
899
1466
  export function planCodexConfigInstall(opts) {
900
1467
  validateCodexManagedConfig(opts.generatedContent);
901
1468
  const requestedPath = opts.configPath ?? defaultCodexConfigPath(opts.homeDir);
@@ -919,16 +1486,29 @@ export function planCodexConfigInstall(opts) {
919
1486
  let summary;
920
1487
  let removedHookIds = [];
921
1488
  let foreignSectionsPreserved = [];
1489
+ let keptHookTables = [];
1490
+ let keptTableDescriptions = [];
1491
+ let hasEndMarkerAfterKept = false;
922
1492
  if (range) {
923
1493
  const oldHookIds = extractHookIds(currentContent.slice(range.start, range.end));
924
1494
  const newHookIds = extractHookIds(managedBlock);
925
1495
  removedHookIds = [...oldHookIds].filter((id) => !newHookIds.has(id));
926
- foreignSectionsPreserved = range.foreignSectionsPreserved;
1496
+ keptHookTables = range.keptHookTables;
1497
+ hasEndMarkerAfterKept = range.strayEnd !== undefined;
927
1498
  const middleForeign = range.strayEnd
928
1499
  ? currentContent.slice(range.end, range.strayEnd.start)
929
1500
  : "";
930
1501
  const tailStart = range.strayEnd ? range.strayEnd.end : range.end;
931
1502
  nextContent = `${currentContent.slice(0, range.start)}${managedBlock}${middleForeign}${currentContent.slice(tailStart)}`;
1503
+ // A kept table sits right after the fresh block in the written config
1504
+ // (it is in `middleForeign`, or at the start of the tail when there is
1505
+ // no stray END), so its offset there follows from the splice.
1506
+ const shift = range.start + managedBlock.length - range.end;
1507
+ keptTableDescriptions = describeKeptHookTables(keptHookTables, keptHookTables.map((t) => lineNumberAt(nextContent, t.start + shift)));
1508
+ // The kept operator hook tables ride the same list, so the dry-run and
1509
+ // the install summary name them exactly like the foreign tables kept
1510
+ // beside them (task 01053b27).
1511
+ foreignSectionsPreserved = [...range.foreignSectionsPreserved, ...keptTableDescriptions];
932
1512
  summary = `updated harness-managed Codex hook block in ${configPath}${viaLink}`;
933
1513
  }
934
1514
  else {
@@ -945,6 +1525,7 @@ export function planCodexConfigInstall(opts) {
945
1525
  if (currentContent !== nextContent) {
946
1526
  withBackupLocation(linkPath, () => assertConfigSemanticInvariant(currentContent, nextContent, configPath));
947
1527
  }
1528
+ assertKeptHookTablesAreOperatorOwned(keptHookTables, currentContent, opts.generatedContent, configPath, hasEndMarkerAfterKept);
948
1529
  return {
949
1530
  configPath,
950
1531
  ...(linkPath !== undefined ? { linkPath } : {}),
@@ -958,6 +1539,7 @@ export function planCodexConfigInstall(opts) {
958
1539
  : summary,
959
1540
  removedHookIds,
960
1541
  foreignSectionsPreserved,
1542
+ keptOperatorHookTables: keptTableDescriptions,
961
1543
  };
962
1544
  }
963
1545
  function timestampForBackup(now) {