session-orchestrator 3.21.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 (345) 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 +11 -2
  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/040-discovery.mdc +6 -6
  38. package/.cursor/rules/050-plan.mdc +10 -10
  39. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  40. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  41. package/.cursor/skills/architecture/SKILL.md +13 -0
  42. package/.cursor/skills/autopilot/SKILL.md +12 -0
  43. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  44. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  45. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  46. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  47. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  48. package/.cursor/skills/daily/SKILL.md +12 -0
  49. package/.cursor/skills/debug/SKILL.md +13 -0
  50. package/.cursor/skills/discovery/SKILL.md +13 -0
  51. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  52. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  53. package/.cursor/skills/domain-model/SKILL.md +13 -0
  54. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  55. package/.cursor/skills/eli5/SKILL.md +13 -0
  56. package/.cursor/skills/eval/SKILL.md +12 -0
  57. package/.cursor/skills/evolve/SKILL.md +13 -0
  58. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  60. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  61. package/.cursor/skills/grill/SKILL.md +13 -0
  62. package/.cursor/skills/hook-development/SKILL.md +13 -0
  63. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  64. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  65. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  66. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  67. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  68. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  69. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  70. package/.cursor/skills/plan/SKILL.md +13 -0
  71. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  72. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  73. package/.cursor/skills/reconcile/SKILL.md +12 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +269 -0
  91. package/README.md +18 -15
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -1
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/memory-proposal-collector.md +6 -4
  98. package/agents/session-reviewer.md +7 -1
  99. package/agents/test-writer.md +2 -1
  100. package/agents/ui-developer.md +2 -1
  101. package/commands/bootstrap.md +2 -2
  102. package/commands/close.md +3 -1
  103. package/commands/eli5.md +33 -0
  104. package/commands/go.md +1 -1
  105. package/commands/journey-audit.md +43 -0
  106. package/commands/release.md +5 -3
  107. package/commands/test.md +2 -2
  108. package/docs/USER-GUIDE.md +2 -2
  109. package/docs/ci-setup.md +14 -0
  110. package/docs/codex-setup.md +64 -0
  111. package/docs/components.md +7 -6
  112. package/docs/cursor-setup.md +26 -47
  113. package/docs/events-schema.md +76 -4
  114. package/docs/github-mirror-protection.md +197 -0
  115. package/docs/pi-setup.md +2 -0
  116. package/docs/rule-authoring.md +3 -1
  117. package/docs/scope-collision-guard.md +52 -5
  118. package/docs/session-config-reference.md +57 -12
  119. package/docs/session-config-template.md +4 -3
  120. package/docs/telemetry.md +22 -0
  121. package/hooks/_lib/lock-bootstrap.mjs +26 -16
  122. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  123. package/hooks/enforce-scope.mjs +64 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +12 -2
  128. package/hooks/on-session-end.mjs +237 -104
  129. package/hooks/on-session-start.mjs +404 -110
  130. package/hooks/on-stop.mjs +48 -9
  131. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  132. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  133. package/hooks/pre-auq-clarity.mjs +787 -0
  134. package/hooks/pre-bash-issue-budget.mjs +133 -21
  135. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  136. package/hooks/pre-bash-staging-fence.mjs +4 -0
  137. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  138. package/hooks/skill-invocation-telemetry.mjs +21 -10
  139. package/monitors/monitors.json +6 -0
  140. package/package.json +3 -1
  141. package/pi/prompts/eli5.md +12 -0
  142. package/pi/prompts/journey-audit.md +12 -0
  143. package/rules/_index.md +9 -1
  144. package/rules/always-on/ask-via-tool.md +62 -0
  145. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  146. package/rules/always-on/build-value.md +47 -0
  147. package/rules/always-on/cross-session-messaging.md +59 -0
  148. package/rules/always-on/loop-and-monitor.md +221 -0
  149. package/rules/always-on/parallel-sessions.md +142 -12
  150. package/rules/always-on/receiving-review.md +108 -0
  151. package/rules/always-on/test-value.md +40 -0
  152. package/rules/always-on/verification-before-completion.md +77 -0
  153. package/scripts/archive-closed-prds.mjs +258 -18
  154. package/scripts/auq-audit.mjs +825 -0
  155. package/scripts/autopilot.mjs +12 -8
  156. package/scripts/backfill-evidence-digest.mjs +376 -0
  157. package/scripts/cursor-install.mjs +89 -48
  158. package/scripts/export-hw-learnings.mjs +143 -2
  159. package/scripts/express-path.mjs +299 -0
  160. package/scripts/generate-cursor-adapter.mjs +253 -0
  161. package/scripts/github-protection-audit.mjs +358 -0
  162. package/scripts/lib/auq/clarity.mjs +1314 -0
  163. package/scripts/lib/auq/parse.mjs +1006 -0
  164. package/scripts/lib/auq/schema.mjs +1457 -0
  165. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  166. package/scripts/lib/build-live-signals.mjs +24 -5
  167. package/scripts/lib/ci-status-banner.mjs +221 -68
  168. package/scripts/lib/command-blocker.mjs +70 -0
  169. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  170. package/scripts/lib/config/reconcile.mjs +79 -4
  171. package/scripts/lib/config/section-extractor.mjs +235 -36
  172. package/scripts/lib/config/vault-integration.mjs +12 -1
  173. package/scripts/lib/config-schema.mjs +9 -1
  174. package/scripts/lib/config.mjs +57 -6
  175. package/scripts/lib/convergence-monitor.mjs +13 -2
  176. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  177. package/scripts/lib/dispatcher/cli.mjs +2 -2
  178. package/scripts/lib/dispatcher/rank.mjs +4 -7
  179. package/scripts/lib/express-path.mjs +327 -0
  180. package/scripts/lib/file-lock.mjs +22 -4
  181. package/scripts/lib/gates/gate-full.mjs +84 -11
  182. package/scripts/lib/gates/gate-helpers.mjs +93 -21
  183. package/scripts/lib/git-config-drift.mjs +134 -5
  184. package/scripts/lib/host-identity.mjs +247 -2
  185. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  186. package/scripts/lib/io.mjs +239 -0
  187. package/scripts/lib/issue-budget.mjs +284 -31
  188. package/scripts/lib/learnings/io.mjs +55 -10
  189. package/scripts/lib/learnings/schema.mjs +95 -28
  190. package/scripts/lib/lock-reaper.mjs +7 -1
  191. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  192. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  193. package/scripts/lib/memory-banner.mjs +5 -2
  194. package/scripts/lib/memory-paths.mjs +15 -6
  195. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  196. package/scripts/lib/owner-interview.mjs +78 -32
  197. package/scripts/lib/peer-discovery.mjs +73 -22
  198. package/scripts/lib/platform.mjs +72 -9
  199. package/scripts/lib/plugin-root.mjs +143 -19
  200. package/scripts/lib/project-hygiene.mjs +104 -4
  201. package/scripts/lib/quality-gate.mjs +271 -13
  202. package/scripts/lib/reconcile/emitter.mjs +87 -19
  203. package/scripts/lib/reconcile/engine.mjs +281 -13
  204. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  205. package/scripts/lib/reconcile/renderer.mjs +165 -7
  206. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  207. package/scripts/lib/reconcile/writer.mjs +415 -84
  208. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  209. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  210. package/scripts/lib/resource-probe.mjs +18 -2
  211. package/scripts/lib/rule-loader.mjs +37 -2
  212. package/scripts/lib/rules-sync.mjs +51 -8
  213. package/scripts/lib/scope-gate.mjs +90 -0
  214. package/scripts/lib/session-close-backfill.mjs +369 -28
  215. package/scripts/lib/session-discovery.mjs +13 -3
  216. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  217. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  218. package/scripts/lib/session-id.mjs +30 -14
  219. package/scripts/lib/session-identity/own-session.mjs +159 -0
  220. package/scripts/lib/session-lock.mjs +85 -30
  221. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  222. package/scripts/lib/session-schema/validator.mjs +40 -0
  223. package/scripts/lib/session-start-probes.mjs +608 -0
  224. package/scripts/lib/session-transition.mjs +277 -0
  225. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  226. package/scripts/lib/spiral-carryover.mjs +111 -9
  227. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  228. package/scripts/lib/state-md/mission-status.mjs +467 -72
  229. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  230. package/scripts/lib/state-md.mjs +12 -2
  231. package/scripts/lib/telemetry/sync.mjs +46 -8
  232. package/scripts/lib/validate/check-agents.mjs +66 -0
  233. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  234. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  235. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  236. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  237. package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -63
  238. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  239. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  240. package/scripts/lib/validate/check-rules.mjs +153 -9
  241. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  242. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  243. package/scripts/lib/validate/repo-files.mjs +275 -0
  244. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  245. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  246. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  247. package/scripts/lib/vault-mirror/process.mjs +99 -43
  248. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  249. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  250. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  251. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  252. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  253. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  254. package/scripts/lib/wave-resource-gate.mjs +67 -73
  255. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  256. package/scripts/materialize-wave-scope.mjs +478 -0
  257. package/scripts/mcp-server.sh +11 -2
  258. package/scripts/parse-config.mjs +65 -0
  259. package/scripts/release.mjs +443 -122
  260. package/scripts/run-quality-gate.mjs +14 -0
  261. package/scripts/token-audit.sh +9 -2
  262. package/scripts/validate-plugin.mjs +6 -0
  263. package/scripts/validate-wave-scope.mjs +73 -1
  264. package/scripts/vault-backfill.mjs +32 -5
  265. package/scripts/vault-mirror.mjs +203 -34
  266. package/skills/_shared/monitor-patterns.md +31 -5
  267. package/skills/_shared/parallel-aware-auq.md +31 -25
  268. package/skills/_shared/parallel-aware-preamble.md +35 -4
  269. package/skills/_shared/platform-tools.md +11 -5
  270. package/skills/_shared/state-ownership.md +61 -8
  271. package/skills/autopilot/SKILL.md +5 -1
  272. package/skills/bootstrap/SKILL.md +5 -4
  273. package/skills/bootstrap/_shared-template.md +18 -10
  274. package/skills/bootstrap/deep-template.md +10 -6
  275. package/skills/bootstrap/fast-template.md +15 -8
  276. package/skills/bootstrap/standard-template.md +10 -6
  277. package/skills/brainstorm/SKILL.md +18 -18
  278. package/skills/brainstorm/soul.md +12 -0
  279. package/skills/claude-md-drift-check/checker.mjs +39 -11
  280. package/skills/discovery/SKILL.md +28 -24
  281. package/skills/dispatcher/SKILL.md +1 -1
  282. package/skills/eli5/SKILL.md +43 -0
  283. package/skills/evolve/SKILL.md +8 -9
  284. package/skills/gitlab-ops/SKILL.md +30 -26
  285. package/skills/grill/SKILL.md +6 -6
  286. package/skills/grill/soul.md +16 -0
  287. package/skills/journey-audit/SKILL.md +269 -0
  288. package/skills/memory-cleanup/SKILL.md +2 -2
  289. package/skills/npm-publish/SKILL.md +4 -4
  290. package/skills/peekaboo-driver/SKILL.md +18 -6
  291. package/skills/persona-panel/SKILL.md +1 -1
  292. package/skills/plan/SKILL.md +18 -16
  293. package/skills/plan/mode-feature.md +1 -1
  294. package/skills/plan/mode-new.md +35 -23
  295. package/skills/plan/soul.md +12 -0
  296. package/skills/reconcile/SKILL.md +44 -4
  297. package/skills/session-end/SKILL.md +70 -24
  298. package/skills/session-end/metrics-collection.md +7 -4
  299. package/skills/session-end/phase-3-6-tail.md +48 -5
  300. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  301. package/skills/session-plan/SKILL.md +6 -1
  302. package/skills/session-plan/wave-template.md +1 -0
  303. package/skills/session-start/SKILL.md +96 -48
  304. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  305. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  306. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  307. package/skills/session-start/phase-8-5-express-path.md +77 -12
  308. package/skills/session-start/soul.md +110 -0
  309. package/skills/test-runner/SKILL.md +2 -2
  310. package/skills/using-orchestrator/SKILL.md +1 -1
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +4 -2
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +129 -24
  315. package/skills/write-executable-plan/SKILL.md +6 -6
  316. package/templates/_shared/journey-manifest.md +110 -0
  317. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  318. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  319. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  320. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  321. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  322. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  323. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  324. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  325. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  326. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  327. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  328. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  329. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  330. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  331. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  332. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  333. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  334. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  335. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  336. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  337. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  338. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  339. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  340. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  341. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  342. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  343. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  344. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
  345. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -7,6 +7,9 @@
