@lannguyensi/harness 0.59.0 → 0.59.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 +27 -3
- package/README.md +70 -266
- package/dist/cli/apply/apply.js +2 -2
- package/dist/cli/apply/apply.js.map +1 -1
- package/dist/cli/apply/install-codex-config.d.ts +105 -2
- package/dist/cli/apply/install-codex-config.js +603 -66
- package/dist/cli/apply/install-codex-config.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js +6 -0
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance.d.ts +70 -2
- package/dist/cli/pack/hook-solution-acceptance.js +81 -28
- package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +4 -2
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js +88 -4
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
- 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
|
|
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,
|
|
@@ -251,33 +396,66 @@ function firstContentLine(text, from, limit) {
|
|
|
251
396
|
function scanOwnedContentEnd(text, start) {
|
|
252
397
|
let pos = start;
|
|
253
398
|
let commentRunStart = null;
|
|
254
|
-
let
|
|
399
|
+
let sawOwnedContent = false;
|
|
400
|
+
let sawMarkedHookTable = false;
|
|
401
|
+
const classify = createLineClassifier(text);
|
|
255
402
|
while (pos < text.length) {
|
|
256
403
|
const lineEnd = lineEndAfter(text, pos);
|
|
257
404
|
const rawLine = text.slice(pos, lineEnd);
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
if (startedInsideString) {
|
|
405
|
+
const kind = classify(pos, rawLine);
|
|
406
|
+
if (kind === "string") {
|
|
261
407
|
commentRunStart = null;
|
|
408
|
+
sawOwnedContent = true;
|
|
262
409
|
pos = lineEnd;
|
|
263
410
|
continue;
|
|
264
411
|
}
|
|
265
|
-
|
|
266
|
-
if (trimmed === CODEX_MANAGED_END) {
|
|
412
|
+
if (rawLine.trim() === CODEX_MANAGED_END) {
|
|
267
413
|
return { end: lineEnd, sawEndMarker: true };
|
|
268
414
|
}
|
|
269
|
-
if (
|
|
415
|
+
if (kind === "comment") {
|
|
270
416
|
if (commentRunStart === null)
|
|
271
417
|
commentRunStart = pos;
|
|
272
418
|
pos = lineEnd;
|
|
273
419
|
continue;
|
|
274
420
|
}
|
|
275
|
-
if (
|
|
421
|
+
if (kind === "foreignHeader") {
|
|
276
422
|
return { end: commentRunStart ?? pos, sawEndMarker: false };
|
|
277
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
|
+
}
|
|
278
437
|
commentRunStart = null;
|
|
438
|
+
sawOwnedContent = true;
|
|
279
439
|
pos = lineEnd;
|
|
280
440
|
}
|
|
441
|
+
// No END marker and no foreign table: a run of blank/comment lines that
|
|
442
|
+
// reaches EOF and holds at least one comment is an operator's trailing
|
|
443
|
+
// comment, not harness content, so back off to the start of that run
|
|
444
|
+
// (task b34ed105). Not when the run holds a harness-authored line (the
|
|
445
|
+
// generated header, a `# harness hook:` line, the source prefix: the
|
|
446
|
+
// generator itself writes comment-only content, so such a run is stale
|
|
447
|
+
// harness output that must be replaced), and not before the scan has seen
|
|
448
|
+
// an owned content line. A run of only blank lines is still consumed.
|
|
449
|
+
if (commentRunStart !== null && sawOwnedContent) {
|
|
450
|
+
const run = text.slice(commentRunStart, pos);
|
|
451
|
+
const hasComment = /(^|\n)[ \t]*#/.test(run);
|
|
452
|
+
const harnessAuthored = run.includes(HARNESS_HOOK_COMMENT_PREFIX) ||
|
|
453
|
+
run.includes(CODEX_MANAGED_SOURCE_PREFIX) ||
|
|
454
|
+
run.includes(GENERATED_HEADER);
|
|
455
|
+
if (hasComment && !harnessAuthored) {
|
|
456
|
+
return { end: commentRunStart, sawEndMarker: false };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
281
459
|
return { end: pos, sawEndMarker: false };
|
|
282
460
|
}
|
|
283
461
|
/**
|
|
@@ -307,9 +485,21 @@ function scanOwnedContentEnd(text, start) {
|
|
|
307
485
|
* document (`to` is `text.length`), telling the operator to move something
|
|
308
486
|
* below a marker that does not exist would be nonsensical, so a different
|
|
309
487
|
* fix (add one, or move the table below the last harness hook table) is
|
|
310
|
-
* offered instead (task 6a037359).
|
|
488
|
+
* offered instead (task 6a037359). `blockOpening` names the line the block
|
|
489
|
+
* starts at in the delete-the-lines fix: the BEGIN marker by default; a
|
|
490
|
+
* legacy config (no BEGIN marker) whose END marker survived passes its
|
|
491
|
+
* source-prefix line or generated header instead, since a BEGIN marker it
|
|
492
|
+
* does not have is no line to delete from (task 01053b27). For the generated
|
|
493
|
+
* header the fix deletes that line and the END marker line too: a generated
|
|
494
|
+
* header left alone above END no longer marks a harness block
|
|
495
|
+
* (`findManagedRange` needs a `[[hooks.` line after it), so the install
|
|
496
|
+
* would add a fresh block and leave that pair behind as an orphan. Exported for
|
|
497
|
+
* direct tests only: the scan
|
|
498
|
+
* that feeds it never hands it a line that is not a real header, so its own
|
|
499
|
+
* naming rule is reachable in isolation only by calling it directly (task
|
|
500
|
+
* 6b56d735).
|
|
311
501
|
*/
|
|
312
|
-
function assertNoSplitBlock(text, from, to, configPath, hasEndMarker) {
|
|
502
|
+
export function assertNoSplitBlock(text, from, to, configPath, hasEndMarker, blockOpening = `'${CODEX_MANAGED_BEGIN}'`) {
|
|
313
503
|
const zoneStart = Math.max(from, 0);
|
|
314
504
|
const zoneEnd = Math.max(to, from);
|
|
315
505
|
const zone = text.slice(zoneStart, zoneEnd);
|
|
@@ -338,13 +528,31 @@ function assertNoSplitBlock(text, from, to, configPath, hasEndMarker) {
|
|
|
338
528
|
const markerLineEnd = lineEndAfter(text, zoneStart + markerOffset);
|
|
339
529
|
const foreignHeaderLine = firstContentLine(text, zoneStart, zoneStart + markerOffset) ??
|
|
340
530
|
firstContentLine(text, markerLineEnd, zoneEnd);
|
|
341
|
-
const firstForeignHeader =
|
|
342
|
-
const
|
|
343
|
-
?
|
|
344
|
-
`
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
531
|
+
const firstForeignHeader = describeForeignHeaderLine(text, foreignHeaderLine);
|
|
532
|
+
const fixes = hasEndMarker
|
|
533
|
+
? [
|
|
534
|
+
`move ${firstForeignHeader} below the '${CODEX_MANAGED_END}' marker`,
|
|
535
|
+
blockOpening === GENERATED_HEADER_OPENING
|
|
536
|
+
? `delete every line from ${blockOpening} through the '${CODEX_MANAGED_END}' marker ` +
|
|
537
|
+
"line, both included, so the install writes a fresh harness block"
|
|
538
|
+
: `delete the lines between ${blockOpening} and '${CODEX_MANAGED_END}' so only ` +
|
|
539
|
+
"harness-owned tables remain between them",
|
|
540
|
+
]
|
|
541
|
+
: [
|
|
542
|
+
`add a '${CODEX_MANAGED_END}' marker line right after the last harness hook table ` +
|
|
543
|
+
`(before ${firstForeignHeader})`,
|
|
544
|
+
`move ${firstForeignHeader} below the last harness hook table`,
|
|
545
|
+
];
|
|
546
|
+
// An uncommented hook table in the middle may be a harness table whose
|
|
547
|
+
// comment line was deleted, so moving it (the operator-table fix) is only
|
|
548
|
+
// right for an operator-owned one; name the restore option first (task
|
|
549
|
+
// 01053b27).
|
|
550
|
+
const isHookTable = HOOK_ARRAY_HEADER_RE.test(foreignHeaderLine?.line ?? "");
|
|
551
|
+
const guidance = isHookTable
|
|
552
|
+
? `If ${firstForeignHeader} is a harness hook table whose '${HARNESS_HOOK_COMMENT_PREFIX}<id> ` +
|
|
553
|
+
"(budget_ms=<n>)' comment line was deleted, restore that comment line directly above it. " +
|
|
554
|
+
`If it is an operator-owned table, ${fixes.join(", or ")}`
|
|
555
|
+
: fixes.join(", or ").replace(/^./, (c) => c.toUpperCase());
|
|
348
556
|
throw new CodexInstallRefusalError(`Codex config ${configPath} has a foreign table (${firstForeignHeader}) sitting before ` +
|
|
349
557
|
`more harness-owned content: line ${offendingLineNumber} contains '${marker}' and still ` +
|
|
350
558
|
"looks like part of the harness-managed hook block; refusing to guess the block " +
|
|
@@ -377,6 +585,23 @@ function parseTableHeader(line) {
|
|
|
377
585
|
function describeTableHeader(line) {
|
|
378
586
|
return parseTableHeader(line)?.header ?? "(unknown)";
|
|
379
587
|
}
|
|
588
|
+
/** How a refusal names the line it found where the foreign table's header
|
|
589
|
+
* should be. A verified header prints as its bracketed key path. A line that
|
|
590
|
+
* has the shape of a header but is not one (an array element on its own line
|
|
591
|
+
* inside a multi-line value) is never printed, since it can hold a config
|
|
592
|
+
* value such as a token; only its line number is. Any other line is
|
|
593
|
+
* `(unknown)`, as before. */
|
|
594
|
+
function describeForeignHeaderLine(text, found) {
|
|
595
|
+
if (found === null)
|
|
596
|
+
return "(unknown)";
|
|
597
|
+
const parsed = parseTableHeader(found.line);
|
|
598
|
+
if (parsed === null)
|
|
599
|
+
return "(unknown)";
|
|
600
|
+
if (!createTopLevelProbe(text)(found.start)) {
|
|
601
|
+
return `line ${lineNumberAt(text, found.start)}`;
|
|
602
|
+
}
|
|
603
|
+
return parsed.header;
|
|
604
|
+
}
|
|
380
605
|
/** A key segment's bare name: a literal (`'...'`) key's inner text, a basic
|
|
381
606
|
* (`"..."`) key's inner text when it holds no escape sequence, otherwise
|
|
382
607
|
* the segment as written. Used only to recognize the `hooks` root however
|
|
@@ -420,18 +645,12 @@ function collectForeignSectionHeaders(text, from, to) {
|
|
|
420
645
|
const counts = new Map();
|
|
421
646
|
let pos = Math.max(from, 0);
|
|
422
647
|
const limit = Math.max(to, from);
|
|
423
|
-
|
|
648
|
+
const classify = createLineClassifier(text);
|
|
424
649
|
while (pos < limit) {
|
|
425
650
|
const lineEnd = Math.min(lineEndAfter(text, pos), limit);
|
|
426
651
|
const rawLine = text.slice(pos, lineEnd);
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (startedInsideString) {
|
|
430
|
-
pos = lineEnd;
|
|
431
|
-
continue;
|
|
432
|
-
}
|
|
433
|
-
const trimmed = rawLine.trim();
|
|
434
|
-
if (NON_HOOK_TABLE_RE.test(trimmed)) {
|
|
652
|
+
if (classify(pos, rawLine) === "foreignHeader") {
|
|
653
|
+
const trimmed = rawLine.trim();
|
|
435
654
|
const root = foreignSectionRootKey(trimmed);
|
|
436
655
|
counts.set(root, (counts.get(root) ?? 0) + 1);
|
|
437
656
|
if (!seenRoots.has(root)) {
|
|
@@ -456,6 +675,107 @@ function extractHookIds(content) {
|
|
|
456
675
|
}
|
|
457
676
|
return ids;
|
|
458
677
|
}
|
|
678
|
+
/**
|
|
679
|
+
* The operator-authored hook tables directly after a managed range that
|
|
680
|
+
* ended at one (`scanOwnedContentEnd`'s `endedAtOperatorHookTable`): every
|
|
681
|
+
* `[[hooks.*]]` header from `from` up to the first non-hook table header or
|
|
682
|
+
* `to`, each by header, line number and offset. Lines are read by
|
|
683
|
+
* `createLineClassifier`, so an array element on its own line inside a kept
|
|
684
|
+
* table's multi-line value neither ends the run nor is named, and a hook
|
|
685
|
+
* table after it is still collected (task 01053b27). Empty for any other ending
|
|
686
|
+
* (the first header at `from` is then a non-hook table, or there is none).
|
|
687
|
+
* The caller has already refused a zone that holds a harness marker
|
|
688
|
+
* (`assertNoSplitBlock`), so no header found here carries a `# harness
|
|
689
|
+
* hook:` comment.
|
|
690
|
+
*/
|
|
691
|
+
function collectKeptHookTables(text, from, to) {
|
|
692
|
+
const tables = [];
|
|
693
|
+
let pos = from;
|
|
694
|
+
const classify = createLineClassifier(text);
|
|
695
|
+
while (pos < to) {
|
|
696
|
+
const lineEnd = Math.min(lineEndAfter(text, pos), to);
|
|
697
|
+
const rawLine = text.slice(pos, lineEnd);
|
|
698
|
+
const kind = classify(pos, rawLine);
|
|
699
|
+
if (kind === "foreignHeader")
|
|
700
|
+
break;
|
|
701
|
+
if (kind === "hookHeader") {
|
|
702
|
+
tables.push({ header: describeTableHeader(rawLine), line: lineNumberAt(text, pos), start: pos });
|
|
703
|
+
}
|
|
704
|
+
pos = lineEnd;
|
|
705
|
+
}
|
|
706
|
+
return tables;
|
|
707
|
+
}
|
|
708
|
+
/** A kept table as the install names it: its header, its 1-based line
|
|
709
|
+
* number in the config before the install and in the config the install
|
|
710
|
+
* writes (`nextLines`, same order as `kept`), never any value text. */
|
|
711
|
+
function describeKeptHookTables(kept, nextLines) {
|
|
712
|
+
return kept.map((t, i) => `${t.header} (line ${t.line} before the install, line ${nextLines[i]} after it)`);
|
|
713
|
+
}
|
|
714
|
+
const UTF8_BOM = "\uFEFF";
|
|
715
|
+
/** A leading UTF-8 BOM is not part of any managed range: a range that would
|
|
716
|
+
* start at offset 0 starts just past the BOM instead, so the splice keeps
|
|
717
|
+
* the BOM exactly once (task b34ed105). */
|
|
718
|
+
function startPastBom(text, start) {
|
|
719
|
+
return start === 0 && text.startsWith(UTF8_BOM) ? UTF8_BOM.length : start;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* The range from `start` to `end`, where the ownership scan stopped short of
|
|
723
|
+
* an END marker, plus what it keeps after `end`: when a stray END marker line
|
|
724
|
+
* follows (a drifted END), the zone up to it is checked for a split block
|
|
725
|
+
* (`assertNoSplitBlock`), its foreign sections and kept operator hook tables
|
|
726
|
+
* are collected, and that END line is excised by the splice (`strayEnd`);
|
|
727
|
+
* without one, the same happens up to the end of the document. Content below
|
|
728
|
+
* that END line is never read here and the splice leaves it untouched.
|
|
729
|
+
* `blockOpening` is the line the split-block refusal names as the start of
|
|
730
|
+
* the block (`assertNoSplitBlock`; the BEGIN marker when omitted).
|
|
731
|
+
*/
|
|
732
|
+
function rangeBeforeStrayEnd(text, start, end, configPath, blockOpening) {
|
|
733
|
+
const strayIdx = findExactLine(text, CODEX_MANAGED_END, end);
|
|
734
|
+
if (strayIdx === -1) {
|
|
735
|
+
assertNoSplitBlock(text, end, text.length, configPath, false);
|
|
736
|
+
const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, text.length);
|
|
737
|
+
const keptHookTables = collectKeptHookTables(text, end, text.length);
|
|
738
|
+
return { start, end, foreignSectionsPreserved, keptHookTables };
|
|
739
|
+
}
|
|
740
|
+
const strayLineStart = lineStartAt(text, strayIdx);
|
|
741
|
+
const strayLineEnd = lineEndAfter(text, strayIdx);
|
|
742
|
+
assertNoSplitBlock(text, end, strayLineStart, configPath, true, blockOpening);
|
|
743
|
+
const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, strayLineStart);
|
|
744
|
+
const keptHookTables = collectKeptHookTables(text, end, strayLineStart);
|
|
745
|
+
return {
|
|
746
|
+
start,
|
|
747
|
+
end,
|
|
748
|
+
strayEnd: { start: strayLineStart, end: strayLineEnd },
|
|
749
|
+
foreignSectionsPreserved,
|
|
750
|
+
keptHookTables,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* The range of a legacy (no BEGIN marker) config: the source-prefix or
|
|
755
|
+
* generated-header paths. When the scan ended at an operator hook table,
|
|
756
|
+
* the zone after it is handled like the BEGIN path's
|
|
757
|
+
* (`rangeBeforeStrayEnd`): an END marker that survived its deleted BEGIN
|
|
758
|
+
* line, sitting after the kept tables, is excised like a drifted END, so
|
|
759
|
+
* the install leaves no orphan END line below the kept tables and the
|
|
760
|
+
* kept-table refusal offers moving an operator-owned table below that END;
|
|
761
|
+
* a harness-commented table after the kept tables refuses through the
|
|
762
|
+
* split-block check instead of being kept as if it were operator content
|
|
763
|
+
* (the fresh block would repeat it) when it sits before that END, or
|
|
764
|
+
* anywhere later when no END follows. Content below that END is untouched,
|
|
765
|
+
* as on the BEGIN path, so a harness-commented table there is kept and not
|
|
766
|
+
* named. The split-block refusal names `blockOpening` (the source-prefix
|
|
767
|
+
* line or the generated header) as the start of the block, since there is
|
|
768
|
+
* no BEGIN marker (task 01053b27). Other endings keep the legacy behaviour.
|
|
769
|
+
*/
|
|
770
|
+
function legacyManagedRange(text, start, scan, configPath, blockOpening) {
|
|
771
|
+
if (scan.endedAtOperatorHookTable) {
|
|
772
|
+
return rangeBeforeStrayEnd(text, start, scan.end, configPath, blockOpening);
|
|
773
|
+
}
|
|
774
|
+
const foreignSectionsPreserved = scan.sawEndMarker
|
|
775
|
+
? []
|
|
776
|
+
: collectForeignSectionHeaders(text, scan.end, text.length);
|
|
777
|
+
return { start, end: scan.end, foreignSectionsPreserved, keptHookTables: [] };
|
|
778
|
+
}
|
|
459
779
|
function findManagedRange(text, configPath) {
|
|
460
780
|
const begin = text.indexOf(CODEX_MANAGED_BEGIN);
|
|
461
781
|
if (begin !== -1) {
|
|
@@ -478,54 +798,36 @@ function findManagedRange(text, configPath) {
|
|
|
478
798
|
}
|
|
479
799
|
let start = lineStartAt(text, begin);
|
|
480
800
|
const prev = previousLine(text, start);
|
|
481
|
-
if (prev
|
|
482
|
-
|
|
801
|
+
if (prev) {
|
|
802
|
+
// A BOM sitting before the source-prefix comment on line 1 must not
|
|
803
|
+
// hide the prefix from this check (task b34ed105).
|
|
804
|
+
const prevText = prev.start === 0 ? prev.text.replace(/^\uFEFF/, "") : prev.text;
|
|
805
|
+
if (prevText.startsWith(CODEX_MANAGED_SOURCE_PREFIX))
|
|
806
|
+
start = prev.start;
|
|
483
807
|
}
|
|
808
|
+
start = startPastBom(text, start);
|
|
484
809
|
const contentStart = lineEndAfter(text, begin);
|
|
485
810
|
const scan = scanOwnedContentEnd(text, contentStart);
|
|
486
811
|
const end = scan.end;
|
|
487
812
|
if (scan.sawEndMarker) {
|
|
488
|
-
return { start, end, foreignSectionsPreserved: [] };
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
if (strayIdx === -1) {
|
|
492
|
-
assertNoSplitBlock(text, end, text.length, configPath, false);
|
|
493
|
-
const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, text.length);
|
|
494
|
-
return { start, end, foreignSectionsPreserved };
|
|
495
|
-
}
|
|
496
|
-
const strayLineStart = lineStartAt(text, strayIdx);
|
|
497
|
-
const strayLineEnd = lineEndAfter(text, strayIdx);
|
|
498
|
-
assertNoSplitBlock(text, end, strayLineStart, configPath, true);
|
|
499
|
-
const foreignSectionsPreserved = collectForeignSectionHeaders(text, end, strayLineStart);
|
|
500
|
-
return {
|
|
501
|
-
start,
|
|
502
|
-
end,
|
|
503
|
-
strayEnd: { start: strayLineStart, end: strayLineEnd },
|
|
504
|
-
foreignSectionsPreserved,
|
|
505
|
-
};
|
|
813
|
+
return { start, end, foreignSectionsPreserved: [], keptHookTables: [] };
|
|
814
|
+
}
|
|
815
|
+
return rangeBeforeStrayEnd(text, start, end, configPath);
|
|
506
816
|
}
|
|
507
817
|
const source = text.indexOf(CODEX_MANAGED_SOURCE_PREFIX);
|
|
508
818
|
if (source !== -1) {
|
|
509
|
-
const start = lineStartAt(text, source);
|
|
510
|
-
|
|
511
|
-
const end = scan.end;
|
|
512
|
-
const foreignSectionsPreserved = scan.sawEndMarker
|
|
513
|
-
? []
|
|
514
|
-
: collectForeignSectionHeaders(text, end, text.length);
|
|
515
|
-
return { start, end, foreignSectionsPreserved };
|
|
819
|
+
const start = startPastBom(text, lineStartAt(text, source));
|
|
820
|
+
return legacyManagedRange(text, start, scanOwnedContentEnd(text, start), configPath, `the '${CODEX_MANAGED_SOURCE_PREFIX}' source-prefix line`);
|
|
516
821
|
}
|
|
517
822
|
const generated = text.indexOf(GENERATED_HEADER);
|
|
518
823
|
if (generated !== -1) {
|
|
519
|
-
const start = lineStartAt(text, generated);
|
|
824
|
+
const start = startPastBom(text, lineStartAt(text, generated));
|
|
520
825
|
const scan = scanOwnedContentEnd(text, start);
|
|
521
826
|
const end = scan.end;
|
|
522
827
|
const candidate = text.slice(start, end);
|
|
523
828
|
if (candidate.includes("[[hooks.") ||
|
|
524
829
|
candidate.includes("[[hooks.pre_tool_use]]")) {
|
|
525
|
-
|
|
526
|
-
? []
|
|
527
|
-
: collectForeignSectionHeaders(text, end, text.length);
|
|
528
|
-
return { start, end, foreignSectionsPreserved };
|
|
830
|
+
return legacyManagedRange(text, start, scan, configPath, GENERATED_HEADER_OPENING);
|
|
529
831
|
}
|
|
530
832
|
}
|
|
531
833
|
return null;
|
|
@@ -714,8 +1016,12 @@ function firstDifferingKeyPath(a, b, path) {
|
|
|
714
1016
|
* from a harness-written one would need the same line-level reading of the
|
|
715
1017
|
* file this net is meant to be independent of. An operator-authored
|
|
716
1018
|
* `[[hooks.<Event>]]` entry that the line scan wrongly counts as part of
|
|
717
|
-
* the harness-managed block is therefore replaced without a refusal
|
|
718
|
-
*
|
|
1019
|
+
* the harness-managed block is therefore replaced without a refusal (task
|
|
1020
|
+
* 01053b27 covers the operator table after the harness tables, which the scan
|
|
1021
|
+
* now keeps and names; still replaced silently are an operator table before
|
|
1022
|
+
* the first harness table inside the block, one under a comment run that
|
|
1023
|
+
* holds a `# harness hook:` line, bare keys directly under a harness table,
|
|
1024
|
+
* and one after an old block that never carried the comments).
|
|
719
1025
|
*
|
|
720
1026
|
* Parsing goes through `parseToml` (a leading byte order mark ignored,
|
|
721
1027
|
* 64-bit integers kept as `bigint`). A document that does not parse refuses
|
|
@@ -863,6 +1169,222 @@ function withBackupLocation(linkPath, check) {
|
|
|
863
1169
|
`sit beside the link, as ${linkPath}.harness-backup-* files.`, err.configPath);
|
|
864
1170
|
}
|
|
865
1171
|
}
|
|
1172
|
+
/** A header key segment's name as TOML reads it: a bare key as written, a
|
|
1173
|
+
* literal (`'...'`) key's inner text, a basic (`"..."`) key's inner text
|
|
1174
|
+
* when it holds no escape sequence. `null` for a basic key with an escape,
|
|
1175
|
+
* which this scan does not decode; a header holding one is not matched to a
|
|
1176
|
+
* parsed entry (see `mapHookArrayHeaders`). */
|
|
1177
|
+
function resolvedKeyName(segment) {
|
|
1178
|
+
const quote = segment[0];
|
|
1179
|
+
if (quote === "'")
|
|
1180
|
+
return segment.slice(1, -1);
|
|
1181
|
+
if (quote === '"') {
|
|
1182
|
+
const inner = segment.slice(1, -1);
|
|
1183
|
+
return inner.includes("\\") ? null : inner;
|
|
1184
|
+
}
|
|
1185
|
+
return segment;
|
|
1186
|
+
}
|
|
1187
|
+
/** Whether a header-shaped line's first key resolves to `hooks`, the only
|
|
1188
|
+
* root `mapHookArrayHeaders` resolves. */
|
|
1189
|
+
function hooksRooted(trimmed) {
|
|
1190
|
+
return resolvedKeyName(parseTableHeader(trimmed)?.keys[0] ?? "") === "hooks";
|
|
1191
|
+
}
|
|
1192
|
+
function subKey(event, index) {
|
|
1193
|
+
return `${event}\u0000${index}`;
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Every table header of the whole document that appends to a hook
|
|
1197
|
+
* event array (`[[hooks.<Event>]]`, however its keys are spelled) or to such
|
|
1198
|
+
* an entry's own `hooks` array (`[[hooks.<Event>.hooks]]`), with the entry it
|
|
1199
|
+
* appends: TOML appends each header's table to its array in document order,
|
|
1200
|
+
* and a sub-array header to the array's LAST entry so far. Only the lines
|
|
1201
|
+
* `createLineClassifier` reads as a header are resolved: a line inside a
|
|
1202
|
+
* multi-line string, and an array element on its own line, are skipped. A line resolved
|
|
1203
|
+
* to such a header cannot be a value line of a document that parses (a
|
|
1204
|
+
* dotted bare path is not a TOML value), and a header this scan cannot
|
|
1205
|
+
* resolve (an escape in a quoted key) is not counted, so the caller compares
|
|
1206
|
+
* `eventCounts` and `subCounts` with the parsed arrays' lengths and treats a
|
|
1207
|
+
* mismatch as "cannot match exactly". The classifier reads a header-shaped
|
|
1208
|
+
* line as a header here only when its root key resolves to `hooks`
|
|
1209
|
+
* (`hooksRooted`, checked before the top-level probe), so every header this
|
|
1210
|
+
* scan resolves has the root `hooks` (a `[[hooks.` line has it by its
|
|
1211
|
+
* prefix), a table outside `hooks` never shifts the order match, and an
|
|
1212
|
+
* array element on its own line (`["x"]`, `[1]`) costs no parse in this
|
|
1213
|
+
* scan, unless the element is the string `hooks` (`["hooks"]`,
|
|
1214
|
+
* `['hooks']`): that line reads as a `hooks` root, so it is probed like the
|
|
1215
|
+
* `hooks`-rooted spellings that `[[hooks.` does not catch
|
|
1216
|
+
* (`[[ hooks.Stop ]]`, `[["hooks".Stop]]`, `[hooks.Stop]`), and the probe
|
|
1217
|
+
* finds it inside the array.
|
|
1218
|
+
*/
|
|
1219
|
+
function mapHookArrayHeaders(text) {
|
|
1220
|
+
const map = { slots: new Map(), eventCounts: new Map(), subCounts: new Map() };
|
|
1221
|
+
let pos = 0;
|
|
1222
|
+
const classify = createLineClassifier(text, hooksRooted);
|
|
1223
|
+
while (pos < text.length) {
|
|
1224
|
+
const lineEnd = lineEndAfter(text, pos);
|
|
1225
|
+
const rawLine = text.slice(pos, lineEnd);
|
|
1226
|
+
const kind = classify(pos, rawLine);
|
|
1227
|
+
const parsed = kind === "hookHeader" || kind === "foreignHeader" ? parseTableHeader(rawLine) : null;
|
|
1228
|
+
// The bracket kind is not checked: a single-bracket `[hooks.<Event>]`
|
|
1229
|
+
// cannot coexist with a `[[hooks.<Event>]]` array in a document that
|
|
1230
|
+
// parses, and a single-bracket `[hooks.<Event>.hooks]` makes that entry's
|
|
1231
|
+
// `hooks` a table, whose `command` is not a hook command either way.
|
|
1232
|
+
if (parsed !== null) {
|
|
1233
|
+
const [, event, sub] = parsed.keys.map(resolvedKeyName);
|
|
1234
|
+
if (typeof event === "string") {
|
|
1235
|
+
const count = map.eventCounts.get(event) ?? 0;
|
|
1236
|
+
if (parsed.keys.length === 2) {
|
|
1237
|
+
map.slots.set(pos, { event, index: count });
|
|
1238
|
+
map.eventCounts.set(event, count + 1);
|
|
1239
|
+
}
|
|
1240
|
+
else if (parsed.keys.length === 3 && sub === "hooks") {
|
|
1241
|
+
// Appends to the event's last entry so far; with no entry before
|
|
1242
|
+
// it (`index` -1) TOML makes the event a table, not an array, and
|
|
1243
|
+
// the caller finds no array to match.
|
|
1244
|
+
const key = subKey(event, count - 1);
|
|
1245
|
+
const subIndex = map.subCounts.get(key) ?? 0;
|
|
1246
|
+
map.slots.set(pos, { event, index: count - 1, subIndex });
|
|
1247
|
+
map.subCounts.set(key, subIndex + 1);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
pos = lineEnd;
|
|
1252
|
+
}
|
|
1253
|
+
return map;
|
|
1254
|
+
}
|
|
1255
|
+
/** The hook command of one parsed hook entry, `{ command = "..." }`, as a
|
|
1256
|
+
* one-element list; empty for anything else. */
|
|
1257
|
+
function hookCommandOf(entry) {
|
|
1258
|
+
return isTomlTable(entry) && typeof entry.command === "string" ? [entry.command] : [];
|
|
1259
|
+
}
|
|
1260
|
+
/** The hook commands of one parsed `hooks.<Event>` entry: its own `command`
|
|
1261
|
+
* plus each `command` of its `hooks` array (inline `hooks = [{ ... }]` or the
|
|
1262
|
+
* expanded `[[hooks.<Event>.hooks]]` form). No other `command` key counts. */
|
|
1263
|
+
function eventEntryCommands(entry) {
|
|
1264
|
+
if (!isTomlTable(entry))
|
|
1265
|
+
return [];
|
|
1266
|
+
const nested = Array.isArray(entry.hooks) ? entry.hooks.flatMap(hookCommandOf) : [];
|
|
1267
|
+
return [...hookCommandOf(entry), ...nested];
|
|
1268
|
+
}
|
|
1269
|
+
/** Every hook command of a parsed document's `hooks.<Event>` arrays. */
|
|
1270
|
+
function documentHookCommands(doc) {
|
|
1271
|
+
if (!isTomlTable(doc) || !isTomlTable(doc.hooks))
|
|
1272
|
+
return [];
|
|
1273
|
+
return Object.values(doc.hooks).flatMap((value) => Array.isArray(value) ? value.flatMap(eventEntryCommands) : []);
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* The hook commands a kept table defines, read from the parsed whole
|
|
1277
|
+
* document (`doc`) at the entry its header appends (`map`): for
|
|
1278
|
+
* `[[hooks.<Event>]]` the entry's own `command`, plus its `hooks` array's
|
|
1279
|
+
* commands unless `[[hooks.<Event>.hooks]]` headers build that array (each
|
|
1280
|
+
* of those is its own kept table); for `[[hooks.<Event>.hooks]]` that nested
|
|
1281
|
+
* entry's `command`. A header appending nowhere else (`[[hooks.<Event>.x]]`)
|
|
1282
|
+
* defines none. `null` when the header cannot be matched exactly: a header
|
|
1283
|
+
* the scan did not resolve, a count of headers that differs from the parsed
|
|
1284
|
+
* array's length, or a nested `hooks` header with no entry before it.
|
|
1285
|
+
*/
|
|
1286
|
+
function keptTableCommands(table, doc, map) {
|
|
1287
|
+
const slot = map.slots.get(table.start);
|
|
1288
|
+
if (slot === undefined) {
|
|
1289
|
+
// Not an append the map resolved: a `[[hooks.<Event>.x]]` table holds no
|
|
1290
|
+
// hook command; a header with a key the map does not resolve cannot be
|
|
1291
|
+
// matched.
|
|
1292
|
+
const resolved = parseTableHeader(table.header)?.keys.every((k) => resolvedKeyName(k) !== null);
|
|
1293
|
+
return resolved === true ? [] : null;
|
|
1294
|
+
}
|
|
1295
|
+
const hooks = isTomlTable(doc) && isTomlTable(doc.hooks) ? doc.hooks : undefined;
|
|
1296
|
+
const entries = hooks?.[slot.event];
|
|
1297
|
+
if (!Array.isArray(entries) || entries.length !== map.eventCounts.get(slot.event))
|
|
1298
|
+
return null;
|
|
1299
|
+
const entry = entries[slot.index];
|
|
1300
|
+
const subCount = map.subCounts.get(subKey(slot.event, slot.index)) ?? 0;
|
|
1301
|
+
if (slot.subIndex === undefined) {
|
|
1302
|
+
return subCount > 0 ? hookCommandOf(entry) : eventEntryCommands(entry);
|
|
1303
|
+
}
|
|
1304
|
+
const nested = isTomlTable(entry) && Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
1305
|
+
if (nested.length !== subCount)
|
|
1306
|
+
return null;
|
|
1307
|
+
return hookCommandOf(nested[slot.subIndex]);
|
|
1308
|
+
}
|
|
1309
|
+
/** The move-below-END option of a kept-table refusal: only when the config
|
|
1310
|
+
* has an END marker after the kept tables, since below it the install never
|
|
1311
|
+
* touches a table (an operator-owned table whose command starts with
|
|
1312
|
+
* `harness `, the operator's own CLI, is kept that way). Every range with
|
|
1313
|
+
* kept tables and an END marker after them records that marker as
|
|
1314
|
+
* `strayEnd`, on the BEGIN path and on the legacy paths alike
|
|
1315
|
+
* (`rangeBeforeStrayEnd`), so the caller keys the option on it. */
|
|
1316
|
+
function moveBelowEndOption(hasEndMarker) {
|
|
1317
|
+
return hasEndMarker
|
|
1318
|
+
? `, or, if it is an operator-owned table, move it below the '${CODEX_MANAGED_END}' marker, ` +
|
|
1319
|
+
"where the install never touches it"
|
|
1320
|
+
: "";
|
|
1321
|
+
}
|
|
1322
|
+
/** Parses the whole document for the kept-table check, refusing with the
|
|
1323
|
+
* parser's position and one-line reason only (never its code frame). */
|
|
1324
|
+
function parseForKeptTableCheck(content, what, configPath) {
|
|
1325
|
+
try {
|
|
1326
|
+
return parseToml(content);
|
|
1327
|
+
}
|
|
1328
|
+
catch (err) {
|
|
1329
|
+
throw new CodexInstallRefusalError(`Codex config ${configPath}: the TOML parser used by harness could not read ${what} ` +
|
|
1330
|
+
`(${describeTomlParseError(err)}), so harness cannot check the operator hook tables it ` +
|
|
1331
|
+
"would keep after the harness hook tables; refusing to install. The file is untouched.", configPath);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Refuses (file untouched) when an operator hook table the install keeps
|
|
1336
|
+
* looks like one harness wrote: one of its hook commands equals a command of
|
|
1337
|
+
* the fresh block, or starts with `harness `. The line scan tells a harness
|
|
1338
|
+
* table from an operator one only by the `# harness hook:` comment above it,
|
|
1339
|
+
* so a harness table whose comment an operator deleted, sitting last, would
|
|
1340
|
+
* otherwise be kept next to the fresh block: the hook would run twice, or a
|
|
1341
|
+
* hook the manifest retired would keep running after every later install
|
|
1342
|
+
* (task 01053b27). A retired hook whose command does not start with
|
|
1343
|
+
* `harness ` (an environment-prefixed report-dir wrap, the memory-router
|
|
1344
|
+
* hook, a custom manifest hook) is not recognised: it is kept and named only.
|
|
1345
|
+
*
|
|
1346
|
+
* The commands come from the parsed WHOLE document (the same `parseToml` the
|
|
1347
|
+
* semantic net uses), each kept table matched to its parsed entry by the
|
|
1348
|
+
* order of the `[[hooks.<Event>]]` headers (`mapHookArrayHeaders`); no part
|
|
1349
|
+
* of the file is parsed on its own. Only hook commands count
|
|
1350
|
+
* (`hooks.<Event>[].command` and `hooks.<Event>[].hooks[].command`), not any
|
|
1351
|
+
* other key named `command`. A kept table that cannot be matched exactly
|
|
1352
|
+
* refuses too. Every message names the table's header and line number only,
|
|
1353
|
+
* never a command or any other value text, and never a parser code frame,
|
|
1354
|
+
* since a refusal is printed to stderr and into `--json` output.
|
|
1355
|
+
*/
|
|
1356
|
+
function assertKeptHookTablesAreOperatorOwned(kept, currentContent, generatedContent, configPath, hasEndMarker) {
|
|
1357
|
+
if (kept.length === 0)
|
|
1358
|
+
return;
|
|
1359
|
+
const freshCommands = new Set(documentHookCommands(parseForKeptTableCheck(generatedContent, "the generated hook block", configPath)));
|
|
1360
|
+
const doc = parseForKeptTableCheck(currentContent, "this file", configPath);
|
|
1361
|
+
const map = mapHookArrayHeaders(currentContent);
|
|
1362
|
+
for (const table of kept) {
|
|
1363
|
+
const commands = keptTableCommands(table, doc, map);
|
|
1364
|
+
if (commands === null) {
|
|
1365
|
+
throw new CodexInstallRefusalError(`Codex config ${configPath} has a hook table ${table.header} (line ${table.line}) with no ` +
|
|
1366
|
+
`'${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line above it, placed after the ` +
|
|
1367
|
+
"harness hook tables, that harness could not match to its entry in the parsed config " +
|
|
1368
|
+
"(a header it does not resolve: an escape sequence in a quoted key, on this table or on " +
|
|
1369
|
+
"another table of the same hook event, or a nested hooks table it cannot place), so it " +
|
|
1370
|
+
"cannot check whether harness wrote that table. Spell those header keys without escape " +
|
|
1371
|
+
`sequences, restore the '${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line ` +
|
|
1372
|
+
`directly above ${table.header} if harness wrote it${moveBelowEndOption(hasEndMarker)}, or ` +
|
|
1373
|
+
"delete the table, then re-run `harness apply --runtime codex --install`. The file is untouched.", configPath);
|
|
1374
|
+
}
|
|
1375
|
+
const harnessShaped = commands.some((command) => freshCommands.has(command) || command.startsWith("harness "));
|
|
1376
|
+
if (!harnessShaped)
|
|
1377
|
+
continue;
|
|
1378
|
+
throw new CodexInstallRefusalError(`Codex config ${configPath} has a hook table ${table.header} (line ${table.line}) with no ` +
|
|
1379
|
+
`'${HARNESS_HOOK_COMMENT_PREFIX}<id> (budget_ms=<n>)' comment line above it, placed after the ` +
|
|
1380
|
+
"harness hook tables, whose command is one harness itself writes. harness cannot tell it " +
|
|
1381
|
+
"from a harness hook table whose comment line was deleted, and keeping it next to the " +
|
|
1382
|
+
"fresh harness block would run that hook twice (or keep a retired harness hook running). " +
|
|
1383
|
+
`Restore the comment line directly above ${table.header}, so the install replaces the table ` +
|
|
1384
|
+
`with the fresh block${moveBelowEndOption(hasEndMarker)}, or delete the table, then re-run ` +
|
|
1385
|
+
`\`harness apply --runtime codex --install\`. The file is untouched.`, configPath);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
866
1388
|
export function planCodexConfigInstall(opts) {
|
|
867
1389
|
validateCodexManagedConfig(opts.generatedContent);
|
|
868
1390
|
const requestedPath = opts.configPath ?? defaultCodexConfigPath(opts.homeDir);
|
|
@@ -886,16 +1408,29 @@ export function planCodexConfigInstall(opts) {
|
|
|
886
1408
|
let summary;
|
|
887
1409
|
let removedHookIds = [];
|
|
888
1410
|
let foreignSectionsPreserved = [];
|
|
1411
|
+
let keptHookTables = [];
|
|
1412
|
+
let keptTableDescriptions = [];
|
|
1413
|
+
let hasEndMarkerAfterKept = false;
|
|
889
1414
|
if (range) {
|
|
890
1415
|
const oldHookIds = extractHookIds(currentContent.slice(range.start, range.end));
|
|
891
1416
|
const newHookIds = extractHookIds(managedBlock);
|
|
892
1417
|
removedHookIds = [...oldHookIds].filter((id) => !newHookIds.has(id));
|
|
893
|
-
|
|
1418
|
+
keptHookTables = range.keptHookTables;
|
|
1419
|
+
hasEndMarkerAfterKept = range.strayEnd !== undefined;
|
|
894
1420
|
const middleForeign = range.strayEnd
|
|
895
1421
|
? currentContent.slice(range.end, range.strayEnd.start)
|
|
896
1422
|
: "";
|
|
897
1423
|
const tailStart = range.strayEnd ? range.strayEnd.end : range.end;
|
|
898
1424
|
nextContent = `${currentContent.slice(0, range.start)}${managedBlock}${middleForeign}${currentContent.slice(tailStart)}`;
|
|
1425
|
+
// A kept table sits right after the fresh block in the written config
|
|
1426
|
+
// (it is in `middleForeign`, or at the start of the tail when there is
|
|
1427
|
+
// no stray END), so its offset there follows from the splice.
|
|
1428
|
+
const shift = range.start + managedBlock.length - range.end;
|
|
1429
|
+
keptTableDescriptions = describeKeptHookTables(keptHookTables, keptHookTables.map((t) => lineNumberAt(nextContent, t.start + shift)));
|
|
1430
|
+
// The kept operator hook tables ride the same list, so the dry-run and
|
|
1431
|
+
// the install summary name them exactly like the foreign tables kept
|
|
1432
|
+
// beside them (task 01053b27).
|
|
1433
|
+
foreignSectionsPreserved = [...range.foreignSectionsPreserved, ...keptTableDescriptions];
|
|
899
1434
|
summary = `updated harness-managed Codex hook block in ${configPath}${viaLink}`;
|
|
900
1435
|
}
|
|
901
1436
|
else {
|
|
@@ -912,6 +1447,7 @@ export function planCodexConfigInstall(opts) {
|
|
|
912
1447
|
if (currentContent !== nextContent) {
|
|
913
1448
|
withBackupLocation(linkPath, () => assertConfigSemanticInvariant(currentContent, nextContent, configPath));
|
|
914
1449
|
}
|
|
1450
|
+
assertKeptHookTablesAreOperatorOwned(keptHookTables, currentContent, opts.generatedContent, configPath, hasEndMarkerAfterKept);
|
|
915
1451
|
return {
|
|
916
1452
|
configPath,
|
|
917
1453
|
...(linkPath !== undefined ? { linkPath } : {}),
|
|
@@ -925,6 +1461,7 @@ export function planCodexConfigInstall(opts) {
|
|
|
925
1461
|
: summary,
|
|
926
1462
|
removedHookIds,
|
|
927
1463
|
foreignSectionsPreserved,
|
|
1464
|
+
keptOperatorHookTables: keptTableDescriptions,
|
|
928
1465
|
};
|
|
929
1466
|
}
|
|
930
1467
|
function timestampForBackup(now) {
|