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.
Files changed (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. 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
- readFileSync,
31
+ renameSync,
32
+ unlinkSync,
32
33
  writeFileSync,
33
34
  } from 'node:fs';
34
35
  import path from 'node:path';
35
36
 
36
- import { DEFAULT_KILL_GRACE_MS } from './wave-executor/dispatch-common.mjs';
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
- * Re-exported, never re-defined: the SIGTERM→SIGKILL grace is a repo
40
- * convention with its own named ceiling in `dispatch-common.mjs`. A second
41
- * literal here would drift from it silently.
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 { DEFAULT_KILL_GRACE_MS };
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 appendFileSync(target, line, 'utf8');
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
- * `records` are the fresh, parseable entries in source order.
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
- if (readFn) raw = readFn(target) ?? '';
541
- else if (existsSync(target)) raw = readFileSync(target, 'utf8');
542
- } catch {
543
- return { records: [], malformedLines: 0, expired: 0 };
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 in place.
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}} [opts.fs] fs seam.
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 unreadable.
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 ?? { existsSync, readFileSync, writeFileSync };
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
- io.writeFileSync(target, kept.length > 0 ? `${kept.join('\n')}\n` : '', 'utf8');
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. The dependency-audit gap is the load-bearing one: three
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
- if (typeof explicit === 'string' && explicit.trim()) return explicit;
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 and does not import this module;
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
- * A rule file MAY carry a leading single-line provenance header before its
24
- * frontmatter block — the vendoring pipeline (`scripts/rules-sync.mjs`)
25
- * requires a first-line `<!-- source: session-orchestrator plugin
26
- * (canonical: ...) -->` comment on every vendored rule. The frontmatter
27
- * parser tolerates any leading run of blank lines and/or single-line HTML
28
- * comments before the opening `---`, so a vendored rule keeps its `globs:`
29
- * scoping and scalar meta instead of silently falling back to always-on.
30
- * Only single-line comments are tolerated (no multi-line comment blocks);
31
- * files with no header, or starting directly with `---`, parse exactly as
32
- * before.
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
- * Skips a leading run of blank lines and/or single-line HTML comments
164
- * (`<!-- ... -->`) at the very start of a rule file's contents, so the
165
- * frontmatter opener (`---`) can be found even when the vendoring pipeline's
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
- function stripLeadingProvenanceHeader(contents) {
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
- * A leading run of blank lines and/or single-line HTML comments (e.g. the
217
- * vendoring pipeline's provenance header) is tolerated before the opening
218
- * `---` — see `stripLeadingProvenanceHeader`. Files with no such header parse
219
- * identically to before this tolerance was added.
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
- * Undo the ONE quoting style the renderer emits for a scalar (#1041 follow-up).
240
+ * Does `contents` carry a frontmatter block that is NOT on line 1 (#1449)?
226
241
  *
227
- * `scripts/lib/reconcile/renderer.mjs` serialises `description:` through
228
- * js-yaml, which picks SINGLE-quote style whenever the plain form would be
229
- * ambiguous (a `: ` inside) and escapes an interior `'` as `''`. A bare
230
- * leading/trailing-quote strip then returns `it''s` for `it's` — measured
231
- * 2026-08-23 on 40 of 146 live learning insights carrying both characters.
232
- * Single-quoted: strip + un-double. Double-quoted: strip + unescape `\"` and
233
- * `\\`. Unquoted: returned verbatim (the pre-#1041 behaviour, unchanged).
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 unquoteYamlScalar(valuePart) {
236
- const v = valuePart.trim();
237
- if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
238
- return v.slice(1, -1).replace(/''/g, "'");
239
- }
240
- if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
241
- return v.slice(1, -1).replace(/\\(["\\])/g, '$1');
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
- export function parseGlobsFrontmatter(contents) {
247
- const match = FRONTMATTER_RE.exec(stripLeadingProvenanceHeader(contents));
248
- if (!match) return { globs: null, meta: {} };
249
-
250
- const fmText = match[1];
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) → always-on
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
  }