7
7
 
8
8
  import { spawnSync } from 'node:child_process';
9
9
 
10
+ import { redactUrlCredentials } from '../vcs-repo-spec.mjs';
11
+ import { isValidRepoPath } from './manifest.mjs';
12
+
10
13
  let _verbose = false;
11
14
 
12
15
  /** Enable verbose stderr logging. */
@@ -38,7 +41,7 @@ export function assertGlabExists(dieFn) {
38
41
  * Run a glab command, return { ok, stdout, stderr }.
39
42
  *
40
43
  * 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
44
+ * queries (`glab api groups/<group>/projects`) that are not scoped to a single
42
45
  * repo/project, so there is no single `-R`/`--repo` spec to pin. If a
43
46
  * caller ever needs single-repo host-pinning here, use `--hostname` (the
44
47
  * flag `glab api` and instance-wide subcommands accept), NOT `-R`/`--repo` —
@@ -46,79 +49,129 @@ export function assertGlabExists(dieFn) {
46
49
  * `resolveRepoHost` (`--hostname`) contract this repo already established.
47
50
  */
48
51
  export function glabRun(glabArgs) {
49
- vlog(`glab ${glabArgs.join(' ')}`);
52
+ vlog(`glab ${redactUrlCredentials(glabArgs.join(' '))}`);
50
53
  const result = spawnSync('glab', glabArgs, {
51
54
  encoding: 'utf8',
52
55
  maxBuffer: 10 * 1024 * 1024,
53
56
  });
54
57
 
55
58
  if (result.error) {
56
- return { ok: false, stdout: '', stderr: result.error.message };
59
+ return { ok: false, stdout: '', stderr: redactUrlCredentials(result.error.message) };
57
60
  }
58
61
  return {
59
62
  ok: result.status === 0,
60
63
  stdout: result.stdout || '',
61
- stderr: result.stderr || '',
64
+ stderr: redactUrlCredentials(result.stderr || ''),
65
+ };
66
+ }
67
+
68
+ const PROJECT_VISIBILITIES = new Set(['private', 'internal', 'public']);
69
+ const ISO_TIMESTAMP = /^(?<year>[1-9]\d{3})-(?<month>\d{2})-(?<day>\d{2})T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
70
+
71
+ /**
72
+ * GitLab returns `created_at` as an ISO-8601 timestamp. Validate the calendar
73
+ * components separately because Date normalizes impossible dates such as Feb 30.
74
+ */
75
+ function isValidCreatedAt(value) {
76
+ if (typeof value !== 'string') return false;
77
+
78
+ const match = ISO_TIMESTAMP.exec(value);
79
+ if (!match?.groups) return false;
80
+
81
+ const { year, month, day } = match.groups;
82
+ const calendarDate = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
83
+ return (
84
+ calendarDate.getUTCFullYear() === Number(year) &&
85
+ calendarDate.getUTCMonth() === Number(month) - 1 &&
86
+ calendarDate.getUTCDate() === Number(day)
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Reduce a GitLab project response to the fields vault-backfill consumes.
92
+ *
93
+ * The group API with `simple=true` still includes presentation and URL fields.
94
+ * Keeping only this projection prevents those opaque project objects from
95
+ * flowing into logs or downstream actions.
96
+ */
97
+ function normalizeRepo(project) {
98
+ return {
99
+ id: project.id,
100
+ path: project.path_with_namespace ?? project.path,
101
+ visibility: project.visibility === undefined ? 'private' : project.visibility,
102
+ createdAt: project.created_at === undefined ? '' : project.created_at.slice(0, 10),
62
103
  };
63
104
  }
64
105
 
106
+ function isSimpleProject(project) {
107
+ const path = project?.path_with_namespace ?? project?.path;
108
+ return (
109
+ project !== null &&
110
+ typeof project === 'object' &&
111
+ !Array.isArray(project) &&
112
+ Number.isInteger(project.id) &&
113
+ project.id > 0 &&
114
+ isValidRepoPath(path) &&
115
+ (project.visibility === undefined || PROJECT_VISIBILITIES.has(project.visibility)) &&
116
+ (project.created_at === undefined || isValidCreatedAt(project.created_at))
117
+ );
118
+ }
119
+
65
120
  /**
66
- * Parse `glab repo list --output json` output (JSON array or JSONL).
67
- * Returns array of { id, path, name, visibility, createdAt }.
121
+ * Parse `glab api --paginate --slurp` output (a project array or page arrays).
122
+ *
123
+ * Returns an array of { id, path, visibility, createdAt }, or null when an
124
+ * exit-zero response does not fully match the simple-project list contract.
68
125
  */
69
126
  export function parseRepoList(stdout) {
70
127
  const trimmed = stdout.trim();
71
- if (!trimmed) return [];
128
+ if (!trimmed) return null;
72
129
 
130
+ let data;
73
131
  try {
74
- const data = JSON.parse(trimmed);
75
- if (!Array.isArray(data)) return [];
76
- return data.map((r) => ({
77
- id: r.id ?? 0,
78
- path: r.path_with_namespace ?? r.path ?? '',
79
- name: r.name ?? '',
80
- visibility: r.visibility ?? 'private',
81
- createdAt: (r.created_at ?? '').slice(0, 10),
82
- }));
132
+ data = JSON.parse(trimmed);
83
133
  } catch {
84
- // Try line-by-line JSONL
85
- const repos = [];
86
- for (const line of trimmed.split('\n')) {
87
- const l = line.trim();
88
- if (!l) continue;
89
- try {
90
- const r = JSON.parse(l);
91
- repos.push({
92
- id: r.id ?? 0,
93
- path: r.path_with_namespace ?? r.path ?? '',
94
- name: r.name ?? '',
95
- visibility: r.visibility ?? 'private',
96
- createdAt: (r.created_at ?? '').slice(0, 10),
97
- });
98
- } catch {
99
- // skip malformed line
100
- }
101
- }
102
- return repos;
134
+ return null;
103
135
  }
136
+
137
+ if (!Array.isArray(data)) return null;
138
+ if (data.length === 0) return [];
139
+
140
+ const projects = data.every(isSimpleProject)
141
+ ? data
142
+ : data.every(Array.isArray)
143
+ ? data.flat()
144
+ : null;
145
+
146
+ if (!projects || !projects.every(isSimpleProject)) return null;
147
+ return projects.map(normalizeRepo);
104
148
  }
105
149
 
106
150
  /**
107
151
  * List all repos in a GitLab group. Returns repo array or null on API error.
108
152
  */
109
153
  export function listGroupRepos(group) {
154
+ const encodedGroup = encodeURIComponent(group);
110
155
  const { ok, stdout, stderr } = glabRun([
111
- 'repo', 'list', '-g', group, '--output', 'json', '--per-page', '100',
156
+ 'api', `groups/${encodedGroup}/projects?simple=true&per_page=100`, '--paginate', '--slurp',
112
157
  ]);
113
158
 
114
159
  if (!ok) {
115
160
  process.stderr.write(
116
- `[vault-backfill] WARN: glab repo list failed for group '${group}': ${stderr.trim()}\n`,
161
+ `[vault-backfill] WARN: glab api groups/<group>/projects failed for group '${group}': ${stderr.trim()}\n`,
162
+ );
163
+ return null;
164
+ }
165
+
166
+ const repos = parseRepoList(stdout);
167
+ if (repos === null) {
168
+ process.stderr.write(
169
+ `[vault-backfill] WARN: glab api groups/<group>/projects returned an unexpected project-list response shape for group '${group}'\n`,
117
170
  );
118
171
  return null;
119
172
  }
120
173
 
121
- return parseRepoList(stdout);
174
+ return repos;
122
175
  }
123
176
 
124
177
  /**
@@ -150,23 +203,3 @@ export function checkVaultYaml(repoPath) {
150
203
  );
151
204
  return 'error';
152
205
  }
153
-
154
- /**
155
- * Fetch repo owner via glab API. Returns username or 'unknown' on failure.
156
- */
157
- export function fetchRepoOwner(repoId) {
158
- const { ok, stdout } = glabRun(['api', `projects/${repoId}`]);
159
- if (!ok) return 'unknown';
160
-
161
- try {
162
- const data = JSON.parse(stdout);
163
- return (
164
- data?.namespace?.path ||
165
- data?.owner?.username ||
166
- data?.namespace?.name ||
167
- 'unknown'
168
- );
169
- } catch {
170
- return 'unknown';
171
- }
172
- }
@@ -7,8 +7,34 @@
7
7
 
8
8
  const VALID_TIERS = new Set(['top', 'active', 'archived']);
9
9
  const VALID_VISIBILITIES = new Set(['public', 'internal', 'private']);
10
+ const GITLAB_PATH_SEGMENT_RE = /^[A-Za-z0-9_.-]+$/;
10
11
  export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
11
12
 
13
+ /**
14
+ * Return whether a value is a canonical GitLab namespace/project path.
15
+ *
16
+ * GitLab project paths have at least one namespace and one project segment.
17
+ * Each segment uses the slug character set; dot segments remain excluded so
18
+ * the result is safe to use as a path below a controlled staging directory.
19
+ *
20
+ * @param {unknown} value
21
+ * @returns {value is string}
22
+ */
23
+ export function isValidRepoPath(value) {
24
+ if (typeof value !== 'string') return false;
25
+
26
+ const segments = value.split('/');
27
+ return (
28
+ segments.length >= 2 &&
29
+ segments.every(
30
+ (segment) =>
31
+ segment !== '.' &&
32
+ segment !== '..' &&
33
+ GITLAB_PATH_SEGMENT_RE.test(segment),
34
+ )
35
+ );
36
+ }
37
+
12
38
  /**
13
39
  * Validate and normalise a manifest object.
14
40
  * Calls dieFn(1, ...) on any schema violation.
@@ -34,14 +60,8 @@ export function validateManifest(raw, dieFn) {
34
60
  const prefix = `manifest.repos[${i}]`;
35
61
 
36
62
  if (typeof entry.id !== 'number') dieFn(1, `${prefix}.id must be a number`);
37
- if (typeof entry.path !== 'string' || !entry.path) {
38
- dieFn(1, `${prefix}.path must be a non-empty string`);
39
- }
40
- if (entry.path.startsWith('/') || entry.path.split('/').includes('..')) {
41
- dieFn(
42
- 1,
43
- `${prefix}.path '${entry.path}' is invalid — must be a relative repo path (no leading '/' or '..' segments)`,
44
- );
63
+ if (!isValidRepoPath(entry.path)) {
64
+ dieFn(1, `${prefix}.path must be a valid GitLab namespace/project path`);
45
65
  }
46
66
  if (typeof entry.slug !== 'string') dieFn(1, `${prefix}.slug must be a string`);
47
67
  if (!SLUG_RE.test(entry.slug)) {
@@ -14,6 +14,7 @@ import { isRealSession } from '../session-schema/filters.mjs';
14
14
  import { resolveRepoNamespace } from './namespace.mjs';
15
15
  import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2 } from './render-learnings.mjs';
16
16
  import { detectSessionSchema, normalizeSessionEntry, generateSessionNote, generateSessionNoteV2, generateSessionNoteV3 } from './render-sessions.mjs';
17
+ import { emitMirrorEvent } from './telemetry.mjs';
17
18
 
18
19
  const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
19
20
 
@@ -256,8 +257,33 @@ export { deriveRepo } from './namespace.mjs';
256
257
  * @param {object} [opts.meta] — optional extra fields merged into the emitted JSON
257
258
  * (used for quality-gate skips to carry a `reason` field). Callers that omit
258
259
  * `meta` get the base JSON shape unchanged.
260
+ * @param {number} [opts.line] — 1-based JSONL line number. When FINITE, this
261
+ * entry also gets one `orchestrator.vault.mirror_completed` ledger record
262
+ * (#1147) — UNLESS `action` is `skipped-noop`, whose per-entry record is
263
+ * suppressed as ledger flood and reported only in the run-level roll-up
264
+ * (#1151; see the gate below). Omitting `line` keeps the stdout-only
265
+ * behaviour, which is what the direct unit tests of this function exercise.
266
+ * @param {boolean} [opts.dryRun] — the run's dry-run flag, for telemetry only.
267
+ * @param {string} [opts.skipClass] — `validation` | `mapper-crash`; telemetry only.
268
+ * @param {string} [opts.reason] — explicit telemetry reason. Defaults to
269
+ * `meta.reason` when that exists, so the quality-gate strings
270
+ * (`confidence:X < min:Y` / `narrative:N < min:M` / `status:…`) are REUSED
271
+ * rather than recomputed — recomputing is how one fact becomes two copies.
272
+ * @returns {Promise<string>} the `action` string, so the CLI's main loop can
273
+ * tally a run-level denominator without a second census of these call sites.
259
274
  */
260
- export function emitAction({ action, path, kind, id, vaultDir, meta }) {
275
+ export async function emitAction({
276
+ action,
277
+ path,
278
+ kind,
279
+ id,
280
+ vaultDir,
281
+ meta,
282
+ line,
283
+ dryRun,
284
+ skipClass,
285
+ reason,
286
+ }) {
261
287
  let rel;
262
288
  if (path === null || path === undefined) {
263
289
  rel = null;
@@ -271,7 +297,59 @@ export function emitAction({ action, path, kind, id, vaultDir, meta }) {
271
297
  if (meta && typeof meta === 'object') {
272
298
  Object.assign(payload, meta);
273
299
  }
300
+ // The stdout JSON-per-entry protocol is the CONTRACT other callers parse —
301
+ // it stays byte-identical; the ledger record below is purely additive.
274
302
  process.stdout.write(JSON.stringify(payload) + '\n');
303
+
304
+ // Per-entry ledger record for every action EXCEPT `skipped-noop` (#1151).
305
+ //
306
+ // `skipped-noop` is the STEADY STATE, not an event: on a populated vault
307
+ // nearly every record is already mirrored, so one `--kind session` run over
308
+ // this repo's ~276-record sessions ledger wrote ~276 per-entry records that
309
+ // between them said "nothing happened" — burying every other event class in
310
+ // the same file. No information is lost by dropping them: `finishRun()` in
311
+ // `scripts/vault-mirror.mjs` counts this class into the run event's `skipped`
312
+ // total AND names it in `action_breakdown['skipped-noop']`, so the noop COUNT
313
+ // stays measured per run and the gate stays falsifiable (HR-105).
314
+ //
315
+ // BV-004 ceiling: what this drops is the per-LINE locator for noops — the
316
+ // ledger can still say HOW MANY records were unchanged, never WHICH. Revisit
317
+ // if a consumer ever needs to name them (a staleness audit over unchanged
318
+ // notes would); reinstate it behind an opt-in flag then, not unconditionally.
319
+ if (Number.isFinite(line) && action !== 'skipped-noop') {
320
+ const telemetryReason =
321
+ reason ?? (meta && typeof meta.reason === 'string' ? meta.reason : undefined);
322
+ await emitMirrorEvent({
323
+ action,
324
+ kind,
325
+ line,
326
+ recordId: id,
327
+ // The VAULT-RELATIVE path (`rel`), never the absolute one: the ledger
328
+ // record can travel over the optional Clank webhook unredacted.
329
+ path: rel,
330
+ skipClass,
331
+ reason: telemetryReason,
332
+ dryRun,
333
+ });
334
+ }
335
+
336
+ return action;
337
+ }
338
+
339
+ /**
340
+ * Per-entry stdout + telemetry wrapper used by {@link processLearning} and
341
+ * {@link processSession}. Threads the line number, kind, vaultDir and dry-run
342
+ * flag out of the processor `ctx` so the 18 call sites do not repeat them.
343
+ *
344
+ * @param {number} lineNum — 1-based JSONL line number of the entry.
345
+ * @param {object} ctx — processor context (`vaultDir`, `dryRun`, `kind`).
346
+ * @param {object} opts — the remaining {@link emitAction} fields (`action`,
347
+ * `path`, `id`, and optionally `meta`).
348
+ * @returns {Promise<string>} the `action` string.
349
+ */
350
+ function emitEntryAction(lineNum, ctx, opts) {
351
+ const { vaultDir, dryRun, kind } = ctx;
352
+ return emitAction({ ...opts, vaultDir, kind, line: lineNum, dryRun });
275
353
  }
276
354
 
277
355
  // ── Secret masking (#974) — THE choke-point ───────────────────────────────────
@@ -447,7 +525,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
447
525
  const {
448
526
  vaultDir,
449
527
  dryRun,
450
- kind,
528
+ kind: _kind, // threaded to emitAction via ctx by emitEntryAction (#1147); kept for ctx symmetry
451
529
  force = false,
452
530
  qualityMinConfidence = 0.5,
453
531
  qualityMinNarrativeChars: _qualityMinNarrativeChars = 400, // unused for learnings; kept for ctx symmetry
@@ -531,15 +609,12 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
531
609
  // Missing/non-numeric confidence is treated as 1.0 (legacy entries pass).
532
610
  const learningConfidence = typeof entry.confidence === 'number' ? entry.confidence : 1.0;
533
611
  if (learningConfidence < qualityMinConfidence) {
534
- emitAction({
612
+ return emitEntryAction(_lineNum, ctx, {
535
613
  action: 'skipped-quality-low',
536
614
  path: null,
537
- kind,
538
615
  id: entryId,
539
- vaultDir,
540
616
  meta: { reason: `confidence:${learningConfidence} < min:${qualityMinConfidence}` },
541
617
  });
542
- return;
543
618
  }
544
619
 
545
620
  // #660: namespace new writes under a per-repo subdirectory.
@@ -572,8 +647,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
572
647
  // Render the candidate and compare canonical fields before deciding to skip.
573
648
  const candidateContent = generator(entry, slug, generatorOpts);
574
649
  if (learningContentMatches(legacyContent, candidateContent)) {
575
- emitAction({ action: 'skipped-noop', path: legacyFlatPath, kind, id: slug, vaultDir });
576
- return;
650
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: slug });
577
651
  }
578
652
  // Content differs — fall through to write into the namespaced path.
579
653
  }
@@ -588,15 +662,13 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
588
662
  if (!fm || !fm['_generator']) {
589
663
  // Hand-written: skip
590
664
  process.stderr.write(`SKIP hand-written: ${targetPath}\n`);
591
- emitAction({ action: 'skipped-handwritten', path: targetPath, kind, id: entryId, vaultDir });
592
- return;
665
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: entryId });
593
666
  }
594
667
 
595
668
  if (fm['_generator'] !== GENERATOR_MARKER) {
596
669
  // Different generator — treat as hand-written to be safe
597
670
  process.stderr.write(`SKIP unknown generator: ${targetPath}\n`);
598
- emitAction({ action: 'skipped-handwritten', path: targetPath, kind, id: entryId, vaultDir });
599
- return;
671
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: entryId });
600
672
  }
601
673
 
602
674
  if (fm['id'] !== slug) {
@@ -611,16 +683,14 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
611
683
  const disambigFm = parseFrontmatter(disambigContent);
612
684
  if (!disambigFm || !disambigFm['_generator']) {
613
685
  process.stderr.write(`SKIP hand-written (disambig): ${targetPath}\n`);
614
- emitAction({ action: 'skipped-handwritten', path: targetPath, kind, id: entryId, vaultDir });
615
- return;
686
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: entryId });
616
687
  }
617
688
  // Check updated advancement; if date has not advanced, also diff content.
618
689
  const entryUpdated = toDate(dateSource);
619
690
  if (disambigFm['updated'] && disambigFm['updated'] >= entryUpdated) {
620
691
  const candidateContent = generator(entry, slug, generatorOpts);
621
692
  if (learningContentMatches(disambigContent, candidateContent)) {
622
- emitAction({ action: 'skipped-noop', path: targetPath, kind, id: disambigSlug, vaultDir });
623
- return;
693
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: disambigSlug });
624
694
  }
625
695
  // Content differs — fall through to write.
626
696
  }
