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.
- package/.agents/skills/architecture/SKILL.md +3 -0
- package/.agents/skills/architecture/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -0
- package/.agents/skills/eval/SKILL.md +1 -1
- package/.agents/skills/frontmatter-guard/SKILL.md +1 -0
- package/.agents/skills/gitlab-portfolio/SKILL.md +1 -0
- package/.agents/skills/hook-development/SKILL.md +1 -0
- package/.agents/skills/mcp-builder/SKILL.md +1 -0
- package/.agents/skills/tmux-layout/SKILL.md +3 -0
- package/.agents/skills/tmux-layout/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +1 -0
- package/.agents/skills/vault-sync/SKILL.md +1 -0
- package/.agents/skills/write-executable-plan/SKILL.md +3 -0
- package/.agents/skills/write-executable-plan/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/architecture/SKILL.md +3 -0
- package/.codex-plugin/skills/architecture/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -0
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/frontmatter-guard/SKILL.md +1 -0
- package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +1 -0
- package/.codex-plugin/skills/hook-development/SKILL.md +1 -0
- package/.codex-plugin/skills/mcp-builder/SKILL.md +1 -0
- package/.codex-plugin/skills/tmux-layout/SKILL.md +3 -0
- package/.codex-plugin/skills/tmux-layout/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/vault-mirror/SKILL.md +1 -0
- package/.codex-plugin/skills/vault-sync/SKILL.md +1 -0
- package/.codex-plugin/skills/write-executable-plan/SKILL.md +3 -0
- package/.codex-plugin/skills/write-executable-plan/agents/openai.yaml +5 -0
- package/.cursor/commands/architecture.md +13 -0
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/tmux-layout.md +13 -0
- package/.cursor/commands/write-executable-plan.md +13 -0
- package/.cursor/skills/architecture/SKILL.md +0 -1
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/tmux-layout/SKILL.md +0 -1
- package/.cursor/skills/write-executable-plan/SKILL.md +0 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +68 -0
- package/README.md +13 -9
- package/agents/eval-judge.md +5 -5
- package/docs/baseline.md +4 -3
- package/docs/codex-setup.md +6 -4
- package/docs/components.md +5 -4
- package/docs/events-schema.md +45 -4
- package/docs/github-mirror-protection.md +3 -2
- package/docs/install.md +39 -3
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +2 -2
- package/docs/session-config-reference.md +7 -5
- package/docs/session-config-template.md +3 -2
- package/evals/README.md +60 -0
- package/evals/instruction-ablation/README.md +147 -0
- package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
- package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
- package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
- package/evals/instruction-ablation/run.mjs +336 -0
- package/evals/psa-003-foreign-work/case.yaml +16 -0
- package/evals/psa-003-foreign-work/fixture.sh +12 -0
- package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
- package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
- package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
- package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
- package/evals/psa-003-foreign-work/prompt.md +6 -0
- package/hooks/_lib/hook-import-set.json +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/post-tool-batch-wave-signal.mjs +1 -0
- package/hooks/post-tooluse-frontend-slop.mjs +11 -1
- package/hooks/pre-task-scope-disjoint.mjs +8 -6
- package/monitors/monitors.json +1 -1
- package/package.json +3 -1
- package/pi/prompts/architecture.md +11 -0
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/tmux-layout.md +11 -0
- package/pi/prompts/write-executable-plan.md +11 -0
- package/scripts/backfill-abandoned-sessions.mjs +2 -2
- package/scripts/backfill-sessions.mjs +9 -7
- package/scripts/codex-install.mjs +108 -23
- package/scripts/emit-session.mjs +15 -2
- package/scripts/fleet-auflagen.mjs +54 -0
- package/scripts/generate-agents-skills.mjs +9 -16
- package/scripts/generate-codex-skills.mjs +8 -6
- package/scripts/generate-cursor-adapter.mjs +9 -74
- package/scripts/generate-pi-prompts.mjs +8 -74
- package/scripts/github-protection-audit.mjs +8 -0
- package/scripts/lib/auto-dialectic.mjs +10 -9
- package/scripts/lib/auto-dream.mjs +8 -10
- package/scripts/lib/autopilot/telemetry.mjs +12 -6
- package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
- package/scripts/lib/ci-status-banner.mjs +38 -5
- package/scripts/lib/config/drift-check.mjs +1 -1
- package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/convergence-monitor.mjs +5 -3
- package/scripts/lib/ecosystem-health.mjs +5 -3
- package/scripts/lib/eval/engine.mjs +61 -27
- package/scripts/lib/eval/judge.mjs +14 -8
- package/scripts/lib/eval/session-resolve.mjs +7 -2
- package/scripts/lib/events-retention-banner.mjs +5 -2
- package/scripts/lib/events-schema.mjs +8 -0
- package/scripts/lib/events.mjs +19 -5
- package/scripts/lib/fleet-protocol.mjs +98 -1
- package/scripts/lib/instruction-budget-guard.mjs +25 -18
- package/scripts/lib/io.mjs +172 -35
- package/scripts/lib/learnings/candidates.mjs +7 -28
- package/scripts/lib/learnings/io.mjs +7 -2
- package/scripts/lib/learnings/judgment.mjs +5 -8
- package/scripts/lib/learnings/select.mjs +3 -3
- package/scripts/lib/maintenance-due-banner.mjs +2 -2
- package/scripts/lib/memory-proposals/sink.mjs +5 -6
- package/scripts/lib/memory-proposals/store.mjs +3 -5
- package/scripts/lib/mirror-issues-banner.mjs +11 -1
- package/scripts/lib/native-command.mjs +48 -0
- package/scripts/lib/orphan-reaper/scan.mjs +29 -3
- package/scripts/lib/peer-cards/writer.mjs +6 -18
- package/scripts/lib/platform.mjs +113 -4
- package/scripts/lib/plugin-package-stage.mjs +169 -0
- package/scripts/lib/plugin-surface.mjs +121 -0
- package/scripts/lib/quality-gate-wave.mjs +40 -0
- package/scripts/lib/quality-gate.mjs +36 -30
- package/scripts/lib/reconcile/idempotency.mjs +9 -10
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +48 -11
- package/scripts/lib/reconcile/writer.mjs +11 -17
- package/scripts/lib/rule-loader.mjs +32 -6
- package/scripts/lib/scope-echo.mjs +12 -2
- package/scripts/lib/scope-gate.mjs +2 -1
- package/scripts/lib/session-close-backfill.mjs +3 -4
- package/scripts/lib/session-id.mjs +2 -1
- package/scripts/lib/session-record-repair.mjs +28 -21
- package/scripts/lib/session-schema/constants.mjs +20 -5
- package/scripts/lib/session-schema/validator.mjs +12 -0
- package/scripts/lib/session-start-probes.mjs +22 -7
- package/scripts/lib/sessions-staleness-banner.mjs +2 -3
- package/scripts/lib/skill-evolution/engine.mjs +6 -9
- package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
- package/scripts/lib/slopcheck.mjs +9 -7
- package/scripts/lib/sunset/walker.mjs +3 -3
- package/scripts/lib/telemetry/sync.mjs +3 -1
- package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
- package/scripts/lib/test-sibling-coverage.mjs +167 -0
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
- package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
- package/scripts/lib/user-invocable-skills.mjs +37 -10
- package/scripts/lib/ux-grill/run-record.mjs +5 -3
- package/scripts/lib/validate/check-banner-parity.mjs +1 -0
- package/scripts/lib/validate/check-skills.mjs +12 -2
- package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
- package/scripts/lib/validate/check-test-value-bans.mjs +3 -2
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/vault-archive.mjs +2 -4
- package/scripts/lib/vault-mirror/process.mjs +207 -26
- package/scripts/lib/vault-mirror/utils.mjs +16 -1
- package/scripts/lib/vault-sync-baseline.mjs +9 -8
- package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
- package/scripts/lib/wave-resource-gate.mjs +2 -1
- package/scripts/lib/wave-transcript-tail.mjs +8 -4
- package/scripts/lib/worktree/meta.mjs +7 -3
- package/scripts/mcp-server.mjs +158 -0
- package/scripts/migrate-sessions-jsonl.mjs +7 -5
- package/scripts/migrate-vault-paths.mjs +7 -4
- package/scripts/print-learnings-index.mjs +2 -1
- package/scripts/release.mjs +4 -1
- package/scripts/relocate-vault-corpus.mjs +5 -3
- package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
- package/scripts/run-quality-gate.mjs +9 -39
- package/scripts/self-update.mjs +179 -39
- package/scripts/site-numbers.mjs +8 -3
- package/scripts/sweep-expired-learnings.mjs +6 -4
- package/scripts/sync-vault-schema.mjs +3 -7
- package/scripts/windows-setup.mjs +45 -0
- package/skills/_shared/fleet-protocol.md +10 -0
- package/skills/architecture/SKILL.md +1 -0
- package/skills/bootstrap/_shared-template.md +5 -2
- package/skills/claude-md-drift-check/SKILL.md +4 -1
- package/skills/claude-md-drift-check/checker.mjs +83 -10
- package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
- package/skills/eval/SKILL.md +12 -13
- package/skills/eval/rubric-v3.md +521 -0
- package/skills/frontmatter-guard/SKILL.md +1 -0
- package/skills/gitlab-portfolio/SKILL.md +1 -0
- package/skills/hook-development/SKILL.md +1 -0
- package/skills/mcp-builder/SKILL.md +1 -0
- package/skills/remote-offload/SKILL.md +1 -1
- package/skills/repo-audit/SKILL.md +2 -2
- package/skills/session-end/SKILL.md +32 -1
- package/skills/session-end/metrics-collection.md +15 -9
- package/skills/session-end/vault-operations.md +12 -2
- package/skills/session-start/SKILL.md +2 -2
- package/skills/session-start/phase-7-1-premise-check.md +2 -1
- package/skills/session-start/references/operations-contract.md +4 -1
- package/skills/tmux-layout/SKILL.md +1 -0
- package/skills/vault-mirror/SKILL.md +1 -0
- package/skills/vault-sync/SKILL.md +6 -6
- package/skills/vault-sync/validator.sh +29 -10
- package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
- package/skills/wave-executor/references/wave-loop-review.md +13 -7
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +4 -4
- 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,
|
|
20
|
-
*
|
|
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
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
|
1286
|
-
*
|
|
1287
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
|
|
1328
|
-
|
|
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 {
|
|
21
|
-
import {
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
429
|
-
//
|
|
430
|
-
ready:
|
|
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(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
await
|
|
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
|
}
|