session-orchestrator 5.4.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +95 -3
- package/README.md +11 -11
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +15 -13
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- 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.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +70 -170
- 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 -154
- package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +584 -207
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- 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 +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- 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-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- 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 +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- 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 +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- 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 +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- package/skills/navigator/references/slot-check.sh +0 -114
|
@@ -22,26 +22,48 @@
|
|
|
22
22
|
// agent's fence — i.e. another agent's intent to stage that path.
|
|
23
23
|
//
|
|
24
24
|
// Behavior summary
|
|
25
|
-
// - No
|
|
26
|
-
//
|
|
27
|
-
// -
|
|
25
|
+
// - No wave-scope.json → exit 0 (no active wave; sub-mode B short-circuits).
|
|
26
|
+
// Sub-mode C still runs if a fence directory exists.
|
|
27
|
+
// - Sub-mode B, in order (#1493.1): a manifest bound to ANOTHER session is
|
|
28
|
+
// not this commit's scope (stand down, one stderr note); `enforcement:
|
|
29
|
+
// "off"` skips.
|
|
30
|
+
// - Non-empty allowedPaths: a staged path outside it BLOCKS (exit 1) under
|
|
31
|
+
// every enforcement level, `warn` included. Deliberately not tied to
|
|
32
|
+
// `enforcement`: in a warn-mode wave enforce-scope lets out-of-scope writes
|
|
33
|
+
// through, so this guard is the only hard PSA-004 stop left for a
|
|
34
|
+
// lint-staged sweep — softening it would weaken detection for the very
|
|
35
|
+
// class it exists to catch.
|
|
36
|
+
// - EMPTY allowedPaths is no longer a silent pass: a Discovery (read-only)
|
|
37
|
+
// manifest means no agent is committing, i.e. a coordinator commit between
|
|
38
|
+
// waves → allow with a note; a manifest older than the newest session
|
|
39
|
+
// clock may be a leftover or a parallel session's live one → report,
|
|
40
|
+
// never block; any other role means the `--union` step never completed →
|
|
41
|
+
// `strict` (the default when the field is absent, as in enforce-scope)
|
|
42
|
+
// blocks, `warn` reports and lets the commit through. Only this new
|
|
43
|
+
// verdict follows `enforcement`: it judges the coordinator's own
|
|
44
|
+
// bookkeeping, not a staged foreign file.
|
|
28
45
|
// - Cross-agent fence overlap → exit 1 with "staging-fence: cross-agent
|
|
29
46
|
// overlap" stderr.
|
|
30
47
|
// - --no-verify bypass: this hook is invoked as a git pre-commit hook;
|
|
31
48
|
// `git commit --no-verify` skips it entirely (operator opts out by name).
|
|
32
49
|
|
|
33
50
|
import { execSync } from 'node:child_process';
|
|
34
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
35
|
-
import { join } from 'node:path';
|
|
51
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
52
|
+
import { join, relative } from 'node:path';
|
|
36
53
|
|
|
37
54
|
// Re-use existing helpers from scripts/lib/hardening.mjs.
|
|
38
55
|
// IMPORTANT: import is resolved relative to THIS file's location (the hook
|
|
39
56
|
// ships with the session-orchestrator package), NOT relative to the git
|
|
40
57
|
// repo it is protecting. The two diverge when the hook is invoked from a
|
|
41
58
|
// consumer repo or a test tmp-dir.
|
|
42
|
-
import { pathMatchesPattern
|
|
59
|
+
import { pathMatchesPattern } from '../scripts/lib/hardening.mjs';
|
|
43
60
|
import { withStagingFenceLock } from '../scripts/lib/session-lock.mjs';
|
|
44
61
|
import { isMainModule } from '../scripts/lib/is-main-module.mjs';
|
|
62
|
+
import { classifyEmptyScope, findOwnScopeFile, sessionAgeMs } from '../scripts/lib/scope-gate.mjs';
|
|
63
|
+
import {
|
|
64
|
+
classifyManifestSession,
|
|
65
|
+
readProcessLocalSessionIds,
|
|
66
|
+
} from '../scripts/lib/session-identity/own-session.mjs';
|
|
45
67
|
|
|
46
68
|
// Resolve the "current" agent id from SO_WAVE_AGENT_ID (when set by the
|
|
47
69
|
// caller) or fall back to a PID-derived marker. The fence files themselves
|
|
@@ -56,19 +78,6 @@ const ownAgentId = process.env.SO_WAVE_AGENT_ID ?? null;
|
|
|
56
78
|
*/
|
|
57
79
|
const ALL_PATHS_MARKER = '*';
|
|
58
80
|
|
|
59
|
-
/**
|
|
60
|
-
* Build a regex that finds a staged path inside a `git add` command string.
|
|
61
|
-
* Word-boundary on both sides so `src/foo.ts` does not match `src/foo.ts.bak`.
|
|
62
|
-
* The path is escaped so glob metacharacters (`*`, `?`) and shell metas
|
|
63
|
-
* cannot be reinterpreted.
|
|
64
|
-
*
|
|
65
|
-
* LEGACY PATH ONLY since #1404 — see the fallback branch in findOverlaps.
|
|
66
|
-
*/
|
|
67
|
-
function pathRegex(p) {
|
|
68
|
-
const escaped = p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
69
|
-
return new RegExp(`(?:^|[\\s'"])${escaped}(?:$|[\\s'"])`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
81
|
/**
|
|
73
82
|
* Normalise a path token so reader and writer compare the same spelling.
|
|
74
83
|
*
|
|
@@ -107,19 +116,13 @@ function pathsOverlap(fencePath, ourPath) {
|
|
|
107
116
|
* Walk a single sibling fence file and return the staged paths a sibling
|
|
108
117
|
* agent also recorded an intent to stage.
|
|
109
118
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* for an entry that has `command` and no `paths`.
|
|
118
|
-
* NAMED CEILING (BV-004): this fallback exists only to span the sessions
|
|
119
|
-
* running across the upgrade. REVISIT TRIGGER — remove it one minor
|
|
120
|
-
* release after 5.3.0 (i.e. in 5.4.0), by which point no process started
|
|
121
|
-
* before the upgrade can still be writing fence files. The command text it
|
|
122
|
-
* reads is never PRINTED (issue #1404) — only the fact of the match is.
|
|
119
|
+
* One entry shape is read: the #1404 `{ paths, command_hash, timestamp }`,
|
|
120
|
+
* whose path LIST is compared against our staged set. An entry without a
|
|
121
|
+
* `paths` array is skipped — that includes the pre-#1404 `{ command }` shape,
|
|
122
|
+
* whose raw-command regex fallback was removed in 5.4.x as its own revisit
|
|
123
|
+
* trigger demanded (#1487 point 16): the only fence writer,
|
|
124
|
+
* hooks/pre-bash-staging-fence.mjs, has written `paths` + `command_hash` and
|
|
125
|
+
* never `command` since 5.3.0, and no other hook or bridge writes fence files.
|
|
123
126
|
*/
|
|
124
127
|
function findOverlaps(fenceJsonPath, ourStaged) {
|
|
125
128
|
let body;
|
|
@@ -135,36 +138,126 @@ function findOverlaps(fenceJsonPath, ourStaged) {
|
|
|
135
138
|
const siblingAgent = body.agent_id ?? '<unknown>';
|
|
136
139
|
const matches = [];
|
|
137
140
|
for (const entry of body.staged_paths) {
|
|
138
|
-
if (Array.isArray(entry?.paths))
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
141
|
+
if (!Array.isArray(entry?.paths)) continue;
|
|
142
|
+
const hash = typeof entry.command_hash === 'string' ? entry.command_hash : '<no-hash>';
|
|
143
|
+
for (const raw of entry.paths) {
|
|
144
|
+
if (typeof raw !== 'string') continue;
|
|
145
|
+
const fencePath = normalizeStagedPath(raw);
|
|
146
|
+
for (const ours of ourStaged) {
|
|
147
|
+
if (pathsOverlap(fencePath, normalizeStagedPath(ours))) {
|
|
148
|
+
matches.push({ ourPath: ours, siblingAgent, fencePath, hash });
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const cmd = entry?.command;
|
|
153
|
-
if (typeof cmd !== 'string') continue;
|
|
154
|
-
for (const ours of ourStaged) {
|
|
155
|
-
if (pathRegex(ours).test(cmd)) {
|
|
156
|
-
matches.push({
|
|
157
|
-
ourPath: ours,
|
|
158
|
-
siblingAgent,
|
|
159
|
-
fencePath: '<legacy entry, pre-#1404>',
|
|
160
|
-
hash: '<legacy entry, pre-#1404>',
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
151
|
}
|
|
164
152
|
}
|
|
165
153
|
return matches;
|
|
166
154
|
}
|
|
167
155
|
|
|
156
|
+
/** mtime of a file in ms, or `null` when it cannot be stat'ed. */
|
|
157
|
+
function mtimeMsOf(file) {
|
|
158
|
+
try {
|
|
159
|
+
return statSync(file).mtimeMs;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Sub-mode B's decision, pure: which lines to print and whether to block.
|
|
167
|
+
*
|
|
168
|
+
* Until #1493.1 an empty `allowedPaths` skipped the check outright, the
|
|
169
|
+
* manifest's `role` and `enforcement: "off"` were never read, and a PEER
|
|
170
|
+
* session's manifest governed this session's commits. Each rung below closes
|
|
171
|
+
* one of those; the order is load-bearing — ownership first (a foreign
|
|
172
|
+
* manifest's enforcement level is not ours to honour), then `off`, then the
|
|
173
|
+
* verdict. `warn` softens ONLY the empty-union verdict; a non-empty violation
|
|
174
|
+
* blocks at every level (header § Behavior summary).
|
|
175
|
+
*
|
|
176
|
+
* @param {object} p
|
|
177
|
+
* @param {Record<string, unknown>} p.scope parsed manifest
|
|
178
|
+
* @param {string[]} p.stagedFiles
|
|
179
|
+
* @param {string} p.scopeRel manifest path for messages, repo-relative
|
|
180
|
+
* @param {Set<string>} p.ownIds this process's session ids
|
|
181
|
+
* @param {number|null} p.scopeMtimeMs
|
|
182
|
+
* @param {number|null} p.sessionAge ms since the newest session clock
|
|
183
|
+
* @returns {{action: 'pass'|'report'|'block', lines: string[]}}
|
|
184
|
+
*/
|
|
185
|
+
function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs, sessionAge }) {
|
|
186
|
+
if (stagedFiles.length === 0) return { action: 'pass', lines: [] };
|
|
187
|
+
|
|
188
|
+
// Same classifier and same disposition as enforce-scope Gate 3b: only a
|
|
189
|
+
// PROVABLY foreign manifest stands down; unknown ownership enforces.
|
|
190
|
+
if (classifyManifestSession(scope, ownIds).verdict === 'foreign') {
|
|
191
|
+
return {
|
|
192
|
+
action: 'pass',
|
|
193
|
+
lines: [`wave-scope-commit-guard: ${scopeRel} belongs to another session — its allowedPaths do not apply to this commit.`],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const enforcement = scope.enforcement ?? 'strict';
|
|
198
|
+
if (enforcement === 'off') return { action: 'pass', lines: [] };
|
|
199
|
+
|
|
200
|
+
const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
|
|
201
|
+
if (allowedPaths.length > 0) {
|
|
202
|
+
const violations = stagedFiles.filter(
|
|
203
|
+
(f) => !allowedPaths.some((pattern) => pathMatchesPattern(f, pattern)),
|
|
204
|
+
);
|
|
205
|
+
if (violations.length === 0) return { action: 'pass', lines: [] };
|
|
206
|
+
// Blocks under `warn` too — see the header: in a warn-mode wave this is
|
|
207
|
+
// the only hard stop a lint-staged sweep meets before the commit exists.
|
|
208
|
+
return {
|
|
209
|
+
action: 'block',
|
|
210
|
+
lines: [
|
|
211
|
+
'✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths:',
|
|
212
|
+
...violations.map((v) => ` - ${v}`),
|
|
213
|
+
'',
|
|
214
|
+
'These files were likely added by lint-staged eslint --fix / prettier --write.',
|
|
215
|
+
'To proceed:',
|
|
216
|
+
' 1) git restore --staged <path> # for each foreign path',
|
|
217
|
+
' 2) git commit # retry',
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// From here on the verdict judges the coordinator's own bookkeeping (an empty
|
|
223
|
+
// union), not a staged foreign file — only this part follows `enforcement`.
|
|
224
|
+
const failAction = enforcement === 'strict' ? 'block' : 'report';
|
|
225
|
+
|
|
226
|
+
const role = typeof scope.role === 'string' ? scope.role.trim() : '';
|
|
227
|
+
const reason = classifyEmptyScope({
|
|
228
|
+
role,
|
|
229
|
+
parseOk: true,
|
|
230
|
+
scopeMtimeMs,
|
|
231
|
+
sessionStartMs: typeof sessionAge === 'number' ? Date.now() - sessionAge : null,
|
|
232
|
+
});
|
|
233
|
+
if (reason === 'read-only-role') {
|
|
234
|
+
// No wave agent commits (PSA-007), so a commit under a read-only manifest
|
|
235
|
+
// is the coordinator's own, between waves.
|
|
236
|
+
return {
|
|
237
|
+
action: 'pass',
|
|
238
|
+
lines: [`wave-scope-commit-guard: ${scopeRel} is a ${role} (read-only) manifest — treated as a coordinator commit between waves; allowedPaths not checked.`],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (reason === 'stale-manifest') {
|
|
242
|
+
// Older than the newest session clock of this working copy: a leftover OR a
|
|
243
|
+
// parallel session's live manifest, which the clocks cannot tell apart
|
|
244
|
+
// (scope-gate.mjs § "…and the reader may not be the OWNER") — so it is
|
|
245
|
+
// never grounds to block this commit.
|
|
246
|
+
return {
|
|
247
|
+
action: 'report',
|
|
248
|
+
lines: [`⚠ wave-scope-commit-guard: ${scopeRel} grants no paths and predates the newest session start in this working copy — a leftover or a parallel session's live manifest; allowedPaths not checked for this commit.`],
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
action: failAction,
|
|
253
|
+
lines: [
|
|
254
|
+
`${failAction === 'block' ? '✗' : '⚠'} wave-scope-commit-guard: ${scopeRel} grants no paths${role ? ` for role \`${role}\`` : ''} — the coordinator's \`--union\` step did not complete, so none of the ${stagedFiles.length} staged path(s) can be checked against this wave's scope.`,
|
|
255
|
+
'Re-run the union step (skills/wave-executor/references/wave-loop-scope-manifest.md § 3.3), then retry the commit; do not hand-edit allowedPaths.',
|
|
256
|
+
...(failAction === 'block' ? [] : ['(enforcement: warn — reported, the commit proceeds)']),
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
168
261
|
/**
|
|
169
262
|
* Both sub-modes, in the order the pre-commit hook needs them. Every statement
|
|
170
263
|
* here used to sit at module top level, which meant a bare `import()` of this
|
|
@@ -181,7 +274,16 @@ async function main() {
|
|
|
181
274
|
// skills/wave-executor/wave-loop.md), so this guard never fired. findScopeFile
|
|
182
275
|
// returns null when no scope file exists at any precedence dir, preserving
|
|
183
276
|
// the "no active wave → exit 0" semantics below.
|
|
184
|
-
|
|
277
|
+
//
|
|
278
|
+
// #1504 point 6: the first manifest that is not provably a peer's, so a
|
|
279
|
+
// peer's higher-precedence manifest no longer hides this session's own one;
|
|
280
|
+
// when every candidate is foreign the first comes back and
|
|
281
|
+
// `scopeCommitVerdict` passes it as before. The git process inherits the
|
|
282
|
+
// committing session's env; there is no hook payload here, so the env tier is
|
|
283
|
+
// the only process-local identity.
|
|
284
|
+
const ownIds = new Set(readProcessLocalSessionIds());
|
|
285
|
+
const located = findOwnScopeFile(repoRoot, ownIds, classifyManifestSession);
|
|
286
|
+
const scopePath = located.path ?? located.foreignPath;
|
|
185
287
|
const fenceDir = join(repoRoot, '.orchestrator', 'staging-fence');
|
|
186
288
|
|
|
187
289
|
// -------------------------------------------------------------------------
|
|
@@ -200,22 +302,16 @@ async function main() {
|
|
|
200
302
|
process.exit(1);
|
|
201
303
|
}
|
|
202
304
|
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
process.stderr.write('To proceed:\n');
|
|
214
|
-
process.stderr.write(' 1) git restore --staged <path> # for each foreign path\n');
|
|
215
|
-
process.stderr.write(' 2) git commit # retry\n');
|
|
216
|
-
process.exit(1);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
305
|
+
const verdict = scopeCommitVerdict({
|
|
306
|
+
scope: scope !== null && typeof scope === 'object' ? scope : {},
|
|
307
|
+
stagedFiles,
|
|
308
|
+
scopeRel: relative(repoRoot, scopePath) || scopePath,
|
|
309
|
+
ownIds,
|
|
310
|
+
scopeMtimeMs: mtimeMsOf(scopePath),
|
|
311
|
+
sessionAge: sessionAgeMs(repoRoot),
|
|
312
|
+
});
|
|
313
|
+
for (const line of verdict.lines) process.stderr.write(`${line}\n`);
|
|
314
|
+
if (verdict.action === 'block') process.exit(1);
|
|
219
315
|
}
|
|
220
316
|
|
|
221
317
|
// -------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://session-orchestrator.com",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"!**/.DS_Store"
|
|
55
55
|
],
|
|
56
56
|
"scripts": {
|
|
57
|
+
"update:local": "node scripts/self-update.mjs",
|
|
57
58
|
"test": "node scripts/check-package-manager.mjs && vitest --run",
|
|
58
59
|
"test:coverage": "node scripts/check-package-manager.mjs && vitest --run --coverage",
|
|
59
60
|
"test:watch": "node scripts/check-package-manager.mjs && vitest",
|
|
@@ -92,11 +93,6 @@
|
|
|
92
93
|
"prettier": "^3.0.0",
|
|
93
94
|
"vitest": "^4.1.11"
|
|
94
95
|
},
|
|
95
|
-
"overrides": {
|
|
96
|
-
"vite": "^8.0.16",
|
|
97
|
-
"fast-uri": "^3.1.8",
|
|
98
|
-
"brace-expansion": "^5.0.12"
|
|
99
|
-
},
|
|
100
96
|
"license": "MIT",
|
|
101
97
|
"repository": {
|
|
102
98
|
"type": "git",
|
|
@@ -62,18 +62,22 @@
|
|
|
62
62
|
* Telemetry: `--apply` emits `orchestrator.learnings.session_write_applied`
|
|
63
63
|
* after the write — its presence is the proof the write ran. Best-effort: a
|
|
64
64
|
* telemetry failure never changes the exit code. Skipped (stderr WARN) when
|
|
65
|
-
* `--file` lies outside `--repo-root
|
|
65
|
+
* `--file` lies outside `--repo-root` after resolving symlinks on both sides,
|
|
66
|
+
* so the record is never pinned to a repo
|
|
66
67
|
* whose store was not written.
|
|
67
68
|
*
|
|
68
69
|
* Exit codes:
|
|
69
70
|
* 0 Success (including a no-op)
|
|
70
71
|
* 1 Usage/input/validation error — nothing written
|
|
71
|
-
* 2 Library/IO error (unreadable store,
|
|
72
|
-
*
|
|
72
|
+
* 2 Library/IO error (unreadable store, a failure inside the
|
|
73
|
+
* prune/rewrite, store lock not acquired). Unparseable store lines are
|
|
74
|
+
* NOT an error: they are kept verbatim, counted as `malformed` in the
|
|
75
|
+
* summary, and WARNed on stderr with their line numbers.
|
|
73
76
|
*/
|
|
74
77
|
|
|
75
|
-
import { readFileSync } from 'node:fs';
|
|
78
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
76
79
|
import path from 'node:path';
|
|
80
|
+
import { validatePathInsideProject } from './lib/path-utils.mjs';
|
|
77
81
|
import { LearningsLockError, readLearnings, withLearningsLock } from './lib/learnings/io.mjs';
|
|
78
82
|
import { pruneLearnings } from './lib/learnings/expiry-sweep.mjs';
|
|
79
83
|
import {
|
|
@@ -310,9 +314,29 @@ export function buildNextGeneration({ current, updates, newLearnings, decayRate,
|
|
|
310
314
|
return { next, confirmed, contradicted, appended: newIds.size, decayed, undecayable };
|
|
311
315
|
}
|
|
312
316
|
|
|
317
|
+
/**
|
|
318
|
+
* A path resolved through symlinks when it exists, else absolute as given.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} p
|
|
321
|
+
* @returns {string}
|
|
322
|
+
*/
|
|
323
|
+
function canonicalPath(p) {
|
|
324
|
+
try {
|
|
325
|
+
return realpathSync(p);
|
|
326
|
+
} catch {
|
|
327
|
+
return path.resolve(p);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
313
331
|
async function emitSessionWriteApplied({ repoRoot, filePath, payload }) {
|
|
314
|
-
|
|
315
|
-
|
|
332
|
+
// Canonical, not lexical (#1506.4b; same check as sweep-expired-learnings
|
|
333
|
+
// --drop-malformed, #1500 F4): a store reached through a symlinked directory
|
|
334
|
+
// inside the repo but living outside it is refused, and a root spelled
|
|
335
|
+
// through a symlink (macOS /var → /private/var) still accepts the store
|
|
336
|
+
// inside it. Runs after the write, so the store exists and resolves.
|
|
337
|
+
const file = canonicalPath(filePath);
|
|
338
|
+
const rel = path.relative(canonicalPath(repoRoot), file);
|
|
339
|
+
if (!validatePathInsideProject(file, repoRoot, { canonicalizeRoot: true }).ok) {
|
|
316
340
|
process.stderr.write(
|
|
317
341
|
`apply-session-learnings: skipped ${SESSION_WRITE_EVENT} — ${filePath} lies outside ` +
|
|
318
342
|
`--repo-root ${repoRoot}\n`,
|
|
@@ -377,14 +401,11 @@ async function main(argv) {
|
|
|
377
401
|
process.stderr.write(`apply-session-learnings: cannot read ${filePath}: ${err.message}\n`);
|
|
378
402
|
return { code: 2 };
|
|
379
403
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
);
|
|
386
|
-
return { code: 2 };
|
|
387
|
-
}
|
|
404
|
+
// A malformed store line is no reason to refuse (#1489): pruneLearnings()
|
|
405
|
+
// keeps it verbatim at the end of the rewritten store, never archives it,
|
|
406
|
+
// and prints the WARN; the count rides on the summary as `malformed`.
|
|
407
|
+
// Refusing here blocked the session-end learnings write on every close,
|
|
408
|
+
// because nothing on the sanctioned write path ever removes such a line.
|
|
388
409
|
|
|
389
410
|
let built;
|
|
390
411
|
try {
|
|
@@ -44,7 +44,7 @@ import { readFileSync } from 'node:fs';
|
|
|
44
44
|
import { spawnSync } from 'node:child_process';
|
|
45
45
|
import { join } from 'node:path';
|
|
46
46
|
|
|
47
|
-
import { findProjectRoot, resolveInstructionFile, warn } from './lib/common.mjs';
|
|
47
|
+
import { findProjectRoot, redactHomeDir, resolveInstructionFile, warn } from './lib/common.mjs';
|
|
48
48
|
import { parseSessionConfig } from './lib/config.mjs';
|
|
49
49
|
import { glabRun as defaultGlabRun } from './lib/vault-backfill/glab.mjs';
|
|
50
50
|
import { archiveFileToVault, titleFromMarkdown } from './lib/vault-archive.mjs';
|
|
@@ -381,8 +381,13 @@ EXIT CODES
|
|
|
381
381
|
}
|
|
382
382
|
|
|
383
383
|
function printHuman(archived, skipped, isDryRun, vaultDir, vaultSubdir) {
|
|
384
|
+
// Runs as a custom phase at every session start and end, so this line lands
|
|
385
|
+
// in every transcript — home dir as `~` (#1490). `--json` keeps the real
|
|
386
|
+
// `vaultDir`: that field is a machine contract, not a log line. A
|
|
387
|
+
// `skipped[].reason` is redacted in BOTH outputs — it carries fs error text,
|
|
388
|
+
// and main() redacts it before it enters the array (8273d97a).
|
|
384
389
|
process.stdout.write(
|
|
385
|
-
`Doc archive ${isDryRun ? '(dry-run)' : '(apply)'} → ${vaultDir}/${vaultSubdir}\n`,
|
|
390
|
+
`Doc archive ${isDryRun ? '(dry-run)' : '(apply)'} → ${redactHomeDir(`${vaultDir}/${vaultSubdir}`)}\n`,
|
|
386
391
|
);
|
|
387
392
|
if (archived.length > 0) {
|
|
388
393
|
process.stdout.write(` ${isDryRun ? 'WOULD ARCHIVE' : 'ARCHIVED'} (${archived.length}):\n`);
|
|
@@ -488,19 +493,38 @@ export function main({
|
|
|
488
493
|
const root = repoRoot ?? findProjectRoot();
|
|
489
494
|
const instr = resolveInstructionFile(root);
|
|
490
495
|
if (!instr) {
|
|
491
|
-
process.stderr.write(`archive-closed-prds: no CLAUDE.md/AGENTS.md at ${root}.\n`);
|
|
496
|
+
process.stderr.write(`archive-closed-prds: no CLAUDE.md/AGENTS.md at ${redactHomeDir(root)}.\n`);
|
|
492
497
|
return { code: 1, archived: [], skipped: [] };
|
|
493
498
|
}
|
|
494
499
|
|
|
495
500
|
let vaultDir;
|
|
501
|
+
let vaultIntegration;
|
|
496
502
|
try {
|
|
497
503
|
const content = readFileSync(instr.path, 'utf8');
|
|
498
504
|
const config = parseSessionConfig(content, hostPaths ? { hostPaths } : undefined);
|
|
499
|
-
|
|
505
|
+
vaultIntegration = config?.['vault-integration'];
|
|
506
|
+
vaultDir = vaultIntegration?.['vault-dir'];
|
|
500
507
|
} catch (err) {
|
|
501
|
-
process.stderr.write(`archive-closed-prds: failed to parse Session Config: ${err.message}\n`);
|
|
508
|
+
process.stderr.write(`archive-closed-prds: failed to parse Session Config: ${redactHomeDir(err.message)}\n`);
|
|
502
509
|
return { code: 2, archived: [], skipped: [] };
|
|
503
510
|
}
|
|
511
|
+
// The switch every vault writer obeys (board-writer, narrative-mirror): off
|
|
512
|
+
// → nothing archived, exit 0. parseSessionConfig has already applied the
|
|
513
|
+
// host-local lowering (resolveVaultIntegrationHost: env SO_VAULT_INTEGRATION
|
|
514
|
+
// > owner.yaml > committed). This runs with --apply at every session start
|
|
515
|
+
// and end, so ignoring it wrote PRDs into the vault and git-rm'd them (#1496).
|
|
516
|
+
if (vaultIntegration?.enabled !== true) {
|
|
517
|
+
const override = vaultIntegration?.['host-override'];
|
|
518
|
+
const disabled = `vault-integration is ${override ? `switched off by ${override}` : 'not enabled'} — nothing archived`;
|
|
519
|
+
if (json) {
|
|
520
|
+
process.stdout.write(
|
|
521
|
+
JSON.stringify({ dryRun: isDryRun, vaultDir: null, vaultSubdir, archived: [], skipped: [], disabled }, null, 2) + '\n',
|
|
522
|
+
);
|
|
523
|
+
} else {
|
|
524
|
+
process.stdout.write(`Doc archive: ${disabled}\n`);
|
|
525
|
+
}
|
|
526
|
+
return { code: 0, archived: [], skipped: [] };
|
|
527
|
+
}
|
|
504
528
|
if (!vaultDir || typeof vaultDir !== 'string' || vaultDir.trim() === '') {
|
|
505
529
|
process.stderr.write(
|
|
506
530
|
'archive-closed-prds: vault-integration.vault-dir is not configured — cannot archive.\n',
|
|
@@ -534,8 +558,10 @@ export function main({
|
|
|
534
558
|
try {
|
|
535
559
|
header = readHeaderRegion(abs);
|
|
536
560
|
} catch (err) {
|
|
537
|
-
|
|
538
|
-
|
|
561
|
+
// fs errors name the ABSOLUTE path they failed on (#1490).
|
|
562
|
+
const msg = redactHomeDir(err.message);
|
|
563
|
+
skipped.push({ source: rel, reason: `unreadable: ${msg}` });
|
|
564
|
+
warn(`archive-closed-prds: cannot read ${rel}: ${msg}`);
|
|
539
565
|
continue;
|
|
540
566
|
}
|
|
541
567
|
|
|
@@ -603,8 +629,9 @@ export function main({
|
|
|
603
629
|
title: titleFromMarkdown(header),
|
|
604
630
|
});
|
|
605
631
|
} catch (err) {
|
|
606
|
-
|
|
607
|
-
|
|
632
|
+
const msg = redactHomeDir(err.message);
|
|
633
|
+
skipped.push({ source: rel, reason: `archive-failed: ${msg}`, iid, via, ownership });
|
|
634
|
+
warn(`archive-closed-prds: failed to archive ${rel}: ${msg}`);
|
|
608
635
|
continue;
|
|
609
636
|
}
|
|
610
637
|
entry.iid = iid;
|
|
@@ -614,7 +641,7 @@ export function main({
|
|
|
614
641
|
if (!isDryRun) {
|
|
615
642
|
const rm = gitRunFn(['-C', root, 'rm', '--', rel]);
|
|
616
643
|
entry.removed = rm.ok;
|
|
617
|
-
if (!rm.ok) warn(`archive-closed-prds: 'git rm ${rel}' failed: ${rm.stderr.trim()}`);
|
|
644
|
+
if (!rm.ok) warn(`archive-closed-prds: 'git rm ${rel}' failed: ${redactHomeDir(rm.stderr.trim())}`);
|
|
618
645
|
}
|
|
619
646
|
|
|
620
647
|
archived.push(entry);
|
|
@@ -641,7 +668,7 @@ if (isMainModule(import.meta.url)) {
|
|
|
641
668
|
const { code } = main();
|
|
642
669
|
process.exit(code ?? 0);
|
|
643
670
|
} catch (err) {
|
|
644
|
-
process.stderr.write(`archive-closed-prds: unexpected error: ${err?.stack ?? err}\n`);
|
|
671
|
+
process.stderr.write(`archive-closed-prds: unexpected error: ${redactHomeDir(String(err?.stack ?? err))}\n`);
|
|
645
672
|
process.exit(2);
|
|
646
673
|
}
|
|
647
674
|
}
|
package/scripts/autopilot.mjs
CHANGED
|
@@ -400,12 +400,19 @@ async function main() {
|
|
|
400
400
|
});
|
|
401
401
|
|
|
402
402
|
const sessionIds = state.sessions.join(', ') || '(none)';
|
|
403
|
+
// #1498: `tokens_unknown_sessions` (#1487 Pkt 4) reached state, return value
|
|
404
|
+
// and autopilot.jsonl, but no report showed it — so the token total read as
|
|
405
|
+
// the whole sum. Printed only when non-zero: then the total is a lower bound.
|
|
406
|
+
const unknownTokens = state.tokens_unknown_sessions > 0
|
|
407
|
+
? ` tokens_unknown_sessions=${state.tokens_unknown_sessions} (the token total is a lower bound)`
|
|
408
|
+
: '';
|
|
403
409
|
const summary =
|
|
404
410
|
`${state.autopilot_run_id} ` +
|
|
405
411
|
`kill_switch=${state.kill_switch ?? 'none'} ` +
|
|
406
412
|
`iterations=${state.iterations_completed} ` +
|
|
407
413
|
`sessions=[${sessionIds}] ` +
|
|
408
|
-
`duration=${state.duration_seconds}s
|
|
414
|
+
`duration=${state.duration_seconds}s ` +
|
|
415
|
+
`tokens=${state.total_tokens_used}${unknownTokens}`;
|
|
409
416
|
process.stdout.write(summary + '\n');
|
|
410
417
|
|
|
411
418
|
if (state.kill_switch === KILL_SWITCHES.FAILED_WAVE) {
|