session-orchestrator 5.7.0 → 5.9.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/.agents/skills/architecture/SKILL.md +3 -0
  2. package/.agents/skills/architecture/agents/openai.yaml +5 -0
  3. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -0
  4. package/.agents/skills/eval/SKILL.md +1 -1
  5. package/.agents/skills/frontmatter-guard/SKILL.md +1 -0
  6. package/.agents/skills/gitlab-portfolio/SKILL.md +1 -0
  7. package/.agents/skills/hook-development/SKILL.md +1 -0
  8. package/.agents/skills/mcp-builder/SKILL.md +1 -0
  9. package/.agents/skills/tmux-layout/SKILL.md +3 -0
  10. package/.agents/skills/tmux-layout/agents/openai.yaml +5 -0
  11. package/.agents/skills/vault-mirror/SKILL.md +1 -0
  12. package/.agents/skills/vault-sync/SKILL.md +1 -0
  13. package/.agents/skills/write-executable-plan/SKILL.md +3 -0
  14. package/.agents/skills/write-executable-plan/agents/openai.yaml +5 -0
  15. package/.claude-plugin/marketplace.json +1 -1
  16. package/.claude-plugin/plugin.json +1 -1
  17. package/.codex-plugin/plugin.json +1 -1
  18. package/.codex-plugin/skills/architecture/SKILL.md +3 -0
  19. package/.codex-plugin/skills/architecture/agents/openai.yaml +5 -0
  20. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -0
  21. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  22. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +1 -0
  23. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +1 -0
  24. package/.codex-plugin/skills/hook-development/SKILL.md +1 -0
  25. package/.codex-plugin/skills/mcp-builder/SKILL.md +1 -0
  26. package/.codex-plugin/skills/tmux-layout/SKILL.md +3 -0
  27. package/.codex-plugin/skills/tmux-layout/agents/openai.yaml +5 -0
  28. package/.codex-plugin/skills/vault-mirror/SKILL.md +1 -0
  29. package/.codex-plugin/skills/vault-sync/SKILL.md +1 -0
  30. package/.codex-plugin/skills/write-executable-plan/SKILL.md +3 -0
  31. package/.codex-plugin/skills/write-executable-plan/agents/openai.yaml +5 -0
  32. package/.cursor/commands/architecture.md +13 -0
  33. package/.cursor/commands/eval.md +1 -1
  34. package/.cursor/commands/tmux-layout.md +13 -0
  35. package/.cursor/commands/write-executable-plan.md +13 -0
  36. package/.cursor/skills/architecture/SKILL.md +0 -1
  37. package/.cursor/skills/eval/SKILL.md +1 -1
  38. package/.cursor/skills/tmux-layout/SKILL.md +0 -1
  39. package/.cursor/skills/write-executable-plan/SKILL.md +0 -1
  40. package/.cursor-plugin/plugin.json +1 -1
  41. package/CHANGELOG.md +68 -0
  42. package/README.md +13 -9
  43. package/agents/eval-judge.md +5 -5
  44. package/docs/baseline.md +4 -3
  45. package/docs/codex-setup.md +6 -4
  46. package/docs/components.md +5 -4
  47. package/docs/events-schema.md +45 -4
  48. package/docs/github-mirror-protection.md +3 -2
  49. package/docs/install.md +39 -3
  50. package/docs/pi-setup.md +1 -1
  51. package/docs/rule-authoring.md +2 -2
  52. package/docs/session-config-reference.md +7 -5
  53. package/docs/session-config-template.md +3 -2
  54. package/evals/README.md +60 -0
  55. package/evals/instruction-ablation/README.md +147 -0
  56. package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
  57. package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
  58. package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
  59. package/evals/instruction-ablation/run.mjs +336 -0
  60. package/evals/psa-003-foreign-work/case.yaml +16 -0
  61. package/evals/psa-003-foreign-work/fixture.sh +12 -0
  62. package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
  63. package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
  64. package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
  65. package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
  66. package/evals/psa-003-foreign-work/prompt.md +6 -0
  67. package/hooks/_lib/hook-import-set.json +10 -2
  68. package/hooks/hooks-codex.json +1 -1
  69. package/hooks/hooks.json +1 -1
  70. package/hooks/post-tool-batch-wave-signal.mjs +1 -0
  71. package/hooks/post-tooluse-frontend-slop.mjs +11 -1
  72. package/hooks/pre-task-scope-disjoint.mjs +8 -6
  73. package/monitors/monitors.json +1 -1
  74. package/package.json +3 -1
  75. package/pi/prompts/architecture.md +11 -0
  76. package/pi/prompts/eval.md +1 -1
  77. package/pi/prompts/tmux-layout.md +11 -0
  78. package/pi/prompts/write-executable-plan.md +11 -0
  79. package/scripts/backfill-abandoned-sessions.mjs +2 -2
  80. package/scripts/backfill-sessions.mjs +9 -7
  81. package/scripts/codex-install.mjs +108 -23
  82. package/scripts/emit-session.mjs +15 -2
  83. package/scripts/fleet-auflagen.mjs +54 -0
  84. package/scripts/generate-agents-skills.mjs +9 -16
  85. package/scripts/generate-codex-skills.mjs +8 -6
  86. package/scripts/generate-cursor-adapter.mjs +9 -74
  87. package/scripts/generate-pi-prompts.mjs +8 -74
  88. package/scripts/github-protection-audit.mjs +8 -0
  89. package/scripts/lib/auto-dialectic.mjs +10 -9
  90. package/scripts/lib/auto-dream.mjs +8 -10
  91. package/scripts/lib/autopilot/telemetry.mjs +12 -6
  92. package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
  93. package/scripts/lib/ci-status-banner.mjs +38 -5
  94. package/scripts/lib/config/drift-check.mjs +1 -1
  95. package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
  96. package/scripts/lib/config.mjs +5 -1
  97. package/scripts/lib/convergence-monitor.mjs +5 -3
  98. package/scripts/lib/ecosystem-health.mjs +5 -3
  99. package/scripts/lib/eval/engine.mjs +61 -27
  100. package/scripts/lib/eval/judge.mjs +14 -8
  101. package/scripts/lib/eval/session-resolve.mjs +7 -2
  102. package/scripts/lib/events-retention-banner.mjs +5 -2
  103. package/scripts/lib/events-schema.mjs +8 -0
  104. package/scripts/lib/events.mjs +19 -5
  105. package/scripts/lib/fleet-protocol.mjs +98 -1
  106. package/scripts/lib/instruction-budget-guard.mjs +25 -18
  107. package/scripts/lib/io.mjs +172 -35
  108. package/scripts/lib/learnings/candidates.mjs +7 -28
  109. package/scripts/lib/learnings/io.mjs +7 -2
  110. package/scripts/lib/learnings/judgment.mjs +5 -8
  111. package/scripts/lib/learnings/select.mjs +3 -3
  112. package/scripts/lib/maintenance-due-banner.mjs +2 -2
  113. package/scripts/lib/memory-proposals/sink.mjs +5 -6
  114. package/scripts/lib/memory-proposals/store.mjs +3 -5
  115. package/scripts/lib/mirror-issues-banner.mjs +11 -1
  116. package/scripts/lib/native-command.mjs +48 -0
  117. package/scripts/lib/orphan-reaper/scan.mjs +29 -3
  118. package/scripts/lib/peer-cards/writer.mjs +6 -18
  119. package/scripts/lib/platform.mjs +113 -4
  120. package/scripts/lib/plugin-package-stage.mjs +169 -0
  121. package/scripts/lib/plugin-surface.mjs +121 -0
  122. package/scripts/lib/quality-gate-wave.mjs +40 -0
  123. package/scripts/lib/quality-gate.mjs +36 -30
  124. package/scripts/lib/reconcile/idempotency.mjs +9 -10
  125. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +48 -11
  126. package/scripts/lib/reconcile/writer.mjs +11 -17
  127. package/scripts/lib/rule-loader.mjs +32 -6
  128. package/scripts/lib/scope-echo.mjs +12 -2
  129. package/scripts/lib/scope-gate.mjs +2 -1
  130. package/scripts/lib/session-close-backfill.mjs +3 -4
  131. package/scripts/lib/session-id.mjs +2 -1
  132. package/scripts/lib/session-record-repair.mjs +28 -21
  133. package/scripts/lib/session-schema/constants.mjs +20 -5
  134. package/scripts/lib/session-schema/validator.mjs +12 -0
  135. package/scripts/lib/session-start-probes.mjs +22 -7
  136. package/scripts/lib/sessions-staleness-banner.mjs +2 -3
  137. package/scripts/lib/skill-evolution/engine.mjs +6 -9
  138. package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
  139. package/scripts/lib/slopcheck.mjs +9 -7
  140. package/scripts/lib/sunset/walker.mjs +3 -3
  141. package/scripts/lib/telemetry/sync.mjs +3 -1
  142. package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
  143. package/scripts/lib/test-sibling-coverage.mjs +167 -0
  144. package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
  145. package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
  146. package/scripts/lib/user-invocable-skills.mjs +37 -10
  147. package/scripts/lib/ux-grill/run-record.mjs +5 -3
  148. package/scripts/lib/validate/check-banner-parity.mjs +1 -0
  149. package/scripts/lib/validate/check-skills.mjs +12 -2
  150. package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
  151. package/scripts/lib/validate/check-test-value-bans.mjs +3 -2
  152. package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
  153. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  154. package/scripts/lib/vault-archive.mjs +2 -4
  155. package/scripts/lib/vault-mirror/process.mjs +207 -26
  156. package/scripts/lib/vault-mirror/utils.mjs +16 -1
  157. package/scripts/lib/vault-sync-baseline.mjs +9 -8
  158. package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
  159. package/scripts/lib/wave-resource-gate.mjs +2 -1
  160. package/scripts/lib/wave-transcript-tail.mjs +8 -4
  161. package/scripts/lib/worktree/meta.mjs +7 -3
  162. package/scripts/mcp-server.mjs +158 -0
  163. package/scripts/migrate-sessions-jsonl.mjs +7 -5
  164. package/scripts/migrate-vault-paths.mjs +7 -4
  165. package/scripts/print-learnings-index.mjs +2 -1
  166. package/scripts/release.mjs +4 -1
  167. package/scripts/relocate-vault-corpus.mjs +5 -3
  168. package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
  169. package/scripts/run-quality-gate.mjs +9 -39
  170. package/scripts/self-update.mjs +179 -39
  171. package/scripts/site-numbers.mjs +8 -3
  172. package/scripts/sweep-expired-learnings.mjs +6 -4
  173. package/scripts/sync-vault-schema.mjs +3 -7
  174. package/scripts/windows-setup.mjs +45 -0
  175. package/skills/_shared/fleet-protocol.md +10 -0
  176. package/skills/architecture/SKILL.md +1 -0
  177. package/skills/bootstrap/_shared-template.md +5 -2
  178. package/skills/claude-md-drift-check/SKILL.md +4 -1
  179. package/skills/claude-md-drift-check/checker.mjs +83 -10
  180. package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
  181. package/skills/eval/SKILL.md +12 -13
  182. package/skills/eval/rubric-v3.md +521 -0
  183. package/skills/frontmatter-guard/SKILL.md +1 -0
  184. package/skills/gitlab-portfolio/SKILL.md +1 -0
  185. package/skills/hook-development/SKILL.md +1 -0
  186. package/skills/mcp-builder/SKILL.md +1 -0
  187. package/skills/remote-offload/SKILL.md +1 -1
  188. package/skills/repo-audit/SKILL.md +2 -2
  189. package/skills/session-end/SKILL.md +32 -1
  190. package/skills/session-end/metrics-collection.md +15 -9
  191. package/skills/session-end/vault-operations.md +12 -2
  192. package/skills/session-start/SKILL.md +2 -2
  193. package/skills/session-start/phase-7-1-premise-check.md +2 -1
  194. package/skills/session-start/references/operations-contract.md +4 -1
  195. package/skills/tmux-layout/SKILL.md +1 -0
  196. package/skills/vault-mirror/SKILL.md +1 -0
  197. package/skills/vault-sync/SKILL.md +6 -6
  198. package/skills/vault-sync/validator.sh +29 -10
  199. package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
  200. package/skills/wave-executor/references/wave-loop-review.md +13 -7
  201. package/skills/wave-executor/references/wave-loop-scope-manifest.md +4 -4
  202. package/skills/write-executable-plan/SKILL.md +1 -0
