session-orchestrator 3.20.0 → 3.22.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/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
|
@@ -917,3 +917,661 @@ export function suggestForScopeViolation(relPath, allowedCsv) {
|
|
|
917
917
|
`If '${relPath}' belongs to this wave, add its directory to the plan's wave scope and restart.`
|
|
918
918
|
);
|
|
919
919
|
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Merge many agents' declared file scopes into ONE deduplicated, order-stable
|
|
923
|
+
* list — the mechanical form of "allowedPaths is the UNION of all agent file
|
|
924
|
+
* scopes" (#1020, wave-loop.md § Scope Manifest #3).
|
|
925
|
+
*
|
|
926
|
+
* Motivation: today the coordinator writes `wave-scope.json` `allowedPaths` from
|
|
927
|
+
* one hand-kept list and the agent briefs from a SECOND, separately formulated
|
|
928
|
+
* list. Nothing couples them; they diverged five times in one session (#1020).
|
|
929
|
+
* Deriving the union FROM the per-agent declarations makes that divergence
|
|
930
|
+
* structurally impossible instead of discipline-dependent.
|
|
931
|
+
*
|
|
932
|
+
* Accepts BOTH input shapes, because the two call sites differ:
|
|
933
|
+
* - `[['a.mjs','b.mjs'], ['c.mjs']]` — bare scope arrays
|
|
934
|
+
* - `[{id:'W1-D1', files:['a.mjs']}, {id:'W1-D2', …}]` — the CLI/plan record
|
|
935
|
+
* shape, which is also {@link findScopeCollisions}' input (one source object
|
|
936
|
+
* feeding both consumers is the whole point of #1020).
|
|
937
|
+
*
|
|
938
|
+
* Order is INSERTION order (first-seen wins), never sorted: the union is written
|
|
939
|
+
* into a manifest that a human reads next to the plan, and a stable order keeps
|
|
940
|
+
* its diff readable across re-unions (#796 rewrites it mid-wave).
|
|
941
|
+
*
|
|
942
|
+
* Fail-closed & no-throw (module convention): a non-array input returns `[]`;
|
|
943
|
+
* non-array / non-object members and non-string, empty entries are skipped.
|
|
944
|
+
* Pure, sync, no I/O — hook-safe per the module header.
|
|
945
|
+
*
|
|
946
|
+
* @param {Array<string[]|{id?: string, files?: string[]}>} scopes
|
|
947
|
+
* @returns {string[]} deduplicated union in first-seen order
|
|
948
|
+
*/
|
|
949
|
+
export function unionFileScopes(scopes) {
|
|
950
|
+
if (!Array.isArray(scopes)) return [];
|
|
951
|
+
const out = [];
|
|
952
|
+
const seen = new Set();
|
|
953
|
+
for (const scope of scopes) {
|
|
954
|
+
let files = null;
|
|
955
|
+
if (Array.isArray(scope)) files = scope;
|
|
956
|
+
else if (scope !== null && typeof scope === 'object' && Array.isArray(scope.files)) {
|
|
957
|
+
files = scope.files;
|
|
958
|
+
}
|
|
959
|
+
if (files === null) continue;
|
|
960
|
+
for (const entry of files) {
|
|
961
|
+
if (typeof entry !== 'string' || entry.length === 0) continue;
|
|
962
|
+
if (seen.has(entry)) continue;
|
|
963
|
+
seen.add(entry);
|
|
964
|
+
out.push(entry);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return out;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Is this scope entry RECURSIVE — does it grant everything below a directory?
|
|
972
|
+
* `**` is the explicit form; a trailing `/` is the implicit one, because
|
|
973
|
+
* {@link pathMatchesPattern} matches a `dir/` prefix with `startsWith`, i.e. at
|
|
974
|
+
* ANY depth. Both must count, or `tests/` vs `tests/lib/*.mjs` reads as disjoint.
|
|
975
|
+
* @param {string} entry
|
|
976
|
+
* @returns {boolean}
|
|
977
|
+
*/
|
|
978
|
+
function isRecursiveScopeEntry(entry) {
|
|
979
|
+
return entry.includes('**') || entry.endsWith('/');
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Literal SUFFIX of a glob entry — the text after its last `*` metachar
|
|
984
|
+
* (`src/**\/*.mjs` → `.mjs`, `src/**` → `''`). Returns `null` for an entry with
|
|
985
|
+
* no `*` at all (a `dir/` prefix), where the concept does not apply: such an
|
|
986
|
+
* entry constrains only the head of a path, never its tail.
|
|
987
|
+
* @param {string} entry
|
|
988
|
+
* @returns {string|null}
|
|
989
|
+
*/
|
|
990
|
+
function literalScopeSuffix(entry) {
|
|
991
|
+
const star = entry.lastIndexOf('*');
|
|
992
|
+
return star === -1 ? null : entry.slice(star + 1);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Normalize the `agentScopes` input of {@link findScopeCollisions} into
|
|
997
|
+
* `{id, declaredId, files}` records. Never throws; malformed members are
|
|
998
|
+
* repaired rather than dropped.
|
|
999
|
+
*
|
|
1000
|
+
* A member with NO usable `id` keeps its files in the check under a synthetic
|
|
1001
|
+
* `<unnamed#i>` id (i = its index). Dropping it instead would be a FALSE
|
|
1002
|
+
* NEGATIVE — the whole point of this function is that an unreviewed scope is
|
|
1003
|
+
* exactly the one that collides.
|
|
1004
|
+
*
|
|
1005
|
+
* @param {Array<{id?: string, files?: string[]}>} agentScopes
|
|
1006
|
+
* @returns {Array<{id: string, declaredId: string|null, files: string[]}>}
|
|
1007
|
+
*/
|
|
1008
|
+
function normalizeAgentScopes(agentScopes) {
|
|
1009
|
+
const out = [];
|
|
1010
|
+
for (let i = 0; i < agentScopes.length; i++) {
|
|
1011
|
+
const raw = agentScopes[i];
|
|
1012
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
|
1013
|
+
const declaredId = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : null;
|
|
1014
|
+
const files = Array.isArray(raw.files)
|
|
1015
|
+
? raw.files.filter((f) => typeof f === 'string' && f.length > 0)
|
|
1016
|
+
: [];
|
|
1017
|
+
out.push({ id: declaredId ?? `<unnamed#${i}>`, declaredId, files });
|
|
1018
|
+
}
|
|
1019
|
+
return out;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Classify a SINGLE cross-agent entry pair, in the binding three-stage order.
|
|
1024
|
+
* Returns the collision `kind`, or `null` when the two entries are disjoint.
|
|
1025
|
+
*
|
|
1026
|
+
* Stage order is not cosmetic — see {@link findScopeCollisions} for why the two
|
|
1027
|
+
* exact stages must run BEFORE the approximate one.
|
|
1028
|
+
*
|
|
1029
|
+
* @param {string} x — an entry from agent A
|
|
1030
|
+
* @param {string} y — an entry from agent B
|
|
1031
|
+
* @param {(glob: string) => Set<string>} expand — memoized KNOWN-set expander
|
|
1032
|
+
* @returns {'concrete'|'glob-expanded'|'glob-prefix'|null}
|
|
1033
|
+
*/
|
|
1034
|
+
function classifyEntryCollision(x, y, expand) {
|
|
1035
|
+
// Stage 1 — exact string equality. The commonest real case (#1020 Vorfall 3),
|
|
1036
|
+
// and the ONLY stage that works for a file that does not exist yet.
|
|
1037
|
+
if (x === y) return 'concrete';
|
|
1038
|
+
|
|
1039
|
+
const xIsGlob = isGlobScopeEntry(x);
|
|
1040
|
+
const yIsGlob = isGlobScopeEntry(y);
|
|
1041
|
+
|
|
1042
|
+
// Stage 2 — concrete vs glob. `pathMatchesPattern` is DIRECTED (arg 1 is a
|
|
1043
|
+
// literal path, arg 2 becomes the regex); used in that one correct direction
|
|
1044
|
+
// it is exact and needs no filesystem witness.
|
|
1045
|
+
if (!xIsGlob && yIsGlob) return pathMatchesPattern(x, y) ? 'concrete' : null;
|
|
1046
|
+
if (xIsGlob && !yIsGlob) return pathMatchesPattern(y, x) ? 'concrete' : null;
|
|
1047
|
+
if (!xIsGlob && !yIsGlob) return null; // two distinct concrete paths: disjoint
|
|
1048
|
+
|
|
1049
|
+
// Stage 3a — glob ∩ glob, decided by a shared WITNESS from the KNOWN set.
|
|
1050
|
+
const xHits = expand(x);
|
|
1051
|
+
for (const witness of expand(y)) {
|
|
1052
|
+
if (xHits.has(witness)) return 'glob-expanded';
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Stage 3b — prefix fallback, for the intersection that exists only in files
|
|
1056
|
+
// NOT YET on disk (the KNOWN set cannot witness those).
|
|
1057
|
+
const xPrefix = literalScopePrefix(x);
|
|
1058
|
+
const yPrefix = literalScopePrefix(y);
|
|
1059
|
+
if (!(xPrefix.startsWith(yPrefix) || yPrefix.startsWith(xPrefix))) return null;
|
|
1060
|
+
if (!(isRecursiveScopeEntry(x) || isRecursiveScopeEntry(y))) return null;
|
|
1061
|
+
// Suffix compatibility is a NECESSARY condition, so filtering on it adds no
|
|
1062
|
+
// false negative: a string ending in both `sx` and `sy` forces the shorter to
|
|
1063
|
+
// be a suffix of the longer. It removes the obvious false positive
|
|
1064
|
+
// `scripts/**\/*.ts` vs `scripts/**\/*.mjs`, which share a prefix and are both
|
|
1065
|
+
// recursive yet can never match the same path.
|
|
1066
|
+
const xSuffix = literalScopeSuffix(x);
|
|
1067
|
+
const ySuffix = literalScopeSuffix(y);
|
|
1068
|
+
if (
|
|
1069
|
+
xSuffix !== null &&
|
|
1070
|
+
ySuffix !== null &&
|
|
1071
|
+
!(xSuffix.endsWith(ySuffix) || ySuffix.endsWith(xSuffix))
|
|
1072
|
+
) {
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
return 'glob-prefix';
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Detect files claimed by TWO agents of the SAME wave, BEFORE dispatch (#1020).
|
|
1080
|
+
*
|
|
1081
|
+
* ## The bug this closes
|
|
1082
|
+
* `tests/scripts/sweep-expired-learnings-cli.test.mjs` was handed to two agents
|
|
1083
|
+
* of one wave (#1020 Vorfall 3). Nothing caught it up front: the pre-dispatch
|
|
1084
|
+
* assertion {@link assertFileScopeSubset} checks each agent against the union
|
|
1085
|
+
* (a SUBSET relation, which two overlapping agents both satisfy), and the
|
|
1086
|
+
* commit-time `wave-scope-commit-guard` only sees the union as well. It surfaced
|
|
1087
|
+
* afterwards, from an agent's own PSA-002 report. Per
|
|
1088
|
+
* `.claude/rules/parallel-sessions.md` § Decision Tree a file inside two
|
|
1089
|
+
* declared scopes of one dispatch round is never a benign sibling signal — it is
|
|
1090
|
+
* a deconfliction gap, and that round ended well by luck, not construction.
|
|
1091
|
+
*
|
|
1092
|
+
* ## The three stages, and why the order is binding
|
|
1093
|
+
* 1. **Exact string equality** → `concrete`. Covers the commonest real case AND
|
|
1094
|
+
* every file that does not exist yet (no filesystem witness required).
|
|
1095
|
+
* 2. **Concrete vs glob** via {@link pathMatchesPattern} → `concrete`. Exact and
|
|
1096
|
+
* I/O-free, because the matcher is used in its one correct direction.
|
|
1097
|
+
* 3. **Glob vs glob** — expand both against
|
|
1098
|
+
* `KNOWN = opts.knownFiles ∪ {every concrete entry of every agent}`;
|
|
1099
|
+
* a non-empty intersection is `glob-expanded`. As a fallback for files not
|
|
1100
|
+
* yet on disk, a literal-prefix containment plus at least one recursive
|
|
1101
|
+
* entry is `glob-prefix`.
|
|
1102
|
+
*
|
|
1103
|
+
* Stage 3 must come LAST because {@link pathMatchesPattern} is DIRECTED and
|
|
1104
|
+
* therefore useless for glob∩glob: it compiles argument 2 into a regex and tests
|
|
1105
|
+
* argument 1 as a literal string. Measured:
|
|
1106
|
+
* `pathMatchesPattern('scripts/**\/*.mjs', 'scripts/lib/*.mjs') === false`, even
|
|
1107
|
+
* though both match `scripts/lib/x.mjs`. {@link assertFileScopeSubset} documents
|
|
1108
|
+
* that boundary at its own glob branch and OVER-approximates coverage, which is
|
|
1109
|
+
* the safe direction for a subset check. For a COLLISION check the sign flips:
|
|
1110
|
+
* the same over-approximation becomes a FALSE NEGATIVE — a missed collision,
|
|
1111
|
+
* i.e. exactly the incident. Hence stages 1 and 2 decide first, and stage 3 is
|
|
1112
|
+
* reached only for pairs neither of them can settle.
|
|
1113
|
+
*
|
|
1114
|
+
* ## Duplicate ids are a SEPARATE finding, not a collision
|
|
1115
|
+
* Two records carrying the same `id` are a malformed plan, not two agents
|
|
1116
|
+
* fighting over a file; reporting them as a self-collision (`a === b`) would be
|
|
1117
|
+
* noise. They are listed in `duplicateIds` and such pairs are skipped in the
|
|
1118
|
+
* pairwise scan. `duplicateIds` is always present (empty when clean) so
|
|
1119
|
+
* consumers need no conditional-key handling.
|
|
1120
|
+
*
|
|
1121
|
+
* ## `knownFiles` is INJECTED, never discovered
|
|
1122
|
+
* The module header's hook-safe invariant (pure, sync, no I/O, no process spawn)
|
|
1123
|
+
* is binding: `hooks/enforce-scope.mjs` reaches this module on a hot path, and
|
|
1124
|
+
* under the exit-0/stdout-JSON protocol a throw here reads as "no decision" =
|
|
1125
|
+
* ALLOW. So `git ls-files` belongs to the CLI layer and its result arrives as a
|
|
1126
|
+
* parameter. An absent/invalid `knownFiles` is not an error — stage 3a simply
|
|
1127
|
+
* has fewer witnesses and stage 3b carries the load.
|
|
1128
|
+
*
|
|
1129
|
+
* Fail-closed & no-throw: a non-array `agentScopes` returns
|
|
1130
|
+
* `{ ok: false, collisions: [], duplicateIds: [] }` ("cannot assert → treat as
|
|
1131
|
+
* failure", the same convention as {@link assertFileScopeSubset}).
|
|
1132
|
+
*
|
|
1133
|
+
* Output ordering is deterministic: agent pairs in input order, then kinds in
|
|
1134
|
+
* stage order (`concrete` → `glob-expanded` → `glob-prefix`); `evidence` holds
|
|
1135
|
+
* the involved entries of that kind, deduplicated in first-seen order.
|
|
1136
|
+
*
|
|
1137
|
+
* @param {Array<{id?: string, files?: string[]}>} agentScopes — one wave's agents
|
|
1138
|
+
* @param {{knownFiles?: string[]}} [opts] — existing repo files (injected)
|
|
1139
|
+
* @returns {{ok: boolean,
|
|
1140
|
+
* collisions: Array<{a: string, b: string, evidence: string[],
|
|
1141
|
+
* kind: 'concrete'|'glob-expanded'|'glob-prefix'}>,
|
|
1142
|
+
* duplicateIds: string[]}}
|
|
1143
|
+
*/
|
|
1144
|
+
export function findScopeCollisions(agentScopes, opts = {}) {
|
|
1145
|
+
if (!Array.isArray(agentScopes)) return { ok: false, collisions: [], duplicateIds: [] };
|
|
1146
|
+
const options = opts !== null && typeof opts === 'object' ? opts : {};
|
|
1147
|
+
const agents = normalizeAgentScopes(agentScopes);
|
|
1148
|
+
|
|
1149
|
+
// Duplicate DECLARED ids (synthetic `<unnamed#i>` ids are unique by index).
|
|
1150
|
+
const seenIds = new Set();
|
|
1151
|
+
const dupIds = new Set(); // Set preserves insertion order → stable report
|
|
1152
|
+
for (const agent of agents) {
|
|
1153
|
+
if (agent.declaredId === null) continue;
|
|
1154
|
+
if (seenIds.has(agent.declaredId)) dupIds.add(agent.declaredId);
|
|
1155
|
+
else seenIds.add(agent.declaredId);
|
|
1156
|
+
}
|
|
1157
|
+
const duplicateIds = [...dupIds];
|
|
1158
|
+
|
|
1159
|
+
// KNOWN = injected repo files ∪ every concrete entry of every agent. The
|
|
1160
|
+
// second half matters: a file the wave is about to CREATE is not in
|
|
1161
|
+
// `git ls-files`, but if one agent names it concretely it can still witness
|
|
1162
|
+
// another agent's glob.
|
|
1163
|
+
const known = [];
|
|
1164
|
+
const knownSeen = new Set();
|
|
1165
|
+
const addKnown = (f) => {
|
|
1166
|
+
if (typeof f !== 'string' || f.length === 0 || knownSeen.has(f)) return;
|
|
1167
|
+
knownSeen.add(f);
|
|
1168
|
+
known.push(f);
|
|
1169
|
+
};
|
|
1170
|
+
if (Array.isArray(options.knownFiles)) options.knownFiles.forEach(addKnown);
|
|
1171
|
+
for (const agent of agents) {
|
|
1172
|
+
for (const entry of agent.files) if (!isGlobScopeEntry(entry)) addKnown(entry);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const expansions = new Map();
|
|
1176
|
+
const expand = (glob) => {
|
|
1177
|
+
let hits = expansions.get(glob);
|
|
1178
|
+
if (hits === undefined) {
|
|
1179
|
+
hits = new Set(known.filter((f) => pathMatchesPattern(f, glob)));
|
|
1180
|
+
expansions.set(glob, hits);
|
|
1181
|
+
}
|
|
1182
|
+
return hits;
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
const KIND_ORDER = ['concrete', 'glob-expanded', 'glob-prefix'];
|
|
1186
|
+
const collisions = [];
|
|
1187
|
+
for (let i = 0; i < agents.length; i++) {
|
|
1188
|
+
for (let j = i + 1; j < agents.length; j++) {
|
|
1189
|
+
const a = agents[i];
|
|
1190
|
+
const b = agents[j];
|
|
1191
|
+
if (a.id === b.id) continue; // duplicate-id record: reported separately
|
|
1192
|
+
const buckets = new Map();
|
|
1193
|
+
for (const x of a.files) {
|
|
1194
|
+
for (const y of b.files) {
|
|
1195
|
+
const kind = classifyEntryCollision(x, y, expand);
|
|
1196
|
+
if (kind === null) continue;
|
|
1197
|
+
let evidence = buckets.get(kind);
|
|
1198
|
+
if (evidence === undefined) {
|
|
1199
|
+
evidence = new Set();
|
|
1200
|
+
buckets.set(kind, evidence);
|
|
1201
|
+
}
|
|
1202
|
+
evidence.add(x);
|
|
1203
|
+
evidence.add(y);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
for (const kind of KIND_ORDER) {
|
|
1207
|
+
const evidence = buckets.get(kind);
|
|
1208
|
+
if (evidence !== undefined) {
|
|
1209
|
+
collisions.push({ a: a.id, b: b.id, evidence: [...evidence], kind });
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
return { ok: collisions.length === 0 && duplicateIds.length === 0, collisions, duplicateIds };
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// ---------------------------------------------------------------------------
|
|
1219
|
+
// Empty-`allowedPaths` classification (#1057)
|
|
1220
|
+
// ---------------------------------------------------------------------------
|
|
1221
|
+
//
|
|
1222
|
+
// ## The bug this fixes — and the one it deliberately does NOT
|
|
1223
|
+
//
|
|
1224
|
+
// FIVE distinct repository states produce `allowedPaths.length === 0`, and the
|
|
1225
|
+
// DENY VERDICT IS CORRECT IN ALL FIVE. What collapses is the REASON: every one
|
|
1226
|
+
// of them printed the single sentence {@link suggestForScopeViolation} emits for
|
|
1227
|
+
// an empty allowlist — "update the session plan and restart the wave".
|
|
1228
|
+
//
|
|
1229
|
+
// 1. A Discovery wave, where `[]` is the deliberate read-only contract
|
|
1230
|
+
// (`skills/session-plan/SKILL.md`, `wave-loop.md` § Scope Manifest #5, and
|
|
1231
|
+
// the #256 NO-OP regression lock in tests/hooks/enforce-scope.test.mjs).
|
|
1232
|
+
// There the sentence is CORRECT but misleading — nothing is broken.
|
|
1233
|
+
// 2. Corrupt JSON, which `hooks/enforce-scope.mjs` folds onto `scope = {}`
|
|
1234
|
+
// (#794 GAP-5), and malformed `allowedPaths` shapes, which `Array.isArray`
|
|
1235
|
+
// folds onto `[]` (#558). There the sentence is USELESS — the plan is fine,
|
|
1236
|
+
// the file is not.
|
|
1237
|
+
// 3. A leftover manifest from a session that crashed before deleting it.
|
|
1238
|
+
// There the sentence is ACTIVELY WRONG: restarting the wave does not remove
|
|
1239
|
+
// a file the previous session left behind — only the coordinator can clear
|
|
1240
|
+
// it, and the branch says so WITHOUT handing over the command (see "The
|
|
1241
|
+
// channel belongs to the AGENT" below, which is why).
|
|
1242
|
+
// 4. A writable role whose union came out empty because the coordinator's
|
|
1243
|
+
// `--union` step did not complete. There the operator must re-run `--union`,
|
|
1244
|
+
// not restart.
|
|
1245
|
+
//
|
|
1246
|
+
// The classifier below is the discriminator. It changes NO verdict — see
|
|
1247
|
+
// {@link suggestForEmptyScope}, which only ever selects a different sentence.
|
|
1248
|
+
//
|
|
1249
|
+
// ## The channel belongs to the AGENT — no branch may carry a runnable command
|
|
1250
|
+
//
|
|
1251
|
+
// Every string {@link suggestForEmptyScope} returns travels in
|
|
1252
|
+
// `permissionDecisionReason`, which the harness feeds to the CLAUDE whose Edit
|
|
1253
|
+
// was just denied. A remediation command placed there is an instruction the
|
|
1254
|
+
// DENIED AGENT can run — and for this guard the remediation IS the disarm:
|
|
1255
|
+
// removing `wave-scope.json` drops `hooks/enforce-scope.mjs` at Gate 3
|
|
1256
|
+
// (`if (!scopePath) return emitAllow()`), which takes out project-root
|
|
1257
|
+
// containment (Gate 6) as well as the allowlist check (Gate 7), for the rest of
|
|
1258
|
+
// the session. Measured 2026-08-19 against HEAD 55279a2: `rm -f <manifest>`
|
|
1259
|
+
// exits 0 with no envelope from `hooks/pre-bash-destructive-guard.mjs` (its
|
|
1260
|
+
// only rm rule is `rm-rf-destructive`, which needs `-r`), and with the manifest
|
|
1261
|
+
// gone a `Write` to `/etc` is ALLOWED. The first shape of this branch shipped
|
|
1262
|
+
// exactly that command, so the guard was handing the agent the key to itself.
|
|
1263
|
+
//
|
|
1264
|
+
// The rule this leaves behind: a branch may DIAGNOSE without limit and may name
|
|
1265
|
+
// the manifest as the SUBJECT of its diagnosis; it may never carry a runnable
|
|
1266
|
+
// command, and never present the manifest as a removal TARGET. Operator
|
|
1267
|
+
// remediation stays where the operator already looks —
|
|
1268
|
+
// `skills/session-end/SKILL.md` § 3.0 Defensive Cleanup and
|
|
1269
|
+
// `skills/wave-executor/wave-loop.md` steps 5/7 — not in a channel whose reader
|
|
1270
|
+
// is the denied agent.
|
|
1271
|
+
//
|
|
1272
|
+
// `emitDeny`'s `opts.systemMessage` is NOT the escape hatch this wants: the repo
|
|
1273
|
+
// documents its audience two ways that contradict each other — `scripts/lib/io.mjs`
|
|
1274
|
+
// § "Emitted payload" calls it "what the operator sees";
|
|
1275
|
+
// `skills/hook-development/SKILL.md` § "PreToolUse output schema" calls it
|
|
1276
|
+
// "Explanation shown to Claude". A session-wide guard disarm is not the payload
|
|
1277
|
+
// to bet on whichever of the two is right. Locked by
|
|
1278
|
+
// tests/lib/scope-gate.test.mjs § "no branch carries a runnable shell command".
|
|
1279
|
+
//
|
|
1280
|
+
// ## …and the reader may not be the OWNER (measured 2026-08-19)
|
|
1281
|
+
//
|
|
1282
|
+
// The staleness comparison is per-WORKING-COPY, not per-session. Both clocks
|
|
1283
|
+
// `sessionAgeMs` reads — `.orchestrator/current-session.json` `timestamp` and
|
|
1284
|
+
// `.orchestrator/session.lock` `started_at` — are shared by every session in the
|
|
1285
|
+
// checkout, and it takes the MINIMUM AGE, i.e. the NEWEST of the two. So a second
|
|
1286
|
+
// session starting at 18:51 moves the clock forward for EVERYONE, and every
|
|
1287
|
+
// manifest written before 18:51 — including the live one the first session is
|
|
1288
|
+
// using right now — classifies as `stale-manifest` for every subsequent reader.
|
|
1289
|
+
//
|
|
1290
|
+
// Measured in this repo at HEAD 55279a2: `session.lock` `started_at`
|
|
1291
|
+
// 2026-08-19T12:02:57Z (the running deep session) vs. `current-session.json`
|
|
1292
|
+
// `timestamp` 2026-08-19T18:51:54Z (a parallel session) — 6h49m apart, effective
|
|
1293
|
+
// `sessionStartMs` 18:51:54. Every wave manifest that session wrote between 12:02
|
|
1294
|
+
// and 18:51 is `mtime < sessionStartMs`. It is not hypothetical either: a
|
|
1295
|
+
// parallel session in this working copy was shown this very branch for the
|
|
1296
|
+
// coordinator's LIVE wave-4 manifest and told to delete it; it declined because
|
|
1297
|
+
// the operator works carefully, which is not a control.
|
|
1298
|
+
//
|
|
1299
|
+
// That is why the branch says "either a leftover or a parallel session's live
|
|
1300
|
+
// manifest" and routes to `blocked` instead of to a repair: the classifier cannot
|
|
1301
|
+
// distinguish the two, and the reader is frequently not the owner. PSA-001's
|
|
1302
|
+
// question — "Did I create this file? If not, it is not mine to touch" — is the
|
|
1303
|
+
// only safe posture a string in this channel can carry.
|
|
1304
|
+
//
|
|
1305
|
+
// Revisit trigger: a manifest that records its OWNING session id, which would let
|
|
1306
|
+
// this compare ownership instead of clocks. Until then the ambiguity is stated in
|
|
1307
|
+
// the text rather than resolved by a guess.
|
|
1308
|
+
//
|
|
1309
|
+
// ## Named ceiling (BV-004)
|
|
1310
|
+
//
|
|
1311
|
+
// - This buys a CORRECT DIAGNOSIS, never an unlock and never a command. A
|
|
1312
|
+
// writable wave with a broken union still denies every write; the reader is
|
|
1313
|
+
// told WHICH of the five states it is in, and who owns the repair.
|
|
1314
|
+
// - It cannot see a union that is NON-EMPTY but WRONG. That is
|
|
1315
|
+
// `--assert-subset`'s job and stays there.
|
|
1316
|
+
// - `stale-manifest` degrades to `'unknown'` when no session clock is
|
|
1317
|
+
// readable — never to an allow. Absence is preserved, never guessed.
|
|
1318
|
+
// - There is NO age threshold and no TTL. The comparison is a PROVENANCE
|
|
1319
|
+
// subtraction (manifest mtime vs. this session's start), so a legitimate
|
|
1320
|
+
// 14-hour deep session never ages into a blind spot. Deliberately NOT
|
|
1321
|
+
// `IN_FLIGHT_TTL_MS` — see `hooks/post-bash-write-verify.mjs` § "Why the
|
|
1322
|
+
// minimum, and why NOT a staleness cap" for the argument this inherits.
|
|
1323
|
+
// - Revisit trigger: a second read-only wave role, or a manifest written by a
|
|
1324
|
+
// process whose clock is not this repo's `.orchestrator/` pair.
|
|
1325
|
+
// ---------------------------------------------------------------------------
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* The closed set of {@link classifyEmptyScope} verdicts. Shaped after
|
|
1329
|
+
* `DEGRADED_REASONS` in `scripts/lib/mirror-issues-banner.mjs`: a frozen array
|
|
1330
|
+
* so a consumer can enumerate the states rather than re-listing them in prose.
|
|
1331
|
+
*
|
|
1332
|
+
* `'unknown'` is a first-class member, not an error — it is what the classifier
|
|
1333
|
+
* returns when the inputs do not DECIDE, and it maps to the pre-#1057 generic
|
|
1334
|
+
* sentence. Absence-preserving by construction.
|
|
1335
|
+
*
|
|
1336
|
+
* @type {ReadonlyArray<'unreadable'|'read-only-role'|'stale-manifest'|'writer-defect'|'unknown'>}
|
|
1337
|
+
*/
|
|
1338
|
+
export const EMPTY_SCOPE_REASONS = Object.freeze([
|
|
1339
|
+
'unreadable',
|
|
1340
|
+
'read-only-role',
|
|
1341
|
+
'stale-manifest',
|
|
1342
|
+
'writer-defect',
|
|
1343
|
+
'unknown',
|
|
1344
|
+
]);
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Wave roles for which an EMPTY `allowedPaths` is the intended contract rather
|
|
1348
|
+
* than a defect. THE list — `skills/session-plan/SKILL.md` § Discovery and
|
|
1349
|
+
* `skills/wave-executor/wave-loop.md` § Scope Manifest #5 describe it; they do
|
|
1350
|
+
* not restate it.
|
|
1351
|
+
*
|
|
1352
|
+
* Canonical casing; comparison is trimmed + case-insensitive (see
|
|
1353
|
+
* {@link isReadOnlyWaveRole}) for the same reason
|
|
1354
|
+
* {@link TEST_SIBLING_EXPANSION_ROLES} is: the manifest on disk is written by
|
|
1355
|
+
* LLM prose and by hand, and `"discovery"` vs `Discovery` must not silently
|
|
1356
|
+
* change which sentence the operator reads.
|
|
1357
|
+
*
|
|
1358
|
+
* @type {ReadonlyArray<string>}
|
|
1359
|
+
*/
|
|
1360
|
+
export const READ_ONLY_WAVE_ROLES = Object.freeze(['Discovery']);
|
|
1361
|
+
|
|
1362
|
+
/** Lower-cased lookup for {@link isReadOnlyWaveRole}. @type {ReadonlyMap<string, string>} */
|
|
1363
|
+
const READ_ONLY_ROLE_KEYS = new Map(READ_ONLY_WAVE_ROLES.map((r) => [r.toLowerCase(), r]));
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Is this wave role one for which `allowedPaths: []` is BY DESIGN?
|
|
1367
|
+
*
|
|
1368
|
+
* Trimmed + case-insensitive; a non-string role is never read-only (fail-closed
|
|
1369
|
+
* in the direction that produces a MORE alarming message, never a quieter one).
|
|
1370
|
+
*
|
|
1371
|
+
* @param {unknown} role
|
|
1372
|
+
* @returns {boolean}
|
|
1373
|
+
*/
|
|
1374
|
+
export function isReadOnlyWaveRole(role) {
|
|
1375
|
+
if (typeof role !== 'string') return false;
|
|
1376
|
+
return READ_ONLY_ROLE_KEYS.has(role.trim().toLowerCase());
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* Classify WHY a wave manifest grants zero paths. Pure, sync, no I/O — every
|
|
1381
|
+
* observation is passed in, exactly like {@link testSiblingExpansionApplies}.
|
|
1382
|
+
* Never throws.
|
|
1383
|
+
*
|
|
1384
|
+
* Modelled on `readLockDetailed` (`scripts/lib/session-lock.mjs`): a small
|
|
1385
|
+
* closed status union, where "cannot tell" is its own member instead of being
|
|
1386
|
+
* folded into the most alarming one.
|
|
1387
|
+
*
|
|
1388
|
+
* ## Precedence (each rung is load-bearing)
|
|
1389
|
+
*
|
|
1390
|
+
* 1. `parseOk === false` → `'unreadable'`. FIRST, because a manifest that did
|
|
1391
|
+
* not parse has no trustworthy `role` either — reading `role` off `{}` and
|
|
1392
|
+
* reporting "writer defect" would blame the coordinator for a corrupt file.
|
|
1393
|
+
* Only an EXPLICIT `false` classifies; `undefined` means "caller did not
|
|
1394
|
+
* observe it" and falls through.
|
|
1395
|
+
* 2. `role` ∈ {@link READ_ONLY_WAVE_ROLES} → `'read-only-role'`. Before the
|
|
1396
|
+
* clock comparison ON PURPOSE: for a Discovery wave the empty scope is the
|
|
1397
|
+
* contract whether the manifest is one second or one day old, so a stale
|
|
1398
|
+
* Discovery leftover reports the read-only sentence. The cost is named
|
|
1399
|
+
* rather than hidden — it is the one state where a leftover manifest is
|
|
1400
|
+
* described by its role instead of by its age.
|
|
1401
|
+
* 3. `scopeMtimeMs < sessionStartMs` → `'stale-manifest'`. Requires BOTH
|
|
1402
|
+
* clocks to be finite numbers; either one absent ⇒ `'unknown'`, never a
|
|
1403
|
+
* guess in either direction.
|
|
1404
|
+
* 4. A writable role with a manifest at least as new as this session ⇒
|
|
1405
|
+
* `'writer-defect'`.
|
|
1406
|
+
* 5. Everything else ⇒ `'unknown'`.
|
|
1407
|
+
*
|
|
1408
|
+
* @param {{role?: unknown, parseOk?: unknown, scopeMtimeMs?: unknown, sessionStartMs?: unknown}} [input]
|
|
1409
|
+
* @returns {'unreadable'|'read-only-role'|'stale-manifest'|'writer-defect'|'unknown'}
|
|
1410
|
+
*/
|
|
1411
|
+
export function classifyEmptyScope(input = {}) {
|
|
1412
|
+
if (input === null || typeof input !== 'object') return 'unknown';
|
|
1413
|
+
|
|
1414
|
+
if (input.parseOk === false) return 'unreadable';
|
|
1415
|
+
if (isReadOnlyWaveRole(input.role)) return 'read-only-role';
|
|
1416
|
+
|
|
1417
|
+
const mtime = typeof input.scopeMtimeMs === 'number' && Number.isFinite(input.scopeMtimeMs)
|
|
1418
|
+
? input.scopeMtimeMs
|
|
1419
|
+
: null;
|
|
1420
|
+
const started = typeof input.sessionStartMs === 'number' && Number.isFinite(input.sessionStartMs)
|
|
1421
|
+
? input.sessionStartMs
|
|
1422
|
+
: null;
|
|
1423
|
+
if (mtime === null || started === null) return 'unknown';
|
|
1424
|
+
|
|
1425
|
+
if (mtime < started) return 'stale-manifest';
|
|
1426
|
+
return typeof input.role === 'string' && input.role.trim().length > 0
|
|
1427
|
+
? 'writer-defect'
|
|
1428
|
+
: 'unknown';
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* The suggestion half of a scope-violation deny, when `allowedPaths` is EMPTY.
|
|
1433
|
+
*
|
|
1434
|
+
* A strict superset of {@link suggestForScopeViolation}'s empty-allowlist
|
|
1435
|
+
* branch: `'unknown'` delegates to it verbatim, so the pre-#1057 sentence has
|
|
1436
|
+
* exactly one copy and every other branch is an ADDITION. Pure, sync, never
|
|
1437
|
+
* throws.
|
|
1438
|
+
*
|
|
1439
|
+
* @param {string} relPath — the project-relative path that was blocked
|
|
1440
|
+
* @param {string} reason — a {@link EMPTY_SCOPE_REASONS} member; anything else
|
|
1441
|
+
* is treated as `'unknown'` (fail-safe toward the generic text)
|
|
1442
|
+
* @param {{role?: unknown, scopePath?: unknown}} [opts]
|
|
1443
|
+
* `scopePath` is the manifest's location, project-relative where possible. It
|
|
1444
|
+
* is named as the SUBJECT of a diagnosis ("this file is a leftover") and never
|
|
1445
|
+
* as the target of a removal — see the module block above, § "The channel
|
|
1446
|
+
* belongs to the AGENT".
|
|
1447
|
+
* @returns {string}
|
|
1448
|
+
*/
|
|
1449
|
+
export function suggestForEmptyScope(relPath, reason, opts = {}) {
|
|
1450
|
+
const bag = opts !== null && typeof opts === 'object' ? opts : {};
|
|
1451
|
+
const scopeHint = typeof bag.scopePath === 'string' && bag.scopePath.length > 0
|
|
1452
|
+
? bag.scopePath
|
|
1453
|
+
: '<state-dir>/wave-scope.json';
|
|
1454
|
+
const rawRole = typeof bag.role === 'string' ? bag.role.trim() : '';
|
|
1455
|
+
|
|
1456
|
+
switch (reason) {
|
|
1457
|
+
case 'unreadable':
|
|
1458
|
+
return (
|
|
1459
|
+
`wave-scope.json is unreadable — failing closed. ` +
|
|
1460
|
+
`The manifest exists but did not parse into a usable scope record, so NO path can be granted. ` +
|
|
1461
|
+
`Inspect '${scopeHint}'; a truncated or half-written manifest is repaired by re-running the ` +
|
|
1462
|
+
`coordinator's scope-manifest step, not by editing the plan — report \`blocked\` and leave the ` +
|
|
1463
|
+
`manifest to the coordinator.`
|
|
1464
|
+
);
|
|
1465
|
+
|
|
1466
|
+
case 'read-only-role': {
|
|
1467
|
+
// Canonical casing from the list, so ' DISCOVERY ' and 'discovery' both
|
|
1468
|
+
// render the documented sentence.
|
|
1469
|
+
const canonical = READ_ONLY_ROLE_KEYS.get(rawRole.toLowerCase()) ?? rawRole;
|
|
1470
|
+
return (
|
|
1471
|
+
`${canonical} wave is read-only — no writes permitted. ` +
|
|
1472
|
+
`An empty allowedPaths is this role's deliberate contract (#256), not a misconfiguration: ` +
|
|
1473
|
+
`report '${relPath}' as a finding instead of editing it.`
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
case 'stale-manifest':
|
|
1478
|
+
// NO COMMAND, and the manifest is never a removal TARGET here. Two reasons,
|
|
1479
|
+
// both measured (module block, § "The channel belongs to the AGENT" and
|
|
1480
|
+
// § "…and the reader may not be the owner"): the string is read by the agent
|
|
1481
|
+
// whose Edit was just denied, and clearing the manifest disarms the whole
|
|
1482
|
+
// gate — and the file may belong to a PARALLEL session in this working copy
|
|
1483
|
+
// that is still using it. The diagnosis, which is what #1057 bought, stays.
|
|
1484
|
+
return (
|
|
1485
|
+
`'${scopeHint}' was written before the most recent session-start clock in this working copy — ` +
|
|
1486
|
+
`either a leftover from a crashed session, or the live manifest of a PARALLEL session that is ` +
|
|
1487
|
+
`still using it. This guard cannot tell which, and restarting the wave clears neither. ` +
|
|
1488
|
+
`Treat the file as not yours (PSA-001): report \`blocked\` to your coordinator and name it. ` +
|
|
1489
|
+
`Do NOT remove, move or edit it yourself (PSA-003/PSA-007).`
|
|
1490
|
+
);
|
|
1491
|
+
|
|
1492
|
+
case 'writer-defect':
|
|
1493
|
+
return (
|
|
1494
|
+
`the wave's allowedPaths union is empty for role \`${rawRole}\` — ` +
|
|
1495
|
+
`the coordinator's \`--union\` step did not complete. Re-run it; do not hand-edit.`
|
|
1496
|
+
);
|
|
1497
|
+
|
|
1498
|
+
default:
|
|
1499
|
+
return suggestForScopeViolation(relPath, '');
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Milliseconds since one of this session's clocks was written, or `null` when
|
|
1505
|
+
* none is readable — the MINIMUM over `.orchestrator/current-session.json`
|
|
1506
|
+
* `timestamp` and `.orchestrator/session.lock` `started_at`.
|
|
1507
|
+
*
|
|
1508
|
+
* MOVED here from `hooks/post-bash-write-verify.mjs` (#1057) so both consumers
|
|
1509
|
+
* share ONE implementation: a lib module may be imported by a hook, but a hook
|
|
1510
|
+
* must never be imported by another hook or by this lib (module header, #554
|
|
1511
|
+
* A2). Behaviour is byte-identical to the original — including the `Math.min`
|
|
1512
|
+
* choice and the dropping of NEGATIVE (future-dated) ages, both of which that
|
|
1513
|
+
* hook's docblock argues at length under "Why the minimum, and why NOT a
|
|
1514
|
+
* staleness cap". That argument is the reason this repo has no TTL here either.
|
|
1515
|
+
*
|
|
1516
|
+
* Sync fs reads at CALL time only (same shape as {@link getEnforcementLevel});
|
|
1517
|
+
* no I/O at import time. Never throws — an unreadable or malformed clock is
|
|
1518
|
+
* simply absent.
|
|
1519
|
+
*
|
|
1520
|
+
* @param {string} repoRoot
|
|
1521
|
+
* @param {number} [now]
|
|
1522
|
+
* @returns {number|null}
|
|
1523
|
+
*/
|
|
1524
|
+
export function sessionAgeMs(repoRoot, now = Date.now()) {
|
|
1525
|
+
const dir = path.join(repoRoot, '.orchestrator');
|
|
1526
|
+
const ages = [
|
|
1527
|
+
clockAgeMs(path.join(dir, 'current-session.json'), 'timestamp', now),
|
|
1528
|
+
clockAgeMs(path.join(dir, 'session.lock'), 'started_at', now),
|
|
1529
|
+
].filter((age) => age !== null);
|
|
1530
|
+
return ages.length > 0 ? Math.min(...ages) : null;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Age in ms of one JSON clock file's ISO timestamp field, or `null` when the
|
|
1535
|
+
* file is missing, unparseable, carries no parseable timestamp, or is dated in
|
|
1536
|
+
* the FUTURE. Private helper of {@link sessionAgeMs}; moved verbatim with it.
|
|
1537
|
+
*
|
|
1538
|
+
* @param {string} file
|
|
1539
|
+
* @param {string} field
|
|
1540
|
+
* @param {number} now
|
|
1541
|
+
* @returns {number|null}
|
|
1542
|
+
*/
|
|
1543
|
+
function clockAgeMs(file, field, now) {
|
|
1544
|
+
try {
|
|
1545
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
1546
|
+
const startedAt = Date.parse(parsed?.[field]);
|
|
1547
|
+
if (!Number.isFinite(startedAt)) return null;
|
|
1548
|
+
const age = now - startedAt;
|
|
1549
|
+
return age >= 0 ? age : null;
|
|
1550
|
+
} catch {
|
|
1551
|
+
return null;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* Absolute epoch-ms at which this session started, or `null` when no clock is
|
|
1557
|
+
* readable — the value {@link classifyEmptyScope} compares a manifest's mtime
|
|
1558
|
+
* against.
|
|
1559
|
+
*
|
|
1560
|
+
* Derived from {@link sessionAgeMs} rather than re-reading the files, so there
|
|
1561
|
+
* is ONE clock policy: `now - min(ages)` is the LATEST of the two recorded start
|
|
1562
|
+
* times, which is exactly the freshness `Math.min` was chosen to express (a
|
|
1563
|
+
* leftover `current-session.json` from a previous session is outvoted by a
|
|
1564
|
+
* freshly-acquired `session.lock`). `now` is threaded through so both halves see
|
|
1565
|
+
* the same instant.
|
|
1566
|
+
*
|
|
1567
|
+
* Never throws. No clock ⇒ `null` ⇒ the caller cannot decide staleness and must
|
|
1568
|
+
* fall back to `'unknown'`.
|
|
1569
|
+
*
|
|
1570
|
+
* @param {string} repoRoot
|
|
1571
|
+
* @param {number} [now]
|
|
1572
|
+
* @returns {number|null}
|
|
1573
|
+
*/
|
|
1574
|
+
export function sessionStartedAtMs(repoRoot, now = Date.now()) {
|
|
1575
|
+
const age = sessionAgeMs(repoRoot, now);
|
|
1576
|
+
return age === null ? null : now - age;
|
|
1577
|
+
}
|