session-orchestrator 4.0.1 → 4.2.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 (145) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  6. package/.cursor/skills/session-plan/SKILL.md +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/CHANGELOG.md +57 -0
  9. package/README.md +55 -51
  10. package/agents/ux-evaluator.md +1 -1
  11. package/commands/close.md +3 -3
  12. package/commands/go.md +2 -0
  13. package/commands/memory-cleanup.md +4 -3
  14. package/commands/persona-panel.md +1 -1
  15. package/commands/release.md +4 -4
  16. package/commands/session.md +3 -2
  17. package/docs/README.md +4 -4
  18. package/docs/USER-GUIDE.md +115 -48
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +55 -1
  21. package/docs/ci-setup.md +1 -1
  22. package/docs/codex-setup.md +1 -0
  23. package/docs/components.md +2 -2
  24. package/docs/cursor-setup.md +1 -0
  25. package/docs/events-schema.md +4 -1
  26. package/docs/instruction-delivery.md +1 -1
  27. package/docs/memory-proposal-flow.md +3 -3
  28. package/docs/migration-v4.md +2 -2
  29. package/docs/owner-config-schema.md +74 -90
  30. package/docs/persona-panel.md +4 -4
  31. package/docs/pi-setup.md +1 -0
  32. package/docs/rule-authoring.md +13 -6
  33. package/docs/scope-collision-guard.md +2 -0
  34. package/docs/session-config-reference.md +55 -22
  35. package/docs/session-config-template.md +9 -5
  36. package/docs/vault-docs-architecture.md +4 -2
  37. package/hooks/_lib/hook-import-set.json +28 -3
  38. package/hooks/_lib/vcs-create-matcher.mjs +214 -16
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks.json +1 -1
  41. package/hooks/pre-bash-issue-budget.mjs +123 -26
  42. package/hooks/subagent-telemetry.mjs +106 -20
  43. package/package.json +4 -4
  44. package/scripts/baseline-archetypes.mjs +28 -0
  45. package/scripts/ci/assert-coverage-green.mjs +100 -0
  46. package/scripts/lib/auto-dialectic.mjs +0 -68
  47. package/scripts/lib/baseline-archetypes.mjs +439 -0
  48. package/scripts/lib/build-live-signals.mjs +5 -6
  49. package/scripts/lib/config/issue-budget.mjs +68 -8
  50. package/scripts/lib/config/private-config-dir.mjs +3 -2
  51. package/scripts/lib/config/remote-hosts.mjs +2 -2
  52. package/scripts/lib/config-schema.mjs +79 -0
  53. package/scripts/lib/events.mjs +3 -3
  54. package/scripts/lib/file-lock.mjs +47 -5
  55. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  56. package/scripts/lib/issue-budget.mjs +76 -3
  57. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  58. package/scripts/lib/maintenance-due-banner.mjs +440 -0
  59. package/scripts/lib/owner-config.example.yaml +29 -46
  60. package/scripts/lib/owner-yaml.mjs +14 -13
  61. package/scripts/lib/project-hygiene.mjs +182 -6
  62. package/scripts/lib/quality-gate.mjs +13 -6
  63. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  64. package/scripts/lib/rules-sync.mjs +34 -4
  65. package/scripts/lib/session-close-backfill.mjs +182 -40
  66. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  67. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  68. package/scripts/lib/session-identity/own-session.mjs +24 -13
  69. package/scripts/lib/session-schema/constants.mjs +6 -0
  70. package/scripts/lib/session-schema/validator.mjs +20 -0
  71. package/scripts/lib/session-shape.mjs +558 -0
  72. package/scripts/lib/session-start-probes.mjs +10 -3
  73. package/scripts/lib/session-token-rollup.mjs +95 -10
  74. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  75. package/scripts/lib/state-md.mjs +1 -0
  76. package/scripts/lib/subagents-schema.mjs +77 -9
  77. package/scripts/lib/telemetry/pricing.mjs +197 -0
  78. package/scripts/lib/telemetry/sync.mjs +50 -1
  79. package/scripts/lib/validate/check-owner-leakage.mjs +17 -8
  80. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  81. package/scripts/lib/validate/check-unwired-features.mjs +8 -7
  82. package/scripts/lib/vault-mirror/process.mjs +2 -1
  83. package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
  84. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  85. package/scripts/lib/wave-resource-gate.mjs +23 -27
  86. package/scripts/lib/wave-sizing.mjs +10 -3
  87. package/scripts/materialize-wave-scope.mjs +68 -14
  88. package/scripts/print-applicable-rules.mjs +7 -6
  89. package/scripts/print-learnings-index.mjs +3 -2
  90. package/scripts/release.mjs +32 -11
  91. package/scripts/session-shape.mjs +266 -0
  92. package/skills/_shared/config-reading.md +15 -9
  93. package/skills/_shared/private-capability-context.md +89 -0
  94. package/skills/bootstrap/SKILL.md +61 -13
  95. package/skills/bootstrap/_shared-template.md +99 -14
  96. package/skills/bootstrap/deep-template.md +36 -26
  97. package/skills/bootstrap/fast-template.md +44 -8
  98. package/skills/bootstrap/intensity-heuristic.md +10 -4
  99. package/skills/bootstrap/private-contract.md +119 -0
  100. package/skills/bootstrap/public-fallback.md +30 -18
  101. package/skills/bootstrap/standard-template.md +39 -24
  102. package/skills/discovery/probes-ui.md +1 -1
  103. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  104. package/skills/evolve/SKILL.md +2 -2
  105. package/skills/gitlab-ops/SKILL.md +3 -3
  106. package/skills/grill/SKILL.md +1 -1
  107. package/skills/memory-cleanup/SKILL.md +2 -2
  108. package/skills/plan/mode-new.md +9 -0
  109. package/skills/reconcile/SKILL.md +1 -1
  110. package/skills/session-end/SKILL.md +3 -2
  111. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  112. package/skills/session-end/phase-3-6-tail.md +23 -65
  113. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  114. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  115. package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
  116. package/skills/session-end/session-metrics-write.md +31 -12
  117. package/skills/session-plan/SKILL.md +56 -48
  118. package/skills/session-plan/wave-template.md +8 -15
  119. package/skills/session-start/SKILL.md +18 -2
  120. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  121. package/skills/session-start/phase-8-5-express-path.md +12 -9
  122. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  123. package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
  124. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  125. package/skills/test-runner/rubric-v1.md +2 -2
  126. package/skills/wave-executor/SKILL.md +42 -12
  127. package/skills/wave-executor/circuit-breaker.md +3 -1
  128. package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
  129. package/skills/wave-executor/references/wave-loop-review.md +1 -1
  130. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  131. package/templates/nextjs-minimal/package.json +1 -1
  132. package/templates/node-minimal/package.json +1 -1
  133. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  134. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  135. package/scripts/lib/owner-config/coerce.mjs +0 -29
  136. package/scripts/lib/owner-config/constants.mjs +0 -21
  137. package/scripts/lib/owner-config/defaults.mjs +0 -50
  138. package/scripts/lib/owner-config/error.mjs +0 -19
  139. package/scripts/lib/owner-config/index.mjs +0 -13
  140. package/scripts/lib/owner-config/merge.mjs +0 -52
  141. package/scripts/lib/owner-config/validate.mjs +0 -259
  142. package/scripts/lib/owner-config-loader.mjs +0 -170
  143. package/scripts/lib/owner-config.mjs +0 -28
  144. package/scripts/lib/soul-resolve.mjs +0 -130
  145. package/scripts/lib/vault-mirror/render.mjs +0 -8
