session-orchestrator 3.17.0 → 3.20.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 (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -187,6 +187,7 @@ const ALLOWED_EMOJI_CODEPOINTS = new Set([
187
187
  0x2139, // ℹ
188
188
  0x2194, // ↔
189
189
  0x26a0, // ⚠
190
+ 0x26d4, // ⛔ (deny headline in emitDeny's operator systemMessage — #906)
190
191
  0x2705, // ✅
191
192
  0x274c, // ❌
192
193
  0x2b50, // ⭐
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-unwired-features.mjs — census of DECLARED-BUT-UNREAD Session Config keys.
4
+ *
5
+ * ## The defect class
6
+ *
7
+ * This repo's recurring systemic failure is not a broken feature — it is a
8
+ * feature that was built, documented, schema-validated and tested, and then
9
+ * never switched on. One 2026-08-08 analysis found three independent instances:
10
+ * `efficiency.output-level` (10 test files, 0 runtime consumers, ~15 months
11
+ * dead), `issue-budget` (complete with a PreToolUse hook and an overflow
12
+ * collector, never entered in the live Session Config), and
13
+ * `compact-nudge` / `goal-integration` (0 `.mjs` read sites — schema + prose
14
+ * only). Prose cannot catch this class; every one of those keys was documented
15
+ * exactly as prescribed. A mechanical census can.
16
+ *
17
+ * ## What this check owns — and what it deliberately does NOT
18
+ *
19
+ * The declared config surface has THREE faces, and only one of the three edges
20
+ * between them was previously guarded:
21
+ *
22
+ * template ↔ live Session Config → owned by `claude-md-drift-check`
23
+ * Check 6 (`session-config-parity`).
24
+ * NOT duplicated here.
25
+ * template ↔ code ┐
26
+ * live cfg ↔ code ┘ → owned by THIS check.
27
+ *
28
+ * So: a key that appears in `docs/session-config-template.md` and/or in the
29
+ * live `## Session Config` block, but that NO `.mjs` under `scripts/` or
30
+ * `hooks/` ever reads, is reported here. That is the `compact-nudge` class.
31
+ *
32
+ * ## Two signals, because a mention is not a read
33
+ *
34
+ * S1 `unwired-config-key` — zero non-comment mentions in the consumer
35
+ * corpus. The blunt case.
36
+ * S2 `parser-orphan-config-key` — the key IS mentioned, but no file in the
37
+ * config-parser layer (`PARSER_PATHS`) knows
38
+ * it, so nothing turns the YAML into a value.
39
+ *
40
+ * S2 exists because S1 alone is fooled by a mention that reads nothing.
41
+ * `express-path.enabled` passes S1 on the strength of ONE line —
42
+ * `scripts/lib/state-md/body-sections.mjs:699`, a log-message template literal
43
+ * that interpolates a value its caller already had. No parser resolves
44
+ * `express-path` from config at all; the gate lives entirely in
45
+ * `skills/session-start/phase-8-5-express-path.md` prose. S1 called that wired;
46
+ * S2 calls it what it is.
47
+ *
48
+ * S2 applies to TOP-LEVEL keys only — a nested key reaches code through its
49
+ * parent — and its premise is structural: every Session Config key has to pass
50
+ * through the parser layer to become a value. Measured 2026-08-08: 84 of 89
51
+ * top-level keys satisfy it, so the 5 that do not are signal, not noise.
52
+ *
53
+ * ## Consumer scope, and why "prose-only" is a finding rather than an error
54
+ *
55
+ * Read sites are counted in `scripts/**` and `hooks/**` (`.mjs`/`.js`/`.cjs`),
56
+ * with every `tests/` path excluded — a key read only by its own tests is
57
+ * exactly the dead surface this check hunts.
58
+ *
59
+ * Skill bodies are NOT consumers for this purpose. A key consumed only by
60
+ * markdown prose is real (an LLM reads the instruction), but it is a WEAKER
61
+ * wiring than code: nothing fails when the prose is reworded or the skill is
62
+ * retired. Those keys are legitimate — they belong on the allowlist below with
63
+ * their prose consumer named, which turns an invisible assumption into a
64
+ * reviewable line.
65
+ *
66
+ * ## Allowlisting (how, and the standing requirement)
67
+ *
68
+ * Add an entry to `ALLOWLIST` keyed by the FULL dotted key path, whose value is
69
+ * a non-empty reason naming the actual consumer:
70
+ *
71
+ * 'auto-skill-dispatch': 'prose-only consumer: skills/using-orchestrator/SKILL.md',
72
+ *
73
+ * Every entry needs a reason — an empty or whitespace-only one is itself
74
+ * reported (`allowlist-missing-reason`), so the escape hatch cannot be used to
75
+ * silence a key without saying why. The list also drains itself: an entry is
76
+ * reported as `allowlist-stale` both when its key has left every config surface
77
+ * AND when the key stops triggering a finding (i.e. it finally got wired), so a
78
+ * fixed key does not leave a permanent exemption behind.
79
+ *
80
+ * ## Mode: WARN, not blocking (v1)
81
+ *
82
+ * Findings never fail the process; exit is 0 whenever the scan completed
83
+ * (2 only on a filesystem/tool error). This is deliberate. The repo currently
84
+ * ships 14 of 27 Session Config switches set to `false`; a blocking gate on
85
+ * that inventory would be red from day one, and a gate that is red on day one
86
+ * gets disabled — which is the same disease this file exists to treat, one
87
+ * level up. Warn first, ratchet later once the census is at zero.
88
+ *
89
+ * ## Named residuals (so nobody over-reads the coverage claim)
90
+ *
91
+ * - **Token-level, not dataflow.** A key whose value is parsed and then never
92
+ * consumed downstream reads as WIRED here. `efficiency.output-level` is that
93
+ * shape (parsed by `scripts/lib/owner-yaml.mjs`, consumed by nothing) and
94
+ * would NOT be caught — nor is it in scope, being an `owner.yaml` key rather
95
+ * than a Session Config one. Catching it needs an import/dataflow graph.
96
+ * - **Unused `export`s under `scripts/lib/` are NOT censused here.** A naive
97
+ * token census of that second axis produced 441 candidates over 356 files on
98
+ * 2026-08-08, with false positives in the first screenful (`validateAgentOutput`
99
+ * among them) — a list that size is switched off, not acted on. That axis
100
+ * needs a real import graph; `knip` is not a dependency of this repo
101
+ * (`grep -n knip package.json` → no match) and adopting it is issue #977.
102
+ * - **A comment mention counts as a non-read.** Matches are tallied per line;
103
+ * a key named only in comments is reported, with the comment count attached.
104
+ * - Files are read with `readFileSync`, never a `grep` spawn: one NUL byte
105
+ * makes a text file invisible to grep-based audits (see
106
+ * `.claude/rules/anti-pattern-a-nul-byte-in-a-tracked-production-file-...md`),
107
+ * which would silently drop a consumer and manufacture a false positive.
108
+ *
109
+ * Import-safety: importing this module exposes the collector and runner only;
110
+ * the CLI path is guarded at the bottom of the file.
111
+ */
112
+
113
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
114
+ import path from 'node:path';
115
+ import { pathToFileURL } from 'node:url';
116
+
117
+ /** Documented config surface — every `yaml` fence in this file is a declaration. */
118
+ const TEMPLATE_REL = 'docs/session-config-template.md';
119
+
120
+ /** Live config surface. First existing file wins (CLAUDE.md beats AGENTS.md). */
121
+ const INSTRUCTION_FILES = Object.freeze(['CLAUDE.md', 'AGENTS.md']);
122
+
123
+ /** Directories whose code counts as a runtime consumer. */
124
+ const CONSUMER_DIRS = Object.freeze(['scripts', 'hooks']);
125
+
126
+ /** Extensions that can hold a runtime read site. */
127
+ const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
128
+
129
+ /** Directory names excluded from the consumer scan at any depth. */
130
+ const EXCLUDED_DIRS = Object.freeze(['node_modules', '.git', 'tests', 'test', '__tests__']);
131
+
132
+ /**
133
+ * This file excludes ITSELF from the consumer corpus. Load-bearing: every
134
+ * `ALLOWLIST` key is a string literal here, so without the exclusion each
135
+ * allowlist entry becomes its own read site and the check reports the key as
136
+ * wired — silently blinding itself to exactly the keys an operator flagged as
137
+ * needing review. (Observed on first run: 2 allowlisted keys reported as 0.)
138
+ */
139
+ const SELF_REL = path.join('scripts', 'lib', 'validate', 'check-unwired-features.mjs');
140
+
141
+ /**
142
+ * The config-parser layer: the files a Session Config key must pass through to
143
+ * become a runtime value. Signal S2 (see header) checks top-level keys against
144
+ * this subset. Directories are walked; plain files are taken as-is.
145
+ */
146
+ const PARSER_PATHS = Object.freeze([
147
+ path.join('scripts', 'lib', 'config'),
148
+ path.join('scripts', 'lib', 'config.mjs'),
149
+ path.join('scripts', 'lib', 'config-schema.mjs'),
150
+ path.join('scripts', 'parse-config.mjs'),
151
+ ]);
152
+
153
+ /**
154
+ * Declared-but-unread keys accepted on purpose. Key = full dotted path,
155
+ * value = REASON naming the real consumer. See the header for the contract:
156
+ * an empty reason, a key that left every config surface, and a key that got
157
+ * wired are all reported so the list stays short and true.
158
+ */
159
+ const ALLOWLIST = Object.freeze({
160
+ 'auto-skill-dispatch':
161
+ 'prose-only consumer — skills/using-orchestrator/SKILL.md + skills/_shared/bootstrap-gate.md read this key as an LLM instruction; there is no .mjs gate by design',
162
+ 'auto-commit-per-wave':
163
+ 'prose-only consumer — skills/wave-executor/wave-loop.md gates the per-wave commit step on this key; the commit itself is a coordinator action, not a script',
164
+ 'instruction-budget':
165
+ 'dedicated reader outside the parser layer — scripts/lib/instruction-budget-guard.mjs parses this block itself (S2 exemption only; S1 evidence is real)',
166
+ webhooks:
167
+ 'dedicated reader outside the parser layer — scripts/lib/webhook-url.mjs resolves these URLs env-first (S2 exemption only; S1 evidence is real)',
168
+ });
169
+
170
+ /**
171
+ * @typedef {{
172
+ * key: string,
173
+ * leaf: string,
174
+ * root: string,
175
+ * sources: string[],
176
+ * }} DeclaredKey
177
+ */
178
+
179
+ /**
180
+ * @typedef {{
181
+ * kind: 'unwired-config-key' | 'parser-orphan-config-key' | 'allowlist-missing-reason'
182
+ * | 'allowlist-stale' | 'tool-error',
183
+ * key: string,
184
+ * message: string,
185
+ * }} Finding
186
+ */
187
+
188
+ /**
189
+ * Recursively collect code files, skipping symlinks and excluded directories.
190
+ *
191
+ * @param {string} directory absolute directory path
192
+ * @param {string[]} [acc]
193
+ * @returns {string[]} absolute file paths, sorted
194
+ */
195
+ function walkCode(directory, acc = []) {
196
+ if (!existsSync(directory)) return acc;
197
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
198
+ if (entry.isSymbolicLink()) continue;
199
+ if (EXCLUDED_DIRS.includes(entry.name)) continue;
200
+ const fullPath = path.join(directory, entry.name);
201
+ if (entry.isDirectory()) walkCode(fullPath, acc);
202
+ else if (entry.isFile() && CODE_EXTENSIONS.includes(path.extname(entry.name))) acc.push(fullPath);
203
+ }
204
+ return acc;
205
+ }
206
+
207
+ /**
208
+ * Extract dotted key paths from YAML-ish lines.
209
+ *
210
+ * Indentation drives nesting; a `- ` list-item prefix is treated as one extra
211
+ * level so `custom-phases: [- name: …]` yields `custom-phases.name`. Comment
212
+ * lines and inline `# …` trailers are stripped first, so a commented-out key
213
+ * (e.g. the deliberately-disabled `# bash-write-guard: true`) is NOT counted as
214
+ * declared — commenting a key out IS the documented way to leave it unset.
215
+ *
216
+ * @param {string[]} lines raw YAML lines
217
+ * @param {(key: string, leaf: string, rootKey: string) => void} emit
218
+ * @returns {void}
219
+ */
220
+ function extractKeyLines(lines, emit) {
221
+ /** @type {{name: string, indent: number}[]} */
222
+ const stack = [];
223
+ for (const raw of lines) {
224
+ const trimmed = raw.trim();
225
+ if (!trimmed || trimmed.startsWith('#')) continue;
226
+ const withoutComment = raw.replace(/\s+#.*$/, '');
227
+ let indent = (withoutComment.match(/^(\s*)/) ?? ['', ''])[1].length;
228
+ let body = withoutComment.trim();
229
+ if (body.startsWith('- ')) {
230
+ body = body.slice(2).trim();
231
+ indent += 2;
232
+ }
233
+ const matched = body.match(/^([A-Za-z0-9_.-]+):(?:\s.*)?$/);
234
+ if (!matched) continue;
235
+ const name = matched[1];
236
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
237
+ const parents = stack.map((frame) => frame.name);
238
+ emit([...parents, name].join('.'), name, parents[0] ?? name);
239
+ stack.push({ name, indent });
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Collect every key declared in the template's ```yaml fences and in the live
245
+ * `## Session Config` block(s) of the instruction file.
246
+ *
247
+ * The live-config scan runs to the next `## ` heading at column 0, so the
248
+ * parity-exempt `## Skill Evolution` / `## Dispatcher Autonomy` blocks are
249
+ * picked up as their own declaration sources rather than silently skipped —
250
+ * their keys are just as capable of going unread.
251
+ *
252
+ * @param {string} pluginRoot absolute plugin root
253
+ * @returns {{keys: Map<string, DeclaredKey>, sourcesScanned: string[]}}
254
+ */
255
+ export function collectDeclaredKeys(pluginRoot) {
256
+ /** @type {Map<string, DeclaredKey>} */
257
+ const keys = new Map();
258
+ /** @type {string[]} */
259
+ const sourcesScanned = [];
260
+
261
+ /** @type {(source: string) => (key: string, leaf: string, rootKey: string) => void} */
262
+ const emitter = (source) => (key, leaf, rootKey) => {
263
+ const existing = keys.get(key);
264
+ if (existing) {
265
+ if (!existing.sources.includes(source)) existing.sources.push(source);
266
+ return;
267
+ }
268
+ keys.set(key, { key, leaf, root: rootKey, sources: [source] });
269
+ };
270
+
271
+ const templatePath = path.join(pluginRoot, TEMPLATE_REL);
272
+ if (existsSync(templatePath)) {
273
+ sourcesScanned.push(TEMPLATE_REL);
274
+ const emit = emitter(TEMPLATE_REL);
275
+ let inYamlFence = false;
276
+ /** @type {string[]} */
277
+ let fence = [];
278
+ for (const raw of readFileSync(templatePath, 'utf8').split('\n')) {
279
+ if (raw.trim().startsWith('```')) {
280
+ if (inYamlFence) extractKeyLines(fence, emit);
281
+ inYamlFence = raw.trim().startsWith('```yaml');
282
+ fence = [];
283
+ continue;
284
+ }
285
+ if (inYamlFence) fence.push(raw);
286
+ }
287
+ if (inYamlFence) extractKeyLines(fence, emit);
288
+ }
289
+
290
+ for (const candidate of INSTRUCTION_FILES) {
291
+ const instructionPath = path.join(pluginRoot, candidate);
292
+ if (!existsSync(instructionPath)) continue;
293
+ sourcesScanned.push(candidate);
294
+ const emit = emitter(candidate);
295
+ const lines = readFileSync(instructionPath, 'utf8').split('\n');
296
+ let inConfigBlock = false;
297
+ /** @type {string[]} */
298
+ let block = [];
299
+ for (const raw of lines) {
300
+ if (/^## /.test(raw)) {
301
+ if (inConfigBlock) extractKeyLines(block, emit);
302
+ block = [];
303
+ inConfigBlock = /^## (Session Config|Skill Evolution|Dispatcher Autonomy)\b/.test(raw);
304
+ continue;
305
+ }
306
+ // `>` blockquote prose inside a config block carries no declarations.
307
+ if (inConfigBlock && !raw.trim().startsWith('>')) block.push(raw);
308
+ }
309
+ if (inConfigBlock) extractKeyLines(block, emit);
310
+ break; // CLAUDE.md wins ties (skills/_shared/instruction-file-resolution.md)
311
+ }
312
+
313
+ return { keys, sourcesScanned };
314
+ }
315
+
316
+ /**
317
+ * Build a word-boundary matcher for a config-key token.
318
+ *
319
+ * `-` is part of the boundary class so `mode` does not match `mode-x`, and
320
+ * `$` so `enabled` does not match `isEnabled$`.
321
+ *
322
+ * @param {string} token
323
+ * @returns {RegExp}
324
+ */
325
+ function tokenMatcher(token) {
326
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
327
+ return new RegExp(`(?<![A-Za-z0-9_$-])${escaped}(?![A-Za-z0-9_$-])`);
328
+ }
329
+
330
+ /** @param {string} line @returns {boolean} whether the line is comment-only */
331
+ function isCommentLine(line) {
332
+ const trimmed = line.trim();
333
+ return (
334
+ trimmed.startsWith('//') ||
335
+ trimmed.startsWith('*') ||
336
+ trimmed.startsWith('/*') ||
337
+ trimmed.startsWith('#')
338
+ );
339
+ }
340
+
341
+ /**
342
+ * Count read sites for one declared key across the consumer corpus.
343
+ *
344
+ * Matching is on the LEAF token, because a nested key is read as
345
+ * `cfg['memory']?.banner?.enabled` — the dotted path never appears verbatim.
346
+ * That alone would make a generic leaf (`enabled`, `mode`, `url`) match
347
+ * everywhere, so a nested key with a NON-DISTINCTIVE leaf (no hyphen — i.e. a
348
+ * token that could be a plain JS identifier) additionally requires its
349
+ * top-level ancestor token in the SAME file.
350
+ *
351
+ * A hyphenated leaf is exempt from that ancestor requirement on purpose:
352
+ * `enforcement-gates.path-guard` is read in `hooks/enforce-scope.mjs` as
353
+ * `scope.gates?.['path-guard']`, a file that never names `enforcement-gates`.
354
+ * Requiring the ancestor there produced a false positive on a genuinely-wired
355
+ * gate — and false positives are what get this check switched off.
356
+ *
357
+ * @param {DeclaredKey} declared
358
+ * @param {{relative: string, lines: string[], body: string}[]} corpus
359
+ * @returns {{code: number, comment: number, files: string[]}}
360
+ */
361
+ export function countReadSites(declared, corpus) {
362
+ const leafRe = tokenMatcher(declared.leaf);
363
+ const rootRe = tokenMatcher(declared.root);
364
+ const scopedByAncestor = declared.key !== declared.leaf && !declared.leaf.includes('-');
365
+ let code = 0;
366
+ let comment = 0;
367
+ /** @type {string[]} */
368
+ const files = [];
369
+
370
+ for (const file of corpus) {
371
+ if (scopedByAncestor && !rootRe.test(file.body)) continue;
372
+ if (!leafRe.test(file.body)) continue;
373
+ let codeInFile = 0;
374
+ for (const line of file.lines) {
375
+ if (!leafRe.test(line)) continue;
376
+ if (isCommentLine(line)) comment += 1;
377
+ else codeInFile += 1;
378
+ }
379
+ if (codeInFile > 0) files.push(file.relative);
380
+ code += codeInFile;
381
+ }
382
+
383
+ return { code, comment, files };
384
+ }
385
+
386
+ /**
387
+ * Run the full census.
388
+ *
389
+ * @param {string} pluginRoot absolute plugin root
390
+ * @returns {{
391
+ * ok: boolean,
392
+ * summary: {declaredKeys: number, consumerFiles: number, unwired: number, allowlisted: number},
393
+ * sourcesScanned: string[],
394
+ * findings: Finding[],
395
+ * toolError: boolean,
396
+ * }}
397
+ */
398
+ export function inspectUnwiredFeatures(pluginRoot) {
399
+ /** @type {Finding[]} */
400
+ const findings = [];
401
+ const result = {
402
+ ok: false,
403
+ summary: { declaredKeys: 0, consumerFiles: 0, unwired: 0, allowlisted: 0 },
404
+ /** @type {string[]} */
405
+ sourcesScanned: [],
406
+ findings,
407
+ toolError: false,
408
+ };
409
+
410
+ /** @type {{keys: Map<string, DeclaredKey>, sourcesScanned: string[]}} */
411
+ let declared;
412
+ /** @type {{relative: string, lines: string[], body: string}[]} */
413
+ let corpus;
414
+ /** @type {string} */
415
+ let parserBody;
416
+ try {
417
+ declared = collectDeclaredKeys(pluginRoot);
418
+ corpus = CONSUMER_DIRS.flatMap((dir) => walkCode(path.join(pluginRoot, dir)))
419
+ .sort()
420
+ .filter((absolute) => path.relative(pluginRoot, absolute) !== SELF_REL)
421
+ .map((absolute) => {
422
+ const body = readFileSync(absolute, 'utf8');
423
+ return { relative: path.relative(pluginRoot, absolute), lines: body.split('\n'), body };
424
+ });
425
+ parserBody = PARSER_PATHS.flatMap((relative) => {
426
+ const absolute = path.join(pluginRoot, relative);
427
+ if (!existsSync(absolute)) return [];
428
+ return CODE_EXTENSIONS.includes(path.extname(absolute)) ? [absolute] : walkCode(absolute);
429
+ })
430
+ .map((absolute) => readFileSync(absolute, 'utf8'))
431
+ .join('\n');
432
+ } catch (error) {
433
+ result.toolError = true;
434
+ findings.push({
435
+ kind: 'tool-error',
436
+ key: '-',
437
+ message: `cannot enumerate config surface: ${error instanceof Error ? error.message : String(error)}`,
438
+ });
439
+ return result;
440
+ }
441
+
442
+ result.sourcesScanned = declared.sourcesScanned;
443
+ result.summary.declaredKeys = declared.keys.size;
444
+ result.summary.consumerFiles = corpus.length;
445
+
446
+ /** @type {Set<string>} */
447
+ const flagged = new Set();
448
+
449
+ for (const key of [...declared.keys.keys()].sort()) {
450
+ const meta = /** @type {DeclaredKey} */ (declared.keys.get(key));
451
+ const { code, comment } = countReadSites(meta, corpus);
452
+
453
+ /** @type {Finding | null} */
454
+ let issue = null;
455
+ if (code === 0) {
456
+ const commentNote = comment > 0 ? ` (${comment} comment-only mention(s))` : '';
457
+ issue = {
458
+ kind: 'unwired-config-key',
459
+ key,
460
+ message:
461
+ `declared in ${meta.sources.join(' + ')} but no .mjs under ${CONSUMER_DIRS.join('/ or ')}/ ` +
462
+ `reads it${commentNote} — wire it, delete it, or allowlist it with a reason`,
463
+ };
464
+ } else if (key === meta.root && !tokenMatcher(key).test(parserBody)) {
465
+ issue = {
466
+ kind: 'parser-orphan-config-key',
467
+ key,
468
+ message:
469
+ `mentioned in ${CONSUMER_DIRS.join('/ or ')}/ but unknown to the config-parser layer ` +
470
+ `(${PARSER_PATHS.join(', ')}) — nothing turns this YAML into a value; the mention may be ` +
471
+ 'a log string or a comment-adjacent reference',
472
+ };
473
+ }
474
+ if (!issue) continue;
475
+ flagged.add(key);
476
+
477
+ if (Object.prototype.hasOwnProperty.call(ALLOWLIST, key)) {
478
+ result.summary.allowlisted += 1;
479
+ if (String(ALLOWLIST[key] ?? '').trim() === '') {
480
+ findings.push({
481
+ kind: 'allowlist-missing-reason',
482
+ key,
483
+ message: 'allowlist entry has no reason — name the actual consumer or remove the entry',
484
+ });
485
+ }
486
+ continue;
487
+ }
488
+
489
+ result.summary.unwired += 1;
490
+ findings.push(issue);
491
+ }
492
+
493
+ for (const key of Object.keys(ALLOWLIST).sort()) {
494
+ if (flagged.has(key)) continue;
495
+ findings.push({
496
+ kind: 'allowlist-stale',
497
+ key,
498
+ message: declared.keys.has(key)
499
+ ? 'allowlisted key no longer triggers a finding (it is wired now) — remove the entry'
500
+ : 'allowlisted key is no longer declared in any config surface — remove the entry',
501
+ });
502
+ }
503
+
504
+ result.ok = !result.toolError && findings.length === 0;
505
+ return result;
506
+ }
507
+
508
+ /**
509
+ * Run the human-readable validator CLI.
510
+ *
511
+ * WARN-ONLY: findings print as WARN and still exit 0. See the header for why a
512
+ * blocking gate would be red on day one on this repo's current inventory.
513
+ *
514
+ * @param {string} pluginRoot absolute plugin root
515
+ * @returns {number} 0 = scan completed (with or without findings), 2 = tool error
516
+ */
517
+ export function runCheckUnwiredFeatures(pluginRoot) {
518
+ console.log('--- Check: unwired config keys (declared-but-unread census, WARN-only) ---');
519
+ const inspection = inspectUnwiredFeatures(pluginRoot);
520
+
521
+ if (inspection.toolError) {
522
+ for (const item of inspection.findings) console.log(` FAIL: ${item.key} — ${item.message}`);
523
+ console.log('');
524
+ console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
525
+ return 2;
526
+ }
527
+
528
+ const { declaredKeys, consumerFiles, unwired, allowlisted } = inspection.summary;
529
+ for (const item of inspection.findings) {
530
+ console.log(` WARN: [${item.kind}] ${item.key} — ${item.message}`);
531
+ }
532
+ console.log(
533
+ ` PASS: censused ${declaredKeys} declared key(s) from ${inspection.sourcesScanned.join(' + ') || '(no source)'} ` +
534
+ `against ${consumerFiles} consumer file(s) — ${unwired} unwired, ${allowlisted} allowlisted`,
535
+ );
536
+ console.log('');
537
+ console.log('Results: 1 passed, 0 failed');
538
+ return 0;
539
+ }
540
+
541
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
542
+ if (isMain) {
543
+ const pluginRoot = process.argv[2];
544
+ if (!pluginRoot) {
545
+ console.error('Usage: check-unwired-features.mjs <plugin-root>');
546
+ process.exit(2);
547
+ }
548
+ process.exit(runCheckUnwiredFeatures(path.resolve(pluginRoot)));
549
+ }
@@ -277,9 +277,17 @@ export function validateRuleContent({ content, relPath, targetRoot = null, requi
277
277
  violations.push({
278
278
  rule: 'paths-frontmatter',
279
279
  severity: 'error',
280
+ // NOTE (#795 / corrected 2026-07-25): the previous wording claimed rule-loader.mjs
281
+ // does not recognize `paths:` and that such a rule loads always-on. Both are false
282
+ // since #795 — `rule-loader.mjs:275` accepts `paths` alongside `globs`, and `:313`
283
+ // treats it as a fallback alias (`globs:` wins silently when both are present), so a
284
+ // `paths:`-only rule IS glob-scoped. The probe itself stays: it enforces the canonical
285
+ // vendoring form, which is a convention gate, not a loader-compatibility gate. That
286
+ // intent survives #795 and is the subject of the #742 fleet canonicalisation sweep.
280
287
  message:
281
- `${relPath}: frontmatter declares a top-level 'paths:' key, which rule-loader.mjs does not recognize ` +
282
- `it is silently ignored and the rule loads as always-on instead of glob-scoped. Migrate to 'globs:'.`,
288
+ `${relPath}: frontmatter declares a top-level 'paths:' key. It is a recognized alias ` +
289
+ `for 'globs:' (issue #795), so the rule does load glob-scoped but 'globs:' is the ` +
290
+ `canonical form for vendored rules. Migrate to 'globs:' (see issue #742).`,
283
291
  line: lineWithinFrontmatter(fm.startLine, fm.body, pathsMatch.index),
284
292
  });
285
293
  }
@@ -240,8 +240,23 @@ export function buildArchiveFields({
240
240
  const finalType = VAULT_TYPE_ENUM.includes(base.type) ? base.type : type;
241
241
 
242
242
  // created: keep a valid existing date (original creation), else today.
243
- const created =
244
- typeof base.created === 'string' && ISO_DATE_RE.test(base.created) ? base.created : dateStr;
243
+ //
244
+ // js-yaml's default schema auto-resolves an unquoted ISO-8601-shaped scalar
245
+ // (the canonical `created: 2026-01-01` form) into a native `Date`, NOT a
246
+ // string — same quirk as moc-staleness-banner.mjs's `_readMocStalenessDays`
247
+ // (verified: `YAML.load('created: 2026-01-01').created instanceof Date`).
248
+ // Without this branch, an unquoted existing `created:` silently fails the
249
+ // `typeof === 'string'` check below and gets clobbered with today's date
250
+ // (#837). Accept both shapes; anything else (missing, number, array, an
251
+ // invalid Date, …) falls back to `dateStr` as before.
252
+ let created;
253
+ if (typeof base.created === 'string' && ISO_DATE_RE.test(base.created)) {
254
+ created = base.created;
255
+ } else if (base.created instanceof Date && !Number.isNaN(base.created.getTime())) {
256
+ created = base.created.toISOString().slice(0, 10);
257
+ } else {
258
+ created = dateStr;
259
+ }
245
260
 
246
261
  // title: explicit arg wins, else existing, else undefined (caller may derive).
247
262
  let finalTitle = title;
@@ -36,6 +36,14 @@ export function assertGlabExists(dieFn) {
36
36
 
37
37
  /**
38
38
  * Run a glab command, return { ok, stdout, stderr }.
39
+ *
40
+ * Host-pinning (#872): deliberately ambient — this module runs instance-wide
41
+ * queries (e.g. `glab repo list -g <group>`) that are not scoped to a single
42
+ * repo/project, so there is no single `-R`/`--repo` spec to pin. If a
43
+ * caller ever needs single-repo host-pinning here, use `--hostname` (the
44
+ * flag `glab api` and instance-wide subcommands accept), NOT `-R`/`--repo` —
45
+ * see `scripts/lib/vcs-repo-spec.mjs` for the `resolveRepoSpec` (`-R`) vs
46
+ * `resolveRepoHost` (`--hostname`) contract this repo already established.
39
47
  */
40
48
  export function glabRun(glabArgs) {
41
49
  vlog(`glab ${glabArgs.join(' ')}`);
@@ -10,6 +10,7 @@ import { join, resolve, basename } from 'node:path';
10
10
  import { randomUUID } from 'node:crypto';
11
11
  import { execFileSync } from 'node:child_process';
12
12
  import { subjectToSlug, isValidSlug, uuidPrefix8, toDate, parseFrontmatter } from './utils.mjs';
13
+ import { isRealSession } from '../session-schema/filters.mjs';
13
14
  import { resolveRepoNamespace } from './namespace.mjs';
14
15
  import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2 } from './render-learnings.mjs';
15
16
  import { detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3 } from './render-sessions.mjs';
@@ -464,6 +465,35 @@ export async function processSession(rawEntry, _lineNum, ctx) {
464
465
  session_id = session_id.slice(0, 240).replace(/-+$/, '');
465
466
  }
466
467
 
468
+ // #909 ABANDONED FILTER: a phantom stub backfilled from events.jsonl for a
469
+ // session that never ran /close is legitimate DATA in the ledger but not
470
+ // legitimate SIGNAL in a knowledge store — it records that a start happened,
471
+ // with 0 waves, 0 agents and synthesized fields. Mirroring it would add a note
472
+ // whose every number is a placeholder.
473
+ //
474
+ // The predicate is `isRealSession` from scripts/lib/session-schema/filters.mjs
475
+ // (fail-open: only an explicit `status: 'abandoned'` is filtered; the absent-
476
+ // status pre-#724 majority passes through). Reused rather than re-implemented
477
+ // so the ledger's definition of "real" lives in exactly one place.
478
+ //
479
+ // Placed BEFORE repoNs resolution and BEFORE the quality-gate render: skipping
480
+ // early avoids a git subprocess and a wasted render for a record we discard.
481
+ // This is deliberately REDUNDANT with the renderer's status mapping (#909,
482
+ // render-sessions.mjs) — see that module's header. The filter removes one
483
+ // status from the vault; the mapping keeps every OTHER status honest, and
484
+ // guards the generators' other entry point (the render.mjs barrel).
485
+ if (!isRealSession(entry)) {
486
+ emitAction({
487
+ action: 'skipped-abandoned',
488
+ path: null,
489
+ kind,
490
+ id: session_id,
491
+ vaultDir,
492
+ meta: { reason: `status:${entry?.status}` },
493
+ });
494
+ return;
495
+ }
496
+
467
497
  // #732: resolve the leak-guarded repo namespace ONCE per session, BEFORE the
468
498
  // quality gate, so both the write path (targetDir, below) AND the rendered
469
499
  // frontmatter (`source-repo`) use the SAME sanitised / pseudonym-mapped value.