@@ -16,10 +16,8 @@
16
16
  *
17
17
  * ── Frontmatter schema (mirror, not import) ─────────────────────────────────
18
18
  * The canonical vault frontmatter schema is the GENERATED Zod block in
19
- * skills/vault-sync/validator.mjs (Z.104-134). That schema is NOT exported, and
20
- * `zod` is not a dependency of scripts/ (it lives only as a skill-local shim in
21
- * skills/vault-sync/node_modules/). So the field constraints below are a
22
- * hand-rolled MIRROR of that schema. If the canonical schema changes, update the
19
+ * skills/vault-sync/validator.mjs (Z.104-134). That schema is NOT exported, so
20
+ * the field constraints below are a hand-rolled MIRROR of that schema. If the canonical schema changes, update the
23
21
  * constants here to match. The mirror is proven equivalent by an integration
24
22
  * test that runs the REAL validator subprocess over generated output
25
23
  * (tests/lib/vault-archive.test.mjs § "REAL validator").
@@ -6,11 +6,11 @@
6
6
  */
7
7
 
8
8
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
9
- import { join, resolve, sep } from 'node:path';
9
+ import { basename, join, resolve, sep } from 'node:path';
10
10
  import { createRequire } from 'node:module';
11
11
  import { randomUUID } from 'node:crypto';
