session-orchestrator 5.10.0 → 5.12.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 (88) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +51 -0
  6. package/README.md +22 -11
  7. package/SECURITY.md +1 -1
  8. package/agents/code-implementer.md +7 -1
  9. package/commands/session.md +3 -1
  10. package/docs/agent-authoring.md +2 -0
  11. package/docs/events-schema.md +2 -1
  12. package/docs/fleet-checkin-audit.md +172 -0
  13. package/docs/install.md +12 -8
  14. package/docs/role-profile-contract.md +139 -0
  15. package/docs/scope-collision-guard.md +3 -0
  16. package/docs/session-config-reference.md +7 -7
  17. package/docs/session-config-template.md +2 -2
  18. package/evals/instruction-ablation/run.mjs +11 -6
  19. package/hooks/_lib/hook-import-set.json +27 -2
  20. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +8 -1
  24. package/hooks/on-session-start.mjs +26 -10
  25. package/hooks/post-bash-write-verify.mjs +16 -3
  26. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  27. package/hooks/subagent-telemetry.mjs +8 -3
  28. package/hooks/wave-scope-commit-guard.mjs +4 -1
  29. package/package.json +1 -2
  30. package/rules/opt-in-stack/security-web.md +1 -1
  31. package/scripts/emit-session.mjs +13 -0
  32. package/scripts/fleet-checkin-audit.mjs +138 -0
  33. package/scripts/fleet-checkin.mjs +61 -3
  34. package/scripts/lib/common.mjs +2 -4
  35. package/scripts/lib/config/remote-hosts.mjs +2 -2
  36. package/scripts/lib/config.mjs +1 -1
  37. package/scripts/lib/context-overhead-format.mjs +57 -0
  38. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  39. package/scripts/lib/fleet-protocol.mjs +17 -0
  40. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  41. package/scripts/lib/memory-proposals/store.mjs +245 -9
  42. package/scripts/lib/project-hygiene.mjs +37 -0
  43. package/scripts/lib/role-profile/schema.mjs +219 -0
  44. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  45. package/scripts/lib/session-invocation.mjs +14 -3
  46. package/scripts/lib/session-lock.mjs +57 -10
  47. package/scripts/lib/session-record-repair.mjs +63 -30
  48. package/scripts/lib/session-registry.mjs +14 -4
  49. package/scripts/lib/session-schema/constants.mjs +8 -0
  50. package/scripts/lib/session-schema/validator.mjs +33 -0
  51. package/scripts/lib/so-hud/collect.mjs +130 -0
  52. package/scripts/lib/so-hud/runtime.mjs +76 -0
  53. package/scripts/lib/state-md/idle-reset.mjs +239 -0
  54. package/scripts/lib/state-md/recommendations.mjs +26 -4
  55. package/scripts/lib/subagents-schema.mjs +9 -0
  56. package/scripts/lib/telemetry/pricing.mjs +11 -0
  57. package/scripts/lib/vault-mirror/namespace.mjs +3 -2
  58. package/scripts/lib/vault-mirror/process.mjs +143 -40
  59. package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
  60. package/scripts/lib/vault-mirror/utils.mjs +27 -13
  61. package/scripts/lib/vault-status/board-writer.mjs +32 -4
  62. package/scripts/measure-context-overhead.sh +4 -21
  63. package/scripts/release.mjs +359 -11
  64. package/scripts/resolve-session-invocation.mjs +64 -8
  65. package/scripts/run-quality-gate.mjs +47 -0
  66. package/scripts/so-hud.mjs +45 -0
  67. package/scripts/state-md-idle-reset.mjs +81 -0
  68. package/scripts/validate-wave-scope.mjs +59 -6
  69. package/scripts/vault-orphans.mjs +171 -0
  70. package/skills/_shared/fleet-protocol.md +24 -2
  71. package/skills/_shared/state-ownership.md +3 -3
  72. package/skills/autopilot/SKILL.md +3 -2
  73. package/skills/claude-md-drift-check/checker.mjs +8 -6
  74. package/skills/npm-publish/SKILL.md +3 -3
  75. package/skills/release/SKILL.md +2 -2
  76. package/skills/repo-audit/SKILL.md +1 -1
  77. package/skills/session-end/SKILL.md +3 -2
  78. package/skills/session-end/metrics-collection.md +2 -2
  79. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  80. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  81. package/skills/session-end/references/session-summary-template.md +4 -0
  82. package/skills/session-end/session-metrics-write.md +1 -1
  83. package/skills/session-start/SKILL.md +1 -1
  84. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  85. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  86. package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
  87. package/skills/wave-executor/circuit-breaker.md +1 -0
  88. package/AGENTS.md +0 -205
