session-orchestrator 5.3.0 → 5.5.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/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/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/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -15,11 +15,22 @@
|
|
|
15
15
|
* 1 — source rules not found, or TARGET is not an existing directory
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
readlinkSync,
|
|
23
|
+
symlinkSync,
|
|
24
|
+
statSync,
|
|
25
|
+
lstatSync,
|
|
26
|
+
unlinkSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from 'node:fs';
|
|
19
29
|
import path from 'node:path';
|
|
20
30
|
import { fileURLToPath } from 'node:url';
|
|
21
31
|
|
|
22
32
|
import { CURSOR_TO_CANONICAL_EVENT } from './lib/cursor-hook-bridge.mjs';
|
|
33
|
+
import { shellQuote as shQuote } from './lib/sh-quote.mjs';
|
|
23
34
|
|
|
24
35
|
const __filename = fileURLToPath(import.meta.url);
|
|
25
36
|
const SCRIPT_DIR = path.dirname(__filename);
|
|
@@ -62,14 +73,12 @@ function _isDir(p) {
|
|
|
62
73
|
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
63
74
|
}
|
|
64
75
|
|
|
65
|
-
function shQuote(value) {
|
|
66
|
-
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
76
|
function linkPath(source, dest, label) {
|
|
70
77
|
mkdirSync(path.dirname(dest), { recursive: true });
|
|
71
78
|
if (_isSymlink(dest)) {
|
|
72
|
-
|
|
79
|
+
// existsSync follows the link: false means it points at nothing.
|
|
80
|
+
const state = existsSync(dest) ? 'symlink exists' : 'dangling symlink — not replacing';
|
|
81
|
+
process.stdout.write(` SKIP: ${label} (${state})\n`);
|
|
73
82
|
return 'skip';
|
|
74
83
|
}
|
|
75
84
|
if (existsSync(dest)) {
|
|
@@ -122,6 +131,38 @@ if (_isDir(SOURCE_SKILLS_DIR)) {
|
|
|
122
131
|
}
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Remove the links an earlier install left for a wrapper this plugin no longer
|
|
136
|
+
* ships (#1501 item 6: 05c0264f dropped `commands/navigator.md` and
|
|
137
|
+
* `skills/navigator`, and every installed project kept a dead link). An entry is
|
|
138
|
+
* removed only when it is a symlink, dangles, AND resolves inside this plugin's
|
|
139
|
+
* `.cursor/<kind>/` — so it is provably ours. Regular files and live links are
|
|
140
|
+
* the project's own; a dangling link into another root is not ours to delete
|
|
141
|
+
* and gets a WARN — unless this plugin ships that name, where linkPath() has
|
|
142
|
+
* already reported it as dangling.
|
|
143
|
+
* @param {'rules'|'commands'|'skills'} kind
|
|
144
|
+
*/
|
|
145
|
+
function pruneDeadLinks(kind) {
|
|
146
|
+
const sourceDir = path.join(SO_ROOT, '.cursor', kind);
|
|
147
|
+
const targetDir = path.join(TARGET, '.cursor', kind);
|
|
148
|
+
if (!_isDir(targetDir)) return;
|
|
149
|
+
for (const name of readdirSync(targetDir)) {
|
|
150
|
+
const dest = path.join(targetDir, name);
|
|
151
|
+
if (!_isSymlink(dest) || existsSync(dest)) continue;
|
|
152
|
+
const pointsTo = path.resolve(path.dirname(dest), readlinkSync(dest));
|
|
153
|
+
const rel = path.relative(sourceDir, pointsTo);
|
|
154
|
+
const ours = rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
155
|
+
if (ours) {
|
|
156
|
+
unlinkSync(dest);
|
|
157
|
+
process.stdout.write(` PRUNE: ${kind}/${name} (dangling link into ${sourceDir})\n`);
|
|
158
|
+
} else if (!existsSync(path.join(sourceDir, name))) {
|
|
159
|
+
process.stdout.write(` WARN: ${kind}/${name} (dangling link outside this plugin, left alone: ${pointsTo})\n`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const kind of ['rules', 'commands', 'skills']) pruneDeadLinks(kind);
|
|
165
|
+
|
|
125
166
|
function renderHooksJson(soRoot) {
|
|
126
167
|
const runNode = shQuote(path.join(soRoot, 'hooks', 'run-node.sh'));
|
|
127
168
|
const bridge = shQuote(path.join(soRoot, 'scripts', 'lib', 'cursor-hook-bridge.mjs'));
|
package/scripts/emit-session.mjs
CHANGED
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* a single session record to `.orchestrator/metrics/sessions.jsonl` (or any
|
|
7
7
|
* target path). Replaces the raw shell `>>` append with a validated path:
|
|
8
8
|
*
|
|
9
|
-
* node scripts/emit-session.mjs [--file PATH] [--entry JSON]
|
|
9
|
+
* node scripts/emit-session.mjs [--file PATH] [--entry JSON] [--session-uuid ID]
|
|
10
10
|
*
|
|
11
11
|
* Input modes:
|
|
12
12
|
* --entry '<json>' pass the entry JSON literally (for shell pipelines)
|
|
13
13
|
* (stdin) read the entry JSON from stdin (default when no --entry)
|
|
14
14
|
*
|
|
15
|
+
* --session-uuid ID this session's raw harness UUID (overrides the
|
|
16
|
+
* current-session.json lookup; the test seam)
|
|
17
|
+
*
|
|
15
18
|
* Defaults:
|
|
16
19
|
* --file .orchestrator/metrics/sessions.jsonl
|
|
17
20
|
*
|
|
@@ -33,6 +36,8 @@ import {
|
|
|
33
36
|
stampMemoryCleanup,
|
|
34
37
|
} from './lib/memory-cleanup-stamp.mjs';
|
|
35
38
|
import { parseStateMd, readSessionProfile, resolveStateMdPath } from './lib/state-md.mjs';
|
|
39
|
+
import { readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
|
|
40
|
+
import { rollupSessionTokens } from './lib/session-token-rollup.mjs';
|
|
36
41
|
import { serializeSessionLineChecked } from './lib/session-schema/serializer.mjs';
|
|
37
42
|
import {
|
|
38
43
|
validateSession,
|
|
@@ -50,17 +55,158 @@ export { serializeSessionLineChecked };
|
|
|
50
55
|
// ref (`HEAD`, a branch) would resolve to a DIFFERENT commit when read later.
|
|
51
56
|
const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
52
57
|
|
|
58
|
+
// Rollup fields merged into the record (#1436) — the totals plus the #1244
|
|
59
|
+
// cache buckets and contract marker the former prose recipe merged, and the
|
|
60
|
+
// #1475 cost-coverage counters, which say why `total_cost_usd` is absent when
|
|
61
|
+
// priced < total. `legacy_v1_records` stays in the rollup's own return value.
|
|
62
|
+
const ROLLUP_KEYS = Object.freeze([
|
|
63
|
+
'total_tokens',
|
|
64
|
+
'total_token_input',
|
|
65
|
+
'total_token_output',
|
|
66
|
+
'total_token_input_uncached',
|
|
67
|
+
'total_token_cache_read',
|
|
68
|
+
'total_token_cache_creation',
|
|
69
|
+
'total_cost_usd',
|
|
70
|
+
'subagents_with_tokens',
|
|
71
|
+
'matched_records',
|
|
72
|
+
'cost_records_priced',
|
|
73
|
+
'cost_records_total',
|
|
74
|
+
'_token_schema',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// Rollup keys validateSession() requires to be non-negative integers.
|
|
78
|
+
const INTEGER_ROLLUP_KEYS = new Set([
|
|
79
|
+
'total_tokens',
|
|
80
|
+
'matched_records',
|
|
81
|
+
'cost_records_priced',
|
|
82
|
+
'cost_records_total',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve THIS session's raw harness UUID — the join key `subagents.jsonl`
|
|
87
|
+
* (`parent_session_id`) and `events.jsonl` (`session_id`) carry, which the
|
|
88
|
+
* record itself does not (its `session_id` is the semantic slug).
|
|
89
|
+
*
|
|
90
|
+
* `--session-uuid` wins (an explicit assertion, and the test seam). Otherwise
|
|
91
|
+
* `.orchestrator/current-session.json` is read. That file is SHARED by the
|
|
92
|
+
* working copy — the last SessionStart hook wrote it — so its UUID is adopted
|
|
93
|
+
* ONLY when its `semantic_session_id` (or, for a legacy raw-id record, its
|
|
94
|
+
* `session_id`) equals this record's `session_id`: the same "named owner
|
|
95
|
+
* equals the record" gate as the STATE.md derivation in main(). When this
|
|
96
|
+
* process carries its own session id (`readProcessLocalSessionIds()` — the
|
|
97
|
+
* harness env, which a dispatched subagent inherits from the coordinator), that
|
|
98
|
+
* witness OUTRANKS the shared marker: a marker UUID the process does not carry
|
|
99
|
+
* was written by a peer and is refused even when the labels agree (two sessions
|
|
100
|
+
* can mint the same semantic label, #1066). Ranked, never unioned
|
|
101
|
+
* (.claude/rules/identity-and-locks.md). A foreign marker is WARNed and yields
|
|
102
|
+
* null; an absent marker yields null silently.
|
|
103
|
+
*
|
|
104
|
+
* CEILING: a resumed session that received a NEW raw id keeps its semantic id,
|
|
105
|
+
* so only the latest raw id resolves and subagents dispatched under the earlier
|
|
106
|
+
* one are not rolled up. Revisit if current-session.json gains a raw-id history.
|
|
107
|
+
*
|
|
108
|
+
* Never throws.
|
|
109
|
+
*
|
|
110
|
+
* @param {{ override: string|null, recordSessionId: unknown }} opts
|
|
111
|
+
* @returns {string|null}
|
|
112
|
+
*/
|
|
113
|
+
function resolveOwnSessionUuid({ override, recordSessionId }) {
|
|
114
|
+
const trimmed = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
115
|
+
const explicit = trimmed(override);
|
|
116
|
+
if (explicit) return explicit;
|
|
117
|
+
const markerPath = join(process.cwd(), '.orchestrator', 'current-session.json');
|
|
118
|
+
let marker;
|
|
119
|
+
try {
|
|
120
|
+
if (!existsSync(markerPath)) return null;
|
|
121
|
+
marker = JSON.parse(readFileSync(markerPath, 'utf8'));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
`emit-session: WARN could not read ${markerPath} (${err?.message ?? err}); ` +
|
|
125
|
+
`omitting raw_session_id and the token rollup\n`
|
|
126
|
+
);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const uuid = trimmed(marker?.session_id);
|
|
130
|
+
if (!uuid) return null;
|
|
131
|
+
const semantic = trimmed(marker?.semantic_session_id);
|
|
132
|
+
const namesRecord = (semantic !== '' && semantic === recordSessionId) || uuid === recordSessionId;
|
|
133
|
+
const processIds = readProcessLocalSessionIds();
|
|
134
|
+
const processAgrees = processIds.length === 0 || processIds.includes(uuid);
|
|
135
|
+
if (namesRecord && processAgrees) return uuid;
|
|
136
|
+
// The record's session_id is the semantic label (STATE.md `session`); name
|
|
137
|
+
// both sides of whichever comparison failed, so the operator sees which drifted.
|
|
138
|
+
const why = namesRecord
|
|
139
|
+
? `session label ${recordSessionId} matches, but this process runs as ` +
|
|
140
|
+
`${processIds.join('/')}, not the marker's uuid ${uuid}`
|
|
141
|
+
: `session label mismatch: marker semantic_session_id=${semantic || '<absent>'} ` +
|
|
142
|
+
`vs record session_id=${recordSessionId ?? '<unknown>'}`;
|
|
143
|
+
process.stderr.write(
|
|
144
|
+
`emit-session: WARN current-session.json ${why}; omitting raw_session_id and the token rollup\n`
|
|
145
|
+
);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The `head_sha` of THIS session's own `orchestrator.session.started` event —
|
|
151
|
+
* matched on the raw UUID, so a parallel session's start event never supplies
|
|
152
|
+
* it. The FIRST own event decides: a resume or compact that re-emits the event
|
|
153
|
+
* under the same raw id is not the session's start, so its sha is never taken —
|
|
154
|
+
* not even when the first own event carries none. Returns null when there is no
|
|
155
|
+
* own event, or the first one's head_sha is absent or not a full sha.
|
|
156
|
+
*
|
|
157
|
+
* LIMIT — rotation (#1457 point 3): events.jsonl rotates at SessionStart once
|
|
158
|
+
* it exceeds `events-rotation.max-size-mb` (default 10), and when that start is
|
|
159
|
+
* a compact/resume of THIS session the first SURVIVING own event can be a
|
|
160
|
+
* compact/resume re-emit whose head_sha is later than the real start. When the
|
|
161
|
+
* first own event carries `native_source` 'compact' or 'resume' it is provably
|
|
162
|
+
* not the start, so this returns null (unknown) rather than a wrong ref. An
|
|
163
|
+
* event without `native_source` (Codex/Cursor, or a pre-#1091 writer) is still
|
|
164
|
+
* taken — so a rotation that leaves an unlabelled re-emit first stays wrong.
|
|
165
|
+
*
|
|
166
|
+
* Whole-file read, like deriveMemoryCleanupSignal() on the same file — fine at
|
|
167
|
+
* today's events.jsonl size; revisit if the file outgrows a single read.
|
|
168
|
+
*
|
|
169
|
+
* Never throws.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} eventsFile
|
|
172
|
+
* @param {string} uuid
|
|
173
|
+
* @returns {string|null}
|
|
174
|
+
*/
|
|
175
|
+
function readOwnStartHeadSha(eventsFile, uuid) {
|
|
176
|
+
let raw;
|
|
177
|
+
try {
|
|
178
|
+
raw = readFileSync(eventsFile, 'utf8');
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
for (const line of raw.split('\n')) {
|
|
183
|
+
if (!line.includes(uuid)) continue;
|
|
184
|
+
let ev;
|
|
185
|
+
try {
|
|
186
|
+
ev = JSON.parse(line);
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (ev?.event !== 'orchestrator.session.started' || ev.session_id !== uuid) continue;
|
|
191
|
+
if (ev.native_source === 'compact' || ev.native_source === 'resume') return null;
|
|
192
|
+
return typeof ev.head_sha === 'string' && FULL_SHA_RE.test(ev.head_sha) ? ev.head_sha : null;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
53
197
|
function parseArgs(argv) {
|
|
54
|
-
const args = { file: '.orchestrator/metrics/sessions.jsonl', entry: null };
|
|
198
|
+
const args = { file: '.orchestrator/metrics/sessions.jsonl', entry: null, sessionUuid: null };
|
|
55
199
|
for (let i = 0; i < argv.length; i++) {
|
|
56
200
|
const a = argv[i];
|
|
57
201
|
if (a === '--file') args.file = argv[++i];
|
|
58
202
|
else if (a === '--entry') args.entry = argv[++i];
|
|
203
|
+
else if (a === '--session-uuid') args.sessionUuid = argv[++i];
|
|
59
204
|
else if (a === '--help' || a === '-h') {
|
|
60
205
|
process.stdout.write(
|
|
61
206
|
'Usage: node scripts/emit-session.mjs [--file PATH] [--entry JSON]\n' +
|
|
62
207
|
' --file target JSONL file (default: .orchestrator/metrics/sessions.jsonl)\n' +
|
|
63
208
|
' --entry entry JSON (if omitted, read from stdin)\n' +
|
|
209
|
+
' --session-uuid raw harness UUID (default: owned .orchestrator/current-session.json)\n' +
|
|
64
210
|
'Exit codes: 0 append ok, 1 validation error, 2 I/O error\n'
|
|
65
211
|
);
|
|
66
212
|
process.exit(0);
|
|
@@ -131,6 +277,29 @@ async function main() {
|
|
|
131
277
|
// contract deliberately does not.
|
|
132
278
|
repaired = normalizeWaveKeys(repaired);
|
|
133
279
|
|
|
280
|
+
// Own raw UUID (#1436), resolved once for the three derivations further down:
|
|
281
|
+
// raw_session_id, the session_start_ref events fallback, the token rollup.
|
|
282
|
+
const ownUuid = resolveOwnSessionUuid({
|
|
283
|
+
override: args.sessionUuid,
|
|
284
|
+
recordSessionId: repaired.session_id,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// An explicit session_start_ref that is not a full sha (#1443) names a
|
|
288
|
+
// DIFFERENT commit once the short form becomes ambiguous. Dropped with a WARN
|
|
289
|
+
// rather than rejected: the record is the load-bearing artefact, and the
|
|
290
|
+
// derivations below may still supply the full sha. Explicit `null` stays.
|
|
291
|
+
// Enforced HERE, not in validateSession(): the ledger already holds records
|
|
292
|
+
// with 7-char refs, and the session-start integrity banner runs every one of
|
|
293
|
+
// them through validateSession().
|
|
294
|
+
if (typeof repaired.session_start_ref === 'string' && !FULL_SHA_RE.test(repaired.session_start_ref)) {
|
|
295
|
+
process.stderr.write(
|
|
296
|
+
`emit-session: WARN session_start_ref=${repaired.session_start_ref} is not a full hex sha; ` +
|
|
297
|
+
`dropping it\n`
|
|
298
|
+
);
|
|
299
|
+
const { session_start_ref: _dropped, ...rest } = repaired;
|
|
300
|
+
repaired = rest;
|
|
301
|
+
}
|
|
302
|
+
|
|
134
303
|
// `memory_cleanup_at` derivation (#699 follow-up — Disziplin statt Mechanik).
|
|
135
304
|
// The flag used to be a boolean the coordinator-LLM remembered to pass at
|
|
136
305
|
// session-end; it was forgotten on 2026-08-14 and the cadence marker stalled
|
|
@@ -212,6 +381,9 @@ async function main() {
|
|
|
212
381
|
const hasOwn = (key) => Object.prototype.hasOwnProperty.call(repaired, key);
|
|
213
382
|
const needsProfile = !hasOwn('session_profile');
|
|
214
383
|
const needsStartRef = !hasOwn('session_start_ref');
|
|
384
|
+
// What STATE.md contributed to session_start_ref, for the WARN below when no
|
|
385
|
+
// source supplies one (#1457 point 2): none, or a ref that was discarded.
|
|
386
|
+
let stateMdStartRefNote = 'STATE.md carried none';
|
|
215
387
|
if (needsProfile || needsStartRef) {
|
|
216
388
|
let stateMdContents = '';
|
|
217
389
|
try {
|
|
@@ -223,6 +395,7 @@ async function main() {
|
|
|
223
395
|
`derivation (${err?.message ?? err}); omitting both\n`
|
|
224
396
|
);
|
|
225
397
|
stateMdContents = '';
|
|
398
|
+
stateMdStartRefNote = 'STATE.md unreadable';
|
|
226
399
|
}
|
|
227
400
|
// No initialiser: both the try and the catch below assign `profile`.
|
|
228
401
|
let profile;
|
|
@@ -241,6 +414,7 @@ async function main() {
|
|
|
241
414
|
`emit-session: WARN STATE.md session-start-ref=${rawRef} is not a full hex sha; ` +
|
|
242
415
|
`omitting session_start_ref\n`
|
|
243
416
|
);
|
|
417
|
+
stateMdStartRefNote = 'STATE.md ref discarded (not a full hex sha)';
|
|
244
418
|
}
|
|
245
419
|
} catch (err) {
|
|
246
420
|
process.stderr.write(
|
|
@@ -249,6 +423,7 @@ async function main() {
|
|
|
249
423
|
);
|
|
250
424
|
profile = null;
|
|
251
425
|
startRef = null;
|
|
426
|
+
stateMdStartRefNote = 'STATE.md unparseable';
|
|
252
427
|
}
|
|
253
428
|
const derived = {};
|
|
254
429
|
if (needsProfile && profile !== null) derived.session_profile = profile;
|
|
@@ -258,6 +433,9 @@ async function main() {
|
|
|
258
433
|
if (owner !== null && owner === repaired.session_id) {
|
|
259
434
|
repaired = { ...repaired, ...derived };
|
|
260
435
|
} else {
|
|
436
|
+
if (derived.session_start_ref !== undefined) {
|
|
437
|
+
stateMdStartRefNote = `STATE.md ref discarded (belongs to session=${owner ?? '<absent>'})`;
|
|
438
|
+
}
|
|
261
439
|
// A visible omission: silence here is indistinguishable from "STATE.md
|
|
262
440
|
// carries no such field", and a foreign STATE.md in this working copy
|
|
263
441
|
// is precisely what the operator wants to know about.
|
|
@@ -270,6 +448,124 @@ async function main() {
|
|
|
270
448
|
}
|
|
271
449
|
}
|
|
272
450
|
|
|
451
|
+
// session_start_ref fallback (#1443): STATE.md supplied none (absent key,
|
|
452
|
+
// foreign owner, unparseable) — take the head_sha the SessionStart hook
|
|
453
|
+
// recorded on this session's own start event. Absent there too → omitted,
|
|
454
|
+
// with a WARN whether or not an own UUID was available (#1457 point 2) —
|
|
455
|
+
// without one the fallback cannot run, which is exactly when silence hid it.
|
|
456
|
+
if (!hasOwn('session_start_ref')) {
|
|
457
|
+
const headSha = ownUuid !== null
|
|
458
|
+
? readOwnStartHeadSha(join(dirname(args.file), 'events.jsonl'), ownUuid)
|
|
459
|
+
: null;
|
|
460
|
+
if (headSha !== null) {
|
|
461
|
+
repaired = { ...repaired, session_start_ref: headSha };
|
|
462
|
+
process.stderr.write(
|
|
463
|
+
`emit-session: derived session_start_ref=${headSha} from the own orchestrator.session.started event\n`
|
|
464
|
+
);
|
|
465
|
+
} else {
|
|
466
|
+
process.stderr.write(
|
|
467
|
+
`emit-session: WARN no session_start_ref — ${stateMdStartRefNote}, ` +
|
|
468
|
+
'no own session.started head_sha available\n'
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (ownUuid !== null && !hasOwn('raw_session_id')) {
|
|
474
|
+
repaired = { ...repaired, raw_session_id: ownUuid };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Token rollup (#1436) — mechanical, replacing the session-metrics-write.md
|
|
478
|
+
// prose step that had no production caller. Joins the sibling subagents.jsonl
|
|
479
|
+
// on the own raw UUID and fills ONLY keys the entry does not carry; an
|
|
480
|
+
// explicit `total_tokens` means the caller already rolled up, so nothing is
|
|
481
|
+
// merged. No matching records or a failed read OMITS the fields with a WARN —
|
|
482
|
+
// never a fabricated 0; a null total is omitted rather than written — for
|
|
483
|
+
// `total_cost_usd` that is any session with an unpriced cost candidate
|
|
484
|
+
// (unknown model, no model, no tokens, a started agent without a transcript,
|
|
485
|
+
// or a v1 agent — the complete list is the `session-token-rollup.mjs` module
|
|
486
|
+
// doc, #1498), which the persisted `cost_records_priced < cost_records_total`
|
|
487
|
+
// then explains.
|
|
488
|
+
// match_status explains the omission; diagnostic fields stay out of the record.
|
|
489
|
+
if (ownUuid !== null && !hasOwn('total_tokens')) {
|
|
490
|
+
let rollup = null;
|
|
491
|
+
try {
|
|
492
|
+
rollup = rollupSessionTokens({
|
|
493
|
+
parentSessionId: ownUuid,
|
|
494
|
+
subagentsPath: join(dirname(args.file), 'subagents.jsonl'),
|
|
495
|
+
});
|
|
496
|
+
} catch (err) {
|
|
497
|
+
process.stderr.write(
|
|
498
|
+
`emit-session: WARN token rollup failed (${err?.message ?? err}); omitting token fields\n`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
if (rollup !== null && rollup.matched_records > 0) {
|
|
502
|
+
const merged = {};
|
|
503
|
+
for (const key of ROLLUP_KEYS) {
|
|
504
|
+
if (hasOwn(key) || !Number.isFinite(rollup[key])) continue;
|
|
505
|
+
// Mirrors validateSession(): these two are non-negative INTEGERS, while
|
|
506
|
+
// total_token_input/output may be fractional — one fractional token
|
|
507
|
+
// value upstream must cost the field, not the record.
|
|
508
|
+
if (INTEGER_ROLLUP_KEYS.has(key) && !Number.isInteger(rollup[key])) {
|
|
509
|
+
process.stderr.write(
|
|
510
|
+
`emit-session: WARN token rollup produced an invalid field (${key} must be a ` +
|
|
511
|
+
`non-negative integer, got: ${rollup[key]}); omitting ${key}\n`
|
|
512
|
+
);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
merged[key] = rollup[key];
|
|
516
|
+
}
|
|
517
|
+
// Enrichment must never be the reason the ledger gains no line: validate
|
|
518
|
+
// the enriched record first and fall back to the un-enriched one. Blamed
|
|
519
|
+
// on the rollup only when the un-enriched record validates on its own —
|
|
520
|
+
// otherwise the validation below reports the record's own defect.
|
|
521
|
+
try {
|
|
522
|
+
validateSession({ ...repaired, ...merged });
|
|
523
|
+
repaired = { ...repaired, ...merged };
|
|
524
|
+
} catch (err) {
|
|
525
|
+
if (!(err instanceof ValidationError)) throw err;
|
|
526
|
+
let baseValid = true;
|
|
527
|
+
try {
|
|
528
|
+
validateSession(repaired);
|
|
529
|
+
} catch {
|
|
530
|
+
baseValid = false;
|
|
531
|
+
}
|
|
532
|
+
if (baseValid) {
|
|
533
|
+
process.stderr.write(
|
|
534
|
+
`emit-session: WARN token rollup produced an invalid field (${err.message}); ` +
|
|
535
|
+
`omitting token fields\n`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (rollup.total_tokens === null) {
|
|
540
|
+
process.stderr.write(
|
|
541
|
+
`emit-session: WARN token rollup matched ${rollup.matched_records} subagents.jsonl record(s) ` +
|
|
542
|
+
`for ${ownUuid}, none token-bearing; total_tokens omitted\n`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
} else if (rollup !== null) {
|
|
546
|
+
let warning = `token rollup found no subagents.jsonl records for ${ownUuid}`;
|
|
547
|
+
switch (rollup.match_status) {
|
|
548
|
+
case 'ledger-absent':
|
|
549
|
+
warning += ' (ledger absent)';
|
|
550
|
+
break;
|
|
551
|
+
case 'ledger-empty':
|
|
552
|
+
warning += ' (ledger holds no readable records)';
|
|
553
|
+
break;
|
|
554
|
+
case 'unmatched':
|
|
555
|
+
warning = `token rollup found ${rollup.ledger_records} subagents.jsonl record(s) present, ` +
|
|
556
|
+
`none with parent_session_id=${ownUuid} — not attributable via this UUID, not zero cost`;
|
|
557
|
+
break;
|
|
558
|
+
case 'invalid-key':
|
|
559
|
+
warning = `token rollup rejected invalid parent_session_id=${ownUuid}`;
|
|
560
|
+
break;
|
|
561
|
+
default:
|
|
562
|
+
// Unknown or missing diagnostics retain the generic omission warning.
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
process.stderr.write(`emit-session: WARN ${warning}; omitting token fields\n`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
273
569
|
let validated;
|
|
274
570
|
try {
|
|
275
571
|
validated = validateSession(repaired);
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* research/hardware-patterns.md`), via the same `vault-integration.vault-dir`
|
|
15
15
|
* resolution `scripts/archive-closed-prds.mjs` uses (findProjectRoot →
|
|
16
16
|
* CLAUDE.md/AGENTS.md → parseSessionConfig; host-resolved: SO_VAULT_DIR env >
|
|
17
|
-
* owner.yaml paths.vault-dir > committed
|
|
18
|
-
*
|
|
17
|
+
* owner.yaml vault-dirs: cwd match > owner.yaml paths.vault-dir > committed
|
|
18
|
+
* default, see `resolveVaultDir` in scripts/lib/config.mjs). Pass `--output <path>`
|
|
19
|
+
* to override.
|
|
19
20
|
*
|
|
20
21
|
* Idempotent: running without new data rewrites the same file byte-for-byte
|
|
21
22
|
* modulo the generated-at line.
|
|
@@ -58,7 +59,8 @@ import {
|
|
|
58
59
|
rewriteLearnings,
|
|
59
60
|
CURRENT_ANONYMIZATION_VERSION,
|
|
60
61
|
} from './lib/learnings.mjs';
|
|
61
|
-
import {
|
|
62
|
+
import { withLearningsLock } from './lib/learnings/io.mjs';
|
|
63
|
+
import { findProjectRoot, resolveInstructionFile, expandTilde, redactHomeDir } from './lib/common.mjs';
|
|
62
64
|
import { parseSessionConfig } from './lib/config.mjs';
|
|
63
65
|
import { createSecretValueMasker } from './lib/secret-masker.mjs';
|
|
64
66
|
import { stableHostname, readHostAliases } from './lib/host-identity.mjs';
|
|
@@ -74,7 +76,8 @@ const DEFAULT_VAULT_SUBPATH = path.join('01-projects', 'session-orchestrator', '
|
|
|
74
76
|
* explicitly. Mirrors the vault-dir resolution `scripts/archive-closed-prds.mjs`
|
|
75
77
|
* uses: findProjectRoot → CLAUDE.md/AGENTS.md → parseSessionConfig →
|
|
76
78
|
* vault-integration.vault-dir (host-resolved: SO_VAULT_DIR env > owner.yaml
|
|
77
|
-
* paths.vault-dir > committed default
|
|
79
|
+
* vault-dirs: cwd match > owner.yaml paths.vault-dir > committed default, see
|
|
80
|
+
* `resolveVaultDir` in scripts/lib/config.mjs).
|
|
78
81
|
*
|
|
79
82
|
* @param {object} [opts]
|
|
80
83
|
* @param {string} [opts.repoRoot] — override for findProjectRoot() (tests).
|
|
@@ -82,21 +85,28 @@ const DEFAULT_VAULT_SUBPATH = path.join('01-projects', 'session-orchestrator', '
|
|
|
82
85
|
* — forwarded to parseSessionConfig (tests pass `{ env: {}, ownerConfig: undefined }`
|
|
83
86
|
* for hermetic, owner.yaml-free resolution — issue #653 bleed guard).
|
|
84
87
|
* @returns {string|null} the resolved output path, or null when vault-dir is
|
|
85
|
-
* unconfigured/unresolvable.
|
|
88
|
+
* unconfigured/unresolvable or vault-integration is not enabled.
|
|
86
89
|
*/
|
|
87
90
|
export function resolveDefaultOutput({ repoRoot, hostPaths } = {}) {
|
|
88
91
|
const root = repoRoot ?? findProjectRoot();
|
|
89
92
|
const instr = resolveInstructionFile(root);
|
|
90
93
|
if (!instr) return null;
|
|
91
94
|
|
|
92
|
-
let
|
|
95
|
+
let vi;
|
|
93
96
|
try {
|
|
94
97
|
const content = readFileSync(instr.path, 'utf8');
|
|
95
98
|
const config = parseSessionConfig(content, hostPaths ? { hostPaths } : undefined);
|
|
96
|
-
|
|
99
|
+
vi = config?.['vault-integration'];
|
|
97
100
|
} catch {
|
|
98
101
|
return null;
|
|
99
102
|
}
|
|
103
|
+
// #1506.4a: this default target IS the vault, so it obeys the off switch the
|
|
104
|
+
// other vault writers obey (vault-backfill, board-writer, narrative-mirror:
|
|
105
|
+
// `enabled !== true` → no vault write), already host-lowered by
|
|
106
|
+
// parseSessionConfig (env SO_VAULT_INTEGRATION > owner.yaml > committed). An
|
|
107
|
+
// explicit `--output` is the caller's own path and is not gated.
|
|
108
|
+
if (vi?.enabled !== true) return null;
|
|
109
|
+
const vaultDir = vi['vault-dir'];
|
|
100
110
|
if (!vaultDir || typeof vaultDir !== 'string' || vaultDir.trim() === '') return null;
|
|
101
111
|
|
|
102
112
|
// Expand a `~`-prefixed vault-dir (e.g. the committed Session Config default
|
|
@@ -601,7 +611,8 @@ function parseArgs(argv) {
|
|
|
601
611
|
if (!resolved) {
|
|
602
612
|
process.stderr.write(
|
|
603
613
|
'export-hw-learnings: could not resolve vault-integration.vault-dir for the default ' +
|
|
604
|
-
'--output path (no CLAUDE.md/AGENTS.md found,
|
|
614
|
+
'--output path (no CLAUDE.md/AGENTS.md found, vault-integration.enabled is not true, ' +
|
|
615
|
+
'or vault-dir is unset). ' +
|
|
605
616
|
'Pass --output <path> explicitly.\n'
|
|
606
617
|
);
|
|
607
618
|
process.exit(1);
|
|
@@ -634,6 +645,22 @@ function parseArgs(argv) {
|
|
|
634
645
|
* @returns {Promise<{promoted: number, skipped: number, flags: string[]}>}
|
|
635
646
|
*/
|
|
636
647
|
export async function promoteHwLearnings(opts) {
|
|
648
|
+
// The read → rewrite below runs under the store lock (#1447 point 8): an
|
|
649
|
+
// append landing between them would vanish in the rename. A dry run writes
|
|
650
|
+
// nothing and takes no lock. A lock not acquired throws LearningsLockError
|
|
651
|
+
// (nothing written), which the CLI reports as `export failed: …`, exit 1.
|
|
652
|
+
if (opts.dryRun) return promoteHwLearningsUnlocked(opts);
|
|
653
|
+
return withLearningsLock(opts.input, () => promoteHwLearningsUnlocked(opts));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Body of {@link promoteHwLearnings}; the caller holds the store lock or runs
|
|
658
|
+
* a dry run.
|
|
659
|
+
*
|
|
660
|
+
* @param {Parameters<typeof promoteHwLearnings>[0]} opts
|
|
661
|
+
* @returns {Promise<{promoted: number, skipped: number, flags: string[]}>}
|
|
662
|
+
*/
|
|
663
|
+
async function promoteHwLearningsUnlocked(opts) {
|
|
637
664
|
const repoRoot = opts.repoRoot;
|
|
638
665
|
const { entries, malformed } = await readLearnings(opts.input);
|
|
639
666
|
|
|
@@ -642,7 +669,9 @@ export async function promoteHwLearnings(opts) {
|
|
|
642
669
|
|
|
643
670
|
const flags = [];
|
|
644
671
|
if (malformed.length > 0) {
|
|
645
|
-
|
|
672
|
+
// Not promoted, and kept verbatim by the rewrite below (rewriteLearnings
|
|
673
|
+
// re-reads them under the lock — #1489); a WARN, not a loss.
|
|
674
|
+
flags.push(`${malformed.length} malformed line(s) in learnings.jsonl were not read (kept verbatim)`);
|
|
646
675
|
}
|
|
647
676
|
|
|
648
677
|
if (hwPrivate.length === 0) {
|
|
@@ -744,7 +773,8 @@ if (isMain) {
|
|
|
744
773
|
};
|
|
745
774
|
|
|
746
775
|
run().catch((err) => {
|
|
747
|
-
|
|
776
|
+
// The message can carry absolute store/output paths — never print the home dir.
|
|
777
|
+
process.stderr.write(`export failed: ${redactHomeDir(String(err?.message ?? err))}\n`);
|
|
748
778
|
process.exit(1);
|
|
749
779
|
});
|
|
750
780
|
}
|