12
12
  import { createSecretValueMasker } from '../secret-masker.mjs';
13
- import { subjectToSlug, isValidSlug, uuidPrefix8, toDate, parseFrontmatter } from './utils.mjs';
13
+ import { subjectToSlug, isValidSlug, uuidPrefix8, recordKeySuffix8, toDate, parseFrontmatter } from './utils.mjs';
14
14
  import { isRealSession } from '../session-schema/filters.mjs';
15
15
  import { resolveRepoNamespace } from './namespace.mjs';
16
16
  import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2, learningSourceRecord } from './render-learnings.mjs';
@@ -423,6 +423,44 @@ function notOurGenerator(fm) {
423
423
  return null;
424
424
  }
425
425
 
426
+ /**
427
+ * The disambiguation slug a record uses under `slug` in `dir` (#1506.1). An
428
+ * EXISTING `<slug>-<uuid8>` note that is ours and owned by this record keeps
429
+ * its name — the owner decision was "no mass rename"; every other case gets the
430
+ * full-record-key hash suffix ({@link recordKeySuffix8}), so non-UUID ids that
431
+ * share their first 8 chars no longer land on one file.
432
+ *
433
+ * @param {string} dir - the namespace folder.
434
+ * @param {string} slug - the canonical note slug.
435
+ * @param {{ legacyId: string, recordKey: string }} keys - `legacyId` is what the
436
+ * pre-#1506 suffix was derived from (the raw id); `recordKey` the full key.
437
+ * @returns {{ slug: string, exists: boolean|null }} `exists: true` when the
438
+ * legacy name was chosen (it was just probed); `null` when not yet probed.
439
+ */
440
+ function disambigSlugFor(dir, slug, { legacyId, recordKey }) {
441
+ const legacy = `${slug}-${uuidPrefix8(legacyId)}`;
442
+ const hashed = `${slug}-${recordKeySuffix8(recordKey)}`;
443
+ const legacyPath = join(dir, `${legacy}.md`);
444
+ if (legacy !== hashed && existsSync(legacyPath)) {
445
+ const fm = parseFrontmatter(readFileSync(legacyPath, 'utf8'));
446
+ if (notOurGenerator(fm) === null && foreignOwnerReason(fm, { recordKey }) === null) {
447
+ return { slug: legacy, exists: true };
448
+ }
449
+ }
450
+ return { slug: hashed, exists: null };
451
+ }
452
+
453
+ /**
454
+ * Replace the frontmatter `id:` line of a note — used when a displaced note
455
+ * moves to its disambiguated name (#1506.2), whose `id` must match the file.
456
+ * @param {string} content
457
+ * @param {string} id
458
+ * @returns {string}
459
+ */
460
+ function withNoteId(content, id) {
461
+ return content.replace(/^id: .*$/m, `id: ${id}`);
462
+ }
463
+
426
464
  /**
427
465
  * Emit `skipped-foreign-owner` for a note the writer must not touch: one stderr
428
466
  * `SKIP` line (vault-relative path, like every other SKIP line) and the stdout
@@ -1154,26 +1192,58 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1154
1192
  // that note is masked in place first — never by rewriting it from THIS
1155
1193
  // record, which would be the #1503 overwrite triggered by a secret.
1156
1194
  //
1157
- // BV-004 ceiling: (1) the main `<slug>.md` keeps the FIRST record that
1158
- // claimed it — after a re-learn it goes stale while the current record lives
1159
- // at `<slug>-<uuid8>.md`; (2) disambiguation is ONE level deep — a
1160
- // `-<uuid8>` file owned by yet another record is skipped-foreign-owner.
1161
- // Fine while re-learns are rare (fleet census at review time: 39 slugs in 12
1162
- // repos). Revisit-Trigger: a stale main note is read as current, or a
1163
- // skipped-foreign-owner is reported on a `-<uuid8>` path.
1195
+ // #1506.2: the NEWEST record owns the main `<slug>.md` — a re-learned
1196
+ // record (strictly later `created` day) takes it over, and the displaced
1197
+ // note moves, bytes kept but `id` renamed, to its own record's
1198
+ // disambiguation path; never lost, never overwritten. #1506.1: a NEW
1199
+ // disambiguation file is suffixed with a hash of the full record key, so
1200
+ // non-UUID ids sharing a prefix no longer collide; existing `-<uuid8>`
1201
+ // files keep their names.
1202
+ //
1203
+ // BV-004 ceiling: "newest" is compared at DAY granularity (`created` is a
1204
+ // date) — a same-day re-learn keeps the current owner (stable, no churn).
1205
+ // Revisit-Trigger: a same-day re-learn is reported as a stale main note.
1206
+ //
1207
+ // BV-004 ceiling: the takeover ignores repo provenance — a NEWER record of
1208
+ // another repo mirrored into this namespace (the #1503 shape) also takes the
1209
+ // main note, and `source-repo` cannot tell it apart (both carry the writer's
1210
+ // namespace). Nothing is lost (the displaced note moves, never overwritten);
1211
+ // the CLI's `source-repo-mismatch` refusal is what keeps foreign records out.
1212
+ // Revisit-Trigger: a takeover whose displaced note belongs to this repo's
1213
+ // live store, or any mirror run accepting a foreign `--source` again.
1164
1214
  const recordMismatch = foreignOwnerReason(fm, writer);
1165
1215
  const routedByRecord = recordMismatch !== null;
1166
1216
  if (routedByRecord && maskerWouldChange(existingContent)) {
1167
1217
  healedForeignLeak = maskForeignNoteInPlace(targetPath, existingContent, ctx);
1168
1218
  }
1169
1219
 
1220
+ const entryDay = toDate(dateSource);
1221
+ if (
1222
+ routedByRecord &&
1223
+ fm['id'] === slug &&
1224
+ !expired &&
1225
+ /^\d{4}-\d{2}-\d{2}$/.test(entryDay) &&
1226
+ typeof fm['created'] === 'string' &&
1227
+ entryDay > fm['created']
1228
+ ) {
1229
+ const takenOver = takeOverMainNote({
1230
+ fm, slug, targetDir, targetPath, entry, entryId, writer, generator, generatorOpts,
1231
+ healedForeignLeak, lineNum: _lineNum, ctx,
1232
+ });
1233
+ if (takenOver !== null) return takenOver;
1234
+ }
1235
+
1170
1236
  if (fm['id'] !== slug || routedByRecord) {
1171
1237
  // Different id (or different record) → collision: disambiguate
1172
- const disambigSlug = `${slug}-${uuidPrefix8(entryId)}`;
1238
+ const picked = disambigSlugFor(targetDir, slug, {
1239
+ legacyId: String(entryId),
1240
+ recordKey: writer.recordKey ?? String(entryId),
1241
+ });
1242
+ const disambigSlug = picked.slug;
1173
1243
  targetPath = join(targetDir, `${disambigSlug}.md`);
1174
1244
  slug = disambigSlug;
1175
1245
 
1176
- if (existsSync(targetPath)) {
1246
+ if (picked.exists ?? existsSync(targetPath)) {
1177
1247
  // Still exists with disambig — check if it's ours
1178
1248
  const disambigContent = readFileSync(targetPath, 'utf8');
1179
1249
  const disambigFm = parseFrontmatter(disambigContent);
@@ -1241,28 +1311,55 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1241
1311
  });
1242
1312
  }
1243
1313
 
1314
+ // #1506.2: the main note is this record's — a live disambiguation copy of
1315
+ // the same record (left by an interrupted takeover) is superseded.
1316
+ const stale = fm['source-record'] === writer.recordKey
1317
+ ? staleOwnDisambig(targetDir, slug, entryId, writer)
1318
+ : null;
1319
+
1244
1320
  // Same id: check if updated would advance (unless --force overrides).
1245
1321
  // Even when the date has not advanced, content may have changed (confidence,
1246
1322
  // insight, expires_at, etc.) — compare canonical fields before skipping.
1247
1323
  const entryUpdated = toDate(dateSource);
1324
+ let mainUnchanged = false;
1248
1325
  if (!force && fm['updated'] && fm['updated'] >= entryUpdated) {
1249
1326
  const candidateContent = generator(entry, slug, generatorOpts);
1250
1327
  if (learningContentMatches(existingContent, candidateContent)) {
1251
1328
  // #1028 residue 1: probe the on-disk content against the CURRENT masker
1252
1329
  // before trusting the five-field match — see maskerWouldChange above.
1253
- const stillLeaks = maskerWouldChange(existingContent);
1254
- if (!stillLeaks) {
1255
- return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: slug });
1256
- }
1330
+ mainUnchanged = !maskerWouldChange(existingContent);
1257
1331
  }
1258
1332
  // Content differs, or the on-disk note still leaks under the current env —
1259
1333
  // fall through to overwrite (same path as date-advance branch).
1260
1334
  }
1261
1335
 
1336
+ // #1519.4: archive the stale copy AND refresh the main note in the SAME run
1337
+ // (previously the stale branch returned first, so a simultaneous record
1338
+ // change reached the main note one run later). Stale copy first: a crash
1339
+ // between the two writes leaves an archived copy beside a not-yet-refreshed
1340
+ // main note, which the next run's content diff completes.
1341
+ if (stale !== null && !dryRun) writeFileSync(stale.path, stale.next, 'utf8');
1342
+ if (mainUnchanged) {
1343
+ if (stale !== null) {
1344
+ return emitEntryAction(_lineNum, ctx, {
1345
+ action: 'updated',
1346
+ path: stale.path,
1347
+ id: basename(stale.path, '.md'),
1348
+ meta: { archived_reason: 'superseded' },
1349
+ });
1350
+ }
1351
+ return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: slug });
1352
+ }
1353
+
1262
1354
  // Overwrite with advanced updated date (or forced re-render)
1263
1355
  const content = generator(entry, slug, generatorOpts);
1264
1356
  if (!dryRun) writeFileSync(targetPath, content, 'utf8');
1265
- return emitEntryAction(_lineNum, ctx, { action: 'updated', path: targetPath, id: slug });
1357
+ return emitEntryAction(_lineNum, ctx, {
1358
+ action: 'updated',
1359
+ path: targetPath,
1360
+ id: slug,
1361
+ ...(stale !== null ? { meta: { superseded_id: basename(stale.path, '.md') } } : {}),
1362
+ });
1266
1363
  }
1267
1364
 
1268
1365
  // File does not exist — create
@@ -1282,9 +1379,85 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1282
1379
  }
1283
1380
 
1284
1381
  /**
1285
- * The note slugs a learning record can live under: the canonical slug and, for
1286
- * v1, the pre-#725 legacy variant. Used by the CLI to keep the archive pass off
1287
- * notes a LIVE record still owns (#1513).
1382
+ * The writer's own disambiguation note when it is stale (#1506.2): the writer's
1383
+ * record owns the main note, so a mirror-owned, not-yet-archived note of the
1384
+ * SAME record (explicit `source-record` match) at its disambiguation name is a
1385
+ * second live copy — it is marked `archived` / `superseded`. Consulted by the
1386
+ * takeover and on every run where the main note is already ours, so a crash
1387
+ * between a takeover's writes heals on the next run.
1388
+ *
1389
+ * @returns {{ path: string, next: string }|null} the rewrite, or null.
1390
+ */
1391
+ function staleOwnDisambig(targetDir, slug, entryId, writer) {
1392
+ if (typeof writer.recordKey !== 'string') return null;
1393
+ const own = disambigSlugFor(targetDir, slug, { legacyId: String(entryId), recordKey: writer.recordKey });
1394
+ const path = join(targetDir, `${own.slug}.md`);
1395
+ if (!(own.exists ?? existsSync(path))) return null;
1396
+ const content = readFileSync(path, 'utf8');
1397
+ const fm = parseFrontmatter(content);
1398
+ if (notOurGenerator(fm) !== null || fm['source-record'] !== writer.recordKey || fm['id'] !== own.slug) return null;
1399
+ const next = markArchived(content, 'superseded');
1400
+ return next === content ? null : { path, next };
1401
+ }
1402
+
1403
+ /**
1404
+ * #1506.2 takeover: the writer's record is newer than the record that owns the
1405
+ * main note, so the writer claims `<slug>.md`. The displaced note moves to its
1406
+ * own record's disambiguation path (`id` renamed, bytes otherwise kept); a
1407
+ * stale disambiguation note of the writer's own record (written while the older
1408
+ * record held the main note) is marked `archived` / `superseded`.
1409
+ *
1410
+ * Returns `null` — leaving the caller on the plain disambiguation route — when
1411
+ * the displaced record's target path is occupied by a note that is not ours or
1412
+ * belongs to yet another record: a takeover must never overwrite anything.
1413
+ *
1414
+ * @returns {string|null} the emitted action, or null.
1415
+ */
1416
+ function takeOverMainNote({
1417
+ fm, slug, targetDir, targetPath, entry, entryId, writer, generator, generatorOpts, healedForeignLeak, lineNum, ctx,
1418
+ }) {
1419
+ const { dryRun } = ctx;
1420
+ const displacedKey = fm['source-record'];
1421
+ const displaced = disambigSlugFor(targetDir, slug, { legacyId: displacedKey, recordKey: displacedKey });
1422
+ const displacedPath = join(targetDir, `${displaced.slug}.md`);
1423
+ if (displaced.exists !== true && existsSync(displacedPath)) {
1424
+ const dfm = parseFrontmatter(readFileSync(displacedPath, 'utf8'));
1425
+ if (notOurGenerator(dfm) !== null || dfm['source-record'] !== displacedKey) return null;
1426
+ }
1427
+
1428
+ const ownStale = staleOwnDisambig(targetDir, slug, entryId, writer);
1429
+
1430
+ if (!dryRun) {
1431
+ // Re-read: an in-place leak heal above may have rewritten the note.
1432
+ const current = readFileSync(targetPath, 'utf8');
1433
+ // Rewritten even when it exists (and is ours, checked above): a stale copy
1434
+ // there may carry `status: archived` from an earlier archive pass.
1435
+ writeFileSync(displacedPath, withNoteId(current, displaced.slug), 'utf8');
1436
+ // Before the main note: a crash between the two writes must leave the
1437
+ // takeover re-runnable (the main note still foreign), never a main note of
1438
+ // ours beside a stale live disambiguation note of the same record.
1439
+ if (ownStale !== null && ownStale.path !== displacedPath) writeFileSync(ownStale.path, ownStale.next, 'utf8');
1440
+ writeFileSync(targetPath, generator(entry, slug, generatorOpts), 'utf8');
1441
+ }
1442
+ return emitEntryAction(lineNum, ctx, {
1443
+ action: 'updated',
1444
+ path: targetPath,
1445
+ id: slug,
1446
+ meta: {
1447
+ reason: 'newer record owns the main note',
1448
+ displaced_id: displaced.slug,
1449
+ ...(healedForeignLeak ? { healed_leak: true } : {}),
1450
+ },
1451
+ });
1452
+ }
1453
+
1454
+ /**
1455
+ * The note slugs a learning record can live under: the canonical slug, for v1
1456
+ * the pre-#725 legacy variant, and its two disambiguation names — the
1457
+ * pre-#1506 `-<uuid8>` and the full-key `-<hash8>` (#1506.1). Used by the CLI
1458
+ * to keep the archive pass off notes a LIVE record still owns (#1513). The
1459
+ * disambiguation names are record-specific, so a record displaced from the
1460
+ * main note by a re-learn (#1506.2) is still archived once it leaves the store.
1288
1461
  *
1289
1462
  * @param {object} rawEntry
1290
1463
  * @returns {string[]}
@@ -1298,6 +1471,10 @@ export function learningNoteSlugs(rawEntry) {
1298
1471
  const legacy = legacyConcatSlug(entry.subject);
1299
1472
  if (isValidSlug(legacy) && !slugs.includes(legacy)) slugs.push(legacy);
1300
1473
  }
1474
+ const id = String(entry.id);
1475
+ for (const name of [`${slugs[0]}-${uuidPrefix8(id)}`, `${slugs[0]}-${recordKeySuffix8(learningSourceRecord(entry) ?? id)}`]) {
1476
+ if (!slugs.includes(name)) slugs.push(name);
1477
+ }
1301
1478
  return slugs;
1302
1479
  }
1303
1480
 
@@ -1305,12 +1482,12 @@ export function learningNoteSlugs(rawEntry) {
1305
1482
  * Mark the existing note of a record that left `learnings.jsonl` (#1513).
1306
1483
  *
1307
1484
  * Input is one `learnings-archive.jsonl` record. Its note — canonical slug,
1308
- * legacy slug, or the `-<uuid8>` disambiguation, in the namespace or the flat
1485
+ * legacy slug, or a `-<uuid8>`/`-<hash8>` disambiguation, in the namespace or the flat
1309
1486
  * legacy layout — gets `status: archived` + `archived-reason` (`expired` for an
1310
1487
  * `_archive_reason: expired` record, `superseded` for every other reason). The
1311
1488
  * note's bytes are otherwise kept; nothing is ever created. Ownership rules are
1312
1489
  * the same as for writes: mirror-owned, and a `source-record` naming another
1313
- * record is left alone. Slugs in `ctx.liveLearningSlugs` (records still in the
1490
+ * record is left alone. Names in `ctx.liveLearningSlugs` (records still in the
1314
1491
  * live store) are skipped, so a re-learned subject never flip-flops.
1315
1492
  *
1316
1493
  * @returns {Promise<string>} the emitted action
@@ -1322,10 +1499,14 @@ export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
1322
1499
  throw new Error(`vault-mirror: archived learning entry missing required field 'id' (id=<no id>)`);
1323
1500
  }
1324
1501
  const reason = entry._archive_reason === 'expired' ? 'expired' : 'superseded';
1325
- const slugs = learningNoteSlugs(entry);
1326
1502
  const live = ctx?.liveLearningSlugs instanceof Set ? ctx.liveLearningSlugs : new Set();
1327
- if (slugs.some((s) => live.has(s))) {
1328
- return emitEntryAction(_lineNum, ctx, {
1503
+ // Per name, not per record (#1506.2): a live re-learn owns the canonical
1504
+ // slug, but the displaced note at THIS record's disambiguation name is still
1505
+ // this record's to archive.
1506
+ const allNames = learningNoteSlugs(entry);
1507
+ const names = allNames.filter((s) => !live.has(s));
1508
+ const liveOwned = () =>
1509
+ emitEntryAction(_lineNum, ctx, {
1329
1510
  // `skipped-noop`, not a class of its own: this is the steady state of
1330
1511
  // every run, and noops stay out of the per-entry ledger (#1151).
1331
1512
  action: 'skipped-noop',
@@ -1333,7 +1514,7 @@ export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
1333
1514
  id: entry.id,
1334
1515
  meta: { reason: 'archived record: a live record owns this note' },
1335
1516
  });
1336
- }
1517
+ if (names.length === 0) return liveOwned();
1337
1518
  const repoNs = canonicalNamespace(
1338
1519
  vaultDir,
1339
1520
  resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
@@ -1341,7 +1522,6 @@ export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
1341
1522
  const nsDir = join(resolve(vaultDir), '40-learnings', repoNs);
1342
1523
  const flatDir = join(resolve(vaultDir), '40-learnings');
1343
1524
  const writer = { recordKey: learningSourceRecord(entry) };
1344
- const names = [...slugs, `${slugs[0]}-${uuidPrefix8(String(entry.id))}`];
1345
1525
  for (const dir of [nsDir, flatDir]) {
1346
1526
  for (const name of names) {
1347
1527
  const path = join(dir, `${name}.md`);
@@ -1363,6 +1543,7 @@ export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
1363
1543
  });
1364
1544
  }
1365
1545
  }
1546
+ if (names.length < allNames.length) return liveOwned();
1366
1547
  return emitEntryAction(_lineNum, ctx, {
1367
1548
  action: 'skipped-noop',
1368
1549
  path: null,
@@ -4,10 +4,11 @@
4
4
  * Exports: subjectToSlug, isValidSlug, uuidPrefix8, toDate,
5
5
  * truncateAtWord, yamlQuoteIfNeeded, parseFrontmatter,
6
6
  * slugifyTagSegment, buildTag, slugifyIdSafe, TAG_MAX_LENGTH,
7
- * resolveSourceSessionLink
7
+ * resolveSourceSessionLink, recordKeySuffix8
8
8
  */
