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
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
* Part of #503 (Wave 2; sibling to schema.mjs / reader.mjs / merger.mjs).
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import {
|
|
25
|
+
import { mkdir } from 'node:fs/promises';
|
|
26
26
|
import { join } from 'node:path';
|
|
27
|
-
import {
|
|
27
|
+
import { atomicWriteText, envelopeToError } from '../io.mjs';
|
|
28
28
|
|
|
29
29
|
import { validatePeerCardFrontmatter } from './schema.mjs';
|
|
30
30
|
|
|
@@ -118,21 +118,9 @@ export async function writePeerCard(repoRoot, target, card) {
|
|
|
118
118
|
|
|
119
119
|
const bodyStr = typeof card.body === 'string' ? card.body : '';
|
|
120
120
|
const content = `${serializeFrontmatter(fm)}\n\n${bodyStr.trimStart()}`;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
await rename(tmpPath, finalPath);
|
|
126
|
-
return { ok: true, path: finalPath };
|
|
127
|
-
} catch (err) {
|
|
128
|
-
// Best-effort cleanup of orphaned tmp file. Swallow secondary failures —
|
|
129
|
-
// the primary error is what the caller needs to see.
|
|
130
|
-
try {
|
|
131
|
-
await unlink(tmpPath);
|
|
132
|
-
} catch {
|
|
133
|
-
/* tmp file may not exist if writeFile failed before creating it */
|
|
134
|
-
}
|
|
135
|
-
throw err;
|
|
136
|
-
}
|
|
121
|
+
// The helper unlinks its own tmp on failure; re-raise with the same code.
|
|
122
|
+
const res = await atomicWriteText(finalPath, content, { tmpPrefix: `.${filename}.tmp` });
|
|
123
|
+
if (!res.ok) throw envelopeToError(res);
|
|
124
|
+
return { ok: true, path: finalPath };
|
|
137
125
|
}
|
|
138
126
|
|
package/scripts/lib/platform.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* NOTHING in this module touches the filesystem at import time.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
16
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { resolvePluginRoot as _resolvePluginRootRobust } from './plugin-root.mjs';
|
|
@@ -354,9 +354,14 @@ function _isStrictlyInside(child, parent) {
|
|
|
354
354
|
|
|
355
355
|
/**
|
|
356
356
|
* The SESSION ROOT (#1492) — the working copy whose control files belong to the
|
|
357
|
-
* session a hook fires in: `wave-scope.json`, the
|
|
358
|
-
*
|
|
359
|
-
* enforce-commands, post-bash-write-verify,
|
|
357
|
+
* session a hook fires in: `wave-scope.json`, the `filescopes/` manifests, the
|
|
358
|
+
* `.orchestrator/` dispatch ledger. One resolver for every hook that reads them
|
|
359
|
+
* (enforce-scope, enforce-commands, post-bash-write-verify,
|
|
360
|
+
* pre-task-scope-disjoint). The event records are written via
|
|
361
|
+
* `eventsFilePath(<session root>)` (`scripts/lib/events.mjs`), which maps a
|
|
362
|
+
* linked worktree to its main checkout — ONE ledger per repo (owner decision on
|
|
363
|
+
* #1514 point 1), so a worktree session's events stay readable after the
|
|
364
|
+
* worktree is gone.
|
|
360
365
|
*
|
|
361
366
|
* NOT the launch dir whenever the session works elsewhere: `$CLAUDE_PROJECT_DIR`
|
|
362
367
|
* stays on the dir the session was LAUNCHED in after it enters a worktree
|
|
@@ -453,6 +458,109 @@ export function resolveSessionRoot(cwd, toplevel = '') {
|
|
|
453
458
|
return launch !== '' && _isStrictlyInside(launch, root) ? launch : root;
|
|
454
459
|
}
|
|
455
460
|
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// resolveEventsLedgerRoot (#1514 point 1)
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
/** Repo root holding a `.git` entry → its main checkout ('' = none). Per process. */
|
|
466
|
+
const _mainCheckoutMemo = new Map();
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The MAIN checkout of the linked git worktree rooted at `root` (whose `.git`
|
|
470
|
+
* is a FILE), or `''` when `root` is no linked worktree of a non-bare repo or
|
|
471
|
+
* its git metadata cannot be read.
|
|
472
|
+
*
|
|
473
|
+
* SPAWN-FREE, so it is safe on the hook hot path (every default `emitEvent()`):
|
|
474
|
+
* git's own on-disk layout answers it. A linked worktree's `.git` file reads
|
|
475
|
+
* `gitdir: <X>` (X = `<common>/worktrees/<name>`), and `<X>/commondir` names the
|
|
476
|
+
* common dir relative to X. Only a common dir named `.git` counts — its parent
|
|
477
|
+
* is the main checkout. A submodule (`gitdir:` into `.git/modules/<name>`, no
|
|
478
|
+
* `commondir` file) and a bare repo's worktree (common dir `repo.git`) fall
|
|
479
|
+
* through to `''`. Never throws.
|
|
480
|
+
*
|
|
481
|
+
* @param {string} root a directory holding a `.git` entry
|
|
482
|
+
* @returns {string}
|
|
483
|
+
*/
|
|
484
|
+
function _mainCheckoutOfLinkedWorktree(root) {
|
|
485
|
+
if (_mainCheckoutMemo.has(root)) return _mainCheckoutMemo.get(root);
|
|
486
|
+
let main = '';
|
|
487
|
+
try {
|
|
488
|
+
const dotGit = path.join(root, '.git');
|
|
489
|
+
if (statSync(dotGit).isFile()) {
|
|
490
|
+
const match = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotGit, 'utf8'));
|
|
491
|
+
if (match) {
|
|
492
|
+
const gitDir = path.resolve(root, match[1]);
|
|
493
|
+
const commonDir = path.resolve(gitDir, readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim());
|
|
494
|
+
if (path.basename(commonDir) === '.git' && commonDir !== gitDir) main = path.dirname(commonDir);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
} catch {
|
|
498
|
+
main = ''; // unreadable metadata, no `commondir` (a submodule) — the caller falls back
|
|
499
|
+
}
|
|
500
|
+
_mainCheckoutMemo.set(root, main);
|
|
501
|
+
return main;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* `dir` mapped from a linked worktree to the SAME relative spot in its main
|
|
506
|
+
* checkout (a launch in a worktree's subdirectory keeps its subdirectory), or
|
|
507
|
+
* `''` when `dir` sits in no linked worktree.
|
|
508
|
+
*
|
|
509
|
+
* @param {string} dir absolute
|
|
510
|
+
* @returns {string}
|
|
511
|
+
*/
|
|
512
|
+
function _inMainCheckout(dir) {
|
|
513
|
+
const ancestor = dotGitAncestor(dir);
|
|
514
|
+
if (!ancestor) return '';
|
|
515
|
+
const main = _mainCheckoutOfLinkedWorktree(ancestor);
|
|
516
|
+
return main ? path.join(main, path.relative(ancestor, dir)) : '';
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The root of the ONE events ledger (`<root>/.orchestrator/metrics/events.jsonl`)
|
|
521
|
+
* — owner decision on #1514 point 1: "Event-Ledger immer im Haupt-Checkout".
|
|
522
|
+
*
|
|
523
|
+
* Precedence:
|
|
524
|
+
* 1. the launch dir from env ({@link launchDirFromEnv}, `path.resolve`d) —
|
|
525
|
+
* mapped to the main checkout when it IS a linked worktree. A session
|
|
526
|
+
* LAUNCHED in a worktree (headless, offload, Codex) hands its hooks
|
|
527
|
+
* `$CLAUDE_PROJECT_DIR` = the worktree; without the mapping its hooks wrote
|
|
528
|
+
* the worktree's ledger while its scripts (rung 2) wrote the main one;
|
|
529
|
+
* 2. when `cwd` sits in a LINKED git worktree, that repo's main checkout.
|
|
530
|
+
* Measured 2026-10-03: a Bash-tool command does NOT get
|
|
531
|
+
* `$CLAUDE_PROJECT_DIR`, and after `EnterWorktree` its cwd is the
|
|
532
|
+
* worktree — without this rung a coordinator-run script (`scope-echo
|
|
533
|
+
* --verify`, `materialize-wave-scope`) wrote and read the worktree's
|
|
534
|
+
* ledger while the hooks wrote the launch dir's;
|
|
535
|
+
* 3. {@link getProjectDir} — the pre-#1514 answer, unchanged for a plain
|
|
536
|
+
* repo, a bare repo's worktree, a submodule and a non-git directory.
|
|
537
|
+
*
|
|
538
|
+
* Spawn-free (see {@link _mainCheckoutOfLinkedWorktree}). Deliberately NOT
|
|
539
|
+
* folded into `resolveProjectDir()`: every other consumer of the project dir
|
|
540
|
+
* (config, STATE.md, manifests) keeps its answer.
|
|
541
|
+
*
|
|
542
|
+
* `repoRoot` given: the same mapping applied to that root — for a caller that
|
|
543
|
+
* holds a repo root and wants THE ledger of that repo.
|
|
544
|
+
*
|
|
545
|
+
* @param {string} [repoRoot] an explicit root to map (skips the env/cwd rungs)
|
|
546
|
+
* @param {string} [cwd=process.cwd()]
|
|
547
|
+
* @returns {string} Absolute path
|
|
548
|
+
*/
|
|
549
|
+
export function resolveEventsLedgerRoot(repoRoot, cwd = process.cwd()) {
|
|
550
|
+
if (typeof repoRoot === 'string' && repoRoot.trim() !== '') {
|
|
551
|
+
const root = path.resolve(repoRoot);
|
|
552
|
+
return _inMainCheckout(root) || root;
|
|
553
|
+
}
|
|
554
|
+
const launchRaw = launchDirFromEnv();
|
|
555
|
+
if (launchRaw !== '') {
|
|
556
|
+
const launch = path.resolve(launchRaw);
|
|
557
|
+
return _inMainCheckout(launch) || launch;
|
|
558
|
+
}
|
|
559
|
+
const ancestor = dotGitAncestor(path.resolve(cwd));
|
|
560
|
+
const main = ancestor ? _mainCheckoutOfLinkedWorktree(ancestor) : '';
|
|
561
|
+
return main || getProjectDir();
|
|
562
|
+
}
|
|
563
|
+
|
|
456
564
|
// ---------------------------------------------------------------------------
|
|
457
565
|
// resolveStateDir
|
|
458
566
|
// ---------------------------------------------------------------------------
|
|
@@ -595,6 +703,7 @@ export function _resetPlatformCache() {
|
|
|
595
703
|
_cache.projectDir = undefined;
|
|
596
704
|
_cache.stateDir = undefined;
|
|
597
705
|
_cache.configFile = undefined;
|
|
706
|
+
_mainCheckoutMemo.clear();
|
|
598
707
|
}
|
|
599
708
|
|
|
600
709
|
// The deprecated `export let SO_PLATFORM / SO_PLUGIN_ROOT / SO_PROJECT_DIR /
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin-package-stage.mjs — the host-local packed copy of this package that
|
|
3
|
+
* every harness marketplace reads instead of the working checkout.
|
|
4
|
+
*
|
|
5
|
+
* WHY: a marketplace registered on the clone copies ALL of it into the
|
|
6
|
+
* harness's plugin cache. Measured 2026-10-03: Claude Code cached 7557 files
|
|
7
|
+
* instead of 1164, `.env.local` (NPM_TOKEN) and `.orchestrator/` included
|
|
8
|
+
* (#1515); the Codex cache on the same host held 15742 files, `.env.local`,
|
|
9
|
+
* `.orchestrator/` and `tests/` included (#1518). `npm pack` carries only the
|
|
10
|
+
* package.json `files` allowlist, so the staged copy cannot leak the rest.
|
|
11
|
+
*
|
|
12
|
+
* Shared by scripts/self-update.mjs (Claude Code) and scripts/codex-install.mjs
|
|
13
|
+
* (Codex): one stage dir, one staging routine.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { spawnNativeSync } from './native-command.mjs';
|
|
22
|
+
|
|
23
|
+
const COMMAND_TIMEOUT_MS = 300_000;
|
|
24
|
+
const WORK_PREFIX = '.stage-';
|
|
25
|
+
// A work dir older than this is the leftover of a killed run (the finally never
|
|
26
|
+
// ran). Ceiling: a concurrent stage that itself runs longer than 60 min would be
|
|
27
|
+
// swept mid-run — npm ci is the slow step at ~minutes; revisit if that changes.
|
|
28
|
+
const STALE_WORK_MS = 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Remove `.stage-*` work dirs in `parent` older than STALE_WORK_MS. Only real
|
|
32
|
+
* directories carrying this helper's exact prefix; a symlink is never followed
|
|
33
|
+
* or removed. Best effort: a sweep failure never blocks staging.
|
|
34
|
+
* @param {string} parent
|
|
35
|
+
* @param {number} [now]
|
|
36
|
+
*/
|
|
37
|
+
export function sweepStaleWorkDirs(parent, now = Date.now()) {
|
|
38
|
+
let entries;
|
|
39
|
+
try { entries = readdirSync(parent, { withFileTypes: true }); } catch { return; }
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (!entry.name.startsWith(WORK_PREFIX) || !entry.isDirectory()) continue;
|
|
42
|
+
const dir = path.join(parent, entry.name);
|
|
43
|
+
try {
|
|
44
|
+
const st = lstatSync(dir);
|
|
45
|
+
if (st.isDirectory() && !st.isSymbolicLink() && now - st.mtimeMs > STALE_WORK_MS) {
|
|
46
|
+
rmSync(dir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
} catch { /* best effort */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `<XDG_CACHE_HOME or ~/.cache>/session-orchestrator/plugin-package`. The XDG
|
|
54
|
+
* spec says a relative XDG_CACHE_HOME is invalid and must be ignored.
|
|
55
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
56
|
+
* @param {string} [home]
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function resolveStageDir(env = process.env, home = os.homedir()) {
|
|
60
|
+
const xdg = (env.XDG_CACHE_HOME || '').trim();
|
|
61
|
+
return path.join(path.isAbsolute(xdg) ? xdg : path.join(home, '.cache'), 'session-orchestrator', 'plugin-package');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Tarball filename from `npm pack --json`. npm <= 11 emits `[ { filename } ]`,
|
|
66
|
+
* npm >= 12.0.2 `{ "<name>": { filename } }` (same split pack-policy-floor.test
|
|
67
|
+
* documents for `files`).
|
|
68
|
+
* @param {unknown} packJson
|
|
69
|
+
* @returns {string | null}
|
|
70
|
+
*/
|
|
71
|
+
export function packedFilename(packJson) {
|
|
72
|
+
const entry = Array.isArray(packJson) ? packJson[0] : Object.values(packJson ?? {})[0];
|
|
73
|
+
return typeof entry?.filename === 'string' && entry.filename !== '' ? entry.filename : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Minimal command runner with the shape stagePackage expects.
|
|
78
|
+
* @returns {{ok: true, stdout: string} | {ok: false, detail: string}}
|
|
79
|
+
*/
|
|
80
|
+
export function spawnRun(cmd, args, { cwd, env } = {}) {
|
|
81
|
+
const shown = `${cmd} ${args.join(' ')}`;
|
|
82
|
+
const r = spawnNativeSync(cmd, args, { cwd, env, encoding: 'utf8', timeout: COMMAND_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
|
|
83
|
+
if (r.error) return { ok: false, detail: `${shown}: ${r.error.message}` };
|
|
84
|
+
if (r.status !== 0) {
|
|
85
|
+
return { ok: false, detail: `${shown} exited ${r.status}: ${(r.stderr || r.stdout || '').trim().slice(-400)}` };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, stdout: r.stdout };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Replace `stageDir` with the extracted `npm pack` of `soRoot`. The work dir
|
|
92
|
+
* sits beside `stageDir` so every rename stays on one filesystem, and it is
|
|
93
|
+
* removed on every path out — the previous stage is only swapped out once the
|
|
94
|
+
* new one is complete and valid, and is put back if the swap itself fails.
|
|
95
|
+
*
|
|
96
|
+
* @param {{soRoot: string, stageDir: string, run?: Function, log?: (line: string) => void,
|
|
97
|
+
* dryRun?: boolean, installDeps?: boolean,
|
|
98
|
+
* validate?: (dir: string) => ({ok: true} | {ok: false, detail: string})}} options
|
|
99
|
+
* `installDeps` runs `npm ci --omit=dev --ignore-scripts` in the new copy
|
|
100
|
+
* before the swap, for a harness that copies the marketplace as-is (Codex).
|
|
101
|
+
* `validate` vets the new copy before the swap; a failure keeps the old stage.
|
|
102
|
+
* @returns {{ok: true} | {ok: false, detail: string}}
|
|
103
|
+
*/
|
|
104
|
+
export function stagePackage({
|
|
105
|
+
soRoot, stageDir, run = spawnRun, log = () => {}, dryRun = false, installDeps = false, validate,
|
|
106
|
+
}) {
|
|
107
|
+
log(`packing ${soRoot} into ${stageDir} (package files only)`);
|
|
108
|
+
if (dryRun) {
|
|
109
|
+
run('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', '<work>']);
|
|
110
|
+
run('tar', ['-xzf', '<work>/<tarball>', '-C', '<work>']);
|
|
111
|
+
if (installDeps) run('npm', ['ci', '--omit=dev', '--ignore-scripts'], { cwd: '<work>/package' });
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|
|
114
|
+
let work;
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(path.dirname(stageDir), { recursive: true });
|
|
117
|
+
sweepStaleWorkDirs(path.dirname(stageDir));
|
|
118
|
+
work = mkdtempSync(path.join(path.dirname(stageDir), WORK_PREFIX));
|
|
119
|
+
// An inherited silent loglevel suppresses the JSON parsed below.
|
|
120
|
+
const env = { ...process.env };
|
|
121
|
+
delete env.npm_config_loglevel;
|
|
122
|
+
delete env.NPM_CONFIG_LOGLEVEL;
|
|
123
|
+
// --ignore-scripts explicitly: never rely on the clone's .npmrc for prepack.
|
|
124
|
+
const pack = run('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', work], { cwd: soRoot, env });
|
|
125
|
+
if (!pack.ok) return { ok: false, detail: pack.detail };
|
|
126
|
+
let filename;
|
|
127
|
+
try { filename = packedFilename(JSON.parse(pack.stdout)); } catch { filename = null; }
|
|
128
|
+
if (!filename) return { ok: false, detail: 'npm pack --json reported no tarball filename' };
|
|
129
|
+
const untar = run('tar', ['-xzf', path.join(work, filename), '-C', work]);
|
|
130
|
+
if (!untar.ok) return { ok: false, detail: untar.detail };
|
|
131
|
+
const pkg = path.join(work, 'package');
|
|
132
|
+
// npm never packs package-lock.json, and every dependency install of the
|
|
133
|
+
// plugin copy runs `npm ci`, which refuses to run without one.
|
|
134
|
+
copyFileSync(path.join(soRoot, 'package-lock.json'), path.join(pkg, 'package-lock.json'));
|
|
135
|
+
if (installDeps) {
|
|
136
|
+
// The packed copy carries no .npmrc, so the repo's ignore-scripts=true
|
|
137
|
+
// (SEC-020) has to travel as a flag.
|
|
138
|
+
const ci = run('npm', ['ci', '--omit=dev', '--ignore-scripts'], { cwd: pkg, env });
|
|
139
|
+
if (!ci.ok) return { ok: false, detail: ci.detail };
|
|
140
|
+
}
|
|
141
|
+
if (validate) {
|
|
142
|
+
const verdict = validate(pkg);
|
|
143
|
+
if (!verdict.ok) return { ok: false, detail: verdict.detail };
|
|
144
|
+
}
|
|
145
|
+
const previous = path.join(work, 'previous');
|
|
146
|
+
const hadPrevious = existsSync(stageDir);
|
|
147
|
+
if (hadPrevious) renameSync(stageDir, previous);
|
|
148
|
+
try {
|
|
149
|
+
renameSync(pkg, stageDir);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// Put the old stage back before the finally deletes the work dir with it.
|
|
152
|
+
// If even that fails, keep the work dir: it is the only copy left.
|
|
153
|
+
if (hadPrevious) {
|
|
154
|
+
try {
|
|
155
|
+
renameSync(previous, stageDir);
|
|
156
|
+
} catch (restoreError) {
|
|
157
|
+
work = undefined;
|
|
158
|
+
throw new Error(`${error.message}; restoring the previous stage failed (${restoreError.message}) — it is kept at ${previous}`, { cause: restoreError });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
return { ok: true };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return { ok: false, detail: `staging ${stageDir}: ${error.message}` };
|
|
166
|
+
} finally {
|
|
167
|
+
if (work) rmSync(work, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin-surface.mjs — the one enumeration of the plugin's public sources
|
|
3
|
+
* (`commands/*.md`, `skills/<name>/SKILL.md`) and the one hand-written
|
|
4
|
+
* frontmatter parser the Cursor and Pi wrapper generators share (#1240).
|
|
5
|
+
*
|
|
6
|
+
* Four generators derive harness surfaces from the same sources
|
|
7
|
+
* (`generate-agents-skills.mjs`, `generate-codex-skills.mjs`,
|
|
8
|
+
* `generate-cursor-adapter.mjs`, `generate-pi-prompts.mjs`). Before this module
|
|
9
|
+
* the agents/codex pair skipped names with a leading `.` or `_` while the
|
|
10
|
+
* cursor/pi pair did not, so a `commands/_draft.md` produced Cursor and Pi
|
|
11
|
+
* wrappers but no Codex or `.agents` mirror. {@link isHiddenSourceName} is now
|
|
12
|
+
* the single NAME filter all four apply. The file/directory test is not yet
|
|
13
|
+
* shared: the list helpers here follow symlinks (statSync), while
|
|
14
|
+
* generate-codex-skills.mjs keeps its own dirent loop, which does not. No
|
|
15
|
+
* source is a symlink today (`find skills commands -maxdepth 2 -type l` → 0);
|
|
16
|
+
* revisit when one is added.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A source name with a leading `.` or `_` is private (drafts, `_shared/`
|
|
24
|
+
* helpers, dotfiles) and never becomes a public command or skill.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} name file or directory name
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isHiddenSourceName(name) {
|
|
30
|
+
return /^[._]/.test(name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isDir(p) {
|
|
34
|
+
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isFile(p) {
|
|
38
|
+
try { return statSync(p).isFile(); } catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Public `commands/*.md` file names (with extension), sorted.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} root plugin root
|
|
45
|
+
* @returns {string[]}
|
|
46
|
+
*/
|
|
47
|
+
export function listCommandFiles(root) {
|
|
48
|
+
const dir = path.join(root, 'commands');
|
|
49
|
+
if (!isDir(dir)) return [];
|
|
50
|
+
return readdirSync(dir)
|
|
51
|
+
.filter((name) => name.endsWith('.md') && !isHiddenSourceName(name) && isFile(path.join(dir, name)))
|
|
52
|
+
.sort();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Public `skills/<name>` directory names that carry a SKILL.md, sorted.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} root plugin root
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
export function listSkillDirs(root) {
|
|
62
|
+
const dir = path.join(root, 'skills');
|
|
63
|
+
if (!isDir(dir)) return [];
|
|
64
|
+
return readdirSync(dir)
|
|
65
|
+
.filter((name) => !isHiddenSourceName(name) && isDir(path.join(dir, name)) && existsSync(path.join(dir, name, 'SKILL.md')))
|
|
66
|
+
.sort();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse YAML-ish frontmatter including `>` / `|` folded scalars, returning
|
|
71
|
+
* DECODED values (one layer of surrounding quotes stripped). Not js-yaml on
|
|
72
|
+
* purpose: the Cursor/Pi wrappers re-emit these values verbatim, and every
|
|
73
|
+
* merged skill uses `description: >`, which this parser folds to its text.
|
|
74
|
+
*
|
|
75
|
+
* A UTF-8 BOM before the opening `---`, or CRLF line endings, used to make the
|
|
76
|
+
* probes below miss the block entirely: the file parsed as "no frontmatter",
|
|
77
|
+
* so every flag in it (`user-invocable`, `disable-model-invocation`) silently
|
|
78
|
+
* disappeared and the skill was demoted out of the generated surface with no
|
|
79
|
+
* diagnostic anywhere. Both are normalised away first — the same treatment
|
|
80
|
+
* `parseAgentFrontmatter` (`scripts/lib/agent-frontmatter.mjs`) gives them.
|
|
81
|
+
*
|
|
82
|
+
* NOT replaced by the shared `parseSkillFrontmatter`: that one returns the
|
|
83
|
+
* `__BLOCK_SCALAR__` sentinel for a `description: >`, which would be emitted
|
|
84
|
+
* as the wrapper description.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} content
|
|
87
|
+
* @returns {Record<string, string>}
|
|
88
|
+
*/
|
|
89
|
+
export function parseFrontmatter(content) {
|
|
90
|
+
const text = (content.charCodeAt(0) === 0xfeff ? content.slice(1) : content).replace(/\r\n?/g, '\n');
|
|
91
|
+
if (!text.startsWith('---\n')) return {};
|
|
92
|
+
const end = text.indexOf('\n---\n', 4);
|
|
93
|
+
if (end === -1) return {};
|
|
94
|
+
|
|
95
|
+
const lines = text.slice(4, end).split('\n');
|
|
96
|
+
const fields = {};
|
|
97
|
+
let i = 0;
|
|
98
|
+
while (i < lines.length) {
|
|
99
|
+
const match = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
100
|
+
if (!match) {
|
|
101
|
+
i += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const key = match[1];
|
|
105
|
+
const raw = match[2];
|
|
106
|
+
if (raw === '>' || raw === '| ' || raw === '|' || raw === '>-' || raw === '|-') {
|
|
107
|
+
const folded = [];
|
|
108
|
+
i += 1;
|
|
109
|
+
while (i < lines.length && (lines[i].startsWith(' ') || lines[i].startsWith('\t') || lines[i] === '')) {
|
|
110
|
+
folded.push(lines[i].replace(/^\s+/, ''));
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
const joiner = raw.startsWith('|') ? '\n' : ' ';
|
|
114
|
+
fields[key] = folded.filter(Boolean).join(joiner).trim();
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
fields[key] = raw.replace(/^["']|["']$/g, '');
|
|
118
|
+
i += 1;
|
|
119
|
+
}
|
|
120
|
+
return fields;
|
|
121
|
+
}
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
*/
|
|
63
63
|
|
|
64
64
|
import { spawnSync } from 'node:child_process';
|
|
65
|
-
import { existsSync,
|
|
65
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
66
66
|
import { dirname, join, resolve } from 'node:path';
|
|
67
67
|
import { fileURLToPath } from 'node:url';
|
|
68
68
|
|
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
spawnInGroup,
|
|
76
76
|
} from './process-group.mjs';
|
|
77
77
|
import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
|
|
78
|
+
import { atomicWriteWithBackup, writeJsonAtomicSync } from './io.mjs';
|
|
78
79
|
import { readProcessLocalSessionIds } from './session-identity/own-session.mjs';
|
|
79
80
|
|
|
80
81
|
export { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
|
|
@@ -406,12 +407,10 @@ function writeLastGreenSha(repoRoot) {
|
|
|
406
407
|
if (sha.status !== 0 || !sha.stdout) return;
|
|
407
408
|
const head = sha.stdout.trim();
|
|
408
409
|
if (!head) return;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
writeFileSync(tmp, head + '\n', 'utf8');
|
|
414
|
-
renameSync(tmp, target);
|
|
410
|
+
// Never throws; an fs failure is the same silent best-effort as before.
|
|
411
|
+
atomicWriteWithBackup(join(repoRoot, '.orchestrator', 'runtime', 'last-green-sha.txt'), head + '\n', {
|
|
412
|
+
tmpPrefix: '.tmp-last-green-sha',
|
|
413
|
+
});
|
|
415
414
|
} catch {
|
|
416
415
|
// best-effort
|
|
417
416
|
}
|
|
@@ -657,14 +656,12 @@ function readCorrectiveContext(repoRoot) {
|
|
|
657
656
|
function writeDiagnosticsBundle(repoRoot, bundle) {
|
|
658
657
|
try {
|
|
659
658
|
const dir = join(repoRoot, '.orchestrator', 'metrics', 'verification-failures');
|
|
660
|
-
mkdirSync(dir, { recursive: true });
|
|
661
659
|
// Replace colons in ISO timestamp for cross-fs portability.
|
|
662
660
|
const ts = new Date().toISOString().replace(/:/g, '-');
|
|
663
661
|
const target = join(dir, `${ts}.json`);
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
return target;
|
|
662
|
+
// Same bytes as before: indent 2 + trailing newline, mkdir -p of `dir`.
|
|
663
|
+
const res = writeJsonAtomicSync(target, redactDiagnosticsBundle(bundle), { tmpPrefix: '.tmp-diagnostics' });
|
|
664
|
+
return res.ok ? target : null;
|
|
668
665
|
} catch {
|
|
669
666
|
return null;
|
|
670
667
|
}
|
|
@@ -842,19 +839,27 @@ export function gateKillFields(run) {
|
|
|
842
839
|
* through two LLM hops: `skills/wave-executor/wave-loop.md` step 7 hand-writes
|
|
843
840
|
* them, `skills/session-end/metrics-collection.md` § 1.7 parses them back out of
|
|
844
841
|
* the STATE.md Wave History header into sessions.jsonl. `counts` is a SECOND,
|
|
845
|
-
* machine-measured emission of the same fact
|
|
846
|
-
*
|
|
847
|
-
* 4404
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
* `
|
|
842
|
+
* machine-measured emission of the same fact. Measured 2026-10-05 against this
|
|
843
|
+
* repo's `.orchestrator/metrics/events.jsonl` (main checkout @ 1386a98e; the
|
|
844
|
+
* file has been rotated since the 4404-record 2026-07-31 reading this note
|
|
845
|
+
* used to quote): 82 `orchestrator.quality_gate.*` records, 46 carrying
|
|
846
|
+
* `counts` (`grep '"orchestrator.quality_gate' <file> | grep -c '"counts"'`).
|
|
847
|
+
*
|
|
848
|
+
* `waves[].*` is PER-WAVE, so the event needs a per-wave key. The
|
|
849
|
+
* `scripts/run-quality-gate.mjs` wrapper emits `wave_number` when a wave-scope
|
|
850
|
+
* sidecar names one (#966 step 1, `resolveWaveNumber`) and OMITS it otherwise —
|
|
851
|
+
* 37 of the same 82 records carry it
|
|
852
|
+
* (`grep '"orchestrator.quality_gate' <file> | grep -c '"wave_number"'`).
|
|
853
|
+
* Readers exist: `skills/session-end/metrics-collection.md` § 1.7 reads
|
|
854
|
+
* `counts` by `wave_number` first and keeps the STATE.md header only as a
|
|
855
|
+
* fallback (#966 step 3), and `scripts/lib/convergence-monitor.mjs` folds a
|
|
856
|
+
* gate record only when it carries both. THIS emitter still sets no
|
|
857
|
+
* `wave_number` — its records are mid-wave auto-fix retries, so they fall to
|
|
858
|
+
* that fallback by design. A record without the key could only be attributed
|
|
859
|
+
* to a wave by a wall-clock window whose own boundaries (`waves[].started_at`
|
|
860
|
+
* / `completed_at`) are themselves LLM-written, against the posture
|
|
861
|
+
* `scripts/lib/eval/session-resolve.mjs` documents for window-attributed gate
|
|
862
|
+
* events ("a contaminated window means gate-attribution is unsafe").
|
|
858
863
|
*
|
|
859
864
|
* Note also that THIS emitter only runs under `verification-auto-fix.enabled:
|
|
860
865
|
* true` (default `false`, and `false` in this repo's Session Config). The gate
|
|
@@ -57,9 +57,10 @@
|
|
|
57
57
|
* (absence reads as `'written'`, the only outcome that existed then).
|
|
58
58
|
*/
|
|
59
59
|
|
|
60
|
-
import {
|
|
61
|
-
import {
|
|
62
|
-
import { createHash
|
|
60
|
+
import { readFileSync } from 'node:fs';
|
|
61
|
+
import { isAbsolute, join } from 'node:path';
|
|
62
|
+
import { createHash } from 'node:crypto';
|
|
63
|
+
import { atomicWriteWithBackup } from '../io.mjs';
|
|
63
64
|
|
|
64
65
|
/** Default repo-relative location of the reconcile-candidate work-queue. */
|
|
65
66
|
export const DEFAULT_STORE_PATH = '.orchestrator/runtime/reconcile-candidates.jsonl';
|
|
@@ -159,18 +160,16 @@ function readStore(absPath) {
|
|
|
159
160
|
* @returns {{ ok: true, lines: number } | { ok: false, reason: 'fs-error', error: string }}
|
|
160
161
|
*/
|
|
161
162
|
function writeStore(absPath, records) {
|
|
163
|
+
let content;
|
|
162
164
|
try {
|
|
163
|
-
const dir = dirname(absPath);
|
|
164
|
-
mkdirSync(dir, { recursive: true });
|
|
165
165
|
const body = records.map((r) => JSON.stringify(r)).join('\n');
|
|
166
|
-
|
|
167
|
-
const tmpFile = join(dir, `.reconcile-candidates.${randomBytes(6).toString('hex')}.tmp`);
|
|
168
|
-
writeFileSync(tmpFile, content, { encoding: 'utf8' });
|
|
169
|
-
renameSync(tmpFile, absPath);
|
|
170
|
-
return { ok: true, lines: records.length };
|
|
166
|
+
content = records.length > 0 ? body + '\n' : '';
|
|
171
167
|
} catch (err) {
|
|
172
168
|
return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
|
|
173
169
|
}
|
|
170
|
+
// mkdir -p + tmp + rename; its failure envelope is this function's own.
|
|
171
|
+
const res = atomicWriteWithBackup(absPath, content, { tmpPrefix: '.reconcile-candidates.tmp' });
|
|
172
|
+
return res.ok ? { ok: true, lines: records.length } : res;
|
|
174
173
|
}
|
|
175
174
|
|
|
176
175
|
/**
|