@@ -628,8 +698,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
628
698
 
629
699
  const content = generator(entry, slug, generatorOpts);
630
700
  if (!dryRun) writeFileSync(targetPath, content, 'utf8');
631
- emitAction({ action: 'skipped-collision-resolved', path: targetPath, kind, id: slug, vaultDir });
632
- return;
701
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-collision-resolved', path: targetPath, id: slug });
633
702
  }
634
703
 
635
704
  // Same id: check if updated would advance (unless --force overrides).
@@ -639,8 +708,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
639
708
  if (!force && fm['updated'] && fm['updated'] >= entryUpdated) {
640
709
  const candidateContent = generator(entry, slug, generatorOpts);
641
710
  if (learningContentMatches(existingContent, candidateContent)) {
642
- emitAction({ action: 'skipped-noop', path: targetPath, kind, id: slug, vaultDir });
643
- return;
711
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: slug });
644
712
  }
645
713
  // Content differs — fall through to overwrite (same path as date-advance branch).
646
714
  }
@@ -648,21 +716,20 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
648
716
  // Overwrite with advanced updated date (or forced re-render)
649
717
  const content = generator(entry, slug, generatorOpts);
650
718
  if (!dryRun) writeFileSync(targetPath, content, 'utf8');
651
- emitAction({ action: 'updated', path: targetPath, kind, id: slug, vaultDir });
652
- return;
719
+ return emitEntryAction(_lineNum, ctx, { action: 'updated', path: targetPath, id: slug });
653
720
  }