9
9
 
10
10
  import { parseSessionId } from '../session-id.mjs';
11
+ import { digestSha256Short } from '../crypto-digest-utils.mjs';
11
12
 
12
13
  /**
13
14
  * Convert a subject string to a kebab slug.
@@ -135,6 +136,20 @@ export function uuidPrefix8(id) {
135
136
  return id.replace(/-/g, '').slice(0, 8);
136
137
  }
137
138
 
139
+ /**
140
+ * The disambiguation suffix of a NEW `<slug>-<suffix>.md` note (#1506.1): the
141
+ * first 8 hex chars of a SHA-256 over the FULL record key. `uuidPrefix8` only
142
+ * separates UUID ids — non-UUID ids sharing their first 8 chars collided, and
143
+ * the third record of a subject was never mirrored. Existing `-<uuid8>` notes
144
+ * keep their names; only newly created disambiguation files use this suffix.
145
+ *
146
+ * @param {string} recordKey - the record's `source-record` (or its raw id).
147
+ * @returns {string} 8 lowercase hex chars.
148
+ */
149
+ export function recordKeySuffix8(recordKey) {
150
+ return digestSha256Short(String(recordKey));
151
+ }
152
+
138
153
  /** Format a UTC ISO date string as YYYY-MM-DD. */
139
154
  export function toDate(isoString) {
140
155
  if (!isoString) return '';
@@ -17,8 +17,9 @@
17
17
  */
18
18
 
19
19
  import { digestSha256Short } from './crypto-digest-utils.mjs';
20
- import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs';
21
- import { dirname } from 'node:path';
20
+ import { atomicWriteWithBackup, envelopeToError } from './io.mjs';
21
+ import { readFileSync, existsSync } from 'node:fs';
22
+ import { basename } from 'node:path';
22
23
 
23
24
  // ---------------------------------------------------------------------------
24
25
  // computeSchemaHash
@@ -60,12 +61,12 @@ export function writeBaseline(filePath, { errors, warnings, schemaHash, isoTimes
60
61
  warnings,
61
62
  };
62
63
 
63
- const dir = dirname(filePath);
64
- mkdirSync(dir, { recursive: true });
65
-
66
- const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
67
- writeFileSync(tmp, JSON.stringify(doc, null, 2) + '\n', 'utf8');
68
- renameSync(tmp, filePath);
64
+ // Parent dirs are created by the helper (mkdir -p); a failure re-raises with
65
+ // the original message and `code`.
66
+ const res = atomicWriteWithBackup(filePath, JSON.stringify(doc, null, 2) + '\n', {
67
+ tmpPrefix: `.${basename(filePath)}.tmp`,
68
+ });
69
+ if (!res.ok) throw envelopeToError(res);
69
70
  }
70
71
 
71
72
  // ---------------------------------------------------------------------------
@@ -412,6 +412,11 @@ export async function dispatchRemote(
412
412
  * `Ferdinands-Macbook-2 ready=yes · load 14.23 · mem free 96% · headless: slots
413
413
  * none, keychain-route ok · 9 jobs · claude procs 12`
414
414
  *
415
+ * New doctor lines split `gate-ready` and `headless-ready`; only the latter
416
+ * authorizes Claude dispatch. Legacy `ready` remains accepted without new markers.
417
+ * `load` also holds the new `load5 (5 min)` reading; `memFreePct` holds free
418
+ * (Darwin) or available (Linux) percent. No live new-format measurement is implied.
419
+ *
415
420
  * Every metric is `null` when its segment is absent — never a fabricated `0`,
416
421
  * which would read as "measured, idle" for a host that answered nothing.
417
422
  * @param {string} raw
@@ -420,19 +425,32 @@ export async function dispatchRemote(
420
425
  */
421
426
  export function parseDoctorLine(raw) {
422
427
  const text = String(raw ?? '');
423
- const num = (re) => {
428
+ // New-format markers suppress the legacy fallback, even if their value or
429
+ // key boundary is malformed. Conflicting/unknown headless values fail closed.
430
+ const key = /\b(?:gate-ready|headless-ready)\b/i.test(text) ? 'headless-ready' : 'ready';
431
+ const values = [...text.matchAll(new RegExp(`(?:^|\\s)${key}=([^\\s]*)`, 'gi'))];
432
+ const count = (re) => {
424
433
  const m = re.exec(text);
425
434
  return m ? Number(m[1]) : null;
426
435
  };
436
+ const segments = text.split(/[·\r\n]/).map((segment) => segment.trim());
437
+ const num = (re, max = Infinity) => {
438
+ const m = segments.map((segment) => re.exec(segment)).find(Boolean);
439
+ const value = m ? Number(m[1]) : NaN;
440
+ return Number.isFinite(value) && value <= max ? value : null;
441
+ };
427
442
  return {
428
- // `ready` is the ONLY decision field: anything that is not a literal
429
- // `ready=yes` is not-ready, including an unparseable line.
430
- ready: /\bready=yes\b/i.test(text),
443
+ // Claude dispatch uses headless readiness, independently of gate readiness.
444
+ // Only the old format falls back to an exact, standalone `ready=yes` token.
445
+ ready: values.length > 0 && values.every((m) => /^yes$/i.test(m[1])),
431
446
  raw: text,
432
- load: num(/\bload\s+([0-9]+(?:\.[0-9]+)?)/i),
433
- memFreePct: num(/\bmem free\s+([0-9]+(?:\.[0-9]+)?)\s*%/i),
434
- jobs: num(/\b([0-9]+)\s+jobs?\b/i),
435
- claudeProcs: num(/\bclaude procs\s+([0-9]+)/i),
447
+ load: num(/^load(?:5\s+\(5 min\))?\s+([0-9]+(?:\.[0-9]+)?)$/i),
448
+ // The existing field holds Darwin free % or Linux available %, never kB.
449
+ memFreePct:
450
+ num(/^mem (?:free|available)\s+([0-9]+(?:\.[0-9]+)?)\s*%$/i, 100) ??
451
+ num(/^mem available\s+[0-9]+\s+kB available\s*\/\s*[0-9]+\s+kB total\s+\(([0-9]+(?:\.[0-9]+)?)% available\)$/i, 100),
452
+ jobs: count(/\b([0-9]+)\s+jobs?\b/i),
453
+ claudeProcs: count(/\bclaude procs\s+([0-9]+)/i),
436
454
  };
437
455
  }
438
456
 
@@ -491,7 +509,7 @@ export function remoteDoctor({ host, execFn = execFileSync, timeoutMs = 90_000 }
491
509
  * @param {string} alias — declared host alias (`remote-hosts[].alias`).
492
510
  * @param {object} [opts]
493
511
  * @param {Function} [opts.execFn] — `child_process.execFileSync` seam.
494
- * @returns {Promise<boolean>} true only on a literal `ready=yes` line.
512
+ * @returns {Promise<boolean>} true only for headless-ready=yes (legacy ready=yes without new markers).
495
513
  */
496
514
  export async function remoteReadyProbe(alias, { execFn } = {}) {
497
515
  try {
@@ -134,7 +134,8 @@ async function applyDecisionRules(measurements, opts) {
134
134
  * The gate does NOT probe the network — a placement decision must stay a pure
135
135
  * function of its inputs. The caller supplies a readiness WITNESS:
136
136
  * - `opts.remoteReady` — `{ [alias]: boolean }`, e.g. built from the
137
- * SessionStart `Offload m5: ready=yes` banner or `remoteDoctor()`.
137
+ * SessionStart `Offload <alias>: headless-ready=yes` banner (legacy `ready=yes` only
138
+ * without new readiness markers) or `remoteDoctor().ready`.
138
139
  * - `opts.probeFn` — `async (alias) => boolean`, consulted only for hosts the
139
140
  * `remoteReady` map does not already answer for. Default `null`. A probeFn
140
141
  * that REJECTS is read as not-ready and its message is appended to
@@ -56,14 +56,14 @@ import {
56
56
  import { homedir } from 'node:os';
57
57
  import { join, relative, isAbsolute } from 'node:path';
58
58
 
59
- import { emitEvent, sessionAttribution } from './events.mjs';
59
+ import { emitEvent, eventsFilePath, sessionAttribution } from './events.mjs';
60
60
  import { tryAcquireFileLock, releaseFileLock } from './file-lock.mjs';
61
61
  import { readLock, isLockLive } from './session-lock.mjs';
62
62
  import { resolveSubagentSidecar } from '../../hooks/_lib/subagent-paths.mjs';
63
63
  import { isMainModule } from './is-main-module.mjs';
64
+ import { getProjectDir } from './platform.mjs';
64
65
 
65
66
  const DEFAULT_INTERVAL_S = 2;
66
- const EVENTS_FILE_REL = '.orchestrator/metrics/events.jsonl';
67
67
  const WAVE_SCOPE_REL = '.claude/wave-scope.json';
68
68
 
69
69
  /**
@@ -785,7 +785,11 @@ function sleep(ms) {
785
785
  * @param {number} args.intervalS
786
786
  */
787
787
  async function tailLoop({ intervalS }) {
788
- const repoRoot = process.env.CLAUDE_PLUGIN_ROOT || process.cwd();
788
+ // #1517: the PROJECT root (CLAUDE_PROJECT_DIR > CLAUDE.md or AGENTS.md / .git walk-up > cwd).
789
+ // CLAUDE_PLUGIN_ROOT is the installed plugin directory — taking it as the root
790
+ // put the singleton lock, the session.lock read and the projects-dir encoding
791
+ // inside the plugin cache.
792
+ const repoRoot = getProjectDir();
789
793
 
790
794
  // The monitor fires once per wave-executor INVOCATION, and a deep session
791
795
  // invokes it repeatedly — so refuse to be the second tailer.
@@ -813,7 +817,7 @@ async function tailLoop({ intervalS }) {
813
817
  // Restart-safety: do not re-announce findings already recorded for this
814
818
  // session. Seeded on the SEMANTIC id — the same key `report()` writes.
815
819
  try {
816
- const evPath = join(repoRoot, EVENTS_FILE_REL);
820
+ const evPath = eventsFilePath(repoRoot); // #1514 point 1: the repo's one ledger
817
821
  if (existsSync(evPath)) {
818
822
  const seeded = seedFromEvents(readFileSync(evPath, 'utf8').split('\n'), semanticSessionId, state);
819
823
  if (seeded > 0) note(`seeded ${seeded} prior finding counters from events.jsonl`);
@@ -13,6 +13,7 @@
13
13
  import fs from 'node:fs/promises';
14
14
  import path from 'node:path';
15
15
  import { WORKTREE_META_DIR } from './constants.mjs';
16
+ import { atomicWriteText, envelopeToError } from '../io.mjs';
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Public helpers
@@ -58,7 +59,10 @@ export async function _writeWorktreeMeta(suffix, { branch, wtPath, baseRef, base
58
59
  createdAt: new Date().toISOString(),
59
60
  };
60
61
 
61
- const tmpPath = `${metaPath}.tmp`;
62
- await fs.writeFile(tmpPath, JSON.stringify(meta, null, 2), 'utf8');
63
- await fs.rename(tmpPath, metaPath);
62
+ // Random tmp suffix: the former fixed `<meta>.tmp` name let two concurrent
63
+ // writers of the same suffix clobber each other's tmp (#1032).
64
+ const res = await atomicWriteText(metaPath, JSON.stringify(meta, null, 2), {
65
+ tmpPrefix: `.${suffix}.json.tmp`,
66
+ });
67
+ if (!res.ok) throw envelopeToError(res);
64
68
  }