session-orchestrator 3.19.0 → 3.20.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/CHANGELOG.md +80 -0
- package/README.md +9 -9
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/package.json +2 -2
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +3 -40
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/scope-gate.mjs +194 -72
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/gitlab-ops/SKILL.md +5 -0
- package/skills/grill/soul.md +44 -1
- package/skills/plan/soul.md +46 -3
- package/skills/session-end/SKILL.md +1 -24
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-start/SKILL.md +2 -0
- package/skills/session-start/soul.md +41 -1
- package/skills/wave-executor/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +36 -71
|
@@ -73,6 +73,7 @@ if (!shouldRunHook('pre-bash-destructive-guard')) process.exit(0);
|
|
|
73
73
|
/** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
|
|
74
74
|
/** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
|
|
75
75
|
/** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
|
|
76
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
|
|
76
77
|
let resolveProjectDir;
|
|
77
78
|
let resolvePluginRoot;
|
|
78
79
|
/**
|
|
@@ -81,10 +82,10 @@ let resolvePluginRoot;
|
|
|
81
82
|
* primitives).
|
|
82
83
|
*
|
|
83
84
|
* Held as ONE object rather than destructured into six bindings on purpose: the
|
|
84
|
-
* required-export list then exists in exactly one place —
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
85
|
+
* required-export list then exists in exactly one place — the `requires` array
|
|
86
|
+
* on the `blocker` spec passed to `armGuard` (#993), which validates both the
|
|
87
|
+
* working-tree and the HEAD copy against it. The previous split (six names
|
|
88
|
+
* destructured here, two of them checked in the loader) is what let a HEAD copy
|
|
88
89
|
* missing four exports banner "DEGRADED — still armed" and then fail open on
|
|
89
90
|
* every command.
|
|
90
91
|
*
|
|
@@ -96,8 +97,38 @@ let loadEffectivePolicy;
|
|
|
96
97
|
let isSessionConfigHeading;
|
|
97
98
|
let emitEvent;
|
|
98
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Labels of guard modules that loaded from HEAD rather than the working tree
|
|
102
|
+
* (#993/#995). Populated by `bootstrap()` from `armGuard`'s return; read by
|
|
103
|
+
* `main()`, which pushes a visible-channel DEGRADED notice when it is non-empty —
|
|
104
|
+
* the stderr degradation banner alone is invisible under the exit-0 protocol.
|
|
105
|
+
*
|
|
106
|
+
* @type {string[]}
|
|
107
|
+
*/
|
|
108
|
+
let degradedLabels = [];
|
|
109
|
+
|
|
99
110
|
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
100
111
|
|
|
112
|
+
/** This hook's name — threaded into both guard banners (#993: no hard-wired literal). */
|
|
113
|
+
const HOOK_NAME = 'pre-bash-destructive-guard';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The two consequence blocks, spliced VERBATIM into the DEGRADED and GUARD
|
|
117
|
+
* INACTIVE banners (#993). Byte-identical to the pre-#993 inline banner text so
|
|
118
|
+
* the #992 banner-visibility tests stay green.
|
|
119
|
+
*/
|
|
120
|
+
const GUARD_CONSEQUENCE = {
|
|
121
|
+
degraded: [
|
|
122
|
+
' Consequence: destructive-command enforcement IS still armed, but it is evaluating the',
|
|
123
|
+
' COMMITTED (HEAD) command lexer — any uncommitted change to that file is NOT in effect.',
|
|
124
|
+
],
|
|
125
|
+
inactive: [
|
|
126
|
+
' Consequence: destructive Bash commands (git reset --hard, rm -rf, git push --force,',
|
|
127
|
+
' git stash, redirect-truncate of protected artefacts) are NOT being blocked. This is a',
|
|
128
|
+
' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
101
132
|
/**
|
|
102
133
|
* Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
|
|
103
134
|
* is one of the ones that may have failed to load.
|
|
@@ -117,22 +148,76 @@ function bannerProjectDir() {
|
|
|
117
148
|
* @returns {Promise<void>}
|
|
118
149
|
*/
|
|
119
150
|
async function bootstrap() {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
151
|
+
const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
|
|
152
|
+
|
|
153
|
+
const { armGuard } = await import('./_lib/guard-source-loader.mjs');
|
|
154
|
+
// #993: one generic loader for every repo dependency. Only `blocker` opts into
|
|
155
|
+
// the `git show HEAD:` fallback (dependency-free, so data:-URL loadable) and
|
|
156
|
+
// carries the COMPLETE required-export set — validated on the working-tree AND
|
|
157
|
+
// the HEAD copy, so a partial namespace banners GUARD INACTIVE rather than
|
|
158
|
+
// arming a guard that fails open per command. A load failure of any entry
|
|
159
|
+
// throws; the caller banners GUARD INACTIVE.
|
|
160
|
+
const { modules, degraded } = await armGuard(
|
|
161
|
+
{
|
|
162
|
+
io: { specifier: lib('io.mjs') },
|
|
163
|
+
platform: { specifier: lib('platform.mjs') },
|
|
164
|
+
config: { specifier: lib('config.mjs') },
|
|
165
|
+
blockedCommandsPolicy: { specifier: lib('blocked-commands-policy.mjs') },
|
|
166
|
+
sectionExtractor: { specifier: lib('config', 'section-extractor.mjs') },
|
|
167
|
+
events: { specifier: lib('events.mjs') },
|
|
168
|
+
blocker: {
|
|
169
|
+
specifier: lib('command-blocker.mjs'),
|
|
170
|
+
headFallback: true,
|
|
171
|
+
requires: [
|
|
172
|
+
'tokenizeCommand',
|
|
173
|
+
'commandMatchesBlocked',
|
|
174
|
+
'extractRedirectTargets',
|
|
175
|
+
'redirectRuleMatches',
|
|
176
|
+
'redirectSpanEnd',
|
|
177
|
+
'resolveSegmentVerb',
|
|
178
|
+
'splitChainSegments',
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
hookName: HOOK_NAME,
|
|
184
|
+
repoRoot: PLUGIN_ROOT,
|
|
185
|
+
projectDir: bannerProjectDir(),
|
|
186
|
+
consequence: GUARD_CONSEQUENCE,
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
|
|
191
|
+
({ resolveProjectDir, resolvePluginRoot } = modules.platform);
|
|
192
|
+
({ readConfigFile } = modules.config);
|
|
193
|
+
({ loadEffectivePolicy } = modules.blockedCommandsPolicy);
|
|
194
|
+
({ isSessionConfigHeading } = modules.sectionExtractor);
|
|
195
|
+
({ emitEvent } = modules.events);
|
|
196
|
+
blocker = modules.blocker;
|
|
197
|
+
degradedLabels = degraded;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Flush the aggregated allow-with-notice channel (#995).
|
|
202
|
+
*
|
|
203
|
+
* Warnings raised during rule evaluation accumulate in `notices` and are emitted
|
|
204
|
+
* ONCE, here, on the VISIBLE stdout channel via `emitWarn` (allow-with-notice) —
|
|
205
|
+
* else a plain `emitAllow`. Both exit 0 and never return, so this is always the
|
|
206
|
+
* last statement on an allow path.
|
|
207
|
+
*
|
|
208
|
+
* Why aggregate instead of `emitWarn`-ing inline at each warn site: `emitWarn`
|
|
209
|
+
* is `@returns never`, so an inline call mid-loop would exit BEFORE a later
|
|
210
|
+
* `block` rule was evaluated — flipping a would-be DENY into an ALLOW-with-notice
|
|
211
|
+
* (measured: `bash <33×-c>` overshoots the recursion cap, and a command that
|
|
212
|
+
* both trips the cap AND carries `rm -rf src/` would wave the delete through).
|
|
213
|
+
* A block, when it fires, exits via `emitDeny` and these notices are simply
|
|
214
|
+
* dropped: DENY wins, and the stderr copies of each notice remain for CI/debug.
|
|
215
|
+
*
|
|
216
|
+
* @param {string[]} notices
|
|
217
|
+
* @returns {never}
|
|
218
|
+
*/
|
|
219
|
+
function flushNotices(notices) {
|
|
220
|
+
return notices.length > 0 ? emitWarn(notices.join('\n')) : emitAllow();
|
|
136
221
|
}
|
|
137
222
|
|
|
138
223
|
// Module-level per-path policy cache (issue #250, extended for the #972
|
|
@@ -242,29 +327,6 @@ async function isGitStashNonEmpty(projectDir) {
|
|
|
242
327
|
}
|
|
243
328
|
}
|
|
244
329
|
|
|
245
|
-
/**
|
|
246
|
-
* Given a redirect token at `segment[i]`, return the index of the LAST token
|
|
247
|
-
* belonging to that redirect (the operand word, when one exists). Redirect
|
|
248
|
-
* operators and their operands name IO targets, not command arguments —
|
|
249
|
-
* `rm -rf /tmp/ok > out.log` must not collect `>` or `out.log` as rm targets
|
|
250
|
-
* (#983 FP fix). `dup` (`2>&1`) carries no operand word.
|
|
251
|
-
*
|
|
252
|
-
* @param {Array<{ text: string, quoted: boolean, redirect?: object }>} segment
|
|
253
|
-
* @param {number} i — index of the redirect token
|
|
254
|
-
* @returns {number}
|
|
255
|
-
*/
|
|
256
|
-
function redirectSpanEnd(segment, i) {
|
|
257
|
-
const tok = segment[i];
|
|
258
|
-
// `dup` (2>&1) carries its target inline; `heredoc` consumes its delimiter in
|
|
259
|
-
// the lexer (the body arrives as a separate QUOTED token) — neither owns an
|
|
260
|
-
// operand word, and claiming one would swallow the next real token.
|
|
261
|
-
const hasOperandWord = tok.redirect.mode !== 'dup' && tok.redirect.mode !== 'heredoc';
|
|
262
|
-
if (hasOperandWord && i + 1 < segment.length && !segment[i + 1].redirect) {
|
|
263
|
-
return i + 1;
|
|
264
|
-
}
|
|
265
|
-
return i;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
330
|
/**
|
|
269
331
|
* Parse ALL non-flag path arguments from every `rm` invocation in a command.
|
|
270
332
|
*
|
|
@@ -314,7 +376,7 @@ function parseRmTargets(command) {
|
|
|
314
376
|
for (let i = index + 1; i < segment.length; i++) {
|
|
315
377
|
const tok = segment[i];
|
|
316
378
|
if (tok.redirect) {
|
|
317
|
-
const end = redirectSpanEnd(segment, i);
|
|
379
|
+
const end = blocker.redirectSpanEnd(segment, i);
|
|
318
380
|
const mode = tok.redirect.mode;
|
|
319
381
|
if ((mode === 'truncate' || mode === 'append') && end > i) {
|
|
320
382
|
const operand = segment[end];
|
|
@@ -388,7 +450,7 @@ function commandHasRecursiveForceRm(command) {
|
|
|
388
450
|
let seenDashDash = false;
|
|
389
451
|
for (let i = index + 1; i < segment.length; i++) {
|
|
390
452
|
const t = segment[i];
|
|
391
|
-
if (t.redirect) { i = redirectSpanEnd(segment, i); continue; }
|
|
453
|
+
if (t.redirect) { i = blocker.redirectSpanEnd(segment, i); continue; }
|
|
392
454
|
if (!seenDashDash && t.text === '--') { seenDashDash = true; continue; }
|
|
393
455
|
if (!seenDashDash && !t.quoted && t.text.startsWith('-') && t.text !== '-') {
|
|
394
456
|
if (t.text === '--recursive') recursive = true;
|
|
@@ -675,6 +737,22 @@ async function main() {
|
|
|
675
737
|
const projectDir = resolveProjectDir();
|
|
676
738
|
const sessionId = resolveSessionId(input);
|
|
677
739
|
|
|
740
|
+
// #995 — aggregated allow-with-notice channel. Warn-severity matches and
|
|
741
|
+
// fail-visible markers accumulate here (stderr copies are kept for parity) and
|
|
742
|
+
// flush ONCE, on the visible stdout channel, only after EVERY rule has been
|
|
743
|
+
// evaluated. See flushNotices for why inline emitWarn would fail open.
|
|
744
|
+
const notices = [];
|
|
745
|
+
// A guard armed from HEAD (working-tree module unparseable) is a visible-channel
|
|
746
|
+
// concern too: the DEGRADED banner rides stderr only, which exit-0 discards. The
|
|
747
|
+
// stderr banner already fired once-per-session inside armGuard; surface it on
|
|
748
|
+
// stdout so a degraded ALLOW is not silently indistinguishable from a healthy one.
|
|
749
|
+
if (degradedLabels.length > 0) {
|
|
750
|
+
notices.push(
|
|
751
|
+
`${HOOK_NAME}: DEGRADED — guard module(s) loaded from HEAD, not your working tree ` +
|
|
752
|
+
`(${degradedLabels.join(', ')}); uncommitted changes to them are NOT in effect. See #992.`
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
678
756
|
// G3 — bypass: allow-destructive-ops: true in Session Config
|
|
679
757
|
// Note: parseSessionConfig only returns known fields; allow-destructive-ops is
|
|
680
758
|
// a new field, so we parse the raw markdown for it directly.
|
|
@@ -717,7 +795,10 @@ async function main() {
|
|
|
717
795
|
for (const warning of warnings) {
|
|
718
796
|
process.stderr.write(`⚠ pre-bash-destructive-guard: ${warning}\n`);
|
|
719
797
|
}
|
|
720
|
-
|
|
798
|
+
// No usable policy anywhere → documented total fail-open. Flush any notice
|
|
799
|
+
// already queued (a DEGRADED head-fallback notice), else a plain allow: the
|
|
800
|
+
// policy-load warnings above stay stderr-only, as they always have.
|
|
801
|
+
if (!Array.isArray(rules)) return flushNotices(notices);
|
|
721
802
|
|
|
722
803
|
// G5 — rule evaluation
|
|
723
804
|
for (const rule of rules) {
|
|
@@ -748,15 +829,19 @@ async function main() {
|
|
|
748
829
|
const reasons = [
|
|
749
830
|
...new Set(unresolved.map((e) => e.reason ?? 'variable/substitution')),
|
|
750
831
|
].join(', ');
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
)
|
|
832
|
+
// #995 — the #988-T2 recursion-cap marker used to reach stderr only, so
|
|
833
|
+
// under exit-0 a budget-exhausted command (`bash <33×-c>` hiding
|
|
834
|
+
// `> CLAUDE.md`) allowed with an INVISIBLE notice. Aggregate onto the
|
|
835
|
+
// visible channel; keep the stderr copy for parity.
|
|
836
|
+
const msg = `pre-bash-destructive-guard: unresolved redirect target (${reasons}) — not matched (fail-visible)`;
|
|
837
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
838
|
+
notices.push(msg);
|
|
754
839
|
}
|
|
755
840
|
if (!blocker.redirectRuleMatches(rule, command, { repoRoot: projectDir })) continue;
|
|
756
841
|
if (severity !== 'block') {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
);
|
|
842
|
+
const msg = `pre-bash-destructive-guard: redirect target matched (rule: ${id}) — ${rationale}`;
|
|
843
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
844
|
+
notices.push(msg); // #995 — visible on the allow-with-notice channel
|
|
760
845
|
continue;
|
|
761
846
|
}
|
|
762
847
|
// Reason stays short (stdout-budget): operator + target, never the command.
|
|
@@ -782,9 +867,9 @@ async function main() {
|
|
|
782
867
|
continue;
|
|
783
868
|
}
|
|
784
869
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
);
|
|
870
|
+
const msg = `pre-bash-destructive-guard: '${pattern}' (rule: ${id}) — ${rationale}`;
|
|
871
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
872
|
+
notices.push(msg); // #995 — visible on the allow-with-notice channel
|
|
788
873
|
// Best-effort telemetry — must never affect the warn/allow outcome.
|
|
789
874
|
try {
|
|
790
875
|
await emitEvent('orchestrator.destructive_guard.warned', {
|
|
@@ -832,8 +917,9 @@ async function main() {
|
|
|
832
917
|
// Unknown severity → skip (conservative allow for unknown future severities)
|
|
833
918
|
}
|
|
834
919
|
|
|
835
|
-
// G6 — no blocking match
|
|
836
|
-
|
|
920
|
+
// G6 — no blocking match. Flush the aggregated allow-with-notice channel
|
|
921
|
+
// (#995): emitWarn if any notice queued (visible), else a silent allow.
|
|
922
|
+
return flushNotices(notices);
|
|
837
923
|
}
|
|
838
924
|
|
|
839
925
|
// ---------------------------------------------------------------------------
|
|
@@ -857,8 +943,9 @@ try {
|
|
|
857
943
|
const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
|
|
858
944
|
// Unthrottled by design: EVERY call in an unarmed session says so (#992
|
|
859
945
|
// hardening — the once-per-session marker it used to pass through was
|
|
860
|
-
// suppressible by a bare `touch` on a derivable tmp path).
|
|
861
|
-
|
|
946
|
+
// suppressible by a bare `touch` on a derivable tmp path). hookName is
|
|
947
|
+
// threaded explicitly (#993 — no hard-wired literal in the loader).
|
|
948
|
+
emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
|
|
862
949
|
} catch {
|
|
863
950
|
// Last resort: even the banner helper failed to load. Emit unconditionally
|
|
864
951
|
// (no once-per-session keying) — repeated noise beats a silent disarm.
|