session-orchestrator 5.7.0 → 5.8.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 +49 -0
- package/README.md +10 -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 +4 -3
- 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/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.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.mjs +30 -25
- 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 +9 -13
- package/scripts/lib/session-schema/constants.mjs +20 -5
- package/scripts/lib/session-start-probes.mjs +2 -2
- 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-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/relocate-vault-corpus.mjs +5 -3
- package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
- package/scripts/self-update.mjs +179 -39
- package/scripts/site-numbers.mjs +4 -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 +8 -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/repo-audit/SKILL.md +2 -2
- package/skills/session-end/SKILL.md +32 -1
- 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-scope-manifest.md +4 -4
- package/skills/write-executable-plan/SKILL.md +1 -0
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Native, dependency-free MCP stdio transport. No shell, WSL, or jq required. */
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import { isSessionConfigHeading } from './lib/config/section-extractor.mjs';
|
|
8
|
+
import { isMainModule } from './lib/is-main-module.mjs';
|
|
9
|
+
|
|
10
|
+
const SERVER_INFO = { name: 'session-orchestrator', version: '2.0.0' };
|
|
11
|
+
const TOOLS = [
|
|
12
|
+
{
|
|
13
|
+
name: 'session_config',
|
|
14
|
+
description: 'Reads Session Config section from the project instruction file (CLAUDE.md or AGENTS.md alias)',
|
|
15
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'session_metrics',
|
|
19
|
+
description: 'Reads the last 5 REAL session metrics entries from .orchestrator/metrics/sessions.jsonl (abandoned phantom stubs excluded)',
|
|
20
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
const textContent = (text) => ({ content: [{ type: 'text', text }] });
|
|
24
|
+
const errorResponse = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
25
|
+
|
|
26
|
+
/** Resolve the caller's repository without passing native paths through a shell. */
|
|
27
|
+
export function resolveProjectRoot(cwd = process.cwd()) {
|
|
28
|
+
try {
|
|
29
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
30
|
+
cwd, encoding: 'utf8', windowsHide: true, timeout: 10_000,
|
|
31
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
32
|
+
}).trim();
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isFile(path, nonempty = false) {
|
|
39
|
+
try {
|
|
40
|
+
const stat = statSync(path);
|
|
41
|
+
return stat.isFile() && (!nonempty || stat.size > 0);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read the same raw config body as the legacy shell tool (including comments). */
|
|
49
|
+
export function readSessionConfig(root) {
|
|
50
|
+
const file = ['CLAUDE.md', 'AGENTS.md'].map((name) => join(root, name))
|
|
51
|
+
.find((path) => isFile(path, true));
|
|
52
|
+
if (!file) return `No CLAUDE.md or AGENTS.md found at ${root}`;
|
|
53
|
+
const body = [];
|
|
54
|
+
let inSection = false;
|
|
55
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
56
|
+
if (isSessionConfigHeading(line)) {
|
|
57
|
+
inSection = true;
|
|
58
|
+
} else if (line.startsWith('## ')) {
|
|
59
|
+
inSection = false;
|
|
60
|
+
} else if (inSection) {
|
|
61
|
+
body.push(line);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Shell command substitution removes LF bytes, not spaces or CR bytes.
|
|
65
|
+
return body.join('\n').replace(/\n+$/, '') || `No '## Session Config' section found in ${file}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read the last five real records, with whole-ledger stub count and token coverage. */
|
|
69
|
+
export function readSessionMetrics(root) {
|
|
70
|
+
const file = ['.orchestrator', '.claude'].map((dir) => join(root, dir, 'metrics', 'sessions.jsonl'))
|
|
71
|
+
.find((path) => isFile(path));
|
|
72
|
+
if (!file) return 'No metrics found (checked .orchestrator/metrics/ and .claude/metrics/)';
|
|
73
|
+
const entries = [];
|
|
74
|
+
let stubCount = 0;
|
|
75
|
+
for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
76
|
+
let record;
|
|
77
|
+
try { record = JSON.parse(line); } catch { continue; }
|
|
78
|
+
// Torn writes and non-record JSON must not hide subsequent valid sessions.
|
|
79
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
|
|
80
|
+
if (record.status === 'abandoned') { stubCount++; continue; }
|
|
81
|
+
entries.push(record);
|
|
82
|
+
if (entries.length > 5) entries.shift();
|
|
83
|
+
}
|
|
84
|
+
if (!entries.length) {
|
|
85
|
+
return stubCount ? `No real sessions (abandoned stubs excluded: ${stubCount})`
|
|
86
|
+
: 'No metrics found (file is empty)';
|
|
87
|
+
}
|
|
88
|
+
const record = entries.findLast((entry) => (entry.total_token_input !== null && entry.total_token_input !== undefined)
|
|
89
|
+
|| (entry.total_token_output !== null && entry.total_token_output !== undefined));
|
|
90
|
+
const coverage = record?.subagents_with_tokens ?? 0;
|
|
91
|
+
const summary = record
|
|
92
|
+
? `tokens: ${record.total_token_input ?? '?'} in / ${record.total_token_output ?? '?'} out`
|
|
93
|
+
+ ` (coverage: ${coverage} subagents)`
|
|
94
|
+
+ (coverage === 0 ? ' ⚠ partial — subagent token data missing; total is not a reliable cost estimate' : '')
|
|
95
|
+
+ ` [session: ${record.session_id ?? 'unknown'}]`
|
|
96
|
+
: 'tokens: no token data in last 5 sessions (subagent telemetry not yet captured)';
|
|
97
|
+
return entries.map((entry) => JSON.stringify(entry)).join('\n')
|
|
98
|
+
+ `\n\nabandoned stubs excluded: ${stubCount}\n\n--- token summary ---\n${summary}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Handle one JSON-RPC line. null means a notification or blank line, not a reply. */
|
|
102
|
+
export function handleLine(line, { cwd = process.cwd(), resolveRoot = resolveProjectRoot } = {}) {
|
|
103
|
+
if (!line.trim()) return null;
|
|
104
|
+
let request;
|
|
105
|
+
try { request = JSON.parse(line); } catch { return errorResponse(null, -32700, 'Parse error'); }
|
|
106
|
+
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
|
107
|
+
return errorResponse(null, -32600, 'Invalid Request');
|
|
108
|
+
}
|
|
109
|
+
const hasId = Object.hasOwn(request, 'id');
|
|
110
|
+
const validId = request.id === null || typeof request.id === 'string' || typeof request.id === 'number';
|
|
111
|
+
const id = hasId && validId ? request.id : null;
|
|
112
|
+
if (request.jsonrpc !== '2.0' || typeof request.method !== 'string' || (hasId && !validId)) {
|
|
113
|
+
return errorResponse(id, -32600, 'Invalid Request');
|
|
114
|
+
}
|
|
115
|
+
if (!hasId || request.method.startsWith('notifications/')) return null;
|
|
116
|
+
let result;
|
|
117
|
+
switch (request.method) {
|
|
118
|
+
case 'initialize':
|
|
119
|
+
result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: SERVER_INFO };
|
|
120
|
+
break;
|
|
121
|
+
case 'tools/list':
|
|
122
|
+
result = { tools: TOOLS };
|
|
123
|
+
break;
|
|
124
|
+
case 'tools/call': {
|
|
125
|
+
const name = request.params?.name;
|
|
126
|
+
if (typeof name !== 'string') return errorResponse(id, -32602, 'Invalid tool name');
|
|
127
|
+
if (!TOOLS.some((tool) => tool.name === name)) return errorResponse(id, -32602, `Unknown tool: ${name}`);
|
|
128
|
+
try {
|
|
129
|
+
const root = resolveRoot(cwd);
|
|
130
|
+
result = textContent(root
|
|
131
|
+
? (name === 'session_config' ? readSessionConfig(root) : readSessionMetrics(root))
|
|
132
|
+
: 'Error: not inside a git repository');
|
|
133
|
+
} catch {
|
|
134
|
+
return errorResponse(id, -32603, 'Unable to read session data');
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
default:
|
|
139
|
+
return errorResponse(id, -32601, `Method not found: ${request.method}`);
|
|
140
|
+
}
|
|
141
|
+
return { jsonrpc: '2.0', id, result };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Start newline-delimited JSON-RPC on the supplied streams. */
|
|
145
|
+
export async function serve({ input = process.stdin, output = process.stdout, cwd = process.cwd() } = {}) {
|
|
146
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
147
|
+
for await (const line of lines) {
|
|
148
|
+
const response = handleLine(line, { cwd });
|
|
149
|
+
if (response) output.write(`${JSON.stringify(response)}\n`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (isMainModule(import.meta.url)) {
|
|
154
|
+
serve().catch(() => {
|
|
155
|
+
process.stderr.write('session-orchestrator: MCP stdio transport failed.\n');
|
|
156
|
+
process.exitCode = 2;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -38,8 +38,9 @@
|
|
|
38
38
|
* "already_canonical":N,"unmappable":N,"parse_errors":N,"backup":"<path>|null"}
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
import { readFileSync,
|
|
42
|
-
import {
|
|
41
|
+
import { readFileSync, copyFileSync, existsSync } from 'node:fs';
|
|
42
|
+
import { basename } from 'node:path';
|
|
43
|
+
import { atomicWriteWithBackup, envelopeToError } from './lib/io.mjs';
|
|
43
44
|
import { validateSession, normalizeSession, ValidationError } from './lib/session-schema.mjs';
|
|
44
45
|
import { isMainModule } from './lib/is-main-module.mjs';
|
|
45
46
|
|
|
@@ -422,11 +423,12 @@ async function main() {
|
|
|
422
423
|
// Atomic write: backup → tmp → rename
|
|
423
424
|
const iso = new Date().toISOString().replace(/[:.]/g, '-');
|
|
424
425
|
const backupPath = `${args.file}.bak-${iso}`;
|
|
425
|
-
const tmpPath = join(dirname(args.file), `.${basename(args.file)}.migrate-tmp-${process.pid}`);
|
|
426
426
|
try {
|
|
427
427
|
copyFileSync(args.file, backupPath);
|
|
428
|
-
|
|
429
|
-
|
|
428
|
+
const res = atomicWriteWithBackup(args.file, outputLines.join('\n') + '\n', {
|
|
429
|
+
tmpPrefix: `.${basename(args.file)}.tmp`,
|
|
430
|
+
});
|
|
431
|
+
if (!res.ok) throw envelopeToError(res);
|
|
430
432
|
summary.backup = backupPath;
|
|
431
433
|
} catch (err) {
|
|
432
434
|
const hint = existsSync(backupPath)
|