@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.
- package/CHANGELOG.md +51 -1
- package/README.md +1 -1
- package/dist/cli/apply/apply.d.ts +20 -0
- package/dist/cli/apply/apply.js +13 -7
- package/dist/cli/apply/apply.js.map +1 -1
- package/dist/cli/apply/install-codex-config.d.ts +113 -2
- package/dist/cli/apply/install-codex-config.js +651 -69
- package/dist/cli/apply/install-codex-config.js.map +1 -1
- package/dist/cli/approve/understanding.js +34 -28
- package/dist/cli/approve/understanding.js.map +1 -1
- package/dist/cli/doctor/format.js +22 -0
- package/dist/cli/doctor/format.js.map +1 -1
- package/dist/cli/doctor/index.js +60 -31
- package/dist/cli/doctor/index.js.map +1 -1
- package/dist/cli/doctor/types.d.ts +17 -0
- package/dist/cli/dry-run.js +7 -0
- package/dist/cli/dry-run.js.map +1 -1
- package/dist/cli/index.js +46 -144
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/pack/auto-approve-path.js +25 -11
- package/dist/cli/pack/auto-approve-path.js.map +1 -1
- package/dist/cli/pack/hook-codex-post-tool-use.js +2 -2
- package/dist/cli/pack/hook-codex-post-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js +32 -5
- package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-post-merge-gate-record.d.ts +7 -0
- package/dist/cli/pack/hook-post-merge-gate-record.js +39 -2
- package/dist/cli/pack/hook-post-merge-gate-record.js.map +1 -1
- package/dist/cli/pack/hook-post-tool-use.js +2 -2
- package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +60 -15
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/policy/intercept.js +5 -1
- package/dist/cli/policy/intercept.js.map +1 -1
- package/dist/cli/record/index.d.ts +11 -2
- package/dist/cli/record/index.js +18 -5
- package/dist/cli/record/index.js.map +1 -1
- package/dist/cli/session-start/branch-check.d.ts +3 -29
- package/dist/cli/session-start/branch-check.js +7 -37
- package/dist/cli/session-start/branch-check.js.map +1 -1
- package/dist/cli/session-start/index.d.ts +16 -30
- package/dist/cli/session-start/index.js +72 -26
- package/dist/cli/session-start/index.js.map +1 -1
- package/dist/cli/session-start/shared-options.d.ts +92 -0
- package/dist/cli/session-start/shared-options.js +109 -0
- package/dist/cli/session-start/shared-options.js.map +1 -0
- package/dist/cli/session-start/stale-base-check.d.ts +3 -28
- package/dist/cli/session-start/stale-base-check.js +7 -37
- package/dist/cli/session-start/stale-base-check.js.map +1 -1
- package/dist/cli/session-start/toolchain-parity.d.ts +5 -21
- package/dist/cli/session-start/toolchain-parity.js +6 -38
- package/dist/cli/session-start/toolchain-parity.js.map +1 -1
- package/dist/cli/smoke/index.d.ts +1 -1
- package/dist/cli/smoke/index.js +8 -11
- package/dist/cli/smoke/index.js.map +1 -1
- package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +11 -0
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js +11 -0
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js +1 -1
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.d.ts +36 -0
- package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.js +73 -0
- package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.js.map +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +3 -2
- package/dist/policy-packs/builtin/understanding-before-execution/index.js +5 -2
- package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js +1 -0
- package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +205 -1
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +360 -3
- package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +2 -2
- package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +36 -0
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +50 -0
- package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -1
- package/dist/runtime/bash-prefix-parse.js +319 -126
- package/dist/runtime/bash-prefix-parse.js.map +1 -1
- package/dist/runtime/intercept.d.ts +38 -0
- package/dist/runtime/intercept.js +224 -5
- package/dist/runtime/intercept.js.map +1 -1
- package/dist/runtime/read-only-bash.js +11 -1
- package/dist/runtime/read-only-bash.js.map +1 -1
- package/dist/runtime/shell-word.d.ts +16 -1
- package/dist/runtime/shell-word.js +82 -1
- package/dist/runtime/shell-word.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,
|
|
@@ -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
|
|
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
|
|
260
|
-
|
|
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
|
-
|
|
268
|
-
if (trimmed === CODEX_MANAGED_END) {
|
|
412
|
+
if (rawLine.trim() === CODEX_MANAGED_END) {
|
|
269
413
|
return { end: lineEnd, sawEndMarker: true };
|
|
270
414
|
}
|
|
271
|
-
if (
|
|
415
|
+
if (kind === "comment") {
|
|
272
416
|
if (commentRunStart === null)
|
|
273
417
|
commentRunStart = pos;
|
|
274
418
|
pos = lineEnd;
|
|
275
419
|
continue;
|
|
276
420
|
}
|
|
277
|
-
if (
|
|
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
|
|
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
|
|
361
|
-
|
|
362
|
-
const firstForeignHeader =
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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) {
|