@@ -1,5 +1,6 @@
1
1
  import { matchBlockHeader } from './block-header.mjs';
2
2
  import { preprocessBlockLines } from './block-preprocess.mjs';
3
+ import { _coerceInteger } from './coercers.mjs';
3
4
 
4
5
  /**
5
6
  * issue-budget.mjs — Parser for the `issue-budget:` block-style Session Config key.
@@ -17,10 +18,13 @@ import { preprocessBlockLines } from './block-preprocess.mjs';
17
18
  * `scripts/lib/spiral-carryover.mjs` `runCli()` (programmatic path), both via
18
19
  * `scripts/lib/issue-budget.mjs`.
19
20
  *
20
- * Returns `{ "max-per-session", mode, overflow }`.
21
- * Tolerant parser: malformed values silently fall back to defaults (the
21
+ * Returns `{ "max-per-session", "max-per-session-raw", mode, overflow }`.
22
+ * Tolerant parser: malformed values fall back to defaults (the
22
23
  * `reconcile.min-rule-days` posture, NOT the noisier handover-gate WARN) —
23
- * a hook must never spam stderr on every single Bash call.
24
+ * a hook must never spam stderr on every single Bash call. ONE exception, and
25
+ * it is a safety one: a malformed per-session OVERRIDE with a valid base number
26
+ * (`7 (feature: x)`) keeps the base 7 and WARNs on stderr, because falling back
27
+ * to the built-in 12 there would silently LOOSEN a cap the operator tightened.
24
28
  *
25
29
  * Consumers: `scripts/lib/config.mjs`, `scripts/lib/issue-budget.mjs`,
26
30
  * `hooks/pre-bash-issue-budget.mjs`, `skills/session-end/SKILL.md` Phase 5.
@@ -44,7 +48,9 @@ const OVERFLOW_SINKS = ['collect-issue', 'vault-note'];
44
48
  * Defaults:
45
49
  * max-per-session: 12 integer >= 0; 0 means "no issue may be created"
46
50
  * (a valid, deliberately harsh setting). Malformed
47
- * or negative input falls back to 12.
51
+ * or negative input falls back to 12 — EXCEPT a
52
+ * malformed override over a valid base (`7 (x: y)`),
53
+ * which keeps 7 and WARNs.
48
54
  * mode: strict strict | warn | off
49
55
  * overflow: collect-issue collect-issue | vault-note
50
56
  *
@@ -54,12 +60,29 @@ const OVERFLOW_SINKS = ['collect-issue', 'vault-note'];
54
60
  * mode: strict
55
61
  * overflow: collect-issue
56
62
  *
63
+ * NEW POLICY (not a restatement of an existing rule): `max-per-session` accepts
64
+ * the SAME per-session-type override syntax `agents-per-wave` already uses —
65
+ * `12 (feature: 6)` parses via `_coerceInteger` into
66
+ * `{ default: 12, feature: 6 }`. The key set is OPEN (any session-type label
67
+ * the operator writes), because the session-type vocabulary lives in
68
+ * session-start, not here.
69
+ *
70
+ * The parsed override object is returned under `"max-per-session-raw"`, while
71
+ * `"max-per-session"` stays STRICTLY NUMERIC (the `.default`). That split is
72
+ * load-bearing: three consumers read the key as a number
73
+ * (`scripts/lib/issue-budget.mjs` `chargeIssueBudget`,
74
+ * `hooks/pre-bash-issue-budget.mjs`, and the config-parity docs), and handing
75
+ * any of them an object would surface as `[object Object]` in a cap comparison
76
+ * rather than as an error. Resolution against the CURRENT session type is
77
+ * `resolveMaxPerSession()` in `scripts/lib/issue-budget.mjs`.
78
+ *
57
79
  * @param {string} content — full file contents
58
- * @returns {{ "max-per-session": number, mode: string, overflow: string }}
80
+ * @returns {{ "max-per-session": number, "max-per-session-raw": number|{default: number, [k: string]: number}, mode: string, overflow: string }}
59
81
  */
