session-orchestrator 5.3.0 → 5.5.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/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -25,22 +25,35 @@
|
|
|
25
25
|
import { spawn } from 'node:child_process';
|
|
26
26
|
import { createHash } from 'node:crypto';
|
|
27
27
|
import {
|
|
28
|
-
appendFileSync,
|
|
29
28
|
existsSync,
|
|
29
|
+
lstatSync,
|
|
30
30
|
mkdirSync,
|
|
31
|
-
|
|
31
|
+
renameSync,
|
|
32
|
+
unlinkSync,
|
|
32
33
|
writeFileSync,
|
|
33
34
|
} from 'node:fs';
|
|
34
35
|
import path from 'node:path';
|
|
35
36
|
|
|
36
|
-
import {
|
|
37
|
+
import { NOT_A_REGULAR_FILE, appendNoFollow } from './orphan-reaper/no-follow-append.mjs';
|
|
38
|
+
import { readTailWindow } from './tail-window.mjs';
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
41
|
+
* Grace period between SIGTERM and SIGKILL, in ms — the repo's one definition,
|
|
42
|
+
* used by the gate's group ladder here and by `wave-executor/foreign-dispatch.mjs`
|
|
43
|
+
* and `remote-dispatch.mjs` through `runChild` (`dispatch-common.mjs`).
|
|
44
|
+
*
|
|
45
|
+
* `child.kill('SIGTERM')` is a REQUEST: a child that installs a handler and
|
|
46
|
+
* ignores it never emits `close`, and `runChild`'s only resolve paths are
|
|
47
|
+
* `close` and `error` — so without escalation a wedged `cursor-agent` hangs
|
|
48
|
+
* `dispatchForeign` forever and takes the whole wave with it. SIGKILL is not
|
|
49
|
+
* catchable, so the escalation always terminates.
|
|
50
|
+
*
|
|
51
|
+
* Named ceiling (BV-004): 10 s is enough for a cooperative child to flush its
|
|
52
|
+
* stream-json tail and exit. Revisit if a well-behaved `cursor-agent` is ever
|
|
53
|
+
* measured needing longer than that to shut down — raise the constant, never
|
|
54
|
+
* drop the escalation.
|
|
42
55
|
*/
|
|
43
|
-
export
|
|
56
|
+
export const DEFAULT_KILL_GRACE_MS = 10_000;
|
|
44
57
|
|
|
45
58
|
/** Append-only ledger of every gate process THIS repo's orchestrator started.
|
|
46
59
|
* Gitignored via `.gitignore:125` (`.orchestrator/runtime/`), verified with
|
|
@@ -478,6 +491,26 @@ function ledgerPathFor(repoRoot) {
|
|
|
478
491
|
return path.join(repoRoot, ...GATE_PROCESS_LEDGER_RELPATH.split('/'));
|
|
479
492
|
}
|
|
480
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Read the whole ledger without following a link and without blocking on a
|
|
496
|
+
* FIFO: `readTailWindow`'s `noFollow` open with a window larger than any file,
|
|
497
|
+
* so a regular ledger reads exactly as `readFileSync` read it. A plain
|
|
498
|
+
* `readFileSync` FOLLOWED a symlinked ledger (a foreign file parsed as kill
|
|
499
|
+
* candidates) and blocked for good in `open` on a planted FIFO — inside the
|
|
500
|
+
* gate's own registration path (#1489). THROWS like `readFileSync`: ENOENT,
|
|
501
|
+
* ELOOP for a symlink, `ERR_NOT_REGULAR_FILE` for a FIFO or directory.
|
|
502
|
+
*
|
|
503
|
+
* Named ceiling (BV-004): the file is read whole, as before — fine for a ledger
|
|
504
|
+
* pruned to 24 h (one line per gate command); revisit if it ever stops being
|
|
505
|
+
* pruned.
|
|
506
|
+
*
|
|
507
|
+
* @param {string} target
|
|
508
|
+
* @returns {string}
|
|
509
|
+
*/
|
|
510
|
+
function readLedgerNoFollow(target) {
|
|
511
|
+
return readTailWindow(target, Number.MAX_SAFE_INTEGER, { noFollow: true }).text;
|
|
512
|
+
}
|
|
513
|
+
|
|
481
514
|
/**
|
|
482
515
|
* Append one {@link GateProcessRecord} to the gate-process ledger.
|
|
483
516
|
*
|
|
@@ -487,6 +520,12 @@ function ledgerPathFor(repoRoot) {
|
|
|
487
520
|
* vanishing (a ledger that silently stops being written is indistinguishable
|
|
488
521
|
* from a host with no gate processes).
|
|
489
522
|
*
|
|
523
|
+
* The write is {@link appendNoFollow}, never `appendFileSync(path)`: that
|
|
524
|
+
* followed a symlinked ledger, so every gate registration landed in the link's
|
|
525
|
+
* target, and its open of a planted FIFO blocked until a reader appeared — the
|
|
526
|
+
* gate hung in its own registration path (#1489). A link, FIFO, directory or
|
|
527
|
+
* hard-linked ledger is now refused with the same one WARN line.
|
|
528
|
+
*
|
|
490
529
|
* @param {string} repoRoot
|
|
491
530
|
* @param {GateProcessRecord} record
|
|
492
531
|
* @param {object} [opts]
|
|
@@ -501,7 +540,7 @@ export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
|
|
|
501
540
|
if (!appendFn) mkdirSync(path.dirname(target), { recursive: true });
|
|
502
541
|
const line = `${JSON.stringify(record)}\n`;
|
|
503
542
|
if (appendFn) appendFn(target, line);
|
|
504
|
-
else
|
|
543
|
+
else appendNoFollow(target, line);
|
|
505
544
|
} catch (err) {
|
|
506
545
|
process.stderr.write(
|
|
507
546
|
`process-group: could not record gate process in ${GATE_PROCESS_LEDGER_RELPATH}: ${err?.message ?? String(err)}\n`,
|
|
@@ -520,14 +559,24 @@ export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
|
|
|
520
559
|
* counted separately — they are not candidates for anything, and their PIDs are
|
|
521
560
|
* the most likely to have been recycled.
|
|
522
561
|
*
|
|
562
|
+
* A symlinked, FIFO or otherwise non-regular ledger yields NO records, like an
|
|
563
|
+
* unreadable one ({@link readLedgerNoFollow}): never a link target's lines as
|
|
564
|
+
* candidates, never a blocked open — no records name no process group to
|
|
565
|
+
* signal. But it is not reported as empty (#1498): `state` tells the three
|
|
566
|
+
* outcomes apart — `read`, `absent` (no ledger yet, the ordinary case) and
|
|
567
|
+
* `unreadable` (refused or failed) — because the scan's `ledger-unreadable`
|
|
568
|
+
* skip was unreachable while a refused ledger looked exactly like a clean one.
|
|
569
|
+
*
|
|
523
570
|
* @param {string} repoRoot
|
|
524
571
|
* @param {object} [opts]
|
|
525
572
|
* @param {(filePath: string) => string} [opts.readFn] Reader seam; must throw or
|
|
526
573
|
* return '' for a missing file.
|
|
527
574
|
* @param {number} [opts.nowMs]
|
|
528
575
|
* @param {number} [opts.maxAgeMs]
|
|
529
|
-
* @returns {{records: GateProcessRecord[], malformedLines: number, expired: number
|
|
530
|
-
*
|
|
576
|
+
* @returns {{records: GateProcessRecord[], malformedLines: number, expired: number,
|
|
577
|
+
* state: 'read'|'absent'|'unreadable'}}
|
|
578
|
+
* `records` are the fresh, parseable entries in source order; empty unless
|
|
579
|
+
* `state` is `read`.
|
|
531
580
|
*/
|
|
532
581
|
export function readGateProcessLedger(repoRoot, {
|
|
533
582
|
readFn,
|
|
@@ -535,12 +584,19 @@ export function readGateProcessLedger(repoRoot, {
|
|
|
535
584
|
maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
|
|
536
585
|
} = {}) {
|
|
537
586
|
const target = ledgerPathFor(repoRoot);
|
|
538
|
-
let raw
|
|
587
|
+
let raw;
|
|
539
588
|
try {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
589
|
+
// No `existsSync` pre-check: it FOLLOWS a link, so a dangling symlink read
|
|
590
|
+
// as "no ledger yet" while `appendNoFollow` refused it on every
|
|
591
|
+
// registration (#1498 review). The no-follow open itself tells the cases
|
|
592
|
+
// apart — ENOENT only when nothing is at the path.
|
|
593
|
+
raw = readFn ? (readFn(target) ?? '') : readLedgerNoFollow(target);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
// ENOENT: no ledger yet, or one that vanished (or a `readFn` reporting a
|
|
596
|
+
// missing file). Anything else — ELOOP (a symlink, dangling or not),
|
|
597
|
+
// ERR_NOT_REGULAR_FILE, EACCES — was refused.
|
|
598
|
+
const state = err?.code === 'ENOENT' ? 'absent' : 'unreadable';
|
|
599
|
+
return { records: [], malformedLines: 0, expired: 0, state };
|
|
544
600
|
}
|
|
545
601
|
|
|
546
602
|
/** @type {GateProcessRecord[]} */
|
|
@@ -568,11 +624,48 @@ export function readGateProcessLedger(repoRoot, {
|
|
|
568
624
|
records.push(parsed);
|
|
569
625
|
}
|
|
570
626
|
|
|
571
|
-
return { records, malformedLines, expired };
|
|
627
|
+
return { records, malformedLines, expired, state: 'read' };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Replace a regular file's contents atomically: write `<target>.tmp-<pid>` in
|
|
632
|
+
* the same directory, then rename it over the target. Throws instead of
|
|
633
|
+
* writing when the target is a symlink or not a regular file — the caller turns
|
|
634
|
+
* that into its one WARN line.
|
|
635
|
+
*
|
|
636
|
+
* Why not `writeFileSync(target)`: that truncates first, so a kill between the
|
|
637
|
+
* truncate and the write leaves the file empty, and it FOLLOWS a symlink — a
|
|
638
|
+
* ledger linked to `events.jsonl` emptied the link's target (CWE-59, reproduced
|
|
639
|
+
* 2026-10-01). `rename` replaces the directory entry itself, never a link's
|
|
640
|
+
* target, and the `wx` flag refuses a pre-planted file or link at the tmp name.
|
|
641
|
+
* The ONE copy (#1498): `orphan-reaper/reaper-audit.mjs` imports it for the
|
|
642
|
+
* audit prune — both ledgers its scan housekeeping rewrites. It lives here, not
|
|
643
|
+
* there, because this module imports nothing from `orphan-reaper/` beyond the
|
|
644
|
+
* fs-only `no-follow-append.mjs`, so the graph stays acyclic, and every
|
|
645
|
+
* importer of the audit module already loads this one.
|
|
646
|
+
*
|
|
647
|
+
* @param {string} target
|
|
648
|
+
* @param {string} body
|
|
649
|
+
* @param {{lstatSync: Function, writeFileSync: Function, renameSync: Function, unlinkSync: Function}} [io]
|
|
650
|
+
* fs seam for tests; defaults to `node:fs`.
|
|
651
|
+
*/
|
|
652
|
+
export function replaceRegularFile(target, body, io = { lstatSync, writeFileSync, renameSync, unlinkSync }) {
|
|
653
|
+
if (!io.lstatSync(target).isFile()) throw new Error(NOT_A_REGULAR_FILE);
|
|
654
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
655
|
+
try {
|
|
656
|
+
io.writeFileSync(tmp, body, { encoding: 'utf8', flag: 'wx' });
|
|
657
|
+
io.renameSync(tmp, target);
|
|
658
|
+
} catch (err) {
|
|
659
|
+
// EEXIST: the tmp name belongs to someone else — never remove it.
|
|
660
|
+
if (err?.code !== 'EEXIST') {
|
|
661
|
+
try { io.unlinkSync(tmp); } catch { /* nothing was created */ }
|
|
662
|
+
}
|
|
663
|
+
throw err;
|
|
664
|
+
}
|
|
572
665
|
}
|
|
573
666
|
|
|
574
667
|
/**
|
|
575
|
-
* Drop expired and unusable lines from the ledger, rewriting it
|
|
668
|
+
* Drop expired and unusable lines from the ledger, rewriting it atomically.
|
|
576
669
|
* "Unusable" is {@link isValidLedgerRecord}'s verdict — the same one
|
|
577
670
|
* {@link readGateProcessLedger} applies, so nothing the reader skips survives
|
|
578
671
|
* the pruner.
|
|
@@ -583,6 +676,10 @@ export function readGateProcessLedger(repoRoot, {
|
|
|
583
676
|
*
|
|
584
677
|
* In-process fs only — never a shell `rm`/`mv` (PSA-003, and
|
|
585
678
|
* `.orchestrator/metrics/**` deletions are a blocked-command rule for a reason).
|
|
679
|
+
* The read is {@link readLedgerNoFollow} and the rewrite goes through
|
|
680
|
+
* {@link replaceRegularFile}: a symlinked, FIFO or otherwise non-regular ledger
|
|
681
|
+
* is refused with one WARN and left untouched — refused at the READ, so a link
|
|
682
|
+
* target is never read and a FIFO never blocks the gate's registration path.
|
|
586
683
|
*
|
|
587
684
|
* Named ceiling (BV-004): read-filter-write is not atomic against a concurrent
|
|
588
685
|
* append, so a line appended between the read and the write is lost. Acceptable
|
|
@@ -595,22 +692,37 @@ export function readGateProcessLedger(repoRoot, {
|
|
|
595
692
|
* @param {object} [opts]
|
|
596
693
|
* @param {number} [opts.nowMs]
|
|
597
694
|
* @param {number} [opts.maxAgeMs]
|
|
598
|
-
* @param {{existsSync: Function, readFileSync: Function, writeFileSync: Function
|
|
695
|
+
* @param {{existsSync: Function, readFileSync: Function, writeFileSync: Function,
|
|
696
|
+
* lstatSync: Function, renameSync: Function, unlinkSync: Function}} [opts.fs] fs seam;
|
|
697
|
+
* replaces `node:fs` wholesale, so a partial seam fails closed (WARN, 0).
|
|
599
698
|
* @returns {number} Number of lines removed (expired + malformed). 0 when the
|
|
600
|
-
* ledger is absent, empty, or
|
|
699
|
+
* ledger is absent, empty, unreadable, or not a regular file.
|
|
601
700
|
*/
|
|
602
701
|
export function pruneGateProcessLedger(repoRoot, {
|
|
603
702
|
nowMs = Date.now(),
|
|
604
703
|
maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
|
|
605
704
|
fs: fsSeam,
|
|
606
705
|
} = {}) {
|
|
607
|
-
const io = fsSeam ?? {
|
|
706
|
+
const io = fsSeam ?? {
|
|
707
|
+
existsSync,
|
|
708
|
+
readFileSync: readLedgerNoFollow,
|
|
709
|
+
writeFileSync,
|
|
710
|
+
lstatSync,
|
|
711
|
+
renameSync,
|
|
712
|
+
unlinkSync,
|
|
713
|
+
};
|
|
608
714
|
const target = ledgerPathFor(repoRoot);
|
|
609
715
|
let raw;
|
|
610
716
|
try {
|
|
611
717
|
if (!io.existsSync(target)) return 0;
|
|
612
718
|
raw = io.readFileSync(target, 'utf8');
|
|
613
|
-
} catch {
|
|
719
|
+
} catch (err) {
|
|
720
|
+
// A refused ledger (ELOOP: a symlink; ERR_NOT_REGULAR_FILE: a FIFO, a
|
|
721
|
+
// directory) gets the same one WARN the rewrite gives it; an unreadable or
|
|
722
|
+
// vanished one stays silent, as before.
|
|
723
|
+
if (err?.code === 'ELOOP' || err?.code === 'ERR_NOT_REGULAR_FILE') {
|
|
724
|
+
process.stderr.write(`process-group: could not prune ${GATE_PROCESS_LEDGER_RELPATH}: ${NOT_A_REGULAR_FILE}\n`);
|
|
725
|
+
}
|
|
614
726
|
return 0;
|
|
615
727
|
}
|
|
616
728
|
|
|
@@ -634,7 +746,7 @@ export function pruneGateProcessLedger(repoRoot, {
|
|
|
634
746
|
|
|
635
747
|
if (removed === 0) return 0;
|
|
636
748
|
try {
|
|
637
|
-
|
|
749
|
+
replaceRegularFile(target, kept.length > 0 ? `${kept.join('\n')}\n` : '', io);
|
|
638
750
|
} catch (err) {
|
|
639
751
|
process.stderr.write(
|
|
640
752
|
`process-group: could not prune ${GATE_PROCESS_LEDGER_RELPATH}: ${err?.message ?? String(err)}\n`,
|
|
@@ -462,7 +462,9 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
|
|
|
462
462
|
* H4 — CI configuration hygiene.
|
|
463
463
|
*
|
|
464
464
|
* Measured 5/6. Checks locally declared executable commands without evaluating
|
|
465
|
-
* pipeline conditions or external includes
|
|
465
|
+
* pipeline conditions or external includes; when no local audit step is found
|
|
466
|
+
* but `.gitlab-ci.yml` declares `include:`, the finding is downgraded to an
|
|
467
|
+
* unverifiable, non-fixable one. The dependency-audit gap is the load-bearing one: three
|
|
466
468
|
* of the tested repos carried known-vulnerable dependencies that no pipeline
|
|
467
469
|
* would ever surface.
|
|
468
470
|
*
|
|
@@ -506,6 +508,24 @@ export function checkCiConfig(repoRoot) {
|
|
|
506
508
|
}
|
|
507
509
|
|
|
508
510
|
if (ciConfigs.some(({ text }) => text) && !ciConfigs.some(hasAuditStep)) {
|
|
511
|
+
// Jobs supplied via include: (component/local/project/remote/template) are
|
|
512
|
+
// never resolved, so an audit may exist there — report it as unverifiable.
|
|
513
|
+
let includes = 0;
|
|
514
|
+
try {
|
|
515
|
+
const gitlabText = hasGitlab ? ciConfigs[0].text : '';
|
|
516
|
+
const include = yaml.loadAll(gitlabText, undefined, { schema: GITLAB_SCHEMA }).filter(isConfigMap).at(-1)?.include;
|
|
517
|
+
includes = Array.isArray(include) ? include.length : include ? 1 : 0;
|
|
518
|
+
} catch {
|
|
519
|
+
/* unparseable config — keep the plain missing-audit finding */
|
|
520
|
+
}
|
|
521
|
+
if (includes > 0) {
|
|
522
|
+
findings.push({
|
|
523
|
+
check: 'ci-audit-job',
|
|
524
|
+
fixable: false,
|
|
525
|
+
message: `CI dependency-audit step could not be verified: ${includes} include(s) not evaluated (.gitlab-ci.yml include:)`,
|
|
526
|
+
});
|
|
527
|
+
return findings;
|
|
528
|
+
}
|
|
509
529
|
findings.push({
|
|
510
530
|
check: 'ci-audit-job',
|
|
511
531
|
fixable: true,
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
|
|
64
64
|
import { spawnSync } from 'node:child_process';
|
|
65
65
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
66
|
-
import { dirname, join } from 'node:path';
|
|
66
|
+
import { dirname, join, resolve } from 'node:path';
|
|
67
67
|
import { fileURLToPath } from 'node:url';
|
|
68
68
|
|
|
69
69
|
import { emitEvent, sessionAttribution } from './events.mjs';
|
|
@@ -120,7 +120,8 @@ const GATE_TIMEOUT_MS = DEFAULT_GATE_TIMEOUT_MS;
|
|
|
120
120
|
* @returns {string}
|
|
121
121
|
*/
|
|
122
122
|
function resolveRepoRoot(explicit) {
|
|
123
|
-
|
|
123
|
+
// Absolute on purpose: emitEvent refuses a relative root (#1468).
|
|
124
|
+
if (typeof explicit === 'string' && explicit.trim()) return resolve(explicit);
|
|
124
125
|
return process.cwd();
|
|
125
126
|
}
|
|
126
127
|
|
|
@@ -773,13 +774,44 @@ function coerceMaxRetries(n) {
|
|
|
773
774
|
// Public API
|
|
774
775
|
// ---------------------------------------------------------------------------
|
|
775
776
|
|
|
777
|
+
/**
|
|
778
|
+
* The kill-ladder fields of a `quality_gate.passed/.failed` record (#1439).
|
|
779
|
+
*
|
|
780
|
+
* ONE builder for BOTH emitters — this library's {@link emitGateEvent} and the
|
|
781
|
+
* `scripts/run-quality-gate.mjs` CLI — so the two can never publish different
|
|
782
|
+
* shapes under the same keys. From THIS builder all three keys are always
|
|
783
|
+
* present: `timed_out` `false`, `survivors` `0` and `kill_signals` `[]` are
|
|
784
|
+
* measurements ("not killed"), unlike `counts`, whose absence means "not
|
|
785
|
+
* measured". The CLI then passes the result through `joinGateKillFields()`
|
|
786
|
+
* (`scripts/lib/gates/gate-helpers.mjs`, #1457), which OR-joins the inner
|
|
787
|
+
* per-command ladders and MAY omit the three keys when that inner side was
|
|
788
|
+
* not measured and the outer ladder did not fire.
|
|
789
|
+
*
|
|
790
|
+
* - `timed_out` — the run hit its wall-clock ceiling and was killed (exit 124).
|
|
791
|
+
* - `survivors` — how many pids were still alive after the SIGKILL step; the
|
|
792
|
+
* pids themselves stay in the gate output / diagnostics bundle.
|
|
793
|
+
* - `kill_signals` — the signals actually sent, in order (`SIGTERM`, `SIGKILL`).
|
|
794
|
+
* Copied, because `spawnInGroup` records them at send time into a live array.
|
|
795
|
+
*
|
|
796
|
+
* @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]}|null|undefined} run
|
|
797
|
+
* @returns {{timed_out: boolean, survivors: number, kill_signals: string[]}}
|
|
798
|
+
*/
|
|
799
|
+
export function gateKillFields(run) {
|
|
800
|
+
return {
|
|
801
|
+
timed_out: run?.timedOut === true,
|
|
802
|
+
survivors: Array.isArray(run?.survivors) ? run.survivors.length : 0,
|
|
803
|
+
kill_signals: Array.isArray(run?.killSignals) ? [...run.killSignals] : [],
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
776
807
|
/**
|
|
777
808
|
* Emit exactly one `orchestrator.quality_gate.{passed,failed}` event per
|
|
778
809
|
* `runQualityGateWithRetry` CALL (#928b).
|
|
779
810
|
*
|
|
780
811
|
* NO DOUBLE-COUNTING. The two gate paths never nest:
|
|
781
812
|
* - `scripts/run-quality-gate.mjs` spawns `scripts/lib/gates/gate-*.mjs`
|
|
782
|
-
* directly
|
|
813
|
+
* directly; it imports only the pure `gateKillFields` helper from this
|
|
814
|
+
* module, never `runQualityGateWithRetry`, so it never reaches this emitter;
|
|
783
815
|
* - this module spawns the resolved gate COMMANDS (and `parse-config.mjs`)
|
|
784
816
|
* directly and does not invoke that wrapper.
|
|
785
817
|
* A single run therefore passes through exactly one emitter.
|
|
@@ -846,8 +878,13 @@ function coerceMaxRetries(n) {
|
|
|
846
878
|
* @param {number} attempts
|
|
847
879
|
* @param {string|null} gate
|
|
848
880
|
* @param {{passed: number, failed: number, total: number}|null} [counts]
|
|
881
|
+
* @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]}|null} [killRun]
|
|
882
|
+
* the run that decided the verdict — the last attempt's failing gate; `null`
|
|
883
|
+
* on the passing path, where every gate exited 0 and so none was killed
|
|
884
|
+
* (`spawnInGroup` only starts its kill ladder on timeout or overflow, and
|
|
885
|
+
* both force a non-zero exit code).
|
|
849
886
|
*/
|
|
850
|
-
async function emitGateEvent(repoRoot, ok, attempts, gate, counts) {
|
|
887
|
+
async function emitGateEvent(repoRoot, ok, attempts, gate, counts, killRun = null) {
|
|
851
888
|
try {
|
|
852
889
|
await emitEvent(
|
|
853
890
|
`orchestrator.quality_gate.${ok ? 'passed' : 'failed'}`,
|
|
@@ -857,6 +894,7 @@ async function emitGateEvent(repoRoot, ok, attempts, gate, counts) {
|
|
|
857
894
|
attempts,
|
|
858
895
|
...(gate ? { gate } : {}),
|
|
859
896
|
...(counts ? { counts } : {}),
|
|
897
|
+
...gateKillFields(killRun),
|
|
860
898
|
...sessionAttribution(repoRoot),
|
|
861
899
|
},
|
|
862
900
|
{ repoRoot },
|
|
@@ -985,6 +1023,8 @@ export async function runQualityGateWithRetry(opts) {
|
|
|
985
1023
|
exitCode: result.exitCode,
|
|
986
1024
|
output: result.output,
|
|
987
1025
|
timedOut: result.timedOut,
|
|
1026
|
+
killSignals: result.killSignals,
|
|
1027
|
+
survivors: result.survivors,
|
|
988
1028
|
command: cmd,
|
|
989
1029
|
attempt,
|
|
990
1030
|
};
|
|
@@ -1063,7 +1103,7 @@ export async function runQualityGateWithRetry(opts) {
|
|
|
1063
1103
|
`❌ quality-gate exhausted retries (${attempt}), writing diagnostics to ${bundlePath ?? '<unwritable>'}\n`,
|
|
1064
1104
|
);
|
|
1065
1105
|
|
|
1066
|
-
await emitGateEvent(repoRoot, false, attempt, lastFailure?.gate ?? null, testCounts);
|
|
1106
|
+
await emitGateEvent(repoRoot, false, attempt, lastFailure?.gate ?? null, testCounts, lastFailure);
|
|
1067
1107
|
|
|
1068
1108
|
const out = {
|
|
1069
1109
|
ok: false,
|
|
@@ -49,7 +49,7 @@ import { ramSnapshot, cpuSnapshot, processCounts, swapUsedMb, memoryPressurePctF
|
|
|
49
49
|
|
|
50
50
|
export { evaluate, PROCESSES_PER_SESSION, PRESSURE_HARD_PCT, PRESSURE_SOFT_PCT, PRESSURE_HEALTHY_PCT, DEFAULT_RESOURCE_THRESHOLDS } from './resource-probe/evaluate.mjs';
|
|
51
51
|
export { peerSessionsCount } from './resource-probe/probe-platform.mjs';
|
|
52
|
-
export { parseEtimeToMinutes, countZombieProcesses, countProcessMatches, parseSwapUsageOutput, parseMemoryPressureOutput, parseVmStatAvailableGb } from './resource-probe/parsers.mjs';
|
|
52
|
+
export { parseEtimeToMinutes, parseEtimeToSeconds, countZombieProcesses, countProcessMatches, parseSwapUsageOutput, parseMemoryPressureOutput, parseVmStatAvailableGb } from './resource-probe/parsers.mjs';
|
|
53
53
|
|
|
54
54
|
// ---------------------------------------------------------------------------
|
|
55
55
|
// Public API — probe()
|
|
@@ -20,16 +20,17 @@
|
|
|
20
20
|
* `globs:`. Precedence when BOTH keys are present on the same rule: `globs:`
|
|
21
21
|
* wins (silently — no merge, no warning) and `paths:` is ignored entirely.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* (
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
23
|
+
* Frontmatter counts ONLY when its opening `---` is the very first line of the
|
|
24
|
+
* file (#1449) — the same rule Claude Code applies when it reads `paths:`.
|
|
25
|
+
* Until #1449 this parser skipped leading blank lines and single-line HTML
|
|
26
|
+
* comments (the vendoring pipeline's `<!-- source: … -->` provenance header),
|
|
27
|
+
* so a rule Claude Code loads ALWAYS-ON was classified path-scoped here and
|
|
28
|
+
* `instruction-budget-guard.mjs` under-counted the always-on surface. A
|
|
29
|
+
* provenance comment now belongs directly AFTER the closing `---` (body text,
|
|
30
|
+
* inert). A frontmatter-shaped block that is NOT on line 1 — any text,
|
|
31
|
+
* comment or blank line before it — yields no globs and no meta at all, and
|
|
32
|
+
* the entry carries `defect: 'frontmatter-not-at-top'` so callers can report
|
|
33
|
+
* the file instead of silently loading it always-on.
|
|
33
34
|
*
|
|
34
35
|
* Beyond the `globs:` key (issue #336), the frontmatter parser also captures
|
|
35
36
|
* these scalar activation keys (issue #694), surfaced on each RuleEntry:
|
|
@@ -150,34 +151,15 @@ function matchGlob(filePath, globPattern) {
|
|
|
150
151
|
// Minimal YAML frontmatter parser (matches state-md.mjs style)
|
|
151
152
|
// ---------------------------------------------------------------------------
|
|
152
153
|
|
|
154
|
+
// Anchored at offset 0: frontmatter exists only when `---` is line 1 (#1449).
|
|
153
155
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
154
156
|
|
|
155
|
-
// A single header line is either blank or a complete single-line HTML
|
|
156
|
-
// comment (`<!-- ... -->` on one line). Multi-line comment blocks are
|
|
157
|
-
// deliberately NOT supported — the vendoring convention is a single first
|
|
158
|
-
// line, and tolerating a few stacked comment lines (each matching this RE)
|
|
159
|
-
// is sufficient.
|
|
160
|
-
const HEADER_LINE_RE = /^[ \t]*(?:<!--.*-->)?[ \t]*$/;
|
|
161
|
-
|
|
162
157
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* mandatory provenance header precedes it. Stops at the first line that is
|
|
167
|
-
* neither blank nor a complete single-line comment.
|
|
168
|
-
*
|
|
169
|
-
* @param {string} contents - raw file contents
|
|
170
|
-
* @returns {string} contents starting at the first non-header line (empty
|
|
171
|
-
* string when the entire content is header lines)
|
|
158
|
+
* Defect code for a frontmatter block that does not start on line 1 (#1449).
|
|
159
|
+
* Claude Code ignores such a block, so the rule loads always-on there; the
|
|
160
|
+
* loader mirrors that and tags the entry with this code.
|
|
172
161
|
*/
|
|
173
|
-
|
|
174
|
-
const lines = contents.split(/\r?\n/);
|
|
175
|
-
let idx = 0;
|
|
176
|
-
while (idx < lines.length && HEADER_LINE_RE.test(lines[idx])) {
|
|
177
|
-
idx++;
|
|
178
|
-
}
|
|
179
|
-
return lines.slice(idx).join('\n');
|
|
180
|
-
}
|
|
162
|
+
export const FRONTMATTER_NOT_AT_TOP = 'frontmatter-not-at-top';
|
|
181
163
|
|
|
182
164
|
// Scalar activation keys captured (issue #694 + #692). The `globs` key keeps
|
|
183
165
|
// its dedicated sequence/flow handling and is NOT routed through this table.
|
|
@@ -193,12 +175,36 @@ const SCALAR_META_KEYS = new Set([
|
|
|
193
175
|
'tier',
|
|
194
176
|
]);
|
|
195
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Undo the ONE quoting style the renderer emits for a scalar (#1041 follow-up).
|
|
180
|
+
*
|
|
181
|
+
* `scripts/lib/reconcile/renderer.mjs` serialises `description:` through
|
|
182
|
+
* js-yaml, which picks SINGLE-quote style whenever the plain form would be
|
|
183
|
+
* ambiguous (a `: ` inside) and escapes an interior `'` as `''`. A bare
|
|
184
|
+
* leading/trailing-quote strip then returns `it''s` for `it's` — measured
|
|
185
|
+
* 2026-08-23 on 40 of 146 live learning insights carrying both characters.
|
|
186
|
+
* Single-quoted: strip + un-double. Double-quoted: strip + unescape `\"` and
|
|
187
|
+
* `\\`. Unquoted: returned verbatim (the pre-#1041 behaviour, unchanged).
|
|
188
|
+
*/
|
|
189
|
+
function unquoteYamlScalar(valuePart) {
|
|
190
|
+
const v = valuePart.trim();
|
|
191
|
+
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
|
|
192
|
+
return v.slice(1, -1).replace(/''/g, "'");
|
|
193
|
+
}
|
|
194
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
|
|
195
|
+
return v.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
|
196
|
+
}
|
|
197
|
+
return v.replace(/^["']|["']$/g, '');
|
|
198
|
+
}
|
|
199
|
+
|
|
196
200
|
/**
|
|
197
201
|
* Parses the YAML frontmatter block for the `globs:` field (issue #336),
|
|
198
202
|
* its `paths:` alias (issue #795), and the scalar activation keys (issue #694).
|
|
199
203
|
*
|
|
200
204
|
* Returns:
|
|
201
205
|
* - `{ globs: string[] | null, meta: object }` on success
|
|
206
|
+
* - `{ globs: null, meta: {}, defect: 'frontmatter-not-at-top' }` when a
|
|
207
|
+
* frontmatter block exists but does not start on line 1 (#1449)
|
|
202
208
|
* - throws `Error` on malformed frontmatter so the caller can fall back
|
|
203
209
|
*
|
|
204
210
|
* `globs` is `null` when no frontmatter, and neither `globs:` nor `paths:` is
|
|
@@ -213,41 +219,63 @@ const SCALAR_META_KEYS = new Set([
|
|
|
213
219
|
* - all other recognised keys → quote-stripped strings
|
|
214
220
|
* Unknown keys are ignored without error.
|
|
215
221
|
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
222
|
+
* Only a block whose opening `---` is line 1 is frontmatter (#1449), exactly
|
|
223
|
+
* as Claude Code reads it. Anything before the opener — a provenance comment,
|
|
224
|
+
* a blank line, any text — makes the block body text: no globs, no meta, and
|
|
225
|
+
* the `defect` field names the misplacement (see {@link FRONTMATTER_NOT_AT_TOP}).
|
|
220
226
|
*
|
|
221
227
|
* @param {string} contents - raw file contents
|
|
222
|
-
* @returns {{ globs: string[] | null, meta: Record<string, unknown
|
|
228
|
+
* @returns {{ globs: string[] | null, meta: Record<string, unknown>, defect?: 'frontmatter-not-at-top' }}
|
|
223
229
|
*/
|
|
230
|
+
export function parseGlobsFrontmatter(contents) {
|
|
231
|
+
const match = FRONTMATTER_RE.exec(contents);
|
|
232
|
+
if (match) return parseFrontmatterBlock(match[1]);
|
|
233
|
+
if (hasDisplacedFrontmatter(contents)) {
|
|
234
|
+
return { globs: null, meta: {}, defect: FRONTMATTER_NOT_AT_TOP };
|
|
235
|
+
}
|
|
236
|
+
return { globs: null, meta: {} };
|
|
237
|
+
}
|
|
238
|
+
|
|
224
239
|
/**
|
|
225
|
-
*
|
|
240
|
+
* Does `contents` carry a frontmatter block that is NOT on line 1 (#1449)?
|
|
226
241
|
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
242
|
+
* Only the FIRST bare `---` line is considered, and its block must parse
|
|
243
|
+
* cleanly AND carry at least one key this loader honours (`globs`, `paths`,
|
|
244
|
+
* or a scalar activation key). That second condition keeps a horizontal rule
|
|
245
|
+
* pair around ordinary prose from being reported: prose between two `---`
|
|
246
|
+
* lines almost always holds a colon-less line (a heading, a sentence), which
|
|
247
|
+
* throws, and a recognised key at column 0 is what makes the misplacement
|
|
248
|
+
* change behaviour at all. The known blind spot is a displaced block that is
|
|
249
|
+
* itself malformed — it is treated as body text, which is what Claude Code
|
|
250
|
+
* does with it too.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} contents - raw file contents
|
|
253
|
+
* @returns {boolean}
|
|
234
254
|
*/
|
|
235
|
-
function
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
255
|
+
function hasDisplacedFrontmatter(contents) {
|
|
256
|
+
const lines = contents.split(/\r?\n/);
|
|
257
|
+
const opener = lines.indexOf('---');
|
|
258
|
+
// -1: no opener at all. 0: the opener IS line 1, so FRONTMATTER_RE already
|
|
259
|
+
// had its chance (an unclosed block) — not a displacement.
|
|
260
|
+
if (opener <= 0) return false;
|
|
261
|
+
const block = FRONTMATTER_RE.exec(lines.slice(opener).join('\n'));
|
|
262
|
+
if (!block) return false;
|
|
263
|
+
try {
|
|
264
|
+
const { globs, meta } = parseFrontmatterBlock(block[1]);
|
|
265
|
+
return globs !== null || Object.keys(meta).length > 0;
|
|
266
|
+
} catch {
|
|
267
|
+
return false;
|
|
242
268
|
}
|
|
243
|
-
return v.replace(/^["']|["']$/g, '');
|
|
244
269
|
}
|
|
245
270
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
271
|
+
/**
|
|
272
|
+
* Parses the text BETWEEN the two `---` fences (the frontmatter body).
|
|
273
|
+
* Throws on a malformed top-level line — see {@link parseGlobsFrontmatter}.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} fmText - frontmatter body without its fences
|
|
276
|
+
* @returns {{ globs: string[] | null, meta: Record<string, unknown> }}
|
|
277
|
+
*/
|
|
278
|
+
function parseFrontmatterBlock(fmText) {
|
|
251
279
|
const lines = fmText.split(/\r?\n/);
|
|
252
280
|
|
|
253
281
|
let globsValue = null;
|
|
@@ -466,6 +494,9 @@ function applyGates(meta, filePath, mode, hostClass, now, context = null) {
|
|
|
466
494
|
* from the `alwaysApply` frontmatter key)
|
|
467
495
|
* @property {string[]} matchedGlobs - globs that matched; empty when alwaysOn
|
|
468
496
|
* @property {true} [_parseError] - present only on frontmatter parse-error entries
|
|
497
|
+
* @property {'frontmatter-not-at-top'} [defect] - present only when a frontmatter
|
|
498
|
+
* block exists but does not start on line 1 (#1449); such an entry is always
|
|
499
|
+
* `alwaysOn: true` and carries no meta fields, mirroring Claude Code
|
|
469
500
|
* @property {string} [description] - frontmatter `description` (issue #694), when present
|
|
470
501
|
* @property {string} [mode] - frontmatter `mode` (issue #694), when present
|
|
471
502
|
* @property {string} [hostClass] - frontmatter `host-class` (issue #694), when present
|
|
@@ -484,6 +515,10 @@ function applyGates(meta, filePath, mode, hostClass, now, context = null) {
|
|
|
484
515
|
* Rules without `globs:` (or its `paths:` alias — issue #795) frontmatter are
|
|
485
516
|
* always included (alwaysOn: true). Rules with `globs:`/`paths:` are included
|
|
486
517
|
* only when at least one scopePath matches at least one glob pattern.
|
|
518
|
+
* A rule whose frontmatter does not start on line 1 is always-on with no meta
|
|
519
|
+
* (so no gate excludes it) and carries `defect: 'frontmatter-not-at-top'`
|
|
520
|
+
* (#1449) — no stderr line, because callers such as the instruction-budget
|
|
521
|
+
* guard load the directory several times per run and report the defect themselves.
|
|
487
522
|
*
|
|
488
523
|
* After a successful frontmatter parse, deterministic gates (issue #694) are
|
|
489
524
|
* applied to BOTH always-on and glob-matched rules — a rule must pass ALL
|
|
@@ -557,10 +592,11 @@ export function loadApplicableRules({
|
|
|
557
592
|
|
|
558
593
|
let globs;
|
|
559
594
|
let meta;
|
|
595
|
+
let defect;
|
|
560
596
|
let parseError = false;
|
|
561
597
|
|
|
562
598
|
try {
|
|
563
|
-
({ globs, meta } = parseGlobsFrontmatter(content));
|
|
599
|
+
({ globs, meta, defect } = parseGlobsFrontmatter(content));
|
|
564
600
|
} catch (err) {
|
|
565
601
|
process.stderr.write(
|
|
566
602
|
`[rule-loader] Frontmatter parse error in ${filePath}: ${err.message} — treating as always-on\n`,
|
|
@@ -581,7 +617,8 @@ export function loadApplicableRules({
|
|
|
581
617
|
const metaFields = metaToEntryFields(meta);
|
|
582
618
|
|
|
583
619
|
if (globs === null) {
|
|
584
|
-
// No globs frontmatter (or parse error
|
|
620
|
+
// No globs frontmatter (or parse error, or frontmatter not on line 1 —
|
|
621
|
+
// #1449) → always-on
|
|
585
622
|
results.push({
|
|
586
623
|
path: filePath,
|
|
587
624
|
content,
|
|
@@ -589,6 +626,7 @@ export function loadApplicableRules({
|
|
|
589
626
|
matchedGlobs: [],
|
|
590
627
|
...metaFields,
|
|
591
628
|
...(parseError ? { _parseError: true } : {}),
|
|
629
|
+
...(defect ? { defect } : {}),
|
|
592
630
|
});
|
|
593
631
|
continue;
|
|
594
632
|
}
|