654
721
 
655
722
  // File does not exist — create
656
723
  const content = generator(entry, slug, generatorOpts);
657
724
  if (!dryRun) writeFileSync(targetPath, content, 'utf8');
658
- emitAction({ action: 'created', path: targetPath, kind, id: slug, vaultDir });
725
+ return emitEntryAction(_lineNum, ctx, { action: 'created', path: targetPath, id: slug });
659
726
  }
660
727
 
661
728
  export async function processSession(rawEntry, _lineNum, ctx) {
662
729
  const {
663
730
  vaultDir,
664
731
  dryRun,
665
- kind,
732
+ kind: _kind, // threaded to emitAction via ctx by emitEntryAction (#1147); kept for ctx symmetry
666
733
  force = false,
667
734
  qualityMinNarrativeChars = 400,
668
735
  qualityMinConfidence: _qualityMinConfidence = 0.5, // unused for sessions; kept for ctx symmetry
@@ -716,15 +783,12 @@ export async function processSession(rawEntry, _lineNum, ctx) {
716
783
  // status from the vault; the mapping keeps every OTHER status honest, and
717
784
  // guards the generators' other entry point (the render.mjs barrel).
718
785
  if (!isRealSession(entry)) {
719
- emitAction({
786
+ return emitEntryAction(_lineNum, ctx, {
720
787
  action: 'skipped-abandoned',
721
788
  path: null,
722
- kind,
723
789
  id: session_id,
724
- vaultDir,
725
790
  meta: { reason: `status:${entry?.status}` },
726
791
  });
727
- return;
728
792
  }
729
793
 
730
794
  // #732: resolve the leak-guarded repo namespace ONCE per session, BEFORE the
@@ -752,15 +816,12 @@ export async function processSession(rawEntry, _lineNum, ctx) {
752
816
  const narrativeBody = renderedBody.replace(/^---[\s\S]*?---/m, '').trim();
753
817
  const narrativeChars = narrativeBody.length;
754
818
  if (narrativeChars < qualityMinNarrativeChars) {
755
- emitAction({
819
+ return emitEntryAction(_lineNum, ctx, {
756
820
  action: 'skipped-quality-low',
757
821
  path: null,
758
- kind,
759
822
  id: session_id,
760
- vaultDir,
761
823
  meta: { reason: `narrative:${narrativeChars} < min:${qualityMinNarrativeChars}` },
762
824
  });
763
- return;
764
825
  }
765
826
 
766
827
  // #660: namespace new writes under a per-repo subdirectory. repoNs was
@@ -786,8 +847,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
786
847
  if (legacyFm && legacyFm['_generator'] === GENERATOR_MARKER && legacyFm['id'] === session_id) {
787
848
  const entryUpdated = toDate(entry.completed_at);
788
849
  if (!force && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
789
- emitAction({ action: 'skipped-noop', path: legacyFlatPath, kind, id: session_id, vaultDir });
790
- return;
850
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: legacyFlatPath, id: session_id });
791
851
  }
792
852
  // Updated date would advance — fall through to write into the namespaced path.
793
853
  }
@@ -800,31 +860,27 @@ export async function processSession(rawEntry, _lineNum, ctx) {
800
860
  if (!fm || !fm['_generator']) {
801
861
  // Hand-written: skip
802
862
  process.stderr.write(`SKIP hand-written: ${targetPath}\n`);
803
- emitAction({ action: 'skipped-handwritten', path: targetPath, kind, id: session_id, vaultDir });
804
- return;
863
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: session_id });
805
864
  }
806
865
 
807
866
  if (fm['_generator'] !== GENERATOR_MARKER) {
808
867
  process.stderr.write(`SKIP unknown generator: ${targetPath}\n`);
809
- emitAction({ action: 'skipped-handwritten', path: targetPath, kind, id: session_id, vaultDir });
810
- return;
868
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-handwritten', path: targetPath, id: session_id });
811
869
  }
812
870
 
813
871
  // Same generator: check id and updated
814
872
  if (fm['id'] === session_id) {
815
873
  const entryUpdated = toDate(entry.completed_at);
816
874
  if (!force && fm['updated'] && fm['updated'] >= entryUpdated) {
817
- emitAction({ action: 'skipped-noop', path: targetPath, kind, id: session_id, vaultDir });
818
- return;
875
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: session_id });
819
876
  }
820
877
  if (!dryRun) writeFileSync(targetPath, renderedBody, 'utf8');
821
- emitAction({ action: 'updated', path: targetPath, kind, id: session_id, vaultDir });
822
- return;
878
+ return emitEntryAction(_lineNum, ctx, { action: 'updated', path: targetPath, id: session_id });
823
879
  }
824
880
  }
825
881
 
826
882
  // File does not exist — create. Reuse the rendered body computed during the
827
883
  // quality-gate check (avoids a second generator invocation).
828
884
  if (!dryRun) writeFileSync(targetPath, renderedBody, 'utf8');
829
- emitAction({ action: 'created', path: targetPath, kind, id: session_id, vaultDir });
885
+ return emitEntryAction(_lineNum, ctx, { action: 'created', path: targetPath, id: session_id });
830
886
  }