60
82
  export function _parseIssueBudget(content) {
61
83
  const defaults = {
62
84
  'max-per-session': 12,
85
+ 'max-per-session-raw': 12,
63
86
  mode: 'strict',
64
87
  overflow: 'collect-issue',
65
88
  };
@@ -84,6 +107,7 @@ export function _parseIssueBudget(content) {
84
107
  if (blockLines.length === 0) return { ...defaults };
85
108
 
86
109
  let maxPerSession = defaults['max-per-session'];
110
+ let maxPerSessionRaw = defaults['max-per-session-raw'];
87
111
  let mode = defaults.mode;
88
112
  let overflow = defaults.overflow;
89
113
 
@@ -101,10 +125,45 @@ export function _parseIssueBudget(content) {
101
125
  else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
102
126
 
103
127
  switch (k) {
104
- case 'max-per-session':
105
- // Non-negative integer only; a leading '-' fails \d+ default.
106
- if (/^\d+$/.test(v)) maxPerSession = parseInt(v, 10);
128
+ case 'max-per-session': {
129
+ // Non-negative integer, or the `N (type: M)` override form. A leading
130
+ // '-' fails \d+ inside `_coerceInteger`, which throws → default.
131
+ // Tolerant by contract: this parser runs on every Bash call via the
132
+ // hook, so a malformed value must fall back silently, never throw.
133
+ let coerced;
134
+ try {
135
+ coerced = _coerceInteger(new Map([['max-per-session', v]]), 'max-per-session', 12);
136
+ } catch {
137
+ // A malformed OVERRIDE (`7 (feature: x)`) must never restore the
138
+ // built-in 12: the operator wrote a base cap of 7, and discarding the
139
+ // whole value because the parenthesised part is unparseable LOOSENS
140
+ // the cap by three on one typo — silently, in the direction nobody
141
+ // would choose. Keep the base number, drop the override, and say so
142
+ // once (this branch is reachable only on a malformed value, so it
143
+ // cannot become per-Bash-call stderr spam).
144
+ const base = v.match(/^(\d+)\s*\(/);
145
+ if (base) {
146
+ const n = Number.parseInt(base[1], 10);
147
+ if (Number.isInteger(n) && n >= 0) {
148
+ maxPerSession = n;
149
+ maxPerSessionRaw = n;
150
+ process.stderr.write(
151
+ `⚠ issue-budget: malformed per-session override in 'max-per-session': '${v}' — ` +
152
+ `kept the base cap ${n} and dropped the override.\n`,
153
+ );
154
+ }
155
+ }
156
+ break;
157
+ }
158
+ if (typeof coerced === 'number') {
159
+ maxPerSession = coerced;
160
+ maxPerSessionRaw = coerced;
161
+ } else if (Number.isInteger(coerced?.default) && coerced.default >= 0) {
162
+ maxPerSession = coerced.default;
163
+ maxPerSessionRaw = coerced;
164
+ }
107
165
  break;
166
+ }
108
167
 
109
168
  case 'mode':
110
169
  if (MODES.includes(v.toLowerCase())) mode = v.toLowerCase();
@@ -118,6 +177,7 @@ export function _parseIssueBudget(content) {
118
177
 
119
178
  return {
120
179
  'max-per-session': maxPerSession,
180
+ 'max-per-session-raw': maxPerSessionRaw,
121
181
  mode,
122
182
  overflow,
123
183
  };
@@ -5,8 +5,9 @@
5
5
  * `owner.yaml`, the self-alias ledger) moves with it, so the precedence has to
6
6
  * live in exactly ONE place. Before #1223 three copies with three different
7
7
  * precedences existed: `owner-yaml.mjs`'s import-time homedir-only
8
- * `OWNER_YAML_PATH`, `owner-config-loader.mjs`'s XDG-only + untrimmed
9
- * `resolveOwnerConfigPath()`, and `host-identity.mjs`'s `_privateDir()`. The
8
+ * `OWNER_YAML_PATH`, the now-removed `owner-config-loader.mjs`'s XDG-only +
9
+ * untrimmed `resolveOwnerConfigPath()`, and `host-identity.mjs`'s
10
+ * `_privateDir()`. The
10
11
  * live consequence: `SO_CONFIG_HOME=<sandbox>` moved the alias ledger but NOT
11
12
  * owner.yaml, which kept being read from the operator's REAL home — the
12
13
  * CLAUDE.md (Codex CLI alias: AGENTS.md) "vault-dir resolves HOST-LOCALLY"
@@ -40,8 +40,8 @@ export const REMOTE_HOST_DEFAULTS = Object.freeze({
40
40
  * scripts/lib/config.mjs — impl / db / security / compliance / docs stay local.
41
41
  *
42
42
  * NOT the wave-role enum ("Impl-Core", "Quality", …): those are two different
43
- * enums and conflating them is the documented trap (see resolveApwCap's docstring
44
- * in wave-resource-gate.mjs). The wave→agent-mapping-role translation is
43
+ * enums and conflating them is the documented trap (see the MODE_BLIND_SESSION_TYPE
44
+ * note in wave-resource-gate.mjs). The wave→agent-mapping-role translation is
45
45
  * `OFFLOADABLE_WAVE_ROLES` in that same module.
46
46
  *
47
47
  * @type {readonly string[]}
@@ -86,6 +86,8 @@ export function validateSessionConfig(config) {
86
86
  });
87
87
  }
88
88
 
89
+ validateIssueBudget(config['issue-budget'], errors);
90
+
89
91
  validateVaultIntegration(config['vault-integration'], errors);
90
92
  validateVaultSync(config['vault-sync'], errors);
91
93
 
@@ -124,6 +126,83 @@ export function validateSessionConfig(config) {
124
126
  return { ok: true, config, warnings };
125
127
  }
126
128
 
129
+ /**
130
+ * Validate the `issue-budget` block's cap, including the per-session-type
131
+ * override form (#session-shape) — `12 (feature: 6)` parses to
132
+ * `{ default: 12, feature: 6 }` and lands on `max-per-session-raw`, while
133
+ * `max-per-session` stays the resolved NUMBER every consumer reads.
134
+ *
135
+ * Mirrors `validateAgentsPerWave` in shape, with two deliberate differences:
136
+ * the floor is 0 (`max-per-session: 0` is a valid "no issue may be created"
137
+ * setting) and the override key set is free-form, because the session-type
138
+ * vocabulary is owned by session-start, not by this schema.
139
+ *
140
+ * @param {unknown} block — `config['issue-budget']`
141
+ * @param {Array<{path: string, rule: string, message: string}>} errors
142
+ */
143
+ function validateIssueBudget(block, errors) {
144
+ if (block === undefined || block === null) return;
145
+ if (typeof block !== 'object' || Array.isArray(block)) {
146
+ errors.push({
147
+ path: 'issue-budget',
148
+ rule: 'object',
149
+ message: `issue-budget must be an object (got ${JSON.stringify(block)})`,
150
+ });
151
+ return;
152
+ }
153
+
154
+ const resolved = block['max-per-session'];
155
+ if (resolved !== undefined && (!Number.isInteger(resolved) || resolved < 0)) {
156
+ errors.push({
157
+ path: 'issue-budget.max-per-session',
158
+ rule: 'integer>=0',
159
+ message: `issue-budget.max-per-session must be an integer >= 0 (got ${JSON.stringify(resolved)})`,
160
+ });
161
+ }
162
+
163
+ const raw = block['max-per-session-raw'];
164
+ if (raw === undefined || raw === null) return;
165
+
166
+ if (Number.isInteger(raw)) {
167
+ if (raw < 0) {
168
+ errors.push({
169
+ path: 'issue-budget.max-per-session-raw',
170
+ rule: 'integer>=0',
171
+ message: `issue-budget.max-per-session-raw must be an integer >= 0 (got ${raw})`,
172
+ });
173
+ }
174
+ return;
175
+ }
176
+
177
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
178
+ const defaultVal = raw['default'];
179
+ if (!Number.isInteger(defaultVal) || defaultVal < 0) {
180
+ errors.push({
181
+ path: 'issue-budget.max-per-session-raw.default',
182
+ rule: 'integer>=0',
183
+ message: `issue-budget.max-per-session-raw.default must be an integer >= 0 (got ${JSON.stringify(defaultVal)})`,
184
+ });
185
+ }
186
+ for (const [k, v] of Object.entries(raw)) {
187
+ if (k === 'default') continue;
188
+ if (!Number.isInteger(v) || v < 0) {
189
+ errors.push({
190
+ path: `issue-budget.max-per-session-raw.${k}`,
191
+ rule: 'integer>=0',
192
+ message: `issue-budget.max-per-session-raw.${k} must be an integer >= 0 (got ${JSON.stringify(v)})`,
193
+ });
194
+ }
195
+ }
196
+ return;
197
+ }
198
+
199
+ errors.push({
200
+ path: 'issue-budget.max-per-session-raw',
201
+ rule: 'integer-or-object',
202
+ message: `issue-budget.max-per-session-raw must be an integer >= 0 or an object with numeric entries (got ${JSON.stringify(raw)})`,
203
+ });
204
+ }
205
+
127
206
  function validateAgentsPerWave(value, errors) {
128
207
  if (Number.isInteger(value)) {
129
208
  if (value < 2) {
@@ -27,8 +27,8 @@
27
27
  * 3. **Never a peer's id (#1123).** A shared working copy means
28
28
  * `session.lock` can name a PEER session that won the acquire race. The
29
29
  * lock alone therefore does not prove ownership; the fill happens only
30
- * when a PROCESS-LOCAL id (`CLAUDE_CODE_SESSION_ID`, or a hook payload's
31
- * `session_id`) equals the lock's raw `session_id`. STATE.md is NOT a
30
+ * when a confirmed native PROCESS-LOCAL id (`CLAUDE_CODE_SESSION_ID` or
31
+ * `CODEX_THREAD_ID`) equals the lock's raw `session_id`. STATE.md is NOT a
32
32
  * witness here (#1177 FX1): it is a shared working-copy file written by
33
33
  * the lock holder, so under a peer-owned lock both agreed about the peer
34
34
  * and the union stamped the peer's ids. See {@link attributionForRecord}.
@@ -124,7 +124,7 @@ const STATE_DIR_CANDIDATES = ['.claude', '.codex', '.cursor', '.pi'];
124
124
  * when one exists it decides alone.**
125
125
  *
126
126
  * - No lock (CI, a bare script) → `{}`. Nothing to attribute to.
127
- * - No process-local id (`CLAUDE_CODE_SESSION_ID` absent) → `{}`. Ownership is
127
+ * - No confirmed process-local id (absent or ambiguous native env) → `{}`. Ownership is
128
128
  * UNPROVEN, and an unproven attribution is exactly the peer-id write #1123
129
129
  * forbids; an absent key costs a correlation, a wrong key costs a false one.
130
130
  * - A process-local id that equals the lock's raw `session_id` → fill BOTH
@@ -6,11 +6,12 @@
6
6
  * and the state-lock / staging-fence / session-lock blocks of session-lock.mjs).
7
7
  * Each copy independently re-implemented the same skeleton:
8
8
  *
9
- * 1. atomic create-or-fail via `linkSync(tmp, lock)` (POSIX mutex);
10
- * 2. on EEXIST read + parse the existing body;
11
- * 3. same-host + dead-PID (or unparseable) atomic override + WARN;
12
- * 4. live holder OR cross-hostpoll until a deadline;
13
- * 5. owner-guarded release.
9
+ * 1. exclusive acquisition guard via `linkSync(tmp, lock + '.acquire')`;
10
+ * 2. atomic create-or-fail via `linkSync(tmp, lock)` (POSIX mutex);
11
+ * 3. on EEXIST read + parse the existing body;
12
+ * 4. same-host + dead-PID (or unparseable) guarded override + WARN;
13
+ * 5. release the acquisition guard; live/cross-host holders wait until a deadline;
14
+ * 6. owner-guarded primary-lock release.
14
15
  *
15
16
  * This module is the single home for that skeleton. It is a near-PURE primitive:
16
17
  * it imports ONLY scripts/lib/io.mjs (for writeJsonAtomicSync), the two host
@@ -27,6 +28,8 @@
27
28
  * opinion of its own — every divergence between the five copies is a knob.
28
29
  * - Cross-host locks are NEVER auto-overridden (PSA-003 hard invariant).
29
30
  * - Overrides always go through writeJsonAtomicSync (tmp + renameSync).
31
+ * - #1284 serializes acquisition and takeover: a stale observation must not
32
+ * replace a different process's newly acquired lock.
30
33
  *
31
34
  * No external dependencies — Node 20+ stdlib + io.mjs only.
32
35
  */
@@ -258,6 +261,21 @@ function serializeBody(body, indent) {
258
261
  * overridden via writeJsonAtomicSync and a WARN is emitted. A live holder or a
259
262
  * cross-host body returns `{ acquired: false, reason: 'held' }`.
260
263
  *
264
+ * Every acquisition pass owns the exclusive sibling `${lockPath}.acquire`
265
+ * from before create/read through any takeover. This prevents a waiter from
266
+ * reading an old holder, observing its exit, then replacing a newer holder.
267
+ * All contenders must use this guarded implementation; legacy writers that
268
+ * ignore the sibling guard cannot participate safely in the same protocol.
269
+ *
270
+ * Crash-liveness tradeoff: the guard is held only for this synchronous pass,
271
+ * not for the caller's critical section. If its owner dies during the pass or
272
+ * cleanup fails, the guard remains and attempts return `held` immediately;
273
+ * withFileLock's normal deadline bounds polling. Even a dead-PID or malformed
274
+ * guard is NEVER stolen, because stale-guard replacement would repeat the same
275
+ * race. Recovery requires quiescing every process that can acquire this lock,
276
+ * verifying the guard is abandoned, then explicitly removing only that sibling.
277
+ * The primary lock retains its existing stale/host/owner protections.
278
+ *
261
279
  * The `signalVanished` knob reproduces memory-proposals/store.mjs's distinct
262
280
  * third state: when the lock file disappears between the EEXIST and the read
263
281
  * (concurrent release race), `{ acquired: false, reason: 'vanished' }` is
@@ -284,6 +302,30 @@ function serializeBody(body, indent) {
284
302
  * | { acquired: false, reason: 'held'|'vanished'|'fs-error', existing?: object|null, error?: string }}
285
303
  */
286
304
  export function tryAcquireFileLock(lockPath, opts = {}) {
305
+ const guardPath = `${lockPath}.acquire`;
306
+ const guard = createExclusive(guardPath, {
307
+ pid: process.pid,
308
+ host: os.hostname(),
309
+ acquiredAt: new Date().toISOString(),
310
+ kind: 'acquisition-guard',
311
+ }, { indent: 2, tmpPrefix: `${opts.tmpPrefix ?? '.file.lock'}.acquire` });
312
+ if (!guard.ok) {
313
+ return guard.reason === 'exists'
314
+ ? { acquired: false, reason: 'held', existing: null }
315
+ : { acquired: false, reason: 'fs-error', error: guard.error };
316
+ }
317
+
318
+ try {
319
+ return tryAcquireGuardedFileLock(lockPath, opts);
320
+ } finally {
321
+ // Only this pass can own/remove this guard; no acquisition path replaces
322
+ // it. An unlink failure deliberately leaves subsequent attempts blocked.
323
+ try { fs.unlinkSync(guardPath); } catch { /* fail closed; see recovery above */ }
324
+ }
325
+ }
326
+
327
+ /** Caller must hold the acquisition guard throughout this synchronous pass. */
328
+ function tryAcquireGuardedFileLock(lockPath, opts) {
287
329
  const {
288
330
  staleCheck = 'pid',
289
331
  staleMs,