@@ -0,0 +1,205 @@
1
+ /**
2
+ * vault-orphans.mjs - Phase 3.7e Vault Orphans (#1563).
3
+ *
4
+ * Lists the untracked files in the Meta-Vault that were modified since this
5
+ * session started, so the closing session can NAME what it may have left
6
+ * behind there.
7
+ *
8
+ * Why this exists (measured 2026-10-11): one vault checkout had accumulated 268
9
+ * untracked files over five days - website audits, session reports, reviews,
10
+ * lead handovers - written by sessions of OTHER repos into their configured
11
+ * `vault-dir`. None of them was ever committed: a session's close commits only
12
+ * in its own repo, and vault-mirror auto-commits only its own generator-marked
13
+ * files (`scripts/lib/vault-mirror/auto-commit.mjs`). The writing session is the
14
+ * only one that knows it wrote them, and nothing made it look.
15
+ *
16
+ * THE IRON RULE: this module PROPOSES, it never DISPOSES. Its only git
17
+ * invocation is the read-only `ls-files --others --exclude-standard -z`. No
18
+ * add, commit, rm, mv, clean, stash; no file is written, moved or deleted. The
19
+ * vault is another repo with its own rules (PSA-003).
20
+ *
21
+ * The return field is named `candidates`, not `orphans`: mtime is a HINT, not
22
+ * proof of ownership. A concurrent session of another repo may have written the
23
+ * same file in the same window. The coordinator names them and records one Open
24
+ * Question; it never acts on them.
25
+ *
26
+ * Three states, never two (a missing measurement must not look like zero):
27
+ * - `{ status: 'ok', total: 0 }` - measured, nothing found
28
+ * - `{ status: 'ok', total: N }` - measured, N candidates
29
+ * - `{ status: 'unavailable' }` - could not measure (no vault dir, not a
30
+ * git repo, git failed). Never reported as
31
+ * "no orphans".
32
+ *
33
+ * Exclusion - what another writer already owns: any file whose first 4 KB
34
+ * carries a `_generator: session-orchestrator-` frontmatter line. Census
35
+ * 2026-10-11 @ 686e2c12 (`rg -n "GENERATOR_MARKER\s*=" scripts/`): four marker
36
+ * values, all with that prefix - `vault-mirror@1` (40-learnings/, 50-sessions/,
37
+ * monthly rollups), `vault-status-narrative@1` (01-projects/<slug>/
38
+ * _session-narrative.md), `active-sessions@1` (01-projects/_active-sessions.md),
39
+ * `gitlab-portfolio@1`. The match is line-anchored so a hand-written note that
40
+ * merely QUOTES the marker in prose is still reported.
41
+ *
42
+ * There is deliberately NO blanket exclusion of `40-learnings/` / `50-sessions/`:
43
+ * vault-mirror owns only the MARKED files there. `autoCommitVaultMirror` treats
44
+ * an unmarked staged file in those dirs as an offender and skips its whole
45
+ * commit, so an unmarked untracked file there is owned by nobody AND blocks the
46
+ * mirror - the last thing to hide.
47
+ *
48
+ * Never throws. DI seams (#580-DI-001), synchronous like the Phase 4b sibling
49
+ * ./worktree-orphan-sweep.mjs: `execFileFn` (default `execFileSync`) and
50
+ * `statFn` (default `lstatSync` - a symlink is reported as itself, never
51
+ * followed out of the vault). Every git call passes an args ARRAY, no shell
52
+ * (#577 HARDEN-001).
53
+ */
54
+
55
+ import path from 'node:path';
56
+ import { execFileSync } from 'node:child_process';
57
+ import { closeSync, lstatSync, openSync, readSync } from 'node:fs';
58
+
59
+ import { redactHomeDir } from '../common.mjs';
60
+
61
+ /** Upper bound on the returned `candidates` list; `total` still counts all. */
62
+ export const MAX_CANDIDATES = 200;
63
+
64
+ /** Bytes read per file for the generator check - same window as auto-commit.mjs. */
65
+ const HEAD_BYTES = 4096;
66
+
67
+ /** A frontmatter line naming one of this plugin's generators. */
68
+ const GENERATOR_LINE_RE = /^_generator:[ \t]*session-orchestrator-/m;
69
+
70
+ /**
71
+ * Env vars that retarget git away from `-C <dir>`. An inherited `GIT_DIR` beats
72
+ * `-C` (incident 2026-08-19), which here would list ANOTHER repo's untracked
73
+ * files as vault candidates. Stripped for the one git call below.
74
+ */
75
+ const REPO_REDIRECT_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR'];
76
+
77
+ /**
78
+ * Ceiling for git's NUL-separated path list: 16 MiB is ~150k paths at ~100
79
+ * bytes. Past it, execFileSync throws ENOBUFS and the result is `unavailable`
80
+ * (could not measure), never a silently cut list. Revisit if a vault ever
81
+ * reports that reason.
82
+ */
83
+ const LS_FILES_MAX_BUFFER = 16 * 1024 * 1024;
84
+
85
+ /**
86
+ * A vault on a hung network filesystem must not stall /close: past this the
87
+ * call is killed and the result is `unavailable` (git-failed). 20k untracked
88
+ * files took 0.46 s (2026-10-11), so 20 s is a hang, not a slow vault.
89
+ */
90
+ const LS_FILES_TIMEOUT_MS = 20_000;
91
+
92
+ /** @param {string} absPath @returns {boolean} true when the head names one of our generators */
93
+ function isGeneratorOwned(absPath) {
94
+ let fd;
95
+ try {
96
+ fd = openSync(absPath, 'r');
97
+ const buf = Buffer.alloc(HEAD_BYTES);
98
+ const n = readSync(fd, buf, 0, HEAD_BYTES, 0);
99
+ return GENERATOR_LINE_RE.test(buf.subarray(0, n).toString('utf8'));
100
+ } catch {
101
+ // Unreadable -> not provably someone else's. This module only REPORTS, so
102
+ // the safe direction is to show the file, not to hide it.
103
+ return false;
104
+ } finally {
105
+ if (fd !== undefined) {
106
+ try {
107
+ closeSync(fd);
108
+ } catch {
109
+ /* already closed */
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * @param {string} reason
117
+ * @param {string} [detail]
118
+ * @returns {{status: 'unavailable', reason: string, detail?: string, candidates: []}}
119
+ */
120
+ function unavailable(reason, detail) {
121
+ return detail ? { status: 'unavailable', reason, detail, candidates: [] } : { status: 'unavailable', reason, candidates: [] };
122
+ }
123
+
124
+ /**
125
+ * Find untracked vault files modified at or after `sinceMs`.
126
+ *
127
+ * @param {object} [opts]
128
+ * @param {string} opts.vaultDir - Vault working-tree root (already tilde-expanded).
129
+ * @param {number} opts.sinceMs - Session start, epoch ms. Files with an older
130
+ * mtime are not candidates.
131
+ * @param {Function} [opts.execFileFn] - execFileSync seam: (file, args, options) => string.
132
+ * @param {Function} [opts.statFn] - lstatSync seam: (absPath) => { mtimeMs, isFile() }.
133
+ * @returns {{status: 'ok', candidates: Array<{path: string, mtimeMs: number}>,
134
+ * total: number, truncated: boolean, untracked: number}
135
+ * | {status: 'unavailable', reason: string, detail?: string, candidates: []}}
136
+ * `path` is vault-relative as git reports it. `total` counts every candidate,
137
+ * `candidates` holds at most MAX_CANDIDATES of them (sorted by path),
138
+ * `untracked` is everything git listed before the mtime and generator filters.
139
+ */
140
+ export function findVaultOrphans({ vaultDir, sinceMs, execFileFn, statFn } = {}) {
141
+ try {
142
+ if (typeof vaultDir !== 'string' || vaultDir.trim() === '') return unavailable('no-vault-dir');
143
+ if (typeof sinceMs !== 'number' || !Number.isFinite(sinceMs)) return unavailable('invalid-since');
144
+
145
+ const exec = typeof execFileFn === 'function' ? execFileFn : execFileSync;
146
+ const stat = typeof statFn === 'function' ? statFn : lstatSync;
147
+ const root = path.resolve(vaultDir);
148
+
149
+ const env = { ...process.env };
150
+ for (const key of REPO_REDIRECT_ENV) delete env[key];
151
+
152
+ // -- THE ONLY GIT CALL - read-only --------------------------------------
153
+ let out;
154
+ try {
155
+ out = exec('git', ['-C', root, 'ls-files', '--others', '--exclude-standard', '-z'], {
156
+ encoding: 'utf8',
157
+ env,
158
+ maxBuffer: LS_FILES_MAX_BUFFER,
159
+ timeout: LS_FILES_TIMEOUT_MS,
160
+ stdio: ['ignore', 'pipe', 'pipe'],
161
+ });
162
+ } catch (err) {
163
+ const stderr = typeof err?.stderr === 'string' ? err.stderr : String(err?.stderr ?? '');
164
+ const reason = /not a git repository/i.test(stderr) ? 'not-a-git-repo' : 'git-failed';
165
+ const firstLine = redactHomeDir(stderr.split('\n').find((l) => l.trim() !== '') ?? err?.code ?? '');
166
+ return unavailable(reason, typeof firstLine === 'string' ? firstLine.trim().slice(0, 200) : undefined);
167
+ }
168
+ if (typeof out !== 'string') return unavailable('git-failed', 'non-string git output');
169
+
170
+ const untrackedPaths = out.split('\0').filter((p) => p !== '');
171
+
172
+ // Cost is linear in the untracked count: one lstat per path, plus one 4 KB
173
+ // read per path that passes the mtime filter (cheapest filter first). Fine
174
+ // for the measured 268; revisit if a close reports more than ~10k untracked
175
+ // files, where the per-file read would start to show in /close time.
176
+ const all = [];
177
+ for (const rel of untrackedPaths) {
178
+ const abs = path.join(root, rel);
179
+ let st;
180
+ try {
181
+ st = stat(abs);
182
+ } catch {
183
+ // Vanished between listing and stat (a concurrent writer moved it):
184
+ // nothing left to name.
185
+ continue;
186
+ }
187
+ const mtimeMs = Number(st?.mtimeMs);
188
+ if (!Number.isFinite(mtimeMs) || mtimeMs < sinceMs) continue;
189
+ if (typeof st.isFile === 'function' && st.isFile() && isGeneratorOwned(abs)) continue;
190
+ all.push({ path: rel, mtimeMs });
191
+ }
192
+
193
+ all.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
194
+ return {
195
+ status: 'ok',
196
+ candidates: all.slice(0, MAX_CANDIDATES),
197
+ total: all.length,
198
+ truncated: all.length > MAX_CANDIDATES,
199
+ untracked: untrackedPaths.length,
200
+ };
201
+ } catch (err) {
202
+ // Defensive catch-all - this probe must never throw into /close.
203
+ return unavailable('internal-error', String(err?.message ?? err).slice(0, 200));
204
+ }
205
+ }
@@ -3,15 +3,26 @@
3
3
  * This is a text boundary, not a shell/option parser: quotes and command syntax
4
4
  * in the context remain data. `unknown` is a ledger value, never a live mode.
5
5
  *
6
+ * A bare `/session` defaults to `deep`. In a repo without any session history
7
+ * of its own that default is not silent: the result carries
8
+ * `defaultReason: 'no-session-history'`, and the caller asks for task and mode
9
+ * first (#1561). `hasHistory` left undefined means "not measured" and keeps
10
+ * the plain default.
11
+ *
6
12
  * @param {string} [argumentsText] Complete text following /session.
7
- * @returns {{sessionType: string, context: string, profile?: string, invalidMode?: string}}
13
+ * @param {{hasHistory?: boolean}} [options]
14
+ * @returns {{sessionType: string, context: string, profile?: string, invalidMode?: string, defaultReason?: string}}
8
15
  */
9
- export function resolveSessionInvocation(argumentsText = '') {
16
+ export function resolveSessionInvocation(argumentsText = '', { hasHistory } = {}) {
10
17
  if (typeof argumentsText !== 'string') {
11
18
  throw new TypeError('session invocation arguments must be a string');
12
19
  }
13
20
  const [_, mode, context = ''] = argumentsText.trimStart().match(/^(\S+)(?:\s+([\s\S]*))?$/u) ?? [];
14
- if (mode === undefined) return { sessionType: 'deep', context: '' };
21
+ if (mode === undefined) {
22
+ return hasHistory === false
23
+ ? { sessionType: 'deep', context: '', defaultReason: 'no-session-history' }
24
+ : { sessionType: 'deep', context: '' };
25
+ }
15
26
  if (mode === 'ultradeep') return { sessionType: 'deep', profile: 'ultradeep', context };
16
27
  if (['housekeeping', 'feature', 'deep'].includes(mode)) {
17
28
  return { sessionType: mode, context };
@@ -527,8 +527,10 @@ export function readLockDetailed(opts = {}) {
527
527
  * result — this keeps acquire() synchronous.
528
528
  *
529
529
  * Returns one of:
530
- * { ok: true, lock, exclusivityClass? }
531
- * — lock created
530
+ * { ok: true, lock, exclusivityClass?, ownerProof }
531
+ * — lock created. `ownerProof` reports the owner-proof rewrite that
532
+ * follows every successful lock write (#1541, see withOwnerProof()):
533
+ * `{ ok: true }` or `{ ok: false, reason }` — never thrown.
532
534
  * { ok: false, reason: 'active', existingLock, ageHours, exclusivityClass? }
533
535
  * — local lock held: its last_heartbeat is within ttl_hours. `ageHours`
534
536
  * is the age from `started_at` (null when unparseable). `existingLock`
@@ -777,7 +779,7 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
777
779
  const createResult = createSessionLockExclusive(lockFile, lock);
778
780
 
779
781
  if (createResult.ok) {
780
- return { ok: true, lock, exclusivityClass: callerClass };
782
+ return withOwnerProof({ ok: true, lock, exclusivityClass: callerClass }, repoRoot);
781
783
  }
782
784
  if (createResult.reason === 'fs-error') {
783
785
  return { ok: false, reason: 'fs-error', error: createResult.error, exclusivityClass: callerClass };
@@ -804,7 +806,8 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
804
806
  * Call only after the user has explicitly authorised stale-lock takeover.
805
807
  *
806
808
  * Returns:
807
- * { ok: true, lock, replacedLock? } — lock written (replacedLock present if one was overwritten)
809
+ * { ok: true, lock, replacedLock?, ownerProof } — lock written (replacedLock present if one was overwritten;
810
+ * ownerProof as on acquire(), #1541)
808
811
  * { ok: false, reason: 'missing-session-id' } — no usable `sessionId` given; NOTHING was written
809
812
  * { ok: false, reason: 'fs-error', ... } — filesystem failure
810
813
  *
@@ -833,6 +836,48 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
833
836
  *
834
837
  * @param {{ sessionId: string, mode: string, ttlHours?: number, repoRoot?: string, semanticSessionId?: string, onlyIfNotLive?: boolean }} args
835
838
  */
839
+ /**
840
+ * Rewrite `.orchestrator/runtime/lock-owner-proof.json` for a lock this module
841
+ * has JUST written, and report the outcome on the result (#1541).
842
+ *
843
+ * Invariant: proof = current lock. Before #1541 only the SessionStart bootstrap
844
+ * wrote the proof, so a lock taken on the prose path (Phase 1.2 `acquire()`
845
+ * after a `/close` in the same conversation, or a force-take / reclaim) left
846
+ * the PREVIOUS lock's proof on disk — and `release()` answered
847
+ * `proof-mismatch` for the session's own lock at its next `/close`.
848
+ *
849
+ * Called only on the `ok: true` paths, directly after the lock write, with the
850
+ * same `repoRoot` the lock path was resolved from (both fall back to
851
+ * `process.cwd()`), so the proof lands beside the lock it describes. The
852
+ * bootstrap's own later writeOwnerProof() on the enriched lock yields the same
853
+ * triple (enrichment never touches pid/host/started_at) — idempotent.
854
+ *
855
+ * Never throws and never turns `ok: true` into a failure: the lock IS held, and
856
+ * a missing proof only degrades `/close` to the session_id-only release.
857
+ *
858
+ * Ceiling (BV-004): two plain forceAcquire() calls racing on one path are
859
+ * last-writer-wins for the lock AND the proof, and the two writes can
860
+ * interleave (A lock, B lock, B proof, A proof). The proof then names A while B
861
+ * holds the lock — the same state as before #1541, and safe: release() still
862
+ * gates on the raw session_id first, so the mismatched proof can only refuse a
863
+ * release, never grant one. Revisit if `release-proof-mismatch` shows up after
864
+ * a force-take.
865
+ *
866
+ * Known limit: the proof is one file per repo and identifies the process that
867
+ * last wrote the lock, never the conversation. Twins sharing one raw
868
+ * session_id (two terminals resuming the same conversation) are therefore
869
+ * indistinguishable to it — either can release after the other force-took.
870
+ *
871
+ * @param {{ ok: true, lock: object }} result
872
+ * @param {string|undefined} repoRoot
873
+ * @returns {object} the same result object, with `ownerProof` set
874
+ */
875
+ function withOwnerProof(result, repoRoot) {
876
+ const w = writeOwnerProof({ repoRoot, lock: result.lock });
877
+ result.ownerProof = w.ok ? { ok: true } : { ok: false, reason: w.reason };
878
+ return result;
879
+ }
880
+
836
881
  /**
837
882
  * A `sessionId` is usable only when it is a non-blank string. ONE predicate,
838
883
  * shared by acquire() and forceAcquire(), so the two entry points cannot drift
@@ -863,7 +908,7 @@ export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, re
863
908
 
864
909
  const result = { ok: true, lock };
865
910
  if (replacedLock !== null) result.replacedLock = replacedLock;
866
- return result;
911
+ return withOwnerProof(result, repoRoot);
867
912
  } catch (err) {
868
913
  return { ok: false, reason: 'fs-error', error: err.message };
869
914
  }
@@ -948,7 +993,7 @@ function reclaimIfNotLive({ sessionId, mode, ttlHours, repoRoot, semanticSession
948
993
  if (created.ok) {
949
994
  const result = { ok: true, lock };
950
995
  if (moved !== null) result.replacedLock = moved;
951
- return result;
996
+ return withOwnerProof(result, repoRoot);
952
997
  }
953
998
  if (created.reason === 'exists') return reacquire();
954
999
  return created;
@@ -1108,10 +1153,12 @@ export function isLockOwnedByProof(lock, proof) {
1108
1153
  * persistence at lock genesis; Part 2, the on-session-end consumption side,
1109
1154
  * is a separate change).
1110
1155
  *
1111
- * Intended caller: `bootstrapLock()` (hooks/_lib/lock-bootstrap.mjs)
1112
- * immediately after its enriched-lock write — the `lock` argument there is
1113
- * byte-identical to the on-disk lock, so the proof it yields will verify via
1114
- * `isLockOwnedByProof()` against any later re-read of that same lock.
1156
+ * Callers: `acquire()` / `forceAcquire()` on every `ok: true` lock write
1157
+ * (#1541, via withOwnerProof()), and `bootstrapLock()`
1158
+ * (hooks/_lib/lock-bootstrap.mjs) again immediately after its enriched-lock
1159
+ * write. In every case the `lock` argument is byte-identical to the on-disk
1160
+ * lock, so the proof it yields will verify via `isLockOwnedByProof()` against
1161
+ * any later re-read of that same lock.
1115
1162
  *
1116
1163
  * Envelope shape (schema_version 1):
1117
1164
  * {
@@ -108,13 +108,15 @@ const INCOMPLETE_FIELD_ORDER = Object.freeze([
108
108
  'total_agents',
109
109
  'total_files_changed',
110
110
  ]);
111
- // `timestamp_precision` (#1442) deliberately adds NOTHING to the list above: a
112
- // truncated timestamp is still a measurement (exact to the millisecond), not a
113
- // default. Listing it would make consumers discard a real value —
114
- // `sessions-staleness-banner.mjs` returns null for any timestamp named in
115
- // `_backfill_incomplete_fields`, `session-close-backfill.mjs` keys on
116
- // `includes('started_at')`. The sub-millisecond original survives in the
117
- // `_<field>_raw` sidecar instead.
111
+ // `timestamp_precision` (#1442) and `timestamp_offset` (#1525) deliberately add
112
+ // NOTHING to the list above: a truncated or UTC-converted timestamp is still a
113
+ // measurement (exact to the millisecond), not a default. Listing it would make
114
+ // consumers discard a real value — `sessions-staleness-banner.mjs` returns null
115
+ // for any timestamp named in `_backfill_incomplete_fields`,
116
+ // `session-close-backfill.mjs` keys on `includes('started_at')`. The original
117
+ // spelling survives in the `_<field>_raw` sidecar instead. Two classes because
118
+ // the losses differ: `precision` drops sub-ms digits, `offset` drops nothing
119
+ // but the zone spelling (the instant is kept exactly).
118
120
 
119
121
  /**
120
122
  * Timestamp fields `validateSession` holds to `ISO_8601_UTC_MS_RE`
@@ -124,11 +126,15 @@ const INCOMPLETE_FIELD_ORDER = Object.freeze([
124
126
  const MS_PRECISION_FIELDS = Object.freeze(['started_at', 'completed_at', 'lease_acquired_at']);
125
127
 
126
128
  /**
127
- * ISO-8601 UTC with MORE than three fractional digits and exactly `Z` or
128
- * `+00:00` (#1525). Group 1 is the value cut at the millisecond. Nonzero
129
- * offsets, other timezone spellings and shorter fractions stay unrepaired.
129
+ * Strict ISO-8601 timestamp the repair may normalize (#1525): seconds required,
130
+ * fraction optional and of any length, zone exactly `Z` or `±HH:MM`. Anything
131
+ * looser (`+0000`, `+00`, lowercase `z`, no zone) stays unrepaired, so the
132
+ * lenient `Date.parse` never gets to decide what a value meant.
133
+ * Groups: 1 wall-clock date-time to the second, 2 fraction digits, 3 zone,
134
+ * 4 offset sign, 5 offset hours, 6 offset minutes.
130
135
  */
131
- const ISO_UTC_SUB_MS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})\d+(?:Z|\+00:00)$/;
136
+ const ISO_REPAIRABLE_RE =
137
+ /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|([+-])(\d{2}):(\d{2}))$/;
132
138
 
133
139
  // ---------------------------------------------------------------------------
134
140
  // Helpers
@@ -144,23 +150,45 @@ function isCount(v) {
144
150
  }
145
151
 
146
152
  /**
147
- * Cut a sub-millisecond ISO-8601 UTC timestamp to milliseconds. TRUNCATES,
148
- * never rounds: `.9999Z` must not roll into the next second, and truncation is
149
- * monotone, so `completed_at >= started_at` survives on every pair it held on.
153
+ * Normalize a timestamp to the validator's UTC-millisecond shape (#1442, #1525).
154
+ *
155
+ * - `precision`: a fraction of other than 0 or 3 digits. Sub-millisecond
156
+ * digits are TRUNCATED, never rounded: `.9999Z` must not roll into the next
157
+ * second, and truncation is monotone, so `completed_at >= started_at`
158
+ * survives on every pair it held on. Shorter fractions are zero-padded.
159
+ * - `offset`: any zone spelled other than `Z`. Converting `±HH:MM` to UTC keeps
160
+ * the instant exactly (offsets are whole minutes), so it is lossless and
161
+ * commutes with the truncation above.
150
162
  *
151
163
  * @param {unknown} value
152
- * @returns {string|null} the truncated value, or null when there is nothing to cut
164
+ * @returns {null | {invalid: true} | {value: string, precision: boolean, offset: boolean}}
165
+ * null when there is nothing to normalize (not a string, not the strict
166
+ * shape, or already canonical); `{invalid: true}` when the shape matches but
167
+ * the calendar/clock components or the offset are out of range.
153
168
  */
154
- function truncateToMs(value) {
169
+ function normalizeTimestamp(value) {
155
170
  if (typeof value !== 'string') return null;
156
- const m = ISO_UTC_SUB_MS_RE.exec(value);
171
+ const m = ISO_REPAIRABLE_RE.exec(value);
157
172
  if (!m) return null;
158
- const canonical = `${m[1]}Z`;
159
- const ms = Date.parse(canonical);
173
+ const [, wall, fraction = '', zone, sign, offH, offM] = m;
174
+ const precision = fraction.length !== 0 && fraction.length !== 3;
175
+ const offset = zone !== 'Z';
176
+ if (!precision && !offset) return null; // already what the validator accepts
177
+ const wallCanonical = `${wall}.${fraction.slice(0, 3).padEnd(3, '0')}Z`;
178
+ const wallMs = Date.parse(wallCanonical);
160
179
  // Date.parse rolls over e.g. April 31, non-leap February 29 and 24:00.
161
180
  // Equality with the original calendar components refuses those repairs.
162
- if (!Number.isFinite(ms) || new Date(ms).toISOString() !== canonical) return null;
163
- return canonical;
181
+ if (!Number.isFinite(wallMs) || new Date(wallMs).toISOString() !== wallCanonical) {
182
+ return { invalid: true };
183
+ }
184
+ let offsetMin = 0;
185
+ if (offset) {
186
+ if (Number(offH) > 23 || Number(offM) > 59) return { invalid: true };
187
+ offsetMin = (sign === '-' ? -1 : 1) * (Number(offH) * 60 + Number(offM));
188
+ }
189
+ const utc = new Date(wallMs - offsetMin * 60_000);
190
+ if (!Number.isFinite(utc.getTime())) return { invalid: true };
191
+ return { value: utc.toISOString(), precision, offset };
164
192
  }
165
193
 
166
194
  function orderIncompleteFields(fields) {
@@ -377,12 +405,14 @@ export function repairRecord(record) {
377
405
  // `started_at` — a microsecond `started_at` would otherwise be copied into
378
406
  // `completed_at` and spread the defect instead of fixing it.
379
407
  let precisionCut = false;
408
+ let offsetNormalized = false;
380
409
  for (const key of MS_PRECISION_FIELDS) {
381
- const truncated = truncateToMs(out[key]);
382
- if (truncated === null) continue;
410
+ const norm = normalizeTimestamp(out[key]);
411
+ if (norm === null || 'invalid' in norm) continue;
383
412
  preserveRaw(out, key, record[key], rescued);
384
- out[key] = truncated;
385
- precisionCut = true;
413
+ out[key] = norm.value;
414
+ precisionCut ||= norm.precision;
415
+ offsetNormalized ||= norm.offset;
386
416
  }
387
417
 
388
418
  // -- completed_at ---------------------------------------------------------
@@ -396,18 +426,20 @@ export function repairRecord(record) {
396
426
  preserveRaw(out, 'completed_at', record.completed_at, rescued);
397
427
  const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
398
428
  const startedMs = Date.parse(out.started_at);
399
- const endedTruncated = truncateToMs(out.ended_at);
429
+ const endedNorm = normalizeTimestamp(out.ended_at);
400
430
  if (typeof out.ended_at === 'string' &&
401
- (!Number.isFinite(endedMs) ||
402
- (ISO_UTC_SUB_MS_RE.test(out.ended_at) && endedTruncated === null))) {
431
+ (!Number.isFinite(endedMs) || (endedNorm !== null && 'invalid' in endedNorm))) {
403
432
  // Preserve an invalid legacy measurement for the final schema gate to
404
433
  // reject; do not turn it into a valid default or a rolled-over date.
405
434
  out.completed_at = out.ended_at;
406
435
  } else if (Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs) {
407
436
  // The COPY is cut to milliseconds; `ended_at` itself is not a validated
408
437
  // field and keeps its original bytes, so nothing needs a sidecar here.
409
- if (endedTruncated !== null) precisionCut = true;
410
- out.completed_at = endedTruncated ?? out.ended_at;
438
+ if (endedNorm !== null) {
439
+ precisionCut ||= endedNorm.precision;
440
+ offsetNormalized ||= endedNorm.offset;
441
+ }
442
+ out.completed_at = endedNorm?.value ?? out.ended_at;
411
443
  } else {
412
444
  out.completed_at = out.started_at;
413
445
  }
@@ -415,6 +447,7 @@ export function repairRecord(record) {
415
447
  incomplete.add('completed_at');
416
448
  }
417
449
  if (precisionCut) defects.push('timestamp_precision');
450
+ if (offsetNormalized) defects.push('timestamp_offset');
418
451
 
419
452
  if (defects.length === 0) {
420
453
  return { record, defects: [], incompleteFields: [], changed: false };
@@ -38,7 +38,7 @@ import crypto from 'node:crypto';
38
38
  import { promises as fs } from 'node:fs';
39
39
  import { digestSha256 } from './crypto-digest-utils.mjs';
40
40
 
41
- import { appendFileSync, mkdirSync } from 'node:fs';
41
+ import { appendFileSync, mkdirSync, realpathSync } from 'node:fs';
42
42
 
43
43
  import { utcTimestamp, appendJsonl } from './common.mjs';
44
44
  import { resolvePrivateConfigDir } from './config/private-config-dir.mjs';
@@ -89,14 +89,24 @@ function entryPath(sessionId) {
89
89
  // ---------------------------------------------------------------------------
90
90
 
91
91
  /**
92
- * Stable SHA-256 hex of an absolute path. Used so registry entries can be
92
+ * Stable SHA-256 hex of a canonical repo path. Used so registry entries can be
93
93
  * correlated by repo without exposing local filesystem layout.
94
94
  */
95
95
  export function repoPathHash(absPath) {
96
96
  if (typeof absPath !== 'string' || absPath.length === 0) {
97
97
  throw new TypeError('repoPathHash: absPath must be a non-empty string');
98
98
  }
99
- return digestSha256(path.resolve(absPath));
99
+ return digestSha256(resolveRepoPath(absPath));
100
+ }
101
+
102
+ /** Resolve symlinks and on-disk casing; unavailable paths keep their old identity. */
103
+ function resolveRepoPath(repoPath) {
104
+ const resolved = path.resolve(repoPath);
105
+ try {
106
+ return realpathSync.native(resolved);
107
+ } catch {
108
+ return resolved;
109
+ }
100
110
  }
101
111
 
102
112
  function _assertSessionId(sessionId) {
@@ -255,7 +265,7 @@ export async function registerSelf(opts) {
255
265
  pid: opts.pid ?? process.pid,
256
266
  platform: opts.platform ?? null,
257
267
  repo_path_hash: repoPathHash(projectRoot),
258
- repo_name: path.basename(path.resolve(projectRoot)),
268
+ repo_name: path.basename(resolveRepoPath(projectRoot)),
259
269
  branch: opts.branch ?? null,
260
270
  started_at: now,
261
271
  last_heartbeat: now,
@@ -252,6 +252,14 @@ export const OPTIONAL_FIELDS = Object.freeze([
252
252
  // 2026-10-01), so the counters say which of the two it is.
253
253
  'cost_records_priced',
254
254
  'cost_records_total',
255
+ // #1470 Pkt 4/6 — what `total_cost_usd` is and which price table produced
256
+ // the cost fields. Merged by scripts/emit-session.mjs from
257
+ // scripts/lib/telemetry/pricing.mjs (`COST_BASIS`, `PRICING_TABLE_DATE`):
258
+ // `cost_basis` only beside a written `total_cost_usd`, `pricing_table_date`
259
+ // beside the cost counters. Absent on every record before #1470 — such a
260
+ // cost is not thereby a verified payment either.
261
+ 'cost_basis',
262
+ 'pricing_table_date',
255
263
  // #986 — shape-checked by `_validateOptionalFields` since before this list
256
264
  // existed, never declared until the census test made the gap visible.
257
265
  'discovery_stats',
@@ -26,6 +26,17 @@ import {
26
26
  /** Valid values for the optional `expected_cost_tier` field (ADR-364). */
27
27
  const EXPECTED_COST_TIERS = Object.freeze(['quick', 'standard', 'deep']);
28
28
 
29
+ /**
30
+ * Valid values for the optional `cost_basis` field (#1470 Pkt 4). Must contain
31
+ * `COST_BASIS` from scripts/lib/telemetry/pricing.mjs — the value emit-session
32
+ * writes beside `total_cost_usd`. There is deliberately no payment value: no
33
+ * writer can see what was actually billed.
34
+ */
35
+ const COST_BASES = Object.freeze(['api-list-equivalent']);
36
+
37
+ /** `pricing_table_date` (#1470 Pkt 6): a calendar date, `YYYY-MM-DD`. */
38
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
39
+
29
40
  /**
30
41
  * Valid values for the optional `status` field (Epic #724 C1).
31
42
  * `completed` — record written by a normal /close flow.
@@ -456,6 +467,28 @@ function _validateOptionalFields(entry) {
456
467
  );
457
468
  }
458
469
 
470
+ // #1470 Pkt 4/6 — what the cost figure is and which price table produced it.
471
+ if (entry.cost_basis !== undefined && entry.cost_basis !== null) {
472
+ if (!COST_BASES.includes(entry.cost_basis)) {
473
+ throw new ValidationError(
474
+ `cost_basis must be one of ${COST_BASES.join('|')} or null, got: ${entry.cost_basis}`
475
+ );
476
+ }
477
+ }
478
+ if (entry.pricing_table_date !== undefined && entry.pricing_table_date !== null) {
479
+ if (
480
+ typeof entry.pricing_table_date !== 'string' ||
481
+ !ISO_DATE_RE.test(entry.pricing_table_date) ||
482
+ // Round-trip, not Date.parse alone: V8 rolls 2026-02-30 over to March.
483
+ Number.isNaN(Date.parse(`${entry.pricing_table_date}T00:00:00Z`)) ||
484
+ new Date(`${entry.pricing_table_date}T00:00:00Z`).toISOString().slice(0, 10) !== entry.pricing_table_date
485
+ ) {
486
+ throw new ValidationError(
487
+ `pricing_table_date must be an ISO date (YYYY-MM-DD) or null, got: ${entry.pricing_table_date}`
488
+ );
489
+ }
490
+ }
491
+
459
492
  // Epic #724 C1 — SessionEnd close-through backfill provenance fields.
460
493
  // All additive-optional; null/absent passes without further checks so that
461
494
  // pre-#724 records (and every normally-closed record) validate cleanly.