session-orchestrator 3.20.0 → 3.22.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 (202) 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/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -917,3 +917,661 @@ export function suggestForScopeViolation(relPath, allowedCsv) {
917
917
  `If '${relPath}' belongs to this wave, add its directory to the plan's wave scope and restart.`
918
918
  );
919
919
  }
920
+
921
+ /**
922
+ * Merge many agents' declared file scopes into ONE deduplicated, order-stable
923
+ * list — the mechanical form of "allowedPaths is the UNION of all agent file
924
+ * scopes" (#1020, wave-loop.md § Scope Manifest #3).
925
+ *
926
+ * Motivation: today the coordinator writes `wave-scope.json` `allowedPaths` from
927
+ * one hand-kept list and the agent briefs from a SECOND, separately formulated
928
+ * list. Nothing couples them; they diverged five times in one session (#1020).
929
+ * Deriving the union FROM the per-agent declarations makes that divergence
930
+ * structurally impossible instead of discipline-dependent.
931
+ *
932
+ * Accepts BOTH input shapes, because the two call sites differ:
933
+ * - `[['a.mjs','b.mjs'], ['c.mjs']]` — bare scope arrays
934
+ * - `[{id:'W1-D1', files:['a.mjs']}, {id:'W1-D2', …}]` — the CLI/plan record
935
+ * shape, which is also {@link findScopeCollisions}' input (one source object
936
+ * feeding both consumers is the whole point of #1020).
937
+ *
938
+ * Order is INSERTION order (first-seen wins), never sorted: the union is written
939
+ * into a manifest that a human reads next to the plan, and a stable order keeps
940
+ * its diff readable across re-unions (#796 rewrites it mid-wave).
941
+ *
942
+ * Fail-closed & no-throw (module convention): a non-array input returns `[]`;
943
+ * non-array / non-object members and non-string, empty entries are skipped.
944
+ * Pure, sync, no I/O — hook-safe per the module header.
945
+ *
946
+ * @param {Array<string[]|{id?: string, files?: string[]}>} scopes
947
+ * @returns {string[]} deduplicated union in first-seen order
948
+ */
949
+ export function unionFileScopes(scopes) {
950
+ if (!Array.isArray(scopes)) return [];
951
+ const out = [];
952
+ const seen = new Set();
953
+ for (const scope of scopes) {
954
+ let files = null;
955
+ if (Array.isArray(scope)) files = scope;
956
+ else if (scope !== null && typeof scope === 'object' && Array.isArray(scope.files)) {
957
+ files = scope.files;
958
+ }
959
+ if (files === null) continue;
960
+ for (const entry of files) {
961
+ if (typeof entry !== 'string' || entry.length === 0) continue;
962
+ if (seen.has(entry)) continue;
963
+ seen.add(entry);
964
+ out.push(entry);
965
+ }
966
+ }
967
+ return out;
968
+ }
969
+
970
+ /**
971
+ * Is this scope entry RECURSIVE — does it grant everything below a directory?
972
+ * `**` is the explicit form; a trailing `/` is the implicit one, because
973
+ * {@link pathMatchesPattern} matches a `dir/` prefix with `startsWith`, i.e. at
974
+ * ANY depth. Both must count, or `tests/` vs `tests/lib/*.mjs` reads as disjoint.
975
+ * @param {string} entry
976
+ * @returns {boolean}
977
+ */
978
+ function isRecursiveScopeEntry(entry) {
979
+ return entry.includes('**') || entry.endsWith('/');
980
+ }
981
+
982
+ /**
983
+ * Literal SUFFIX of a glob entry — the text after its last `*` metachar
984
+ * (`src/**\/*.mjs` → `.mjs`, `src/**` → `''`). Returns `null` for an entry with
985
+ * no `*` at all (a `dir/` prefix), where the concept does not apply: such an
986
+ * entry constrains only the head of a path, never its tail.
987
+ * @param {string} entry
988
+ * @returns {string|null}
989
+ */
990
+ function literalScopeSuffix(entry) {
991
+ const star = entry.lastIndexOf('*');
992
+ return star === -1 ? null : entry.slice(star + 1);
993
+ }
994
+
995
+ /**
996
+ * Normalize the `agentScopes` input of {@link findScopeCollisions} into
997
+ * `{id, declaredId, files}` records. Never throws; malformed members are
998
+ * repaired rather than dropped.
999
+ *
1000
+ * A member with NO usable `id` keeps its files in the check under a synthetic
1001
+ * `<unnamed#i>` id (i = its index). Dropping it instead would be a FALSE
1002
+ * NEGATIVE — the whole point of this function is that an unreviewed scope is
1003
+ * exactly the one that collides.
1004
+ *
1005
+ * @param {Array<{id?: string, files?: string[]}>} agentScopes
1006
+ * @returns {Array<{id: string, declaredId: string|null, files: string[]}>}
1007
+ */
1008
+ function normalizeAgentScopes(agentScopes) {
1009
+ const out = [];
1010
+ for (let i = 0; i < agentScopes.length; i++) {
1011
+ const raw = agentScopes[i];
1012
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue;
1013
+ const declaredId = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : null;
1014
+ const files = Array.isArray(raw.files)
1015
+ ? raw.files.filter((f) => typeof f === 'string' && f.length > 0)
1016
+ : [];
1017
+ out.push({ id: declaredId ?? `<unnamed#${i}>`, declaredId, files });
1018
+ }
1019
+ return out;
1020
+ }
1021
+
1022
+ /**
1023
+ * Classify a SINGLE cross-agent entry pair, in the binding three-stage order.
1024
+ * Returns the collision `kind`, or `null` when the two entries are disjoint.
1025
+ *
1026
+ * Stage order is not cosmetic — see {@link findScopeCollisions} for why the two
1027
+ * exact stages must run BEFORE the approximate one.
1028
+ *
1029
+ * @param {string} x — an entry from agent A
1030
+ * @param {string} y — an entry from agent B
1031
+ * @param {(glob: string) => Set<string>} expand — memoized KNOWN-set expander
1032
+ * @returns {'concrete'|'glob-expanded'|'glob-prefix'|null}
1033
+ */
1034
+ function classifyEntryCollision(x, y, expand) {
1035
+ // Stage 1 — exact string equality. The commonest real case (#1020 Vorfall 3),
1036
+ // and the ONLY stage that works for a file that does not exist yet.
1037
+ if (x === y) return 'concrete';
1038
+
1039
+ const xIsGlob = isGlobScopeEntry(x);
1040
+ const yIsGlob = isGlobScopeEntry(y);
1041
+
1042
+ // Stage 2 — concrete vs glob. `pathMatchesPattern` is DIRECTED (arg 1 is a
1043
+ // literal path, arg 2 becomes the regex); used in that one correct direction
1044
+ // it is exact and needs no filesystem witness.
1045
+ if (!xIsGlob && yIsGlob) return pathMatchesPattern(x, y) ? 'concrete' : null;
1046
+ if (xIsGlob && !yIsGlob) return pathMatchesPattern(y, x) ? 'concrete' : null;
1047
+ if (!xIsGlob && !yIsGlob) return null; // two distinct concrete paths: disjoint
1048
+
1049
+ // Stage 3a — glob ∩ glob, decided by a shared WITNESS from the KNOWN set.
1050
+ const xHits = expand(x);
1051
+ for (const witness of expand(y)) {
1052
+ if (xHits.has(witness)) return 'glob-expanded';
1053
+ }
1054
+
1055
+ // Stage 3b — prefix fallback, for the intersection that exists only in files
1056
+ // NOT YET on disk (the KNOWN set cannot witness those).
1057
+ const xPrefix = literalScopePrefix(x);
1058
+ const yPrefix = literalScopePrefix(y);
1059
+ if (!(xPrefix.startsWith(yPrefix) || yPrefix.startsWith(xPrefix))) return null;
1060
+ if (!(isRecursiveScopeEntry(x) || isRecursiveScopeEntry(y))) return null;
1061
+ // Suffix compatibility is a NECESSARY condition, so filtering on it adds no
1062
+ // false negative: a string ending in both `sx` and `sy` forces the shorter to
1063
+ // be a suffix of the longer. It removes the obvious false positive
1064
+ // `scripts/**\/*.ts` vs `scripts/**\/*.mjs`, which share a prefix and are both
1065
+ // recursive yet can never match the same path.
1066
+ const xSuffix = literalScopeSuffix(x);
1067
+ const ySuffix = literalScopeSuffix(y);
1068
+ if (
1069
+ xSuffix !== null &&
1070
+ ySuffix !== null &&
1071
+ !(xSuffix.endsWith(ySuffix) || ySuffix.endsWith(xSuffix))
1072
+ ) {
1073
+ return null;
1074
+ }
1075
+ return 'glob-prefix';
1076
+ }
1077
+
1078
+ /**
1079
+ * Detect files claimed by TWO agents of the SAME wave, BEFORE dispatch (#1020).
1080
+ *
1081
+ * ## The bug this closes
1082
+ * `tests/scripts/sweep-expired-learnings-cli.test.mjs` was handed to two agents
1083
+ * of one wave (#1020 Vorfall 3). Nothing caught it up front: the pre-dispatch
1084
+ * assertion {@link assertFileScopeSubset} checks each agent against the union
1085
+ * (a SUBSET relation, which two overlapping agents both satisfy), and the
1086
+ * commit-time `wave-scope-commit-guard` only sees the union as well. It surfaced
1087
+ * afterwards, from an agent's own PSA-002 report. Per
1088
+ * `.claude/rules/parallel-sessions.md` § Decision Tree a file inside two
1089
+ * declared scopes of one dispatch round is never a benign sibling signal — it is
1090
+ * a deconfliction gap, and that round ended well by luck, not construction.
1091
+ *
1092
+ * ## The three stages, and why the order is binding
1093
+ * 1. **Exact string equality** → `concrete`. Covers the commonest real case AND
1094
+ * every file that does not exist yet (no filesystem witness required).
1095
+ * 2. **Concrete vs glob** via {@link pathMatchesPattern} → `concrete`. Exact and
1096
+ * I/O-free, because the matcher is used in its one correct direction.
1097
+ * 3. **Glob vs glob** — expand both against
1098
+ * `KNOWN = opts.knownFiles ∪ {every concrete entry of every agent}`;
1099
+ * a non-empty intersection is `glob-expanded`. As a fallback for files not
1100
+ * yet on disk, a literal-prefix containment plus at least one recursive
1101
+ * entry is `glob-prefix`.
1102
+ *
1103
+ * Stage 3 must come LAST because {@link pathMatchesPattern} is DIRECTED and
1104
+ * therefore useless for glob∩glob: it compiles argument 2 into a regex and tests
1105
+ * argument 1 as a literal string. Measured:
1106
+ * `pathMatchesPattern('scripts/**\/*.mjs', 'scripts/lib/*.mjs') === false`, even
1107
+ * though both match `scripts/lib/x.mjs`. {@link assertFileScopeSubset} documents
1108
+ * that boundary at its own glob branch and OVER-approximates coverage, which is
1109
+ * the safe direction for a subset check. For a COLLISION check the sign flips:
1110
+ * the same over-approximation becomes a FALSE NEGATIVE — a missed collision,
1111
+ * i.e. exactly the incident. Hence stages 1 and 2 decide first, and stage 3 is
1112
+ * reached only for pairs neither of them can settle.
1113
+ *
1114
+ * ## Duplicate ids are a SEPARATE finding, not a collision
1115
+ * Two records carrying the same `id` are a malformed plan, not two agents
1116
+ * fighting over a file; reporting them as a self-collision (`a === b`) would be
1117
+ * noise. They are listed in `duplicateIds` and such pairs are skipped in the
1118
+ * pairwise scan. `duplicateIds` is always present (empty when clean) so
1119
+ * consumers need no conditional-key handling.
1120
+ *
1121
+ * ## `knownFiles` is INJECTED, never discovered
1122
+ * The module header's hook-safe invariant (pure, sync, no I/O, no process spawn)
1123
+ * is binding: `hooks/enforce-scope.mjs` reaches this module on a hot path, and
1124
+ * under the exit-0/stdout-JSON protocol a throw here reads as "no decision" =
1125
+ * ALLOW. So `git ls-files` belongs to the CLI layer and its result arrives as a
1126
+ * parameter. An absent/invalid `knownFiles` is not an error — stage 3a simply
1127
+ * has fewer witnesses and stage 3b carries the load.
1128
+ *
1129
+ * Fail-closed & no-throw: a non-array `agentScopes` returns
1130
+ * `{ ok: false, collisions: [], duplicateIds: [] }` ("cannot assert → treat as
1131
+ * failure", the same convention as {@link assertFileScopeSubset}).
1132
+ *
1133
+ * Output ordering is deterministic: agent pairs in input order, then kinds in
1134
+ * stage order (`concrete` → `glob-expanded` → `glob-prefix`); `evidence` holds
1135
+ * the involved entries of that kind, deduplicated in first-seen order.
1136
+ *
1137
+ * @param {Array<{id?: string, files?: string[]}>} agentScopes — one wave's agents
1138
+ * @param {{knownFiles?: string[]}} [opts] — existing repo files (injected)
1139
+ * @returns {{ok: boolean,
1140
+ * collisions: Array<{a: string, b: string, evidence: string[],
1141
+ * kind: 'concrete'|'glob-expanded'|'glob-prefix'}>,
1142
+ * duplicateIds: string[]}}
1143
+ */
1144
+ export function findScopeCollisions(agentScopes, opts = {}) {
1145
+ if (!Array.isArray(agentScopes)) return { ok: false, collisions: [], duplicateIds: [] };
1146
+ const options = opts !== null && typeof opts === 'object' ? opts : {};
1147
+ const agents = normalizeAgentScopes(agentScopes);
1148
+
1149
+ // Duplicate DECLARED ids (synthetic `<unnamed#i>` ids are unique by index).
1150
+ const seenIds = new Set();
1151
+ const dupIds = new Set(); // Set preserves insertion order → stable report
1152
+ for (const agent of agents) {
1153
+ if (agent.declaredId === null) continue;
1154
+ if (seenIds.has(agent.declaredId)) dupIds.add(agent.declaredId);
1155
+ else seenIds.add(agent.declaredId);
1156
+ }
1157
+ const duplicateIds = [...dupIds];
1158
+
1159
+ // KNOWN = injected repo files ∪ every concrete entry of every agent. The
1160
+ // second half matters: a file the wave is about to CREATE is not in
1161
+ // `git ls-files`, but if one agent names it concretely it can still witness
1162
+ // another agent's glob.
1163
+ const known = [];
1164
+ const knownSeen = new Set();
1165
+ const addKnown = (f) => {
1166
+ if (typeof f !== 'string' || f.length === 0 || knownSeen.has(f)) return;
1167
+ knownSeen.add(f);
1168
+ known.push(f);
1169
+ };
1170
+ if (Array.isArray(options.knownFiles)) options.knownFiles.forEach(addKnown);
1171
+ for (const agent of agents) {
1172
+ for (const entry of agent.files) if (!isGlobScopeEntry(entry)) addKnown(entry);
1173
+ }
1174
+
1175
+ const expansions = new Map();
1176
+ const expand = (glob) => {
1177
+ let hits = expansions.get(glob);
1178
+ if (hits === undefined) {
1179
+ hits = new Set(known.filter((f) => pathMatchesPattern(f, glob)));
1180
+ expansions.set(glob, hits);
1181
+ }
1182
+ return hits;
1183
+ };
1184
+
1185
+ const KIND_ORDER = ['concrete', 'glob-expanded', 'glob-prefix'];
1186
+ const collisions = [];
1187
+ for (let i = 0; i < agents.length; i++) {
1188
+ for (let j = i + 1; j < agents.length; j++) {
1189
+ const a = agents[i];
1190
+ const b = agents[j];
1191
+ if (a.id === b.id) continue; // duplicate-id record: reported separately
1192
+ const buckets = new Map();
1193
+ for (const x of a.files) {
1194
+ for (const y of b.files) {
1195
+ const kind = classifyEntryCollision(x, y, expand);
1196
+ if (kind === null) continue;
1197
+ let evidence = buckets.get(kind);
1198
+ if (evidence === undefined) {
1199
+ evidence = new Set();
1200
+ buckets.set(kind, evidence);
1201
+ }
1202
+ evidence.add(x);
1203
+ evidence.add(y);
1204
+ }
1205
+ }
1206
+ for (const kind of KIND_ORDER) {
1207
+ const evidence = buckets.get(kind);
1208
+ if (evidence !== undefined) {
1209
+ collisions.push({ a: a.id, b: b.id, evidence: [...evidence], kind });
1210
+ }
1211
+ }
1212
+ }
1213
+ }
1214
+
1215
+ return { ok: collisions.length === 0 && duplicateIds.length === 0, collisions, duplicateIds };
1216
+ }
1217
+
1218
+ // ---------------------------------------------------------------------------
1219
+ // Empty-`allowedPaths` classification (#1057)
1220
+ // ---------------------------------------------------------------------------
1221
+ //
1222
+ // ## The bug this fixes — and the one it deliberately does NOT
1223
+ //
1224
+ // FIVE distinct repository states produce `allowedPaths.length === 0`, and the
1225
+ // DENY VERDICT IS CORRECT IN ALL FIVE. What collapses is the REASON: every one
1226
+ // of them printed the single sentence {@link suggestForScopeViolation} emits for
1227
+ // an empty allowlist — "update the session plan and restart the wave".
1228
+ //
1229
+ // 1. A Discovery wave, where `[]` is the deliberate read-only contract
1230
+ // (`skills/session-plan/SKILL.md`, `wave-loop.md` § Scope Manifest #5, and
1231
+ // the #256 NO-OP regression lock in tests/hooks/enforce-scope.test.mjs).
1232
+ // There the sentence is CORRECT but misleading — nothing is broken.
1233
+ // 2. Corrupt JSON, which `hooks/enforce-scope.mjs` folds onto `scope = {}`
1234
+ // (#794 GAP-5), and malformed `allowedPaths` shapes, which `Array.isArray`
1235
+ // folds onto `[]` (#558). There the sentence is USELESS — the plan is fine,
1236
+ // the file is not.
1237
+ // 3. A leftover manifest from a session that crashed before deleting it.
1238
+ // There the sentence is ACTIVELY WRONG: restarting the wave does not remove
1239
+ // a file the previous session left behind — only the coordinator can clear
1240
+ // it, and the branch says so WITHOUT handing over the command (see "The
1241
+ // channel belongs to the AGENT" below, which is why).
1242
+ // 4. A writable role whose union came out empty because the coordinator's
1243
+ // `--union` step did not complete. There the operator must re-run `--union`,
1244
+ // not restart.
1245
+ //
1246
+ // The classifier below is the discriminator. It changes NO verdict — see
1247
+ // {@link suggestForEmptyScope}, which only ever selects a different sentence.
1248
+ //
1249
+ // ## The channel belongs to the AGENT — no branch may carry a runnable command
1250
+ //
1251
+ // Every string {@link suggestForEmptyScope} returns travels in
1252
+ // `permissionDecisionReason`, which the harness feeds to the CLAUDE whose Edit
1253
+ // was just denied. A remediation command placed there is an instruction the
1254
+ // DENIED AGENT can run — and for this guard the remediation IS the disarm:
1255
+ // removing `wave-scope.json` drops `hooks/enforce-scope.mjs` at Gate 3
1256
+ // (`if (!scopePath) return emitAllow()`), which takes out project-root
1257
+ // containment (Gate 6) as well as the allowlist check (Gate 7), for the rest of
1258
+ // the session. Measured 2026-08-19 against HEAD 55279a2: `rm -f <manifest>`
1259
+ // exits 0 with no envelope from `hooks/pre-bash-destructive-guard.mjs` (its
1260
+ // only rm rule is `rm-rf-destructive`, which needs `-r`), and with the manifest
1261
+ // gone a `Write` to `/etc` is ALLOWED. The first shape of this branch shipped
1262
+ // exactly that command, so the guard was handing the agent the key to itself.
1263
+ //
1264
+ // The rule this leaves behind: a branch may DIAGNOSE without limit and may name
1265
+ // the manifest as the SUBJECT of its diagnosis; it may never carry a runnable
1266
+ // command, and never present the manifest as a removal TARGET. Operator
1267
+ // remediation stays where the operator already looks —
1268
+ // `skills/session-end/SKILL.md` § 3.0 Defensive Cleanup and
1269
+ // `skills/wave-executor/wave-loop.md` steps 5/7 — not in a channel whose reader
1270
+ // is the denied agent.
1271
+ //
1272
+ // `emitDeny`'s `opts.systemMessage` is NOT the escape hatch this wants: the repo
1273
+ // documents its audience two ways that contradict each other — `scripts/lib/io.mjs`
1274
+ // § "Emitted payload" calls it "what the operator sees";
1275
+ // `skills/hook-development/SKILL.md` § "PreToolUse output schema" calls it
1276
+ // "Explanation shown to Claude". A session-wide guard disarm is not the payload
1277
+ // to bet on whichever of the two is right. Locked by
1278
+ // tests/lib/scope-gate.test.mjs § "no branch carries a runnable shell command".
1279
+ //
1280
+ // ## …and the reader may not be the OWNER (measured 2026-08-19)
1281
+ //
1282
+ // The staleness comparison is per-WORKING-COPY, not per-session. Both clocks
1283
+ // `sessionAgeMs` reads — `.orchestrator/current-session.json` `timestamp` and
1284
+ // `.orchestrator/session.lock` `started_at` — are shared by every session in the
1285
+ // checkout, and it takes the MINIMUM AGE, i.e. the NEWEST of the two. So a second
1286
+ // session starting at 18:51 moves the clock forward for EVERYONE, and every
1287
+ // manifest written before 18:51 — including the live one the first session is
1288
+ // using right now — classifies as `stale-manifest` for every subsequent reader.
1289
+ //
1290
+ // Measured in this repo at HEAD 55279a2: `session.lock` `started_at`
1291
+ // 2026-08-19T12:02:57Z (the running deep session) vs. `current-session.json`
1292
+ // `timestamp` 2026-08-19T18:51:54Z (a parallel session) — 6h49m apart, effective
1293
+ // `sessionStartMs` 18:51:54. Every wave manifest that session wrote between 12:02
1294
+ // and 18:51 is `mtime < sessionStartMs`. It is not hypothetical either: a
1295
+ // parallel session in this working copy was shown this very branch for the
1296
+ // coordinator's LIVE wave-4 manifest and told to delete it; it declined because
1297
+ // the operator works carefully, which is not a control.
1298
+ //
1299
+ // That is why the branch says "either a leftover or a parallel session's live
1300
+ // manifest" and routes to `blocked` instead of to a repair: the classifier cannot
1301
+ // distinguish the two, and the reader is frequently not the owner. PSA-001's
1302
+ // question — "Did I create this file? If not, it is not mine to touch" — is the
1303
+ // only safe posture a string in this channel can carry.
1304
+ //
1305
+ // Revisit trigger: a manifest that records its OWNING session id, which would let
1306
+ // this compare ownership instead of clocks. Until then the ambiguity is stated in
1307
+ // the text rather than resolved by a guess.
1308
+ //
1309
+ // ## Named ceiling (BV-004)
1310
+ //
1311
+ // - This buys a CORRECT DIAGNOSIS, never an unlock and never a command. A
1312
+ // writable wave with a broken union still denies every write; the reader is
1313
+ // told WHICH of the five states it is in, and who owns the repair.
1314
+ // - It cannot see a union that is NON-EMPTY but WRONG. That is
1315
+ // `--assert-subset`'s job and stays there.
1316
+ // - `stale-manifest` degrades to `'unknown'` when no session clock is
1317
+ // readable — never to an allow. Absence is preserved, never guessed.
1318
+ // - There is NO age threshold and no TTL. The comparison is a PROVENANCE
1319
+ // subtraction (manifest mtime vs. this session's start), so a legitimate
1320
+ // 14-hour deep session never ages into a blind spot. Deliberately NOT
1321
+ // `IN_FLIGHT_TTL_MS` — see `hooks/post-bash-write-verify.mjs` § "Why the
1322
+ // minimum, and why NOT a staleness cap" for the argument this inherits.
1323
+ // - Revisit trigger: a second read-only wave role, or a manifest written by a
1324
+ // process whose clock is not this repo's `.orchestrator/` pair.
1325
+ // ---------------------------------------------------------------------------
1326
+
1327
+ /**
1328
+ * The closed set of {@link classifyEmptyScope} verdicts. Shaped after
1329
+ * `DEGRADED_REASONS` in `scripts/lib/mirror-issues-banner.mjs`: a frozen array
1330
+ * so a consumer can enumerate the states rather than re-listing them in prose.
1331
+ *
1332
+ * `'unknown'` is a first-class member, not an error — it is what the classifier
1333
+ * returns when the inputs do not DECIDE, and it maps to the pre-#1057 generic
1334
+ * sentence. Absence-preserving by construction.
1335
+ *
1336
+ * @type {ReadonlyArray<'unreadable'|'read-only-role'|'stale-manifest'|'writer-defect'|'unknown'>}
1337
+ */
1338
+ export const EMPTY_SCOPE_REASONS = Object.freeze([
1339
+ 'unreadable',
1340
+ 'read-only-role',
1341
+ 'stale-manifest',
1342
+ 'writer-defect',
1343
+ 'unknown',
1344
+ ]);
1345
+
1346
+ /**
1347
+ * Wave roles for which an EMPTY `allowedPaths` is the intended contract rather
1348
+ * than a defect. THE list — `skills/session-plan/SKILL.md` § Discovery and
1349
+ * `skills/wave-executor/wave-loop.md` § Scope Manifest #5 describe it; they do
1350
+ * not restate it.
1351
+ *
1352
+ * Canonical casing; comparison is trimmed + case-insensitive (see
1353
+ * {@link isReadOnlyWaveRole}) for the same reason
1354
+ * {@link TEST_SIBLING_EXPANSION_ROLES} is: the manifest on disk is written by
1355
+ * LLM prose and by hand, and `"discovery"` vs `Discovery` must not silently
1356
+ * change which sentence the operator reads.
1357
+ *
1358
+ * @type {ReadonlyArray<string>}
1359
+ */
1360
+ export const READ_ONLY_WAVE_ROLES = Object.freeze(['Discovery']);
1361
+
1362
+ /** Lower-cased lookup for {@link isReadOnlyWaveRole}. @type {ReadonlyMap<string, string>} */
1363
+ const READ_ONLY_ROLE_KEYS = new Map(READ_ONLY_WAVE_ROLES.map((r) => [r.toLowerCase(), r]));
1364
+
1365
+ /**
1366
+ * Is this wave role one for which `allowedPaths: []` is BY DESIGN?
1367
+ *
1368
+ * Trimmed + case-insensitive; a non-string role is never read-only (fail-closed
1369
+ * in the direction that produces a MORE alarming message, never a quieter one).
1370
+ *
1371
+ * @param {unknown} role
1372
+ * @returns {boolean}
1373
+ */
1374
+ export function isReadOnlyWaveRole(role) {
1375
+ if (typeof role !== 'string') return false;
1376
+ return READ_ONLY_ROLE_KEYS.has(role.trim().toLowerCase());
1377
+ }
1378
+
1379
+ /**
1380
+ * Classify WHY a wave manifest grants zero paths. Pure, sync, no I/O — every
1381
+ * observation is passed in, exactly like {@link testSiblingExpansionApplies}.
1382
+ * Never throws.
1383
+ *
1384
+ * Modelled on `readLockDetailed` (`scripts/lib/session-lock.mjs`): a small
1385
+ * closed status union, where "cannot tell" is its own member instead of being
1386
+ * folded into the most alarming one.
1387
+ *
1388
+ * ## Precedence (each rung is load-bearing)
1389
+ *
1390
+ * 1. `parseOk === false` → `'unreadable'`. FIRST, because a manifest that did
1391
+ * not parse has no trustworthy `role` either — reading `role` off `{}` and
1392
+ * reporting "writer defect" would blame the coordinator for a corrupt file.
1393
+ * Only an EXPLICIT `false` classifies; `undefined` means "caller did not
1394
+ * observe it" and falls through.
1395
+ * 2. `role` ∈ {@link READ_ONLY_WAVE_ROLES} → `'read-only-role'`. Before the
1396
+ * clock comparison ON PURPOSE: for a Discovery wave the empty scope is the
1397
+ * contract whether the manifest is one second or one day old, so a stale
1398
+ * Discovery leftover reports the read-only sentence. The cost is named
1399
+ * rather than hidden — it is the one state where a leftover manifest is
1400
+ * described by its role instead of by its age.
1401
+ * 3. `scopeMtimeMs < sessionStartMs` → `'stale-manifest'`. Requires BOTH
1402
+ * clocks to be finite numbers; either one absent ⇒ `'unknown'`, never a
1403
+ * guess in either direction.
1404
+ * 4. A writable role with a manifest at least as new as this session ⇒
1405
+ * `'writer-defect'`.
1406
+ * 5. Everything else ⇒ `'unknown'`.
1407
+ *
1408
+ * @param {{role?: unknown, parseOk?: unknown, scopeMtimeMs?: unknown, sessionStartMs?: unknown}} [input]
1409
+ * @returns {'unreadable'|'read-only-role'|'stale-manifest'|'writer-defect'|'unknown'}
1410
+ */
1411
+ export function classifyEmptyScope(input = {}) {
1412
+ if (input === null || typeof input !== 'object') return 'unknown';
1413
+
1414
+ if (input.parseOk === false) return 'unreadable';
1415
+ if (isReadOnlyWaveRole(input.role)) return 'read-only-role';
1416
+
1417
+ const mtime = typeof input.scopeMtimeMs === 'number' && Number.isFinite(input.scopeMtimeMs)
1418
+ ? input.scopeMtimeMs
1419
+ : null;
1420
+ const started = typeof input.sessionStartMs === 'number' && Number.isFinite(input.sessionStartMs)
1421
+ ? input.sessionStartMs
1422
+ : null;
1423
+ if (mtime === null || started === null) return 'unknown';
1424
+
1425
+ if (mtime < started) return 'stale-manifest';
1426
+ return typeof input.role === 'string' && input.role.trim().length > 0
1427
+ ? 'writer-defect'
1428
+ : 'unknown';
1429
+ }
1430
+
1431
+ /**
1432
+ * The suggestion half of a scope-violation deny, when `allowedPaths` is EMPTY.
1433
+ *
1434
+ * A strict superset of {@link suggestForScopeViolation}'s empty-allowlist
1435
+ * branch: `'unknown'` delegates to it verbatim, so the pre-#1057 sentence has
1436
+ * exactly one copy and every other branch is an ADDITION. Pure, sync, never
1437
+ * throws.
1438
+ *
1439
+ * @param {string} relPath — the project-relative path that was blocked
1440
+ * @param {string} reason — a {@link EMPTY_SCOPE_REASONS} member; anything else
1441
+ * is treated as `'unknown'` (fail-safe toward the generic text)
1442
+ * @param {{role?: unknown, scopePath?: unknown}} [opts]
1443
+ * `scopePath` is the manifest's location, project-relative where possible. It
1444
+ * is named as the SUBJECT of a diagnosis ("this file is a leftover") and never
1445
+ * as the target of a removal — see the module block above, § "The channel
1446
+ * belongs to the AGENT".
1447
+ * @returns {string}
1448
+ */
1449
+ export function suggestForEmptyScope(relPath, reason, opts = {}) {
1450
+ const bag = opts !== null && typeof opts === 'object' ? opts : {};
1451
+ const scopeHint = typeof bag.scopePath === 'string' && bag.scopePath.length > 0
1452
+ ? bag.scopePath
1453
+ : '<state-dir>/wave-scope.json';
1454
+ const rawRole = typeof bag.role === 'string' ? bag.role.trim() : '';
1455
+
1456
+ switch (reason) {
1457
+ case 'unreadable':
1458
+ return (
1459
+ `wave-scope.json is unreadable — failing closed. ` +
1460
+ `The manifest exists but did not parse into a usable scope record, so NO path can be granted. ` +
1461
+ `Inspect '${scopeHint}'; a truncated or half-written manifest is repaired by re-running the ` +
1462
+ `coordinator's scope-manifest step, not by editing the plan — report \`blocked\` and leave the ` +
1463
+ `manifest to the coordinator.`
1464
+ );
1465
+
1466
+ case 'read-only-role': {
1467
+ // Canonical casing from the list, so ' DISCOVERY ' and 'discovery' both
1468
+ // render the documented sentence.
1469
+ const canonical = READ_ONLY_ROLE_KEYS.get(rawRole.toLowerCase()) ?? rawRole;
1470
+ return (
1471
+ `${canonical} wave is read-only — no writes permitted. ` +
1472
+ `An empty allowedPaths is this role's deliberate contract (#256), not a misconfiguration: ` +
1473
+ `report '${relPath}' as a finding instead of editing it.`
1474
+ );
1475
+ }
1476
+
1477
+ case 'stale-manifest':
1478
+ // NO COMMAND, and the manifest is never a removal TARGET here. Two reasons,
1479
+ // both measured (module block, § "The channel belongs to the AGENT" and
1480
+ // § "…and the reader may not be the owner"): the string is read by the agent
1481
+ // whose Edit was just denied, and clearing the manifest disarms the whole
1482
+ // gate — and the file may belong to a PARALLEL session in this working copy
1483
+ // that is still using it. The diagnosis, which is what #1057 bought, stays.
1484
+ return (
1485
+ `'${scopeHint}' was written before the most recent session-start clock in this working copy — ` +
1486
+ `either a leftover from a crashed session, or the live manifest of a PARALLEL session that is ` +
1487
+ `still using it. This guard cannot tell which, and restarting the wave clears neither. ` +
1488
+ `Treat the file as not yours (PSA-001): report \`blocked\` to your coordinator and name it. ` +
1489
+ `Do NOT remove, move or edit it yourself (PSA-003/PSA-007).`
1490
+ );
1491
+
1492
+ case 'writer-defect':
1493
+ return (
1494
+ `the wave's allowedPaths union is empty for role \`${rawRole}\` — ` +
1495
+ `the coordinator's \`--union\` step did not complete. Re-run it; do not hand-edit.`
1496
+ );
1497
+
1498
+ default:
1499
+ return suggestForScopeViolation(relPath, '');
1500
+ }
1501
+ }
1502
+
1503
+ /**
1504
+ * Milliseconds since one of this session's clocks was written, or `null` when
1505
+ * none is readable — the MINIMUM over `.orchestrator/current-session.json`
1506
+ * `timestamp` and `.orchestrator/session.lock` `started_at`.
1507
+ *
1508
+ * MOVED here from `hooks/post-bash-write-verify.mjs` (#1057) so both consumers
1509
+ * share ONE implementation: a lib module may be imported by a hook, but a hook
1510
+ * must never be imported by another hook or by this lib (module header, #554
1511
+ * A2). Behaviour is byte-identical to the original — including the `Math.min`
1512
+ * choice and the dropping of NEGATIVE (future-dated) ages, both of which that
1513
+ * hook's docblock argues at length under "Why the minimum, and why NOT a
1514
+ * staleness cap". That argument is the reason this repo has no TTL here either.
1515
+ *
1516
+ * Sync fs reads at CALL time only (same shape as {@link getEnforcementLevel});
1517
+ * no I/O at import time. Never throws — an unreadable or malformed clock is
1518
+ * simply absent.
1519
+ *
1520
+ * @param {string} repoRoot
1521
+ * @param {number} [now]
1522
+ * @returns {number|null}
1523
+ */
1524
+ export function sessionAgeMs(repoRoot, now = Date.now()) {
1525
+ const dir = path.join(repoRoot, '.orchestrator');
1526
+ const ages = [
1527
+ clockAgeMs(path.join(dir, 'current-session.json'), 'timestamp', now),
1528
+ clockAgeMs(path.join(dir, 'session.lock'), 'started_at', now),
1529
+ ].filter((age) => age !== null);
1530
+ return ages.length > 0 ? Math.min(...ages) : null;
1531
+ }
1532
+
1533
+ /**
1534
+ * Age in ms of one JSON clock file's ISO timestamp field, or `null` when the
1535
+ * file is missing, unparseable, carries no parseable timestamp, or is dated in
1536
+ * the FUTURE. Private helper of {@link sessionAgeMs}; moved verbatim with it.
1537
+ *
1538
+ * @param {string} file
1539
+ * @param {string} field
1540
+ * @param {number} now
1541
+ * @returns {number|null}
1542
+ */
1543
+ function clockAgeMs(file, field, now) {
1544
+ try {
1545
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
1546
+ const startedAt = Date.parse(parsed?.[field]);
1547
+ if (!Number.isFinite(startedAt)) return null;
1548
+ const age = now - startedAt;
1549
+ return age >= 0 ? age : null;
1550
+ } catch {
1551
+ return null;
1552
+ }
1553
+ }
1554
+
1555
+ /**
1556
+ * Absolute epoch-ms at which this session started, or `null` when no clock is
1557
+ * readable — the value {@link classifyEmptyScope} compares a manifest's mtime
1558
+ * against.
1559
+ *
1560
+ * Derived from {@link sessionAgeMs} rather than re-reading the files, so there
1561
+ * is ONE clock policy: `now - min(ages)` is the LATEST of the two recorded start
1562
+ * times, which is exactly the freshness `Math.min` was chosen to express (a
1563
+ * leftover `current-session.json` from a previous session is outvoted by a
1564
+ * freshly-acquired `session.lock`). `now` is threaded through so both halves see
1565
+ * the same instant.
1566
+ *
1567
+ * Never throws. No clock ⇒ `null` ⇒ the caller cannot decide staleness and must
1568
+ * fall back to `'unknown'`.
1569
+ *
1570
+ * @param {string} repoRoot
1571
+ * @param {number} [now]
1572
+ * @returns {number|null}
1573
+ */
1574
+ export function sessionStartedAtMs(repoRoot, now = Date.now()) {
1575
+ const age = sessionAgeMs(repoRoot, now);
1576
+ return age === null ? null : now - age;
1577
+ }