session-orchestrator 3.22.0 → 3.23.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 (268) 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/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -6,8 +6,15 @@
6
6
  * - Flow-style integer arrays (`[1, 2, 3]`)
7
7
  * - Block-style sequences of mappings (issue #244), e.g. `docs-tasks:` with
8
8
  * indented `- key: value` entries. Only one nesting level supported.
9
+ * - Single-line FLOW MAPPINGS as list items (`- { k: v, … }`) — hand-written
10
+ * but valid YAML, parsed into the same plain object a block item yields and
11
+ * re-emitted in block notation on the next serialize (#1111).
9
12
  *
10
13
  * That is the full grammar permitted by skills/_shared/state-ownership.md.
14
+ * A list item this grammar cannot represent (a flow SEQUENCE `- [a, b]`, or a
15
+ * flow mapping whose interior is malformed) is dropped from ITS OWN LIST and
16
+ * reported on `parseStateMd(...).warnings` — never escalated to a null
17
+ * document. See `parseBlockValue` for why that scoping is load-bearing.
11
18
  *
12
19
  * Never throws. Returns null for unparseable input rather than raising.
13
20
  *
@@ -35,17 +42,28 @@ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
35
42
  /**
36
43
  * Parses a STATE.md file into frontmatter + body.
37
44
  *
45
+ * `warnings` is OMITTED (not `null`, not `[]`) when the parse was clean, so a
46
+ * caller pinning the whole return with `toEqual({ frontmatter, body })` still
47
+ * matches and no consumer has to learn about it to keep working. When present
48
+ * it is a non-empty array of `{ key, index, reason }` records naming list items
49
+ * that were dropped from `frontmatter[key]` — `index` addresses the RAW source
50
+ * item position (dropped items included), so it stays quotable against the file
51
+ * even though the surviving array is shorter.
52
+ *
38
53
  * @param {string} contents
39
- * @returns {{frontmatter: object, body: string}|null}
54
+ * @returns {{frontmatter: object, body: string, warnings?: Array<{key: string, index: number, reason: string}>}|null}
40
55
  */
41
56
  export function parseStateMd(contents) {
42
57
  if (typeof contents !== 'string') return null;
43
58
  const match = FRONTMATTER_RE.exec(contents);
44
59
  if (!match) return null;
45
60
  const [, fmText, body] = match;
46
- const frontmatter = parseFrontmatter(fmText);
61
+ const warnings = [];
62
+ const frontmatter = parseFrontmatter(fmText, warnings);
47
63
  if (frontmatter === null) return null;
48
- return { frontmatter, body: body.startsWith('\n') ? body.slice(1) : body };
64
+ const parsed = { frontmatter, body: body.startsWith('\n') ? body.slice(1) : body };
65
+ if (warnings.length > 0) parsed.warnings = warnings;
66
+ return parsed;
49
67
  }
50
68
 
51
69
  /**
@@ -70,7 +88,7 @@ export function serializeStateMd({ frontmatter, body }) {
70
88
  return `---\n${fmLines.join('\n')}\n---\n${bodyOut}`;
71
89
  }
72
90
 
73
- function parseFrontmatter(text) {
91
+ function parseFrontmatter(text, warnings) {
74
92
  const out = {};
75
93
  const lines = text.split(/\r?\n/);
76
94
  let i = 0;
@@ -87,7 +105,7 @@ function parseFrontmatter(text) {
87
105
  if (key === '') return null;
88
106
  const valuePart = rstripped.slice(idx + 1).trim();
89
107
  if (valuePart === '') {
90
- const result = parseBlockValue(lines, i + 1);
108
+ const result = parseBlockValue(lines, i + 1, key, warnings);
91
109
  if (result === null) return null;
92
110
  out[key] = result.value;
93
111
  i = result.nextIndex;
@@ -106,8 +124,27 @@ function parseFrontmatter(text) {
106
124
  * no body) and `nextIndex === start` so the caller resumes at `start`.
107
125
  * - `value === [...]` means a block sequence was consumed.
108
126
  * Returns `null` on malformed block syntax.
127
+ *
128
+ * A list item that opens a YAML FLOW collection is resolved PER ITEM, never per
129
+ * document:
130
+ * - `- { id: m-1, task: "x", wave: 1, status: b }` parses into exactly the
131
+ * object the equivalent block item yields (and serializes back as block
132
+ * notation). Splitting it at the first colon — the #1111 bug, measured
133
+ * 2026-08-24 @ f0766e1 as `keys: ["{ id"] · status: undefined` — is what
134
+ * this must never do again.
135
+ * - Anything else flow-shaped (`- [a, b]`, or a `{ … }` whose interior is
136
+ * malformed) is dropped from THIS list with a `warnings` record. It cannot
137
+ * be kept: a non-mapping item makes `isBlockSeqOfMappings` false for the
138
+ * whole array, and the serializer would then emit the entire list through
139
+ * `serializeScalar` as `[object Object]`.
140
+ *
141
+ * Escalating either case to `null` for the WHOLE document — the first #1111 fix
142
+ * — is the failure this scoping exists to prevent: every mutator in this family
143
+ * opens with `parseStateMd(contents); if (parsed === null) return contents;`, so
144
+ * one hand-written item silently turned every STATE.md write into a no-op that
145
+ * reported success. Silent-wrong must not be traded for silent-absent.
109
146
  */
110
- function parseBlockValue(lines, start) {
147
+ function parseBlockValue(lines, start, key, warnings) {
111
148
  let i = start;
112
149
  while (i < lines.length) {
113
150
  const rstripped = lines[i].replace(/\s+$/, '');
@@ -124,6 +161,7 @@ function parseBlockValue(lines, start) {
124
161
  const indent = bulletMatch[1];
125
162
  const contIndent = indent + ' ';
126
163
  const entries = [];
164
+ let itemIndex = 0;
127
165
  while (i < lines.length) {
128
166
  const rstripped = lines[i].replace(/\s+$/, '');
129
167
  if (rstripped === '' || /^\s*#/.test(rstripped)) {
@@ -132,12 +170,26 @@ function parseBlockValue(lines, start) {
132
170
  }
133
171
  if (!rstripped.startsWith(indent + '- ')) break;
134
172
  const firstBody = rstripped.slice(indent.length + 2);
135
- const firstColon = firstBody.indexOf(':');
136
- if (firstColon === -1) return null;
137
- const firstKey = firstBody.slice(0, firstColon).trim();
138
- if (firstKey === '') return null;
139
- const entry = {};
140
- entry[firstKey] = parseScalar(firstBody.slice(firstColon + 1).trim());
173
+ // `entry === null` marks an item this grammar cannot represent: its
174
+ // continuation lines are still consumed below (so the walk stays aligned),
175
+ // but nothing is pushed onto `entries`.
176
+ let entry;
177
+ if (firstBody.startsWith('{')) {
178
+ entry = parseFlowMapping(firstBody);
179
+ if (entry === null) {
180
+ warnings.push({ key, index: itemIndex, reason: 'malformed-flow-mapping' });
181
+ }
182
+ } else if (firstBody.startsWith('[')) {
183
+ entry = null;
184
+ warnings.push({ key, index: itemIndex, reason: 'flow-sequence-item' });
185
+ } else {
186
+ const firstColon = firstBody.indexOf(':');
187
+ if (firstColon === -1) return null;
188
+ const firstKey = firstBody.slice(0, firstColon).trim();
189
+ if (firstKey === '') return null;
190
+ entry = {};
191
+ entry[firstKey] = parseScalar(firstBody.slice(firstColon + 1).trim());
192
+ }
141
193
  i++;
142
194
  while (i < lines.length) {
143
195
  const inner = lines[i].replace(/\s+$/, '');
@@ -150,16 +202,93 @@ function parseBlockValue(lines, start) {
150
202
  if (/^\s/.test(body)) return null;
151
203
  const colon = body.indexOf(':');
152
204
  if (colon === -1) return null;
153
- const key = body.slice(0, colon).trim();
154
- if (key === '') return null;
155
- entry[key] = parseScalar(body.slice(colon + 1).trim());
205
+ const contKey = body.slice(0, colon).trim();
206
+ if (contKey === '') return null;
207
+ if (entry !== null) entry[contKey] = parseScalar(body.slice(colon + 1).trim());
156
208
  i++;
157
209
  }
158
- entries.push(entry);
210
+ if (entry !== null) entries.push(entry);
211
+ itemIndex++;
159
212
  }
160
213
  return { value: entries, nextIndex: i };
161
214
  }
162
215
 
216
+ /**
217
+ * Splits a flow-collection interior on its TOP-LEVEL commas — the ones outside
218
+ * quotes and outside any nested `{}`/`[]`. A naive `split(',')` would cut
219
+ * `task: "a, b"` in half, which is the same class of first-separator mistake
220
+ * that produced the `{ id` key in the first place.
221
+ *
222
+ * @param {string} inner
223
+ * @returns {string[]}
224
+ */
225
+ function splitFlowSegments(inner) {
226
+ const segments = [];
227
+ let depth = 0;
228
+ let quote = null;
229
+ let current = '';
230
+ for (let i = 0; i < inner.length; i++) {
231
+ const ch = inner[i];
232
+ if (quote !== null) {
233
+ current += ch;
234
+ if (ch === '\\' && quote === '"') {
235
+ current += inner[++i] ?? '';
236
+ } else if (ch === quote) {
237
+ quote = null;
238
+ }
239
+ continue;
240
+ }
241
+ if (ch === '"' || ch === "'") quote = ch;
242
+ else if (ch === '{' || ch === '[') depth++;
243
+ else if (ch === '}' || ch === ']') depth--;
244
+ else if (ch === ',' && depth === 0) {
245
+ segments.push(current);
246
+ current = '';
247
+ continue;
248
+ }
249
+ current += ch;
250
+ }
251
+ segments.push(current);
252
+ return segments;
253
+ }
254
+
255
+ /**
256
+ * Parses a SINGLE-LINE YAML flow mapping (`{ k: v, k2: "v, 2" }`) into a plain
257
+ * object, reusing `parseScalar` for each value so a flow item and the equivalent
258
+ * block item yield byte-identical results.
259
+ *
260
+ * Returns `null` when `raw` is not a well-formed single-line flow mapping —
261
+ * unterminated, an empty segment, or a segment with no `:`. Callers report that
262
+ * as a dropped item; nothing here throws.
263
+ *
264
+ * KNOWN CEILING: a NESTED flow mapping value (`{ id: m-1, meta: { a: 1 } }`)
265
+ * keeps `{ a: 1 }` as a STRING rather than an object — this subset has no
266
+ * nested-mapping representation and the serializer has no way to emit one. Flat
267
+ * flow mappings are the whole observed population (hand-written `mission-status`
268
+ * / `docs-tasks` items). Revisit if a nested flow value ever appears in a real
269
+ * STATE.md.
270
+ *
271
+ * @param {string} raw
272
+ * @returns {object|null}
273
+ */
274
+ function parseFlowMapping(raw) {
275
+ const trimmed = raw.trim();
276
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
277
+ const inner = trimmed.slice(1, -1).trim();
278
+ const out = {};
279
+ if (inner === '') return out;
280
+ for (const segment of splitFlowSegments(inner)) {
281
+ const part = segment.trim();
282
+ if (part === '') return null;
283
+ const colon = part.indexOf(':');
284
+ if (colon === -1) return null;
285
+ const key = part.slice(0, colon).trim();
286
+ if (key === '') return null;
287
+ out[key] = parseScalar(part.slice(colon + 1).trim());
288
+ }
289
+ return out;
290
+ }
291
+
163
292
  function parseScalar(raw) {
164
293
  if (raw === '' || raw === 'null' || raw === '~') return null;
165
294
  if (raw === 'true') return true;
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * STATE.md helpers (issue #184).
3
3
  *
4
- * Thin barrel — re-exports all 25 public symbols from the split submodules.
4
+ * Thin barrel — re-exports every public symbol of the split submodules.
5
5
  * Implementation lives in scripts/lib/state-md/*.mjs.
6
6
  *
7
7
  * @see scripts/lib/state-md/yaml-parser.mjs parseStateMd, serializeStateMd
8
8
  * @see scripts/lib/state-md/frontmatter-mutators.mjs touchUpdatedField, updateFrontmatterFields
9
9
  * @see scripts/lib/state-md/body-sections.mjs readCurrentTask, appendDeviation, markExpressPathComplete, appendWhatNotToRetry, readWhatNotToRetry, readOpenQuestions, appendOpenQuestion, markOpenQuestionAnswered
10
- * @see scripts/lib/state-md/mission-status.mjs parseMissionStatus, writeMissionStatus, setMissionStatus, readMissionStatus
10
+ * @see scripts/lib/state-md/mission-status.mjs parseMissionStatus, parseMissionStatusStrict, MISSION_STATUS_VALUES, writeMissionStatus, setMissionStatus, setMissionStatusDetailed, readMissionStatus, recoverFrontmatterMissionStatusDetailed, writeMissionStatusOnDisk, setMissionStatusOnDisk
11
11
  * @see scripts/lib/state-md/recommendations.mjs parseRecommendations
12
12
  */
13
13
 
@@ -43,11 +43,21 @@ export {
43
43
 
44
44
  export {
45
45
  parseMissionStatus,
46
+ // The strict reader and the status vocabulary are part of the same public
47
+ // surface as `parseMissionStatus` (#1111) — a consumer that imports from this
48
+ // barrel (the documented entry point) reached neither until this re-export
49
+ // landed: a static `import { parseMissionStatusStrict } from '.../state-md.mjs'`
50
+ // failed at link time, and the `await import()` form — the one hooks and
51
+ // lazy loaders use — yielded `undefined` and failed only at the call.
52
+ parseMissionStatusStrict,
53
+ MISSION_STATUS_VALUES,
46
54
  writeMissionStatus,
47
55
  setMissionStatus,
48
56
  readMissionStatus,
49
57
  writeMissionStatusOnDisk,
50
58
  setMissionStatusOnDisk,
59
+ recoverFrontmatterMissionStatusDetailed,
60
+ setMissionStatusDetailed,
51
61
  } from './state-md/mission-status.mjs';
52
62
 
53
63
  export { parseRecommendations } from './state-md/recommendations.mjs';
@@ -287,25 +287,63 @@ export async function flush({
287
287
  // ---------------------------------------------------------------------------
288
288
 
289
289
  /**
290
- * Whether a daily-fallback flush is due: the queue is non-empty AND more than 24h
291
- * have passed since the last successful flush (a never-flushed queue with items
292
- * counts as due). Cheap — a single telemetry.json read + a queue stat. Never throws.
290
+ * Whether a daily-fallback flush is due. Two independent ways to be due, both
291
+ * requiring that more than 24h have passed since the last successful flush
292
+ * (never-flushed counts as infinitely stale):
293
+ *
294
+ * (a) RETRY — the offline queue is non-empty: earlier sends failed and the
295
+ * backlog deserves another attempt.
296
+ * (b) CATCH-UP — the queue is empty, but `<metricsDir>/sessions.jsonl` ends on
297
+ * a record whose `completed_at` is NEWER than `last_flush_at`: a session
298
+ * closed since the last successful send and produced no ping.
299
+ *
300
+ * (b) is the reason this predicate exists at all. While it was queue-only, the
301
+ * "daily fallback" could ONLY re-send what had already failed to send — it could
302
+ * never originate a ping. Measured 2026-08-23 (#1138): 588 session closes across
303
+ * 13 repos produced 82 ingest records (~14%), because the ONE writer of the
304
+ * queue is a failed `flush()`, and a `flush()` that never runs never fails.
305
+ *
306
+ * Cheap in the common case: the staleness gate is checked FIRST (one small
307
+ * telemetry.json read), so a host that flushed within the last 24h returns
308
+ * before touching either the queue or the session ledger.
309
+ *
310
+ * Never throws.
293
311
  *
294
312
  * @param {object} [opts]
295
313
  * @param {string} [opts.statePath] telemetry.json path override.
296
314
  * @param {string} [opts.queuePath] queue path override.
315
+ * @param {string} [opts.metricsDir] Metrics dir for the (b) catch-up probe
316
+ * (default `<cwd>/.orchestrator/metrics`).
297
317
  * @param {number} [opts.now] Reference time in epoch-ms (default Date.now()).
298
318
  * @returns {boolean}
299
319
  */
300
- export function shouldDailyFlush({ statePath, queuePath, now = Date.now() } = {}) {
320
+ export function shouldDailyFlush({ statePath, queuePath, metricsDir, now = Date.now() } = {}) {
301
321
  try {
302
- const { count } = queueStats({ path: queuePath });
303
- if (count <= 0) return false;
304
-
322
+ // Staleness gate shared by BOTH disjuncts, so it runs first and short-
323
+ // circuits the two file probes below on every fresh host.
305
324
  const { record } = readTelemetryState({ path: statePath });
306
325
  const raw = record?.last_flush_at;
307
326
  const lastMs = typeof raw === 'string' && !Number.isNaN(Date.parse(raw)) ? Date.parse(raw) : 0;
308
- return now - lastMs > DAILY_FLUSH_MS;
327
+ if (now - lastMs <= DAILY_FLUSH_MS) return false;
328
+
329
+ // (a) Retry an existing backlog.
330
+ const { count } = queueStats({ path: queuePath });
331
+ if (count > 0) return true;
332
+
333
+ // (b) Catch-up: a session completed after the last successful flush.
334
+ // Reuses buildBatch's reader — same file, same skipInvalid tolerance,
335
+ // no second parser.
336
+ //
337
+ // Deliberate simplification (named ceiling): this parses the WHOLE
338
+ // sessions.jsonl to look at its last record. At the observed ledger size
339
+ // (271 records / 292 KB in this repo, 2026-08-23) that is sub-millisecond
340
+ // and it only runs once the 24h gate above has already passed. Revisit
341
+ // with a tail-read if any repo's sessions.jsonl passes ~10 MB.
342
+ const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
343
+ const sessions = readJsonlFile(path.join(dir, 'sessions.jsonl'), { skipInvalid: true });
344
+ const last = sessions.length > 0 ? sessions[sessions.length - 1] : null;
345
+ const completedMs = Date.parse(last?.completed_at);
346
+ return !Number.isNaN(completedMs) && completedMs > lastMs;
309
347
  } catch {
310
348
  return false;
311
349
  }
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url';
10
10
  import yaml from 'js-yaml';
11
11
  import { ALLOWED_MODEL_ALIASES, MODEL_ID_RE } from '../agent-frontmatter.mjs';
12
12
  import { extractInitialFrontmatter } from './frontmatter-block.mjs';
13
+ import { parseFrontmatterDescription } from '../description-surface.mjs';
13
14
 
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
 
@@ -136,6 +137,57 @@ function parseToolsValue(toolsVal) {
136
137
  return v.split(',').map((t) => t.trim()).filter(Boolean);
137
138
  }
138
139
 
140
+ /**
141
+ * Detect a `description:` whose YAML-PARSED value is a silently truncated
142
+ * PREFIX of what the file actually says (#1029).
143
+ *
144
+ * An unquoted (plain) YAML scalar ends at the first ` #`: everything from there
145
+ * on is a comment. The block still parses, so every other rule in this gate
146
+ * stays green while the agent picker receives a stub — `agents/eval-judge.md`
147
+ * shipped 51 of 1,180 characters (96% dropped, measured 2026-08-24) and nothing
148
+ * complained. This is the concrete case the Check-6 preamble describes in prose.
149
+ *
150
+ * Detected by COMPARISON, never by pattern: the parsed value must be a strict
151
+ * prefix of the raw scalar AND the dropped tail must open a comment. A
152
+ * legitimately quoted description whose text merely contains `#` parses in full
153
+ * (parsed === raw, no length gap) and is never flagged; a quoted description
154
+ * carrying YAML escapes diverges from its raw form and fails the prefix test.
155
+ *
156
+ * Raw extraction is delegated to `parseFrontmatterDescription` (the sibling
157
+ * measurement probe already owns it, block scalars and quote-stripping included)
158
+ * rather than re-derived from a fourth regex here.
159
+ *
160
+ * @param {string} content - the agent file's raw content
161
+ * @param {Record<string, unknown>} data - the parsed frontmatter mapping
162
+ * @param {string} frontmatter - the frontmatter block's inner text
163
+ * @returns {{ parsedLength: number, rawLength: number } | null} null when intact
164
+ */
165
+ function detectDescriptionTruncation(content, data, frontmatter) {
166
+ if (!hasField(frontmatter, 'description')) return null;
167
+
168
+ // A `description:` key whose WHOLE value is a comment (`description: #803 …`)
169
+ // parses to null — a 0-character description, the same defect at 100% loss.
170
+ const parsedValue =
171
+ typeof data?.description === 'string'
172
+ ? data.description
173
+ : data?.description === null || data?.description === undefined
174
+ ? ''
175
+ : null;
176
+ if (parsedValue === null) return null;
177
+
178
+ const rawField = parseFrontmatterDescription(content);
179
+ // Block scalars carry no comment semantics and are rejected outright by the
180
+ // inline-string rule below — not this rule's business.
181
+ if (!rawField || rawField.isBlockScalar) return null;
182
+
183
+ const raw = rawField.raw;
184
+ if (raw.length <= parsedValue.length) return null;
185
+ if (!raw.startsWith(parsedValue)) return null;
186
+ if (!/(^|\s)#/.test(raw.slice(parsedValue.length))) return null;
187
+
188
+ return { parsedLength: parsedValue.length, rawLength: raw.length };
189
+ }
190
+
139
191
  // ============================================================================
140
192
  // Check 6: Agent .md files have valid YAML frontmatter
141
193
  // ============================================================================
@@ -226,6 +278,20 @@ if (mdFiles.length === 0) {
226
278
  }
227
279
  parseable++;
228
280
 
281
+ // ------------------------------------------------------------------
282
+ // Prefix-truncation rule (#1029) — the ` #` case the preamble above names.
283
+ // Runs BEFORE the field rules: a description truncated to a stub still
284
+ // satisfies every one of them, so this is the only place the loss shows up.
285
+ // ------------------------------------------------------------------
286
+ const truncated = detectDescriptionTruncation(content, parsedFm.data, frontmatter);
287
+ if (truncated) {
288
+ fail(
289
+ `${agentName}: description is silently truncated by an unquoted ' #' — YAML parses ` +
290
+ `${truncated.parsedLength} of ${truncated.rawLength} chars and reads the rest as a comment; ` +
291
+ `quote the description (double-quoted, escaping any " inside) so the full text parses`,
292
+ );
293
+ }
294
+
229
295
  // ------------------------------------------------------------------
230
296
  // Required fields check
231
297
  // ------------------------------------------------------------------
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ // check-cursor-adapter.mjs — Ensure Cursor-native commands, skills, and hooks stay wired.
3
+ // Usage: check-cursor-adapter.mjs <plugin-root>
4
+ // Exit 0 = all checks passed, 1 = at least one failure.
5
+
6
+ import { spawnSync } from 'node:child_process';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ import { CURSOR_TO_CANONICAL_EVENT } from '../cursor-hook-bridge.mjs';
11
+
12
+ const pluginRoot = process.argv[2];
13
+ if (!pluginRoot) {
14
+ console.error('Usage: check-cursor-adapter.mjs <plugin-root>');
15
+ process.exit(1);
16
+ }
17
+
18
+ let passed = 0;
19
+ let failed = 0;
20
+
21
+ function pass(msg) { console.log(` PASS: ${msg}`); passed += 1; }
22
+ function fail(msg) { console.log(` FAIL: ${msg}`); failed += 1; }
23
+
24
+ console.log('--- Check 1: generated Cursor command and skill wrappers ---');
25
+
26
+ const generator = join(pluginRoot, 'scripts', 'generate-cursor-adapter.mjs');
27
+ if (!existsSync(generator)) {
28
+ fail('scripts/generate-cursor-adapter.mjs exists');
29
+ } else {
30
+ const result = spawnSync(process.execPath, [generator, '--check'], {
31
+ cwd: pluginRoot,
32
+ encoding: 'utf8',
33
+ stdio: ['ignore', 'pipe', 'pipe'],
34
+ });
35
+ if (result.status === 0) {
36
+ pass('.cursor/commands and .cursor/skills are up to date with commands/ and skills/');
37
+ } else {
38
+ const detail = ((result.stdout ?? '') + (result.stderr ?? '')).trim();
39
+ fail(`Cursor adapter wrappers are stale${detail ? `: ${detail}` : ''}`);
40
+ }
41
+ }
42
+
43
+ console.log('');
44
+ console.log('--- Check 2: .cursor/hooks.json native manifest ---');
45
+
46
+ const hooksPath = join(pluginRoot, '.cursor', 'hooks.json');
47
+ if (!existsSync(hooksPath)) {
48
+ fail('.cursor/hooks.json exists');
49
+ } else {
50
+ let hooksJson;
51
+ try {
52
+ hooksJson = JSON.parse(readFileSync(hooksPath, 'utf8'));
53
+ pass('.cursor/hooks.json is valid JSON');
54
+ } catch (err) {
55
+ fail(`.cursor/hooks.json is not valid JSON: ${err.message}`);
56
+ hooksJson = null;
57
+ }
58
+
59
+ if (hooksJson) {
60
+ if (hooksJson.version === 1) {
61
+ pass('.cursor/hooks.json version is 1');
62
+ } else {
63
+ fail('.cursor/hooks.json version must be 1');
64
+ }
65
+
66
+ const declared = Object.keys(hooksJson.hooks || {});
67
+ const expected = Object.keys(CURSOR_TO_CANONICAL_EVENT);
68
+ const missing = expected.filter((event) => !declared.includes(event));
69
+ const extra = declared.filter((event) => !expected.includes(event));
70
+ if (missing.length === 0 && extra.length === 0) {
71
+ pass(`.cursor/hooks.json events match CURSOR_TO_CANONICAL_EVENT (${expected.length})`);
72
+ } else {
73
+ if (missing.length > 0) fail(`.cursor/hooks.json missing events: ${missing.join(', ')}`);
74
+ if (extra.length > 0) fail(`.cursor/hooks.json extra events: ${extra.join(', ')}`);
75
+ }
76
+
77
+ const bridgeRef = 'scripts/lib/cursor-hook-bridge.mjs';
78
+ const allPointAtBridge = declared.every((event) => {
79
+ const entries = Array.isArray(hooksJson.hooks[event]) ? hooksJson.hooks[event] : [];
80
+ return entries.some((entry) => typeof entry.command === 'string' && entry.command.includes(bridgeRef) && entry.command.includes(`--event ${event}`));
81
+ });
82
+ if (allPointAtBridge) {
83
+ pass('.cursor/hooks.json commands invoke cursor-hook-bridge.mjs with --event');
84
+ } else {
85
+ fail('.cursor/hooks.json commands must invoke scripts/lib/cursor-hook-bridge.mjs --event <name>');
86
+ }
87
+ }
88
+ }
89
+
90
+ console.log('');
91
+ console.log('--- Check 3: Cursor hook bridge module ---');
92
+
93
+ const bridgePath = join(pluginRoot, 'scripts', 'lib', 'cursor-hook-bridge.mjs');
94
+ if (existsSync(bridgePath)) {
95
+ pass('scripts/lib/cursor-hook-bridge.mjs exists');
96
+ } else {
97
+ fail('scripts/lib/cursor-hook-bridge.mjs exists');
98
+ }
99
+
100
+ console.log('');
101
+ console.log(`Results: ${passed} passed, ${failed} failed`);
102
+ process.exit(failed > 0 ? 1 : 0);
@@ -27,6 +27,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
27
27
  import path from 'node:path';
28
28
  import { DETECTORS } from './dead-bridge-detectors.mjs';
29
29
  import * as corpus from './dead-bridge-corpus.mjs';
30
+ import { listRepoFiles } from './repo-files.mjs';
30
31
 
31
32
  const DEFAULT_EXTS = ['.mjs', '.md'];
32
33
 
@@ -90,10 +91,31 @@ function walk(absDir, exts) {
90
91
  * }}
91
92
  */
92
93
  export function buildRepoContext(pluginRoot) {
94
+ /**
95
+ * Enumerate `absDir` from the git INDEX when it lies inside the plugin
96
+ * root, falling back to the raw walk otherwise (#1143). The walk cannot see
97
+ * `.gitignore`, so a worktree created under this repo's own
98
+ * `.claude/worktrees/` convention — or any vendored/generated tree, since
99
+ * this walk carries no exclusion set at all — enters the detector corpus as
100
+ * repository content. A detector then reads a PEER checkout's copy of a
101
+ * skill or rule file and counts it as an independent bridge.
102
+ *
103
+ * The containment guard matters: a corpus entry outside the root has no
104
+ * index to consult, and an `ls-files` pathspec of `../..` is an error, not
105
+ * an empty answer.
106
+ * @param {string} absDir
107
+ * @param {string[] | null} exts
108
+ * @returns {string[]}
109
+ */
110
+ const listUnder = (absDir, exts) => {
111
+ const rel = path.relative(pluginRoot, absDir);
112
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return walk(absDir, exts);
113
+ return listRepoFiles(pluginRoot, { dirs: [rel], exts });
114
+ };
93
115
  return {
94
116
  pluginRoot,
95
- listMdFiles: (absDir) => walk(absDir, ['.md']),
96
- listFiles: (absDir, exts = DEFAULT_EXTS) => walk(absDir, exts),
117
+ listMdFiles: (absDir) => listUnder(absDir, ['.md']),
118
+ listFiles: (absDir, exts = DEFAULT_EXTS) => listUnder(absDir, exts),
97
119
  readText: (absPath) => readFileSync(absPath, 'utf8'),
98
120
  exists: (absPath) => existsSync(absPath),
99
121
  };
@@ -62,10 +62,11 @@
62
62
  * Verified against glab 1.91.0 / gh 2.86.0 (2026-08-15).
63
63
  */
64
64
 
65
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
65
+ import { readFileSync } from 'node:fs';
66
66
  import path from 'node:path';
67
67
  import { spawnSync } from 'node:child_process';
68
68
  import { pathToFileURL } from 'node:url';
69
+ import { listRepoFiles } from './repo-files.mjs';
69
70
 
70
71
  /** Directories walked for documentation. Mirrors check-vcs-repo-flag.mjs. */
71
72
  const SCAN_DIRS = Object.freeze([
@@ -80,8 +81,6 @@ const SCAN_DIRS = Object.freeze([
80
81
  ]);
81
82
 
82
83
  /** Never descend into these. */
83
- const EXCLUDED_DIRS = Object.freeze(['node_modules', '.git', 'dist', 'build', 'coverage']);
84
-
85
84
  /** Fence languages whose body is shell. Anything else is prose. */
86
85
  const SHELL_LANGS = Object.freeze(new Set(['bash', 'sh', 'shell', 'console', 'zsh']));
87
86
 
@@ -296,28 +295,6 @@ function probeHelp(bin, group, cache) {
296
295
  return entry;
297
296
  }
298
297
 
299
- /**
300
- * Recursively collect `.md` files.
301
- *
302
- * @param {string} directory
303
- * @param {string[]} acc
304
- * @returns {string[]}
305
- */
306
- function walk(directory, acc = []) {
307
- if (!existsSync(directory)) return acc;
308
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
309
- if (entry.isSymbolicLink()) continue;
310
- if (EXCLUDED_DIRS.includes(entry.name)) continue;
311
- const fullPath = path.join(directory, entry.name);
312
- if (entry.isDirectory()) {
313
- walk(fullPath, acc);
314
- continue;
315
- }
316
- if (entry.isFile() && path.extname(entry.name) === '.md') acc.push(fullPath);
317
- }
318
- return acc;
319
- }
320
-
321
298
  /** @param {string} text @returns {string} */
322
299
  function clampSnippet(text) {
323
300
  const flat = text.replace(/\s+/g, ' ').trim();
@@ -358,13 +335,20 @@ export function inspectDocCliCommands(pluginRoot) {
358
335
  /** @type {string[]} */
359
336
  let files;
360
337
  try {
361
- files = SCAN_DIRS.flatMap((dir) => walk(path.join(pluginRoot, dir)));
362
- for (const entry of readdirSync(pluginRoot, { withFileTypes: true })) {
363
- if (entry.isFile() && path.extname(entry.name) === '.md') {
364
- files.push(path.join(pluginRoot, entry.name));
365
- }
366
- }
367
- files.sort();
338
+ // The index, not the filesystem (#1143). A `readdirSync` walk cannot see
339
+ // `.gitignore`, so every ignored artefact under a scan root entered this
340
+ // census as if it were documentation: measured 2026-08-26 on a clean
341
+ // checkout with NO worktree present, 4 such files — `.claude/STATE.md`
342
+ // (per-session mutable state) and three gitignored `docs/specs/*.md`. A
343
+ // worktree under this repo's own `.claude/worktrees/` convention would
344
+ // add a complete second copy of every scanned doc on top of that.
345
+ // One enumeration covers both populations this check has always scanned:
346
+ // the SCAN_DIRS subtrees, and the repo-root `.md` files at depth 0.
347
+ const scanDirs = new Set(SCAN_DIRS);
348
+ files = listRepoFiles(pluginRoot, { exts: ['.md'] }).filter((absolute) => {
349
+ const segments = path.relative(pluginRoot, absolute).split(path.sep);
350
+ return segments.length === 1 || scanDirs.has(segments[0]);
351
+ });
368
352
  } catch (error) {
369
353
  findings.push({
370
354
  kind: 'tool-error',