session-orchestrator 3.16.0 → 3.19.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 (220) 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 +130 -412
  6. package/README.md +17 -12
  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/docs/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  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 +97 -0
  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/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. package/templates/_shared/loop.md +4 -4
package/package.json CHANGED
@@ -1,16 +1,22 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "3.16.0",
3
+ "version": "3.19.0",
4
4
  "description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
7
7
  "keywords": [
8
8
  "pi-package",
9
9
  "claude-code",
10
+ "claude-code-plugin",
10
11
  "codex",
11
12
  "cursor",
12
13
  "ai-agents",
13
- "orchestration"
14
+ "agentic-coding",
15
+ "subagents",
16
+ "orchestration",
17
+ "developer-tools",
18
+ "mcp",
19
+ "llm"
14
20
  ],
15
21
  "engines": {
16
22
  "node": ">=24.0.0"
@@ -33,6 +39,7 @@
33
39
  ".codex-plugin/",
34
40
  ".cursor/",
35
41
  "docs/*.md",
42
+ "docs/telemetry/telemetry-claims.md",
36
43
  "NOTICE",
37
44
  "SECURITY.md",
38
45
  "!**/node_modules/**",
@@ -42,6 +49,7 @@
42
49
  "test": "node scripts/check-package-manager.mjs && vitest --run",
43
50
  "test:coverage": "node scripts/check-package-manager.mjs && vitest --run --coverage",
44
51
  "test:watch": "node scripts/check-package-manager.mjs && vitest",
52
+ "quality-gate": "node scripts/run-quality-gate.mjs --variant full-gate",
45
53
  "lint": "eslint .",
46
54
  "lint:fix": "eslint . --fix",
47
55
  "typecheck": "node scripts/typecheck.mjs",
@@ -73,13 +81,18 @@
73
81
  "vitest": "^4.1.5"
74
82
  },
75
83
  "overrides": {
76
- "vite": "^8.0.16"
84
+ "vite": "^8.0.16",
85
+ "fast-uri": "^3.1.5",
86
+ "brace-expansion": "^5.0.9"
77
87
  },
78
88
  "license": "MIT",
79
89
  "repository": {
80
90
  "type": "git",
81
91
  "url": "git+https://github.com/Kanevry/session-orchestrator.git"
82
92
  },
