session-orchestrator 5.5.0 → 5.7.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-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +27 -13
- package/docs/USER-GUIDE.md +1 -1
- package/docs/rule-authoring.md +10 -0
- package/docs/scope-collision-guard.md +5 -3
- package/docs/session-config-reference.md +1 -1
- package/docs/session-config-template.md +1 -1
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-stop.mjs +8 -2
- package/hooks/post-bash-write-verify.mjs +121 -7
- package/hooks/post-tool-batch-wave-signal.mjs +11 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -8
- package/hooks/pre-task-scope-disjoint.mjs +20 -35
- package/hooks/wave-scope-commit-guard.mjs +117 -20
- package/package.json +8 -3
- package/scripts/backfill-abandoned-sessions.mjs +5 -7
- package/scripts/backfill-learnings-from-vault.mjs +17 -6
- package/scripts/lib/dispatcher/rank.mjs +5 -1
- package/scripts/lib/events.mjs +62 -49
- package/scripts/lib/io.mjs +9 -4
- package/scripts/lib/learnings/candidates.mjs +7 -16
- package/scripts/lib/memory-proposals/store.mjs +9 -2
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
- package/scripts/lib/scope-gate.mjs +8 -1
- package/scripts/lib/session-close-backfill.mjs +104 -13
- package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
- package/scripts/lib/vault-mirror/process.mjs +388 -56
- package/scripts/lib/vault-mirror/prune.mjs +413 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
- package/scripts/lib/vault-mirror/retention.mjs +388 -0
- package/scripts/lib/vault-status/board-writer.mjs +6 -6
- package/scripts/lib/wave-transcript-tail.mjs +11 -2
- package/scripts/print-applicable-rules.mjs +1 -25
- package/scripts/release.mjs +4 -0
- package/scripts/validate-wave-scope.mjs +39 -4
- package/scripts/vault-mirror-prune.mjs +145 -0
- package/scripts/vault-mirror.mjs +77 -12
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +6 -16
- package/skills/vault-mirror/SKILL.md +27 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +3 -1
- package/hooks/agent-teams-h3-test.sh +0 -362
- package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
- package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
- package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
- package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
|
@@ -267,6 +267,7 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
|
267
267
|
/** @type {typeof import('../scripts/lib/scope-echo.mjs').scopeDigest} */ let scopeDigest;
|
|
268
268
|
/** @type {typeof import('../scripts/lib/platform.mjs').resolveSessionRoot} */ let resolveSessionRoot;
|
|
269
269
|
let findScopeCollisions;
|
|
270
|
+
/** @type {typeof import('../scripts/lib/scope-gate.mjs').scopeEntrySpelling} */ let scopeEntrySpelling;
|
|
270
271
|
let findOwnScopeFile;
|
|
271
272
|
let readProcessLocalSessionIds;
|
|
272
273
|
let classifyManifestSession;
|
|
@@ -581,7 +582,7 @@ async function bootstrap() {
|
|
|
581
582
|
);
|
|
582
583
|
|
|
583
584
|
({ readStdin, emitAllow, emitDeny, emitWarn, writeJsonAtomicSync } = modules.io);
|
|
584
|
-
({ findScopeCollisions, findOwnScopeFile } = modules.scopeGate);
|
|
585
|
+
({ findScopeCollisions, findOwnScopeFile, scopeEntrySpelling } = modules.scopeGate);
|
|
585
586
|
({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
|
|
586
587
|
({ withFileLock } = modules.fileLock);
|
|
587
588
|
({ scopeDigest } = modules.scopeEcho);
|
|
@@ -739,36 +740,6 @@ function cleanScopeLine(line) {
|
|
|
739
740
|
.trim();
|
|
740
741
|
}
|
|
741
742
|
|
|
742
|
-
/**
|
|
743
|
-
* Canonicalise a scope entry's SPELLING so two agents writing the same file two
|
|
744
|
-
* ways are not read as disjoint (review LOW). Measured before this existed:
|
|
745
|
-
* `['./scripts/lib/foo.mjs']` vs `['scripts/lib/foo.mjs']` compared `ok: true`
|
|
746
|
-
* — the hook extracts from PROSE, and `looksLikeRepoPath` admits a `./` prefix,
|
|
747
|
-
* so both spellings reach the comparison verbatim.
|
|
748
|
-
*
|
|
749
|
-
* Purely syntactic and meaning-preserving: `./` prefixes, `/./` segments and
|
|
750
|
-
* duplicated slashes are removed. A TRAILING slash is deliberately kept — it is
|
|
751
|
-
* the directory-prefix operator of `pathMatchesPattern`, so stripping it would
|
|
752
|
-
* silently narrow a scope. The `dir` ↔ `dir/` case is handled by
|
|
753
|
-
* {@link promoteDirEntries}, which decides it on evidence rather than guessing.
|
|
754
|
-
*
|
|
755
|
-
* `scope-gate.mjs` is a hook-safe pure library and out of this change's scope,
|
|
756
|
-
* so the normalisation lives on THIS side of the call, applied to both sides of
|
|
757
|
-
* every comparison.
|
|
758
|
-
*
|
|
759
|
-
* @param {string} entry
|
|
760
|
-
* @returns {string}
|
|
761
|
-
*/
|
|
762
|
-
export function normalizeScopeEntry(entry) {
|
|
763
|
-
if (typeof entry !== 'string') return '';
|
|
764
|
-
let s = entry.trim();
|
|
765
|
-
if (s === '') return '';
|
|
766
|
-
s = s.replace(/\/{2,}/g, '/'); // `a//b` → `a/b`
|
|
767
|
-
s = s.replace(/(?:^|\/)\.\//g, (m) => (m.startsWith('/') ? '/' : '')); // `./a`, `a/./b`
|
|
768
|
-
while (s.startsWith('./')) s = s.slice(2);
|
|
769
|
-
return s;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
743
|
/**
|
|
773
744
|
* Promote an entry that names a DIRECTORY to its `dir/` prefix form, on
|
|
774
745
|
* evidence. `scripts/lib` and `scripts/lib/` are the same claim, but
|
|
@@ -815,7 +786,11 @@ function collectScopePaths(segments) {
|
|
|
815
786
|
for (const raw of segments) {
|
|
816
787
|
// A trailing sentence period is punctuation, never part of a path
|
|
817
788
|
// (measured: "docs/events-schema.md." at the end of an inline declaration).
|
|
818
|
-
|
|
789
|
+
// Lexical cleaning only (prose → path); the SPELLING rule is
|
|
790
|
+
// `scopeEntrySpelling` in scope-gate.mjs, applied in `decide()` (#1510 g).
|
|
791
|
+
// The second trim: a period stripped after `cleanScopeLine`'s trim can
|
|
792
|
+
// expose a space (`foo.mjs .`), which `looksLikeRepoPath` would reject.
|
|
793
|
+
const cleaned = cleanScopeLine(raw).replace(/\.$/, '').trim();
|
|
819
794
|
if (!looksLikeRepoPath(cleaned)) continue;
|
|
820
795
|
if (seen.has(cleaned)) continue;
|
|
821
796
|
seen.add(cleaned);
|
|
@@ -906,7 +881,8 @@ export function extractScopeSignal(prompt) {
|
|
|
906
881
|
* (and the tests that pin them) keep the original signature.
|
|
907
882
|
*
|
|
908
883
|
* @param {string} prompt
|
|
909
|
-
* @returns {string[]} repo-relative paths/globs
|
|
884
|
+
* @returns {string[]} repo-relative paths/globs as declared (lexically cleaned,
|
|
885
|
+
* deduped, order preserved) — the SPELLING rule runs in `decide()` (#1510 g)
|
|
910
886
|
*/
|
|
911
887
|
export function extractScopeFromPrompt(prompt) {
|
|
912
888
|
return extractScopeSignal(prompt).files;
|
|
@@ -2070,9 +2046,11 @@ export function scopeDigestFields(files, prompt, digestFn) {
|
|
|
2070
2046
|
* @param {string} [params.nowIso] dispatch timestamp recorded on the entry
|
|
2071
2047
|
* @param {(paths: string[]) => string} [params.digestFn] scope-digest function
|
|
2072
2048
|
* (injected for testability; defaults to the late-bound `scopeDigest`)
|
|
2049
|
+
* @param {(entry: string) => string} [params.spellFn] scope-entry spelling rule
|
|
2050
|
+
* (injected for testability; defaults to the late-bound `scopeEntrySpelling`)
|
|
2073
2051
|
* @returns {Verdict}
|
|
2074
2052
|
*/
|
|
2075
|
-
export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, collide, isFinished, nowIso, digestFn }) {
|
|
2053
|
+
export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, collide, isFinished, nowIso, digestFn, spellFn }) {
|
|
2076
2054
|
const toolName = input?.tool_name;
|
|
2077
2055
|
// Row 4: not our tool.
|
|
2078
2056
|
if (toolName !== DISPATCH_TOOL) return { action: 'allow' };
|
|
@@ -2083,7 +2061,14 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
|
|
|
2083
2061
|
const at = typeof nowIso === 'string' ? nowIso : new Date().toISOString();
|
|
2084
2062
|
|
|
2085
2063
|
const signal = extractScopeSignal(toolInput.prompt);
|
|
2086
|
-
|
|
2064
|
+
// #1510 g — ONE spelling rule, scope-gate's, applied before the ledger, the
|
|
2065
|
+
// digest and `promoteDirEntries` see an entry: `./scripts/lib` must reach the
|
|
2066
|
+
// dir-promotion as `scripts/lib`, or a bare-dir claim stays a literal and
|
|
2067
|
+
// compares disjoint with the files beneath it. Re-deduped on the spelling.
|
|
2068
|
+
// Unbound (a direct importer that never ran `bootstrap()`) ⇒ entries as
|
|
2069
|
+
// extracted; `findScopeCollisions` still spells both sides itself.
|
|
2070
|
+
const spell = typeof spellFn === 'function' ? spellFn : scopeEntrySpelling;
|
|
2071
|
+
const files = typeof spell === 'function' ? [...new Set(signal.files.map(spell))] : signal.files;
|
|
2087
2072
|
const id = agentIdOf(toolInput);
|
|
2088
2073
|
const priorAgents = (ledger !== null && ledger?.waveKey === waveKey && Array.isArray(ledger.agents))
|
|
2089
2074
|
? ledger.agents
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
// `git commit --no-verify` skips it entirely (operator opts out by name).
|
|
49
49
|
|
|
50
50
|
import { execSync } from 'node:child_process';
|
|
51
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
52
|
-
import { join, relative } from 'node:path';
|
|
51
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
52
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
53
53
|
|
|
54
54
|
// Re-use existing helpers from scripts/lib/hardening.mjs.
|
|
55
55
|
// IMPORTANT: import is resolved relative to THIS file's location (the hook
|
|
@@ -59,6 +59,7 @@ import { join, relative } from 'node:path';
|
|
|
59
59
|
import { pathMatchesPattern } from '../scripts/lib/hardening.mjs';
|
|
60
60
|
import { withStagingFenceLock } from '../scripts/lib/session-lock.mjs';
|
|
61
61
|
import { isMainModule } from '../scripts/lib/is-main-module.mjs';
|
|
62
|
+
import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
|
|
62
63
|
import { classifyEmptyScope, findOwnScopeFile, sessionAgeMs } from '../scripts/lib/scope-gate.mjs';
|
|
63
64
|
import {
|
|
64
65
|
classifyManifestSession,
|
|
@@ -153,6 +154,29 @@ function findOverlaps(fenceJsonPath, ourStaged) {
|
|
|
153
154
|
return matches;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/** Remediation for an allowedPaths violation, printed once per commit. */
|
|
158
|
+
const ALLOWED_PATHS_HINT = [
|
|
159
|
+
'',
|
|
160
|
+
'These files were likely added by lint-staged eslint --fix / prettier --write.',
|
|
161
|
+
'To proceed:',
|
|
162
|
+
' 1) git restore --staged <path> # for each foreign path',
|
|
163
|
+
' 2) git commit # retry',
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A manifest path for messages: relative to the repo root, the absolute path
|
|
168
|
+
* when it does not lie beneath it (#1514.2 — two candidate manifests, so a
|
|
169
|
+
* message must say which one it means).
|
|
170
|
+
*
|
|
171
|
+
* @param {string} repoRoot
|
|
172
|
+
* @param {string} scopePath
|
|
173
|
+
* @returns {string}
|
|
174
|
+
*/
|
|
175
|
+
function manifestLabel(repoRoot, scopePath) {
|
|
176
|
+
const rel = relative(repoRoot, scopePath);
|
|
177
|
+
return rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? scopePath : rel;
|
|
178
|
+
}
|
|
179
|
+
|
|
156
180
|
/** mtime of a file in ms, or `null` when it cannot be stat'ed. */
|
|
157
181
|
function mtimeMsOf(file) {
|
|
158
182
|
try {
|
|
@@ -175,14 +199,23 @@ function mtimeMsOf(file) {
|
|
|
175
199
|
*
|
|
176
200
|
* @param {object} p
|
|
177
201
|
* @param {Record<string, unknown>} p.scope parsed manifest
|
|
178
|
-
* @param {string[]} p.stagedFiles
|
|
179
|
-
* @param {string} p.
|
|
202
|
+
* @param {string[]} p.stagedFiles repo-relative, as `git diff --cached` prints them
|
|
203
|
+
* @param {(f: string) => string|null} [p.scopePathOf] a staged path as the
|
|
204
|
+
* manifest names it — relative to the SESSION root; identity when that is the
|
|
205
|
+
* repo root. `null` = the path lies OUTSIDE the session root: a violation
|
|
206
|
+
* without pattern matching, since a `../`-prefixed path would match a glob
|
|
207
|
+
* like `**\/*.mjs` and enforce-scope denies every write outside its root
|
|
208
|
+
* (REQ-04).
|
|
209
|
+
* @param {string} p.scopeRel manifest path for messages, repo-relative (absolute
|
|
210
|
+
* when outside the repo root) — `manifestLabel`
|
|
180
211
|
* @param {Set<string>} p.ownIds this process's session ids
|
|
181
212
|
* @param {number|null} p.scopeMtimeMs
|
|
182
213
|
* @param {number|null} p.sessionAge ms since the newest session clock
|
|
183
|
-
* @returns {{action: 'pass'|'report'|'block', lines: string[]}}
|
|
214
|
+
* @returns {{action: 'pass'|'report'|'block', lines: string[], hint?: string[]}}
|
|
215
|
+
* `hint` = the remediation block; `main()` prints it ONCE after every
|
|
216
|
+
* manifest was judged, so two blocking manifests do not repeat it (#1514.3).
|
|
184
217
|
*/
|
|
185
|
-
function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs, sessionAge }) {
|
|
218
|
+
function scopeCommitVerdict({ scope, stagedFiles, scopePathOf = (f) => f, scopeRel, ownIds, scopeMtimeMs, sessionAge }) {
|
|
186
219
|
if (stagedFiles.length === 0) return { action: 'pass', lines: [] };
|
|
187
220
|
|
|
188
221
|
// Same classifier and same disposition as enforce-scope Gate 3b: only a
|
|
@@ -200,7 +233,10 @@ function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs
|
|
|
200
233
|
const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
|
|
201
234
|
if (allowedPaths.length > 0) {
|
|
202
235
|
const violations = stagedFiles.filter(
|
|
203
|
-
(f) =>
|
|
236
|
+
(f) => {
|
|
237
|
+
const scoped = scopePathOf(f);
|
|
238
|
+
return scoped === null || !allowedPaths.some((pattern) => pathMatchesPattern(scoped, pattern));
|
|
239
|
+
},
|
|
204
240
|
);
|
|
205
241
|
if (violations.length === 0) return { action: 'pass', lines: [] };
|
|
206
242
|
// Blocks under `warn` too — see the header: in a warn-mode wave this is
|
|
@@ -208,14 +244,10 @@ function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs
|
|
|
208
244
|
return {
|
|
209
245
|
action: 'block',
|
|
210
246
|
lines: [
|
|
211
|
-
|
|
247
|
+
`✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths (${scopeRel}):`,
|
|
212
248
|
...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
249
|
],
|
|
250
|
+
hint: ALLOWED_PATHS_HINT,
|
|
219
251
|
};
|
|
220
252
|
}
|
|
221
253
|
|
|
@@ -258,6 +290,30 @@ function scopeCommitVerdict({ scope, stagedFiles, scopeRel, ownIds, scopeMtimeMs
|
|
|
258
290
|
};
|
|
259
291
|
}
|
|
260
292
|
|
|
293
|
+
/**
|
|
294
|
+
* The session root a commit at `repoRoot` is judged against when it lies
|
|
295
|
+
* STRICTLY inside the repo, else `null` (judge at `repoRoot`, as before) — see
|
|
296
|
+
* the call site in `main()` (#1511 point d). Both sides realpath-resolved, so
|
|
297
|
+
* macOS `/var` vs `/private/var` cannot split one directory in two.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} repoRoot `git rev-parse --show-toplevel`
|
|
300
|
+
* @returns {{sessionRoot: string, realRepoRoot: string}|null}
|
|
301
|
+
*/
|
|
302
|
+
function subdirSessionRoot(repoRoot) {
|
|
303
|
+
const canon = (p) => {
|
|
304
|
+
// `.native` canonicalises letter case on case-insensitive APFS (as
|
|
305
|
+
// enforce-scope's clamp does since #1504 pt 8); the JS realpath keeps a
|
|
306
|
+
// case-different CLAUDE_PROJECT_DIR verbatim, which relativises to `../..`
|
|
307
|
+
// against the git toplevel and switches the subdirectory mode off silently.
|
|
308
|
+
try { return realpathSync.native(p); } catch { return p; }
|
|
309
|
+
};
|
|
310
|
+
const realRepoRoot = canon(repoRoot);
|
|
311
|
+
const sessionRoot = canon(resolveSessionRoot(repoRoot, repoRoot));
|
|
312
|
+
const rel = relative(realRepoRoot, sessionRoot);
|
|
313
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
|
|
314
|
+
return { sessionRoot, realRepoRoot };
|
|
315
|
+
}
|
|
316
|
+
|
|
261
317
|
/**
|
|
262
318
|
* Both sub-modes, in the order the pre-commit hook needs them. Every statement
|
|
263
319
|
* here used to sit at module top level, which meant a bare `import()` of this
|
|
@@ -282,8 +338,34 @@ async function main() {
|
|
|
282
338
|
// committing session's env; there is no hook payload here, so the env tier is
|
|
283
339
|
// the only process-local identity.
|
|
284
340
|
const ownIds = new Set(readProcessLocalSessionIds());
|
|
285
|
-
|
|
286
|
-
|
|
341
|
+
|
|
342
|
+
// #1511 point d — ALSO read the manifest at the SESSION root the scope guards
|
|
343
|
+
// resolve (`resolveSessionRoot`, enforce-scope.mjs): a session launched in a
|
|
344
|
+
// repo SUBDIRECTORY keeps its manifest there, so the repo root held none and
|
|
345
|
+
// sub-mode B never ran for it. git runs this hook at the worktree top level
|
|
346
|
+
// and the committing session's env (the launch dir) is inherited, so the
|
|
347
|
+
// toplevel stands in for the payload `cwd` the Edit/Write guards get. Only a
|
|
348
|
+
// root STRICTLY inside the repo is adopted: the one rung that answers above it
|
|
349
|
+
// (a harness `agent-<hex>` worktree lifted to its launch checkout) would judge
|
|
350
|
+
// this repo's staged paths against another tree's manifest.
|
|
351
|
+
//
|
|
352
|
+
// The repo-root manifest is ALWAYS judged exactly as before #1511 d, and the
|
|
353
|
+
// subdirectory manifest is judged IN ADDITION, each against its own path
|
|
354
|
+
// mapping; either one blocking blocks. So the verdict is never laxer than the
|
|
355
|
+
// repo-root-only guard it extends — choosing ONE of the two manifests let an
|
|
356
|
+
// unbound or `enforcement: off` subdirectory manifest silence a repo-root wave
|
|
357
|
+
// that blocked the same commit (second review, MED-1).
|
|
358
|
+
const subdir = subdirSessionRoot(repoRoot);
|
|
359
|
+
const judgements = [{ root: repoRoot, scopePathOf: (f) => f }];
|
|
360
|
+
if (subdir !== null) {
|
|
361
|
+
judgements.push({
|
|
362
|
+
root: subdir.sessionRoot,
|
|
363
|
+
scopePathOf: (f) => {
|
|
364
|
+
const rel = relative(subdir.sessionRoot, join(subdir.realRepoRoot, f));
|
|
365
|
+
return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? null : rel;
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
}
|
|
287
369
|
const fenceDir = join(repoRoot, '.orchestrator', 'staging-fence');
|
|
288
370
|
|
|
289
371
|
// -------------------------------------------------------------------------
|
|
@@ -293,26 +375,41 @@ async function main() {
|
|
|
293
375
|
const stagedOutput = execSync('git diff --cached --name-only', { encoding: 'utf8' });
|
|
294
376
|
const stagedFiles = stagedOutput.split('\n').filter(Boolean);
|
|
295
377
|
|
|
296
|
-
|
|
378
|
+
let blocked = false;
|
|
379
|
+
/** @type {string[]|null} printed once after the loop (#1514.3) */
|
|
380
|
+
let hint = null;
|
|
381
|
+
const flushHint = () => {
|
|
382
|
+
if (hint !== null) for (const line of hint) process.stderr.write(`${line}\n`);
|
|
383
|
+
};
|
|
384
|
+
for (const { root, scopePathOf } of judgements) {
|
|
385
|
+
const located = findOwnScopeFile(root, ownIds, classifyManifestSession);
|
|
386
|
+
const scopePath = located.path ?? located.foreignPath;
|
|
387
|
+
if (!scopePath) continue;
|
|
388
|
+
const scopeRel = manifestLabel(repoRoot, scopePath);
|
|
297
389
|
let scope;
|
|
298
390
|
try {
|
|
299
391
|
scope = JSON.parse(readFileSync(scopePath, 'utf8'));
|
|
300
392
|
} catch (err) {
|
|
301
|
-
process.stderr.write(`wave-scope-commit-guard: failed to parse
|
|
393
|
+
process.stderr.write(`wave-scope-commit-guard: failed to parse ${scopeRel}: ${err.message}\n`);
|
|
394
|
+
flushHint();
|
|
302
395
|
process.exit(1);
|
|
303
396
|
}
|
|
304
397
|
|
|
305
398
|
const verdict = scopeCommitVerdict({
|
|
306
399
|
scope: scope !== null && typeof scope === 'object' ? scope : {},
|
|
307
400
|
stagedFiles,
|
|
308
|
-
|
|
401
|
+
scopePathOf,
|
|
402
|
+
scopeRel,
|
|
309
403
|
ownIds,
|
|
310
404
|
scopeMtimeMs: mtimeMsOf(scopePath),
|
|
311
|
-
sessionAge: sessionAgeMs(
|
|
405
|
+
sessionAge: sessionAgeMs(root),
|
|
312
406
|
});
|
|
313
407
|
for (const line of verdict.lines) process.stderr.write(`${line}\n`);
|
|
314
|
-
if (verdict.
|
|
408
|
+
if (verdict.hint) hint = verdict.hint;
|
|
409
|
+
if (verdict.action === 'block') blocked = true;
|
|
315
410
|
}
|
|
411
|
+
flushHint();
|
|
412
|
+
if (blocked) process.exit(1);
|
|
316
413
|
|
|
317
414
|
// -------------------------------------------------------------------------
|
|
318
415
|
// Sub-mode C — cross-agent staging-fence reconciliation (issue #552)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "5.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "5.7.0",
|
|
4
|
+
"description": "Your coding agents get an agreed plan, a check after each round of changes and a handover to the next session. Plan, Go, Close: /session reads your repo and agrees the scope, /go runs the work in waves with your tests and typecheck after each implementation wave, /close runs the full gate, verifies, commits and files the rest as issues. Scope and destructive-command guards, crash-safe resume, learnings you approve. For Claude Code, Codex CLI, Cursor and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://session-orchestrator.com",
|
|
7
7
|
"keywords": [
|
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
"orchestration",
|
|
17
17
|
"developer-tools",
|
|
18
18
|
"mcp",
|
|
19
|
-
"llm"
|
|
19
|
+
"llm",
|
|
20
|
+
"workflow",
|
|
21
|
+
"quality-gates",
|
|
22
|
+
"ai-coding",
|
|
23
|
+
"gitlab",
|
|
24
|
+
"github"
|
|
20
25
|
],
|
|
21
26
|
"engines": {
|
|
22
27
|
"node": ">=24.0.0"
|
|
@@ -42,8 +42,8 @@ import path from 'node:path';
|
|
|
42
42
|
import { parseArgs } from 'node:util';
|
|
43
43
|
|
|
44
44
|
import { backfillAbandonedSession, isUuid } from './lib/session-close-backfill.mjs';
|
|
45
|
-
import { SCAN_CHUNK_BYTES, emitEvent, listEventSourcesNewestFirst } from './lib/events.mjs';
|
|
46
|
-
import { ARCHIVE_DIR_NAME
|
|
45
|
+
import { SCAN_CHUNK_BYTES, emitEvent, listEventSourcesNewestFirst, tombstoneGap } from './lib/events.mjs';
|
|
46
|
+
import { ARCHIVE_DIR_NAME } from './lib/events-schema.mjs';
|
|
47
47
|
import { getProjectDir } from './lib/platform.mjs';
|
|
48
48
|
import { isMainModule } from './lib/is-main-module.mjs';
|
|
49
49
|
import { isLockLive, readLock } from './lib/session-lock.mjs';
|
|
@@ -163,7 +163,8 @@ export function planSessions({ repoRoot }) {
|
|
|
163
163
|
* not one of the sources read (#1498). Pruned history used to shrink the plan
|
|
164
164
|
* silently; non-zero `historyGaps` says the candidate count is a floor.
|
|
165
165
|
*
|
|
166
|
-
* The check is `
|
|
166
|
+
* The check is `tombstoneGap` (`scripts/lib/events.mjs`), the one shared with
|
|
167
|
+
* `readEventsWithRotations` rule 1:
|
|
167
168
|
* resolved by BASENAME against this ledger's own `_archive/` (#1411), and a
|
|
168
169
|
* file that exists but is not a source (renamed out of `ARCHIVE_NAME_RE`)
|
|
169
170
|
* counts as a gap too — existence is not reading (#1423).
|
|
@@ -203,10 +204,7 @@ function planWithGaps({ repoRoot }) {
|
|
|
203
204
|
for (const source of [...sources].reverse()) {
|
|
204
205
|
forEachJsonlRecord(source.path, (ev) => {
|
|
205
206
|
if (!ev || typeof ev !== 'object') return;
|
|
206
|
-
if (ev
|
|
207
|
-
&& !read.has(path.join(archiveDir, path.basename(ev.archived_as)))) {
|
|
208
|
-
historyGaps += 1;
|
|
209
|
-
}
|
|
207
|
+
if (tombstoneGap(ev, archiveDir, read) !== null) historyGaps += 1;
|
|
210
208
|
if (typeof ev.session_id === 'string' && typeof ev.semantic_session_id === 'string') {
|
|
211
209
|
if (ev.event === LOCK_ACQUIRED) semanticFromLock.set(ev.session_id, ev.semantic_session_id);
|
|
212
210
|
else if (ev.event === SESSION_ENDED) {
|
|
@@ -837,6 +837,19 @@ export async function main(argv = [], deps = {}) {
|
|
|
837
837
|
const archiveIds = idsInStore(archiveAbs);
|
|
838
838
|
const backups = backupPaths(storeAbs).map((p) => ({ path: p, ids: idsInStore(p) }));
|
|
839
839
|
const { notes, unreadable } = indexVaultNotes(learningsAbs);
|
|
840
|
+
// #1513: `vault-mirror-prune --apply` moves expired/duplicate/superseded
|
|
841
|
+
// learning notes to `90-archive/mirror/<subdir>/…` with their bytes intact.
|
|
842
|
+
// They are still evidence for this restore tool — but only as a FALLBACK:
|
|
843
|
+
// the archive holds the non-canonical twins of live notes, and searching
|
|
844
|
+
// both at once would turn every such learning into an ambiguous match.
|
|
845
|
+
const archivedAbs = resolve(vaultDir, '90-archive', 'mirror', vaultSubdir);
|
|
846
|
+
const archivedNotes = [];
|
|
847
|
+
if (archivedAbs !== learningsAbs && existsSync(archivedAbs)) {
|
|
848
|
+
const archived = indexVaultNotes(archivedAbs);
|
|
849
|
+
for (const n of archived.notes) n.relPath = join('90-archive', 'mirror', vaultSubdir, n.relPath);
|
|
850
|
+
archivedNotes.push(...archived.notes);
|
|
851
|
+
unreadable.push(...archived.unreadable);
|
|
852
|
+
}
|
|
840
853
|
for (const u of unreadable) process.stderr.write(`backfill-learnings-from-vault: WARN unreadable vault note ${u}\n`);
|
|
841
854
|
|
|
842
855
|
const repoHint = basename(root);
|
|
@@ -879,12 +892,10 @@ export async function main(argv = [], deps = {}) {
|
|
|
879
892
|
|
|
880
893
|
orphanIndex += 1;
|
|
881
894
|
const keySubjectSlug = prov.learningKey ? prov.learningKey.split('/').slice(1).join('/') : null;
|
|
882
|
-
const {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
repoHint,
|
|
887
|
-
});
|
|
895
|
+
const query = { subject: prov.subject, keySlug: keySubjectSlug, learningId: id, repoHint };
|
|
896
|
+
let located = locateNote(notes, query);
|
|
897
|
+
if (!located.note && !located.ambiguous && archivedNotes.length > 0) located = locateNote(archivedNotes, query);
|
|
898
|
+
const { note, strategy, ambiguous } = located;
|
|
888
899
|
|
|
889
900
|
const { record, fidelity, conflicts, crossChecks, validates, validationError } = reconstructRecord({
|
|
890
901
|
rule: prov,
|
|
@@ -202,7 +202,11 @@ async function defaultStaleDaysFor(repoRoot, nowMs) {
|
|
|
202
202
|
const days = (nowMs - latestMs) / MS_PER_DAY;
|
|
203
203
|
return days > 0 ? days : 0;
|
|
204
204
|
} catch {
|
|
205
|
-
//
|
|
205
|
+
// Not the missing/unreadable-ledger path: readCanonicalSessions already
|
|
206
|
+
// returns [] for both (ENOENT silently, any other read failure with a
|
|
207
|
+
// stderr WARN — sessions-canonical.mjs § #1188), handled by the
|
|
208
|
+
// `records.length === 0` branch above. This catch only absorbs an
|
|
209
|
+
// unexpected throw (e.g. a non-string repoRoot) ⇒ maximally stale.
|
|
206
210
|
return STALENESS_CAP_DAYS;
|
|
207
211
|
}
|
|
208
212
|
}
|
package/scripts/lib/events.mjs
CHANGED
|
@@ -578,6 +578,60 @@ function discoverArchives(logPath) {
|
|
|
578
578
|
return { archives, legacy: present.map((i) => `${logPath}.${i}`), ringHoles, unindexed };
|
|
579
579
|
}
|
|
580
580
|
|
|
581
|
+
/**
|
|
582
|
+
* The one tombstone-to-archive match (#1505 5c) — shared by
|
|
583
|
+
* {@link readEventsWithRotations} rule 1, {@link scanEventsBackwards}, and
|
|
584
|
+
* `planWithGaps` in `scripts/backfill-abandoned-sessions.mjs`, which used to
|
|
585
|
+
* carry three hand-kept copies of it.
|
|
586
|
+
*
|
|
587
|
+
* #1411 — the tombstone resolves against THIS ledger's own `_archive/`, by
|
|
588
|
+
* BASENAME, never against the absolute value it stores. The writer keeps
|
|
589
|
+
* `archived_as` absolute (`events-rotation.mjs` joins the repo root) and that
|
|
590
|
+
* value stays in the record as PROVENANCE. It is not a lookup key: a moved
|
|
591
|
+
* checkout, a clone, or a sibling git worktree — routine here — makes every
|
|
592
|
+
* tombstone name a path that does not exist in THIS tree, so an exact
|
|
593
|
+
* comparison reported a phantom `missing-archive` for an archive sitting right
|
|
594
|
+
* beside the active file. Basename, not `realpath`: realpath cannot resolve a
|
|
595
|
+
* path that no longer exists, which IS the failure mode.
|
|
596
|
+
*
|
|
597
|
+
* ORDER (why the absolute value has no second chance): the sibling answer is
|
|
598
|
+
* consulted first, and an absolute hit would only be trustworthy while it
|
|
599
|
+
* pointed INSIDE this ledger's own archive dir — but any such path resolves to
|
|
600
|
+
* exactly the sibling path already tested, so once the sibling misses, an
|
|
601
|
+
* absolute hit can ONLY be a still-present OLD checkout. Honouring it would
|
|
602
|
+
* validate THIS ledger against a FOREIGN repo's archive: a silent false
|
|
603
|
+
* negative, worse than the phantom gap.
|
|
604
|
+
*
|
|
605
|
+
* #1423 — THREE outcomes, not two: read (`null`), present-but-unread
|
|
606
|
+
* (`unindexed-archive`), gone (`missing-archive`). The middle one used to be
|
|
607
|
+
* folded into the first by `existsSync`, so a tombstone whose archive had been
|
|
608
|
+
* RENAMED out of `ARCHIVE_NAME_RE` reported `complete: true` while its records
|
|
609
|
+
* were absent.
|
|
610
|
+
*
|
|
611
|
+
* @param {unknown} record a parsed ledger line
|
|
612
|
+
* @param {string} ownArchiveDir this ledger's `_archive/`, built exactly as
|
|
613
|
+
* `discoverArchives` builds it so a read archive matches `readSet` by string
|
|
614
|
+
* @param {Set<string>} readSet the paths this read actually took as sources
|
|
615
|
+
* @returns {{kind: 'unindexed-archive'|'missing-archive', path: string,
|
|
616
|
+
* archived_as: string, first_ts: string|null, last_ts: string|null}|null}
|
|
617
|
+
* `null` when `record` is not a rotation tombstone, names no archive, or its
|
|
618
|
+
* archive was read.
|
|
619
|
+
*/
|
|
620
|
+
export function tombstoneGap(record, ownArchiveDir, readSet) {
|
|
621
|
+
if (record?.event !== ROTATION_EVENT) return null;
|
|
622
|
+
const target = record.archived_as;
|
|
623
|
+
if (typeof target !== 'string' || target.length === 0) return null;
|
|
624
|
+
const sibling = path.join(ownArchiveDir, path.basename(target));
|
|
625
|
+
if (readSet.has(sibling)) return null;
|
|
626
|
+
return {
|
|
627
|
+
kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
|
|
628
|
+
path: sibling,
|
|
629
|
+
archived_as: target,
|
|
630
|
+
first_ts: record.first_ts ?? null,
|
|
631
|
+
last_ts: record.last_ts ?? null,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
581
635
|
/**
|
|
582
636
|
* Read the events ledger ACROSS rotation boundaries — active file plus every
|
|
583
637
|
* archive still on disk — in time order, reporting what is missing instead of
|
|
@@ -660,44 +714,14 @@ export function readEventsWithRotations(repoRoot, opts = {}) {
|
|
|
660
714
|
// `onDisk` still match by string.
|
|
661
715
|
const ownArchiveDir = path.join(path.dirname(activePath), ARCHIVE_DIR_NAME);
|
|
662
716
|
|
|
663
|
-
// Rule 1 — every rotation tombstone must still point at a file
|
|
717
|
+
// Rule 1 — every rotation tombstone must still point at a file (resolution
|
|
718
|
+
// and the three outcomes: {@link tombstoneGap}).
|
|
664
719
|
for (const source of sources) {
|
|
665
720
|
for (const record of source.records) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if (typeof target !== 'string' || target.length === 0) continue;
|
|
669
|
-
// #1411 — resolve the tombstone against THIS ledger's own `_archive/`,
|
|
670
|
-
// by BASENAME, and never against the absolute value it stores.
|
|
671
|
-
//
|
|
672
|
-
// The writer keeps `archived_as` absolute (`events-rotation.mjs` joins
|
|
673
|
-
// the repo root) and that value stays in the record as PROVENANCE. It is
|
|
674
|
-
// not a lookup key: a moved checkout, a clone, or a sibling git worktree
|
|
675
|
-
// — routine here — makes every tombstone name a path that does not exist
|
|
676
|
-
// in THIS tree, so the old exact comparison reported a phantom
|
|
677
|
-
// `missing-archive` for an archive sitting right beside the active file.
|
|
678
|
-
// Basename, not `realpath`: realpath cannot resolve a path that no longer
|
|
679
|
-
// exists, which IS the failure mode.
|
|
680
|
-
//
|
|
681
|
-
// ORDER (why the absolute value has no second chance): the sibling answer
|
|
682
|
-
// is consulted first, and an absolute hit would only be trustworthy while
|
|
683
|
-
// it pointed INSIDE this ledger's own archive dir — but any such path
|
|
684
|
-
// resolves to exactly the sibling path already tested, so once the
|
|
685
|
-
// sibling misses, an absolute hit can ONLY be a still-present OLD
|
|
686
|
-
// checkout. Honouring it would validate THIS ledger against a FOREIGN
|
|
687
|
-
// repo's archive: a silent false negative, worse than the phantom gap.
|
|
688
|
-
const sibling = path.join(ownArchiveDir, path.basename(target));
|
|
689
|
-
if (onDisk.has(sibling)) continue;
|
|
721
|
+
const gap = tombstoneGap(record, ownArchiveDir, onDisk);
|
|
722
|
+
if (gap === null) continue;
|
|
690
723
|
gaps.push({
|
|
691
|
-
|
|
692
|
-
// (here), gone (below). The middle one used to be silently folded into
|
|
693
|
-
// the first by `existsSync`, so a tombstone whose archive had been
|
|
694
|
-
// RENAMED out of `ARCHIVE_NAME_RE` reported `complete: true` while its
|
|
695
|
-
// records were absent from `events`.
|
|
696
|
-
kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
|
|
697
|
-
path: sibling,
|
|
698
|
-
archived_as: target,
|
|
699
|
-
first_ts: record.first_ts ?? null,
|
|
700
|
-
last_ts: record.last_ts ?? null,
|
|
724
|
+
...gap,
|
|
701
725
|
lines: record.lines ?? null,
|
|
702
726
|
size_before: record.size_before ?? null,
|
|
703
727
|
reported_by: source.path,
|
|
@@ -866,8 +890,7 @@ export function scanEventsBackwards(opts = {}) {
|
|
|
866
890
|
* LOW-2): `maybeRotate` renames, prunes, THEN appends the tombstone, from an
|
|
867
891
|
* async SessionStart hook, so a concurrent append can land first and push it
|
|
868
892
|
* to line 2. Called BEFORE the caller's `filter`, which names another event
|
|
869
|
-
* and would otherwise hide the cut.
|
|
870
|
-
* ledger's own `_archive/`, never by the absolute provenance value (#1411).
|
|
893
|
+
* and would otherwise hide the cut. Matched by {@link tombstoneGap}.
|
|
871
894
|
*/
|
|
872
895
|
const noteCut = (line, source) => {
|
|
873
896
|
if (!line.includes(ROTATION_EVENT)) return;
|
|
@@ -877,18 +900,8 @@ export function scanEventsBackwards(opts = {}) {
|
|
|
877
900
|
} catch {
|
|
878
901
|
return; // counted by `consume` like every other malformed line
|
|
879
902
|
}
|
|
880
|
-
const
|
|
881
|
-
if (
|
|
882
|
-
const sibling = path.join(ownArchiveDir, path.basename(target));
|
|
883
|
-
if (listed.has(sibling)) return;
|
|
884
|
-
gaps.push({
|
|
885
|
-
kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
|
|
886
|
-
path: sibling,
|
|
887
|
-
archived_as: target,
|
|
888
|
-
first_ts: record.first_ts ?? null,
|
|
889
|
-
last_ts: record.last_ts ?? null,
|
|
890
|
-
reported_by: source.path,
|
|
891
|
-
});
|
|
903
|
+
const gap = tombstoneGap(record, ownArchiveDir, listed);
|
|
904
|
+
if (gap !== null) gaps.push({ ...gap, reported_by: source.path });
|
|
892
905
|
};
|
|
893
906
|
|
|
894
907
|
/** @returns {boolean} true ⇒ the caller accepted a record; stop everything. */
|
package/scripts/lib/io.mjs
CHANGED
|
@@ -946,16 +946,21 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
|
946
946
|
* neither may mask the original error.
|
|
947
947
|
*
|
|
948
948
|
* ── BV-004 ceiling + revisit trigger ────────────────────────────────────────
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
949
|
+
* THREE PRODUCTION CALL-SITES (re-measured 2026-10-03 @ 0e0c4b08 with
|
|
950
|
+
* `rg -n "atomicWriteWithBackup\(" scripts hooks skills --glob '*.mjs'`):
|
|
951
|
+
* `vault-status/board-writer.mjs#writeBoard` (`backup: false`),
|
|
952
|
+
* `reconcile/rule-expiry-sweep.mjs` (no `backup`, i.e. `false`) and
|
|
953
|
+
* {@link writeJsonAtomicSync}, which carries 14 further call-sites across 11
|
|
954
|
+
* files behind it (`rg -n "writeJsonAtomicSync\("` over the same scope, minus
|
|
955
|
+
* the definition and `host-identity.mjs`'s private `_writeJsonAtomicSync`
|
|
956
|
+
* copy, which does NOT route through this helper). That last one is the load-bearing
|
|
952
957
|
* evidence — the previous revision of this note recorded ONE call-site and
|
|
953
958
|
* concluded the signature was unproven, while the function with an identical
|
|
954
959
|
* body sat 60 lines above in this same file, unmigrated. The cheapest possible
|
|
955
960
|
* migration going unmade is not a neutral fact about a helper: it is the
|
|
956
961
|
* measurement that the helper is not paying for itself.
|
|
957
962
|
*
|
|
958
|
-
* What the
|
|
963
|
+
* What the `writeJsonAtomicSync` call-site does NOT prove: it passes
|
|
959
964
|
* `backup: false` and no `fs`, so the backup half and the injection seam still
|
|
960
965
|
* rest on tests plus one board-writer flag. REVISIT TRIGGER — when a sweep
|
|
961
966
|
* migrates the remaining hand-rolled sites, re-check before widening:
|
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
|
|
186
186
|
import { tokenize } from './affinity.mjs';
|
|
187
187
|
import { readLearnings } from './io.mjs';
|
|
188
|
-
import {
|
|
188
|
+
import { learningKeyOf } from './kebab.mjs';
|
|
189
189
|
import { normalizeDialects } from './schema.mjs';
|
|
190
190
|
|
|
191
191
|
// ---------------------------------------------------------------------------
|
|
@@ -441,27 +441,18 @@ export function emptyPools() {
|
|
|
441
441
|
/**
|
|
442
442
|
* Logical identity of a learning: `` `${type}/${kebab(title || subject)}` ``.
|
|
443
443
|
*
|
|
444
|
-
* Returns `null` when either half is unusable
|
|
445
|
-
*
|
|
446
|
-
*
|
|
444
|
+
* Returns `null` when either half is unusable (non-record input, blank
|
|
445
|
+
* type/title/subject, or an all-symbol title that kebabs to '') — delegates to
|
|
446
|
+
* the shared `kebab.mjs::learningKeyOf` (#1021) so the key space cannot fork
|
|
447
|
+
* from the provenance checker's. An unkeyable record simply does not
|
|
448
|
+
* participate in the exact-key pass. It still seeds and
|
|
447
449
|
* joins pools: a missing title is not a reason to hide a learning from dedupe.
|
|
448
450
|
*
|
|
449
451
|
* @param {unknown} record
|
|
450
452
|
* @returns {string|null}
|
|
451
453
|
*/
|
|
452
454
|
export function learningKey(record) {
|
|
453
|
-
|
|
454
|
-
const type = typeof record.type === 'string' ? record.type.trim() : '';
|
|
455
|
-
const titleOrSubject =
|
|
456
|
-
(typeof record.title === 'string' && record.title.trim() !== '' ? record.title : '') ||
|
|
457
|
-
(typeof record.subject === 'string' && record.subject.trim() !== '' ? record.subject : '');
|
|
458
|
-
if (type === '' || titleOrSubject === '') return null;
|
|
459
|
-
const slug = kebab(titleOrSubject);
|
|
460
|
-
// `kebab` may legally return '' (all-symbol input) — an empty slug is not an
|
|
461
|
-
// identity, so such a record is unkeyable rather than colliding with every
|
|
462
|
-
// other empty-slug record of the same type.
|
|
463
|
-
if (slug === '') return null;
|
|
464
|
-
return `${type}/${slug}`;
|
|
455
|
+
return learningKeyOf(record);
|
|
465
456
|
}
|
|
466
457
|
|
|
467
458
|
/**
|