93
+ "bugs": {
94
+ "url": "https://github.com/Kanevry/session-orchestrator/issues"
95
+ },
83
96
  "pi": {
84
97
  "extensions": [
85
98
  "./pi/extensions/session-orchestrator.ts"
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: Classify and apply a version bump to a machine-readable contract (JSON Schema, API spec, config schema) — version literals, consumer compatibility, changelog entry, downstream drift
3
+ argument-hint: "[--contract <path>]"
4
+ ---
5
+
6
+ # /contract-version-bump
7
+
8
+ Use the Session Orchestrator command definition at `commands/contract-version-bump.md`.
9
+
10
+ Arguments: $@
11
+
12
+ Read that command file and follow it exactly. When it references `$ARGUMENTS`, substitute the arguments above. Keep all Session Orchestrator platform fallbacks intact.
@@ -0,0 +1,32 @@
1
+ # Rules Library (deliverable)
2
+
3
+ This directory is the **deliverable rule library** — the coding-standard and
4
+ discipline rules that *consumer* repos adopt via `/bootstrap --sync-rules`
5
+ (sync logic: [`scripts/lib/rules-sync.mjs`](../scripts/lib/rules-sync.mjs)).
6
+
7
+ It is **not** the always-on rule set of *this* repository — those live in
8
+ [`.claude/rules/`](../.claude/rules/). The two directories share the name
9
+ "rules" but play opposite roles: `rules/` ships rules **out** to other repos,
10
+ `.claude/rules/` is what this repo runs on day-to-day.
11
+
12
+ Every file here carries a
13
+ `<!-- source: session-orchestrator plugin (canonical: rules/<path>) -->` header.
14
+ On sync, plugin-sourced files are overwritten in the consumer while its
15
+ locally-authored rules are preserved (copy-on-write). The canonical manifest of
16
+ what vendors where is [`_index.md`](./_index.md).
17
+
18
+ ## Structure
19
+
20
+ - **[`always-on/`](./always-on/)** — vendored to *every* consumer repo, whatever its stack (parallel-session discipline, commit discipline, the quality-gate triad).
21
+ - **[`opt-in-stack/`](./opt-in-stack/)** — vendored only when the consumer's resolved archetype matches (backend, frontend, Swift, web-security, …).
22
+ - **[`opt-in-domain/`](./opt-in-domain/)** — vendored on archetype match for a specific domain concern (e.g. prompt-caching).
23
+
24
+ The optional `[archetypes: …]` tag on an `_index.md` entry is the allowlist that
25
+ decides which repos receive an opt-in file; an untagged entry is universal.
26
+
27
+ ## Authoring
28
+
29
+ New or changed rules follow the format in
30
+ [`../docs/rule-authoring.md`](../docs/rule-authoring.md) and must be registered
31
+ in [`_index.md`](./_index.md) to be vendored. Category entries must not share a
32
+ basename — all synced files flatten into the consumer's `.claude/rules/`.
@@ -42,6 +42,7 @@ import { findProjectRoot, resolveInstructionFile, warn } from './lib/common.mjs'
42
42
  import { parseSessionConfig } from './lib/config.mjs';
43
43
  import { glabRun as defaultGlabRun } from './lib/vault-backfill/glab.mjs';
44
44
  import { archiveFileToVault, titleFromMarkdown } from './lib/vault-archive.mjs';
45
+ import { defaultGlabRepo } from './lib/vcs-repo-spec.mjs';
45
46
 
46
47
  const DEFAULT_PRD_DIR = 'docs/prd';
47
48
  const DEFAULT_VAULT_SUBDIR = '01-projects/session-orchestrator/prd';
@@ -57,28 +58,17 @@ function defaultGitRun(gitArgs) {
57
58
  return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '' };
58
59
  }
59
60
 
60
- /**
61
- * Best-effort auto-detection of the glab `-R` repo spec from the local git
62
- * remotes (prefers a `gitlab` helper-remote, else `origin`). Returned as the raw
63
- * remote URL, which glab `-R` accepts (HTTPS, `.git`, or SSH forms all work).
64
- *
65
- * This keeps the committed custom-phase command host-agnostic (no private host
66
- * in CLAUDE.md owner-leakage/#494) while still resolving the correct host when
67
- * glab is spawned non-interactively (a bare `glab` spawn ignores the shell
68
- * wrapper and falls back to the ambient GITLAB_HOST, which may not match).
69
- *
70
- * @param {string} repoRoot
71
- * @param {(args: string[]) => { ok: boolean, stdout: string, stderr: string }} gitRunFn
72
- * @returns {string|undefined}
73
- */
74
- export function defaultGlabRepo(repoRoot, gitRunFn) {
75
- for (const remote of ['gitlab', 'origin']) {
76
- const { ok, stdout } = gitRunFn(['-C', repoRoot, 'remote', 'get-url', remote]);
77
- const url = ok ? stdout.trim() : '';
78
- if (url) return url;
79
- }
80
- return undefined;
81
- }
61
+ // Best-effort auto-detection of the glab `-R` repo spec from the local git
62
+ // remotes (prefers a `gitlab` helper-remote, else `origin`) lifted into
63
+ // `scripts/lib/vcs-repo-spec.mjs::defaultGlabRepo` (#839) as the single shared
64
+ // implementation; re-exported here (`export { defaultGlabRepo }` below) so
65
+ // existing callers/tests importing it from this module keep working.
66
+ //
67
+ // This keeps the committed custom-phase command host-agnostic (no private host
68
+ // in CLAUDE.md owner-leakage/#494) while still resolving the correct host when
69
+ // glab is spawned non-interactively (a bare `glab` spawn ignores the shell
70
+ // wrapper and falls back to the ambient GITLAB_HOST, which may not match).
71
+ export { defaultGlabRepo };
82
72
 
83
73
  // ---------------------------------------------------------------------------
84
74
  // Pure helpers
@@ -17,6 +17,7 @@ import { execFile as execFileCb, spawnSync } from 'node:child_process';
17
17
  import { promisify } from 'node:util';
18
18
  import path from 'node:path';
19
19
  import fs from 'node:fs';
20
+ import { resolveRepoSpec } from './lib/vcs-repo-spec.mjs';
20
21
 
21
22
  const execFile = promisify(execFileCb);
22
23
 
@@ -48,7 +49,9 @@ OPTIONS:
48
49
  EXIT CODES:
49
50
  0 Success
50
51
  1 User error (bad flags)
51
- 2 System error (libs/binaries/probe failed)
52
+ 2 System error (libs/binaries/probe failed) — or a terminal stop reason
53
+ (kill-switch / spiral / cohort-abort / spawn-error)
54
+ 3 no productive work — apply run completed but zero loops did any work
52
55
  `;
53
56
 
54
57
  const FLAGS = {
@@ -274,15 +277,34 @@ function extractRefs(_issue, _field) {
274
277
  * Fetch the ready-backlog from GitLab via `glab issue list`.
275
278
  * Uses execFile (shell: false) per SEC-014 / cli-design rules.
276
279
  *
280
+ * Host-pinning (#904): a bare `glab` spawn (no shell wrapper, no `-R`) falls
281
+ * back to the ambient `GITLAB_HOST` config default, which can silently
282
+ * resolve to the WRONG GitLab instance on a multi-host machine. Injecting
283
+ * `GITLAB_HOST` into the spawn env is NOT a fix — it is live-refuted whenever
284
+ * `~/.ssh/config` maps the hostname to a `HostName` IP alias (glab then
285
+ * reports "none of the git remotes ... correspond to the GITLAB_HOST"). Only
286
+ * `-R <spec>` survives that mismatch. See scripts/lib/vcs-repo-spec.mjs
287
+ * docblock; reference idiom: scripts/lib/spiral-carryover.mjs.
288
+ *
277
289
  * @param {Function} execFileFn Promisified execFile
290
+ * @param {object} [opts]
291
+ * @param {string} [opts.repoRoot] Repo root to resolve the `-R` spec from (default: process.cwd())
292
+ * @param {'gitlab'|'github'} [opts.vcs] VCS backend (default: 'gitlab')
293
+ * @param {(o: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined} [opts.resolveRepoSpecFn]
294
+ * DI seam for `resolveRepoSpec` (testability).
278
295
  * @returns {Promise<Array<{iid: number, title: string, blocks: number[], blockedBy: number[], labels: string[]}>>}
279
296
  */
280
- async function fetchReadyBacklog(execFileFn) {
281
- const { stdout } = await execFileFn(
282
- 'glab',
283
- ['issue', 'list', '--label', 'status:ready', '--per-page', '50', '--output', 'json'],
284
- { shell: false, timeout: 30_000 },
285
- );
297
+ async function fetchReadyBacklog(execFileFn, opts = {}) {
298
+ const resolveRepoSpecFn = opts.resolveRepoSpecFn ?? resolveRepoSpec;
299
+ const vcs = opts.vcs === 'github' ? 'github' : 'gitlab';
300
+ const spec = resolveRepoSpecFn({ repoRoot: opts.repoRoot ?? process.cwd(), vcs });
301
+
302
+ const args = ['issue', 'list', '--label', 'status:ready', '--per-page', '50', '--output', 'json'];
303
+ // Never emit '-R undefined' — resolveRepoSpec returns undefined when it
304
+ // cannot auto-detect a remote; omit the flag entirely in that case.
305
+ if (spec) args.push('-R', spec);
306
+
307
+ const { stdout } = await execFileFn('glab', args, { shell: false, timeout: 30_000 });
286
308
  const issues = JSON.parse(stdout);
287
309
  return issues.map((i) => ({
288
310
  iid: i.iid ?? i.id,
@@ -296,11 +318,19 @@ async function fetchReadyBacklog(execFileFn) {
296
318
  /**
297
319
  * Summarise a completed apply run into a canonical result envelope.
298
320
  *
321
+ * Success/exit-code semantics (#905): a `reason` that is not a terminal
322
+ * stop-reason (see `terminalReasons`) used to be reported as `success: true`
323
+ * even when every registered loop did zero productive work (e.g. the inner
324
+ * loop fell back to manual mode with 0 iterations completed) — the classic
325
+ * "fallback counts as complete" bug. `success` now additionally requires
326
+ * either an empty backlog (no loops were ever registered — the legitimate
327
+ * "nothing to do" case) or at least one loop that actually completed work.
328
+ *
299
329
  * @param {import('./lib/autopilot/multi-killswitch.mjs').LoopRegistration[]} allLoops
300
330
  * @param {string} reason
301
331
  * @param {number} startMs
302
332
  * @param {{ kill: string, detail: string }|null} [killDetail]
303
- * @returns {{ success: boolean, data: object }}
333
+ * @returns {{ success: boolean, exitCode: number, data: object }}
304
334
  */
305
335
  function finalize(allLoops, reason, startMs, killDetail = null) {
306
336
  const terminalReasons = new Set([
@@ -310,15 +340,35 @@ function finalize(allLoops, reason, startMs, killDetail = null) {
310
340
  'spawn-error',
311
341
  STALE_SUBAGENT_MIN,
312
342
  ]);
343
+ const isTerminal = terminalReasons.has(reason);
344
+ const completed = allLoops.filter((l) => l.status === 'complete').length;
345
+ const failed = allLoops.filter((l) => l.status === 'failed').length;
346
+ const noWork = allLoops.filter((l) => l.status === 'no-work').length;
347
+ const fellBackToManual = allLoops.filter((l) => l.fallbackToManual === true).length;
348
+
349
+ // Empty-backlog regression guard: zero registered loops is a legitimate
350
+ // "nothing to do" outcome, not a "no productive work" outcome — do not
351
+ // conflate the two.
352
+ const success = !isTerminal && (allLoops.length === 0 || completed > 0);
353
+
354
+ // Exit-code triage: success -> 0; a terminal stop-reason -> 2 (unchanged;
355
+ // this is the only way `!success` can coincide with an empty allLoops); a
356
+ // non-terminal reason with >=1 registered loop but zero productive loops
357
+ // -> 3 ("no productive work" — the run completed cleanly but nothing landed).
358
+ const exitCode = success ? 0 : (isTerminal ? 2 : 3);
359
+
313
360
  return {
314
- success: !terminalReasons.has(reason),
361
+ success,
362
+ exitCode,
315
363
  data: {
316
364
  reason,
317
- loopCount: allLoops.length,
318
- completed: allLoops.filter((l) => l.status === 'complete').length,
319
- failed: allLoops.filter((l) => l.status === 'failed').length,
320
- elapsedMs: Date.now() - startMs,
321
- killDetail: killDetail ?? null,
365
+ loopCount: allLoops.length,
366
+ completed,
367
+ failed,
368
+ noWork,
369
+ fellBackToManual,
370
+ elapsedMs: Date.now() - startMs,
371
+ killDetail: killDetail ?? null,
322
372
  },
323
373
  };
324
374
  }
@@ -430,6 +480,8 @@ async function runApplyLoop(state, libs, opts) {
430
480
  issueIid: issue.iid,
431
481
  status: 'running',
432
482
  killSwitch: null,
483
+ iterationsCompleted: 0,
484
+ fallbackToManual: false,
433
485
  spiralRecoveryCount: 0,
434
486
  startedAt: Date.now(),
435
487
  lastActivityAt: Date.now(),
@@ -453,7 +505,20 @@ async function runApplyLoop(state, libs, opts) {
453
505
  // yet signal "first green test"; wire once runStoryPipeline
454
506
  // propagates a firstGreenAt timestamp in StoryResult.
455
507
  }).then((result) => {
456
- registration.status = result.killSwitch ? 'failed' : 'complete';
508
+ // Three-way classification (#905): a StoryResult with no kill-switch
509
+ // AND zero completed iterations (or an explicit fallback-to-manual
510
+ // flag) is NOT the same as a completed loop — it did no productive
511
+ // work. Conflating the two used to make an empty apply run report
512
+ // false success. See finalize() for the corresponding success/
513
+ // exit-code semantics and multi-killswitch.mjs's LoopRegistration
514
+ // typedef (out of scope here) for the 'queued'|'running'|'complete'|
515
+ // 'failed' status enum this 'no-work' value extends locally.
516
+ registration.iterationsCompleted = result.iterationsCompleted ?? 0;
517
+ registration.fallbackToManual = result.fallbackToManual === true;
518
+ registration.status =
519
+ result.killSwitch ? 'failed'
520
+ : (registration.fallbackToManual || registration.iterationsCompleted === 0) ? 'no-work'
521
+ : 'complete';
457
522
  registration.killSwitch = result.killSwitch ?? null;
458
523
  completed.add(issue.iid);
459
524
  lastCompletionAt = Date.now();
@@ -493,9 +558,17 @@ async function runApplyLoop(state, libs, opts) {
493
558
  Promise.race(Array.from(inFlight.values()).map((v) => v.promise.then(() => null))),
494
559
  new Promise((r) => setTimeout(r, 5000)),
495
560
  ]);
496
- // Sweep completed / failed entries.
561
+ // Sweep completed / failed / no-work entries. 'no-work' MUST be swept
562
+ // here exactly like 'complete'/'failed' — omitting it leaves a settled
563
+ // promise permanently registered as active, which starves
564
+ // shouldStopOrchestrator's "backlog-empty" (activeLoops.length === 0)
565
+ // condition and hangs the orchestrator forever (#905 sweep regression).
497
566
  for (const [id, { registration }] of inFlight.entries()) {
498
- if (registration.status === 'complete' || registration.status === 'failed') {
567
+ if (
568
+ registration.status === 'complete' ||
569
+ registration.status === 'failed' ||
570
+ registration.status === 'no-work'
571
+ ) {
499
572
  inFlight.delete(id);
500
573
  }
501
574
  }
@@ -714,7 +787,7 @@ export async function main(argv = process.argv.slice(2), opts = {}) {
714
787
  } else {
715
788
  // Apply path: fetch real issues via glab (execFile, shell: false — SEC-014).
716
789
  try {
717
- issues = await fetchReadyBacklog(execFile);
790
+ issues = await fetchReadyBacklog(execFile, { repoRoot: process.cwd() });
718
791
  } catch (err) {
719
792
  const msg = `glab issue list failed: ${err.message}`;
720
793
  process.stderr.write(`autopilot-multi: ${msg}\n`);
@@ -783,13 +856,23 @@ export async function main(argv = process.argv.slice(2), opts = {}) {
783
856
  } else {
784
857
  console.log(
785
858
  `Multi-story apply complete: ${result.data.reason}` +
786
- ` — ${result.data.completed}/${result.data.loopCount} loops succeeded`,
859
+ ` — ${result.data.completed}/${result.data.loopCount} loops succeeded, ` +
860
+ `${result.data.failed} failed, ${result.data.noWork} did no work` +
861
+ ` (${result.data.fellBackToManual} fell back to manual)`,
787
862
  );
788
863
  }
789
864
 
790
- exitFn(result.success ? 0 : 2);
865
+ exitFn(result.exitCode);
791
866
  }
792
867
 
868
+ // ---------------------------------------------------------------------------
869
+ // Test-only exports — internal functions exercised directly by
870
+ // tests/scripts/autopilot-multi.test.mjs (no behaviour change on the CLI
871
+ // path; mirrors the export pattern in scripts/archive-closed-prds.mjs).
872
+ // ---------------------------------------------------------------------------
873
+
874
+ export { finalize, fetchReadyBacklog, runApplyLoop };
875
+
793
876
  // ---------------------------------------------------------------------------
794
877
  // CLI guard — prevents top-level execution on import (deep-2 #368)
795
878
  // ---------------------------------------------------------------------------
@@ -41,12 +41,19 @@ import path from 'node:path';
41
41
  import { parseArgs } from 'node:util';
42
42
  import { fileURLToPath } from 'node:url';
43
43
 
44
- import { backfillAbandonedSession } from './lib/session-close-backfill.mjs';
44
+ import { backfillAbandonedSession, isUuid } from './lib/session-close-backfill.mjs';
45
45
  import { SO_PROJECT_DIR } from './lib/platform.mjs';
46
46
 
47
47
  const LOCK_ACQUIRED = 'orchestrator.session.lock.acquired';
48
48
  const SESSION_STARTED = 'orchestrator.session.started';
49
49
 
50
+ /**
51
+ * Default cap on how many candidates may reach the (expensive) shared core in a
52
+ * single run. Only the SessionStart path passes a limit; the CLI stays uncapped.
53
+ * See `backfillOnSessionStart` for the latency rationale.
54
+ */
55
+ export const SESSION_START_LIMIT = 25;
56
+
50
57
  /** Read a JSONL file into parsed objects; missing → []; malformed lines skipped. */
51
58
  function readJsonl(filePath) {
52
59
  let raw;
@@ -101,6 +108,48 @@ export function planSessions({ repoRoot }) {
101
108
  return plan;
102
109
  }
103
110
 
111
+ /**
112
+ * Cheap pre-filter (#926): the set of session ids ALREADY present in
113
+ * sessions.jsonl.
114
+ *
115
+ * WHY THIS EXISTS — latency. `backfillAbandonedSession` re-reads the whole
116
+ * events.jsonl on every call whose id cannot be resolved without it. On a
117
+ * mature store that is O(candidates x events-file): measured 1.26 s for 187
118
+ * candidates over a 1.77 MB events.jsonl, of which 183 were already recorded
119
+ * and therefore pure waste. Reading the (much smaller) sessions.jsonl ONCE up
120
+ * front lets us skip those without entering the core at all.
121
+ *
122
+ * This is a pure latency optimisation with NO behavioural change: it reproduces
123
+ * exactly the core's own early-dedupe branch (same `recordId` derivation, same
124
+ * defensive UUID check) and reports the same `skipped-already-recorded` key.
125
+ * Candidates whose id CANNOT be resolved cheaply (a bare UUID with no
126
+ * lock.acquired bridge — the synthetic-id mint needs events) are never
127
+ * pre-filtered; they still go through the core untouched.
128
+ *
129
+ * @param {string} repoRoot
130
+ * @returns {Set<string>}
131
+ */
132
+ function readRecordedIds(repoRoot) {
133
+ const records = readJsonl(path.join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl'));
134
+ const ids = new Set();
135
+ for (const r of records) {
136
+ if (r && typeof r.session_id === 'string') ids.add(r.session_id);
137
+ }
138
+ return ids;
139
+ }
140
+
141
+ /**
142
+ * Resolve the sessions.jsonl record id for a planned candidate WITHOUT reading
143
+ * events.jsonl. Mirrors the core's own two-phase resolution: a known semantic
144
+ * id, or a `sessionId` that is already semantic. Returns null when the id can
145
+ * only be resolved from events (bare UUID → lock bridge or synthetic mint).
146
+ */
147
+ function cheapRecordId({ sessionId, semanticSessionId }) {
148
+ if (semanticSessionId) return semanticSessionId;
149
+ if (sessionId && !isUuid(sessionId)) return sessionId;
150
+ return null;
151
+ }
152
+
104
153
  /**
105
154
  * Run the migration over every planned session.
106
155
  *
@@ -108,11 +157,35 @@ export function planSessions({ repoRoot }) {
108
157
  * module docblock for why. `assumeDeadBeforeMs` is forwarded as-is (null when
109
158
  * `--assume-dead-before` was not given).
110
159
  *
111
- * @param {{ repoRoot: string, apply: boolean, assumeDeadBeforeMs?: number|null }} args
160
+ * @param {object} args
161
+ * @param {string} args.repoRoot
162
+ * @param {boolean} args.apply
163
+ * @param {number|null} [args.assumeDeadBeforeMs]
164
+ * @param {number|null} [args.limit=null]
165
+ * #926 — cap on how many candidates may reach the shared core. Pre-filtered
166
+ * (already-recorded) candidates do NOT count against it, since they cost
167
+ * nothing. On hitting the cap the run stops early and sets
168
+ * `summary.truncated = true`; the remainder is picked up by the next run.
169
+ * `null` (CLI default) means uncapped.
170
+ * @param {boolean} [args.newestFirst=false]
171
+ * #926 — walk the plan most-recent-first. Load-bearing whenever `limit` is
172
+ * set: a bare-UUID candidate with no lock.acquired bridge cannot be
173
+ * pre-filtered (its synthetic id is only derivable from events), so it always
174
+ * spends a core call just to be told "already recorded". In first-seen order
175
+ * those ancient candidates exhaust the whole budget before the run ever
176
+ * reaches the genuinely-abandoned recent ones. Measured on a copy of this
177
+ * repo's live store: first-seen order → backfilled 0, truncated true.
112
178
  * @returns {Promise<object>} aggregate summary
113
179
  */
114
- export async function runMigration({ repoRoot, apply, assumeDeadBeforeMs = null }) {
115
- const plan = planSessions({ repoRoot });
180
+ export async function runMigration({
181
+ repoRoot,
182
+ apply,
183
+ assumeDeadBeforeMs = null,
184
+ limit = null,
185
+ newestFirst = false,
186
+ }) {
187
+ const planned = planSessions({ repoRoot });
188
+ const plan = newestFirst ? [...planned].reverse() : planned;
116
189
  const summary = {
117
190
  repoRoot,
118
191
  mode: apply ? 'apply' : 'dry-run',
@@ -124,6 +197,10 @@ export async function runMigration({ repoRoot, apply, assumeDeadBeforeMs = null
124
197
  skipped: {},
125
198
  };
126
199
 
200
+ // Pre-filter pass — costs ONE sessions.jsonl read for the whole run.
201
+ const recordedIds = readRecordedIds(repoRoot);
202
+ let considered = 0;
203
+
127
204
  // Dry-run has no incremental sessions.jsonl write, so two started-UUIDs that
128
205
  // bridge to the SAME semantic id (a session that cleared/compacted mid-run)
129
206
  // would both report 'would-backfill' and over-count. Track projected ids
@@ -131,6 +208,24 @@ export async function runMigration({ repoRoot, apply, assumeDeadBeforeMs = null
131
208
  const projected = new Set();
132
209
 
133
210
  for (const item of plan) {
211
+ // -- Cheap pre-filter: already recorded → skip WITHOUT entering the core --
212
+ const known = cheapRecordId(item);
213
+ if (
214
+ (known !== null && recordedIds.has(known)) ||
215
+ (isUuid(item.sessionId) && recordedIds.has(item.sessionId))
216
+ ) {
217
+ summary.skipped['skipped-already-recorded'] =
218
+ (summary.skipped['skipped-already-recorded'] ?? 0) + 1;
219
+ continue;
220
+ }
221
+
222
+ // -- Latency cap (#926, SessionStart path only) --------------------------
223
+ if (typeof limit === 'number' && considered >= limit) {
224
+ summary.truncated = true;
225
+ break;
226
+ }
227
+ considered += 1;
228
+
134
229
  const res = await backfillAbandonedSession({
135
230
  repoRoot,
136
231
  sessionId: item.sessionId,
@@ -143,6 +238,10 @@ export async function runMigration({ repoRoot, apply, assumeDeadBeforeMs = null
143
238
  case 'backfilled':
144
239
  summary.backfilled += 1;
145
240
  if (res.deadByAge) summary.dead_by_age += 1;
241
+ // Keep the pre-filter snapshot in step with what we just wrote, so a
242
+ // second candidate bridging to the SAME semantic id is skipped cheaply
243
+ // instead of re-entering the core (which would reach the same verdict).
244
+ if (typeof res.sessionId === 'string') recordedIds.add(res.sessionId);
146
245
  break;
147
246
  case 'would-backfill':
148
247
  if (projected.has(res.sessionId)) {
@@ -164,6 +263,63 @@ export async function runMigration({ repoRoot, apply, assumeDeadBeforeMs = null
164
263
  return summary;
165
264
  }
166
265
 
266
+ /**
267
+ * SessionStart entry point (#926) — decouple the backfill from the /close path.
268
+ *
269
+ * THE BUG THIS FIXES: `hooks/on-session-end.mjs` calls the backfill correctly,
270
+ * but SessionEnd only fires on a REGULAR close. A session killed by Ctrl-C, a
271
+ * timeout, or a crash leaves no ledger entry at all, and the backfill then waits
272
+ * for the NEXT clean close — which may never come. Observed on this repo:
273
+ * sessions.jsonl 18.9 h behind events.jsonl with 8 commits and 0 records.
274
+ * Running at SessionStart closes the loop: the PREVIOUS abandoned session is
275
+ * reconstructed by the NEXT session's start, whatever killed it.
276
+ *
277
+ * ── SAFETY (the two ways this could do damage) ───────────────────────────────
278
+ * (1) NEVER record the CURRENTLY-STARTING session as abandoned. Two
279
+ * independent defences:
280
+ * a. STRUCTURAL — the caller invokes this BEFORE emitting
281
+ * `orchestrator.session.started`, so this session is not yet in
282
+ * events.jsonl and therefore not a candidate at all (`planSessions`
283
+ * enumerates started-events only).
284
+ * b. GUARD — on a clear/compact/resume re-fire, an EARLIER
285
+ * `session.started` for the same logical session IS present. It
286
+ * resolves to our own semantic id, whose lock is live, so the core's
287
+ * `skipped-own-live-lock` branch (#863) rejects it.
288
+ * (2) NEVER record a RUNNING FOREIGN session as abandoned. The core evaluates
289
+ * lock ownership against the CANDIDATE (not the running process): a
290
+ * candidate that holds a live lock returns `skipped-own-live-lock` BEFORE
291
+ * the dead-by-age relaxation is ever consulted. Verified against a fixture
292
+ * where the live lock holder is >4 h stale and thus relaxation-eligible.
293
+ *
294
+ * Residual risk, stated explicitly: a session that is live but does NOT hold
295
+ * the lock (it lost the acquire race — `bootstrapLock` leaves the foreign lock
296
+ * in place and bails) AND has emitted no event for longer than the lock TTL
297
+ * (`DEFAULT_TTL_HOURS` = 4 h) could still be relaxed past. That candidate is
298
+ * one the system's OWN liveness model already considers dead, since its lock
299
+ * would have expired; we deliberately reuse that same TTL rather than inventing
300
+ * a second notion of liveness.
301
+ *
302
+ * Never throws — a backfill failure must NEVER block a session start.
303
+ *
304
+ * @param {object} args
305
+ * @param {string} args.repoRoot
306
+ * @param {number|null} [args.limit=SESSION_START_LIMIT]
307
+ * @returns {Promise<object|null>} the summary, or null when disabled/failed
308
+ */
309
+ export async function backfillOnSessionStart({ repoRoot, limit = SESSION_START_LIMIT } = {}) {
310
+ try {
311
+ // Escape hatch for operators who never want ledger writes at session start.
312
+ if (process.env.SO_DISABLE_STARTUP_BACKFILL === '1') return null;
313
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
314
+ // newestFirst is mandatory here — see runMigration's param docs for the
315
+ // measured failure (budget burned on ancient already-recorded candidates).
316
+ return await runMigration({ repoRoot, apply: true, limit, newestFirst: true });
317
+ } catch {
318
+ // Swallowed by contract — see the docblock. The hook is informational-only.
319
+ return null;
320
+ }
321
+ }
322
+
167
323
  function renderHuman(summary) {
168
324
  const lines = [];
169
325
  lines.push(`Backfill abandoned sessions — ${summary.mode}`);
@@ -86,7 +86,16 @@ strip_fences "$CLAUDE" > "$CLAUDE_NOFENCE"
86
86
  # ---------------------------------------------------------------------------
87
87
  # Check 3: live `## Session Config` present in CLAUDE.md
88
88
  # ---------------------------------------------------------------------------
89
- if ! grep -qE '^## Session Config[[:space:]]*$' "$CLAUDE"; then
89
+ # The pattern is EXACT, mirroring isSessionConfigHeading() in
90
+ # scripts/lib/config/section-extractor.mjs (the SSOT) — CR tolerated for CRLF
91
+ # checkouts, nothing else. Shell cannot import the JS predicate, so the
92
+ # alignment is by hand and this comment names the authority. It was
93
+ # `[[:space:]]*$` until #968, which accepted `## Session Config␠` — a heading
94
+ # the runtime parser rejects. That made this check, whose entire purpose is to
95
+ # report whether the runtime can find the block, answer "present" for the one
96
+ # state it exists to catch.
97
+ CR=$(printf '\r')
98
+ if ! grep -qE "^## Session Config${CR}?\$" "$CLAUDE"; then
90
99
  emit "$CLAUDE:1" "live-config-missing" "## Session Config heading not found (skills/_shared/config-reading.md depends on it)"
91
100
  fi
92
101
 
@@ -94,6 +103,13 @@ fi
94
103
  # Check 1: H2 parity (CLAUDE.md → README.md)
95
104
  # Canonical runtime-only headings live in CLAUDE.md exclusively and are exempt.
96
105
  # ---------------------------------------------------------------------------
106
+ # NOT tightened to the SSOT (#968), deliberately. This is a README↔CLAUDE H2
107
+ # PARITY-exemption list, not a comparator against "can the runtime read the
108
+ # Session Config block" — four of its five entries have no SSOT at all, and a
109
+ # heading it wrongly exempts costs a missing parity warning, never a silent
110
+ # config fallback. Check 3 above is the site that answers the runtime question,
111
+ # and that one is exact. Tightening only the `Session Config` alternative would
112
+ # make section-extractor.mjs the authority for a fact it does not govern.
97
113
  EXEMPT_HEADINGS_RE='^## (Structure|Destructive-Command Guard|Agent Authoring Rules|Current State|Session Config)[[:space:]]*$'
98
114
 
99
115
  # Extract README H2 set (one per line, normalized — heading text without the leading `## `)
@@ -54,10 +54,25 @@ SESSION_ID="${SESSION_ID:-}"
54
54
  WAVE="${WAVE:-0}"
55
55
  AGENT_TYPE="${AGENT_TYPE:-}"
56
56
 
57
- # --- Build list of last 3 session_ids from sessions.jsonl ---
58
-
57
+ # --- Build list of last 3 REAL session_ids from sessions.jsonl ---
58
+
59
+ # Filter out phantom `status: 'abandoned'` stubs (#834, session-close-backfill
60
+ # — 0 waves, seconds of runtime) BEFORE taking the tail. Otherwise a recent
61
+ # phantom can evict the one real session carrying the stagnation evidence,
62
+ # silently suppressing grounding injection for a genuinely stagnating file.
63
+ #
64
+ # `-R` (raw-input) + `fromjson?` parses each line individually and SKIPS
65
+ # unparseable ones instead of aborting the whole stream — plain
66
+ # `jq -c 'select(...)'` aborts at the FIRST malformed line (jq: parse error,
67
+ # exit 5). sessions.jsonl is append-only from multiple writers, so a torn
68
+ # write earlier in the file must not silently starve LAST_SESSIONS down to
69
+ # "[]" (fail-closed-empty is still wrong here, just a quieter wrong than the
70
+ # stale-session-id case this fixes) — the whole point is that corruption
71
+ # anywhere in the file no longer poisons the tail. Mirrors the per-line
72
+ # try/catch behaviour of the .mjs path (scripts/lib/session-schema/filters.mjs).
59
73
  # Extract session_id values (skip entries without one, skip empty lines)
60
- LAST_SESSIONS=$(tail -n 3 "$SESSIONS_JSONL" \
74
+ LAST_SESSIONS=$(jq -R -c 'fromjson? | select(.status != "abandoned")' "$SESSIONS_JSONL" 2>/dev/null \
75
+ | tail -n 3 \
61
76
  | jq -r 'select(.session_id != null and .session_id != "") | .session_id' 2>/dev/null \
62
77
  | jq -R -s 'split("\n") | map(select(length > 0))' 2>/dev/null) || LAST_SESSIONS="[]"
63
78