session-orchestrator 4.2.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +94 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/commands/ux-grill.md +51 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/codex-setup.md +8 -0
- package/docs/components.md +7 -7
- package/docs/events-schema.md +9 -5
- package/docs/github-mirror-protection.md +61 -20
- package/docs/migration-v5.md +62 -0
- package/docs/scope-collision-guard.md +14 -0
- package/hooks/_lib/hook-import-set.json +44 -2
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +190 -3
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +51 -4
- package/package.json +2 -1
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +114 -13
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget.mjs +336 -6
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +11 -1
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +14 -65
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-start-probes.mjs +419 -53
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +48 -20
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/mcp-server.sh +16 -1
- package/scripts/release.mjs +7 -2
- package/skills/bootstrap/SKILL.md +12 -209
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +7 -3
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +10 -0
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +4 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-plan/SKILL.md +2 -144
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-start/SKILL.md +24 -6
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +3 -128
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +18 -5
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/_shared/journey-manifest.md +0 -114
package/hooks/enforce-scope.mjs
CHANGED
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
* Runs before G6 so a deliberate out-of-repo grant (e.g. a vault path)
|
|
23
23
|
* is reachable at all — G6 would otherwise deny every out-of-repo path
|
|
24
24
|
* without ever consulting allowedPaths. See matchesAbsoluteAllowlist.
|
|
25
|
+
* G5c (#1295) out-of-root carveout for THIS repo's Claude Code auto-memory
|
|
26
|
+
* directory (`~/.claude/projects/<encoded-repo-path>/memory/`). Harness-
|
|
27
|
+
* owned, lives outside the working copy, cannot collide with any wave
|
|
28
|
+
* scope. Evaluated inside G6's out-of-root branch only; a SIBLING repo's
|
|
29
|
+
* memory dir and every other out-of-repo path stay denied.
|
|
25
30
|
* G6 resolved path inside project root
|
|
26
31
|
* G7 relative path matches an allowedPaths pattern
|
|
27
32
|
* G8 (all passed) → allow
|
|
@@ -432,6 +437,73 @@ async function main() {
|
|
|
432
437
|
|
|
433
438
|
// Gate 6: path must be inside the project root
|
|
434
439
|
if (!isPathInside(resolvedPath, projectRoot)) {
|
|
440
|
+
// Gate 5c (#1295) — THIS repo's harness auto-memory directory.
|
|
441
|
+
//
|
|
442
|
+
// Claude Code writes its own auto-memory (`MEMORY.md` + per-fact files)
|
|
443
|
+
// OUTSIDE the working copy, at
|
|
444
|
+
// `~/.claude/projects/<encoded-repo-path>/memory/`. Every wave manifest
|
|
445
|
+
// therefore blocked the coordinator's memory writes until the manifest was
|
|
446
|
+
// torn down — six sessions in a consumer repo re-documented the same
|
|
447
|
+
// workaround (bewerbungs-assistent#307, retro 2026-09-09). That directory
|
|
448
|
+
// is harness-owned and cannot collide with ANY wave file scope, so it is
|
|
449
|
+
// always-allowed territory; it is the single out-of-repo carveout here.
|
|
450
|
+
//
|
|
451
|
+
// NARROWNESS (security boundary — no wider allow than this one directory):
|
|
452
|
+
// - Only the memory dir of THIS repo, as the harness names it. That name
|
|
453
|
+
// is NOT unique: `encodeProjectDir()` maps both `/` and `.` to `-`, so
|
|
454
|
+
// `/x/a.b` and `/x/a-b` share one memory dir — a harness-level collision
|
|
455
|
+
// this gate inherits and cannot narrow. Every OTHER sibling repo's
|
|
456
|
+
// memory dir stays denied.
|
|
457
|
+
// - Containment is exact-prefix on the REALPATH-resolved candidate, so
|
|
458
|
+
// `..` segments and symlink tricks cannot widen it.
|
|
459
|
+
// - Evaluated only on the out-of-root branch: the in-repo gates (Gate 7,
|
|
460
|
+
// Discovery's `allowedPaths: []` deny-all) are untouched.
|
|
461
|
+
//
|
|
462
|
+
// Encoder REUSED, never re-written (BV-001 rung 2): `encodeProjectDir()`
|
|
463
|
+
// from `scripts/lib/wave-transcript-tail.mjs` is the repo's one encoder for
|
|
464
|
+
// `<encoded-repo-path>`. Bound LAZILY here rather than in `bootstrap()` —
|
|
465
|
+
// same pattern as the G3b event import above — so the happy path pays no
|
|
466
|
+
// module load, and an import failure falls through to the deny below
|
|
467
|
+
// (fail-closed, byte-identical to the pre-#1295 behaviour).
|
|
468
|
+
//
|
|
469
|
+
// CEILING (BV-004): `CLAUDE_CONFIG_DIR` is NOT honoured, because nothing in
|
|
470
|
+
// this codebase honours it today (`rg -n "CLAUDE_CONFIG_DIR" scripts hooks`
|
|
471
|
+
// → 0 matches, 2026-09-13) and `wave-transcript-tail.mjs` resolves the same
|
|
472
|
+
// substrate from `homedir()`. Revisit together with that resolver if the
|
|
473
|
+
// harness config dir ever becomes relocatable here.
|
|
474
|
+
// CALLER DISCRIMINATION (#1352): the carve-out is the COORDINATOR's, never a
|
|
475
|
+
// dispatched wave agent's. See {@link classifyCaller} for the measurement.
|
|
476
|
+
// A subagent falls through to the gates below — under a manifest that does
|
|
477
|
+
// not grant the path (Discovery's `allowedPaths: []`) that is a DENY.
|
|
478
|
+
const memoryDirs = await ownMemoryDirs(projectRootRaw, projectRoot);
|
|
479
|
+
const caller = classifyCaller(input);
|
|
480
|
+
if (caller !== 'subagent' && memoryDirs.some((dir) => isInsideDir(resolvedPath, dir))) {
|
|
481
|
+
// One event per decision point, awaited before emitAllow() —
|
|
482
|
+
// emitAllow() calls process.exit(), which would discard a pending append.
|
|
483
|
+
try {
|
|
484
|
+
const { emitEvent } = await import('../scripts/lib/events.mjs');
|
|
485
|
+
await emitEvent(
|
|
486
|
+
'orchestrator.scope.memory_dir_allowed',
|
|
487
|
+
{
|
|
488
|
+
hook: HOOK_NAME,
|
|
489
|
+
manifest: scopePath,
|
|
490
|
+
wave: scope.wave,
|
|
491
|
+
file_path: resolvedPath,
|
|
492
|
+
// HR-105: the fail-OPEN case must be COUNTABLE, or the ambiguity is
|
|
493
|
+
// unfalsifiable — and it takes TWO values to be countable.
|
|
494
|
+
// `'absent'` = no `agent_id` key at all (the harness's own contract
|
|
495
|
+
// for a main-thread call); `'malformed'` = an `agent_id` key that is
|
|
496
|
+
// PRESENT but unusable (number/object/array/blank string), which
|
|
497
|
+
// would otherwise be indistinguishable from the legitimate case.
|
|
498
|
+
// `'coordinator'` = `agent_type` without `agent_id`.
|
|
499
|
+
discriminator: caller,
|
|
500
|
+
},
|
|
501
|
+
{ repoRoot: projectRoot },
|
|
502
|
+
);
|
|
503
|
+
} catch { /* observability is best-effort — never blocks the decision */ }
|
|
504
|
+
return emitAllow();
|
|
505
|
+
}
|
|
506
|
+
|
|
435
507
|
const reason = `Scope violation: path outside project root`;
|
|
436
508
|
const suggestion = suggest(filePath);
|
|
437
509
|
return enforcement === 'strict'
|
|
@@ -500,6 +572,135 @@ async function mtimeMsOf(file) {
|
|
|
500
572
|
}
|
|
501
573
|
}
|
|
502
574
|
|
|
575
|
+
/**
|
|
576
|
+
* #1295 — the Claude Code auto-memory directories that belong to THIS repo.
|
|
577
|
+
*
|
|
578
|
+
* Returns at most two paths, both naming the SAME repo: the encoding of the
|
|
579
|
+
* project root as the harness saw it (`CLAUDE_PROJECT_DIR` / cwd) and — when it
|
|
580
|
+
* differs — the encoding of its realpath. Both are needed because Claude Code
|
|
581
|
+
* encodes the path it was LAUNCHED with, while this hook compares against the
|
|
582
|
+
* canonical root (on macOS `/tmp` → `/private/tmp`). Two encodings of one repo
|
|
583
|
+
* is not a wider grant: a sibling repo's root encodes to neither.
|
|
584
|
+
*
|
|
585
|
+
* Returns `[]` when the encoder cannot be loaded, so the caller falls through to
|
|
586
|
+
* its deny (fail-closed).
|
|
587
|
+
*
|
|
588
|
+
* @param {string} projectRootRaw — project root as resolved from env/cwd
|
|
589
|
+
* @param {string} projectRoot — its realpath
|
|
590
|
+
* @returns {Promise<string[]>}
|
|
591
|
+
*/
|
|
592
|
+
async function ownMemoryDirs(projectRootRaw, projectRoot) {
|
|
593
|
+
try {
|
|
594
|
+
const [{ encodeProjectDir }, { homedir }] = await Promise.all([
|
|
595
|
+
import('../scripts/lib/wave-transcript-tail.mjs'),
|
|
596
|
+
import('node:os'),
|
|
597
|
+
]);
|
|
598
|
+
const home = homedir();
|
|
599
|
+
if (!home) return [];
|
|
600
|
+
// The candidate arrives REALPATH-resolved (SECURITY-REQ-03), so the home
|
|
601
|
+
// side must be too — otherwise the macOS `/tmp` → `/private/tmp` symlink
|
|
602
|
+
// alone makes every comparison miss.
|
|
603
|
+
let homeReal = home;
|
|
604
|
+
try {
|
|
605
|
+
homeReal = await fs.realpath(home);
|
|
606
|
+
} catch { /* non-existent home — the raw form is all there is */ }
|
|
607
|
+
const homes = new Set([home, homeReal]);
|
|
608
|
+
const roots = new Set(
|
|
609
|
+
[projectRootRaw, projectRoot].filter((p) => typeof p === 'string' && p.length > 0),
|
|
610
|
+
);
|
|
611
|
+
const dirs = [];
|
|
612
|
+
for (const h of homes) {
|
|
613
|
+
for (const root of roots) {
|
|
614
|
+
dirs.push(path.join(h, '.claude', 'projects', encodeProjectDir(root), 'memory'));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return dirs;
|
|
618
|
+
} catch {
|
|
619
|
+
return [];
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Exact-prefix containment: is `candidate` `dir` itself or a descendant of it?
|
|
625
|
+
*
|
|
626
|
+
* Both sides are `path.resolve`d first, so `..` segments collapse before the
|
|
627
|
+
* comparison and cannot escape `dir`. The `path.sep` suffix is load-bearing —
|
|
628
|
+
* without it `<dir>-evil/x` would match the prefix of `<dir>`.
|
|
629
|
+
*
|
|
630
|
+
* @param {string} candidate
|
|
631
|
+
* @param {string} dir
|
|
632
|
+
* @returns {boolean}
|
|
633
|
+
*/
|
|
634
|
+
function isInsideDir(candidate, dir) {
|
|
635
|
+
const base = path.resolve(dir);
|
|
636
|
+
const target = path.resolve(candidate);
|
|
637
|
+
return target === base || target.startsWith(base + path.sep);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Classify a PreToolUse payload as coming from a dispatched SUBAGENT or from the
|
|
642
|
+
* main (coordinator) thread — the discriminator Gate 5c's memory carve-out needs
|
|
643
|
+
* (#1352).
|
|
644
|
+
*
|
|
645
|
+
* MEASURED, not assumed (2026-09-13, Claude Code 2.1.270, `strings` over
|
|
646
|
+
* `~/.local/share/claude/versions/2.1.270`):
|
|
647
|
+
* - The base hook-input builder shared by EVERY event is
|
|
648
|
+
* `{session_id: e.id, transcript_path: yf(e.id), cwd, …, agent_id: s?.agentId,
|
|
649
|
+
* agent_type: d}` — PreToolUse spreads it verbatim
|
|
650
|
+
* (`{...Na(s.session,Z(),d,s), hook_event_name:"PreToolUse", tool_name, …}`).
|
|
651
|
+
* - The harness's own schema documents the field: *"Subagent identifier. Present
|
|
652
|
+
* only when the hook fires from within a subagent (e.g., a tool called by an
|
|
653
|
+
* AgentTool worker). Absent for the main thread, even in --agent sessions. Use
|
|
654
|
+
* this field (not agent_type) to distinguish subagent calls from main-thread
|
|
655
|
+
* calls."*
|
|
656
|
+
* - Two candidates are REFUTED by the same source: `transcript_path` is
|
|
657
|
+
* `yf(session.id)` — session-derived, hence IDENTICAL for coordinator and
|
|
658
|
+
* subagent; and `CLAUDE_CODE_CHILD_SESSION=1` appears in a main-thread `env`
|
|
659
|
+
* dump (`isSidechain:false`), so it marks the CLI process, not the caller.
|
|
660
|
+
*
|
|
661
|
+
* `agent_type` is deliberately a WEAKER witness, used only in the negative
|
|
662
|
+
* direction: per the same schema it is present on the main thread of an `--agent`
|
|
663
|
+
* session WITHOUT `agent_id`, so `agent_type`-without-`agent_id` positively names
|
|
664
|
+
* a coordinator, while `agent_type` alone can never name a subagent.
|
|
665
|
+
*
|
|
666
|
+
* FAIL-OPEN on ambiguity, deliberately (BV-004 ceiling): a payload this function
|
|
667
|
+
* cannot read as a subagent keeps the pre-#1352 ALLOW, because fail-closed here
|
|
668
|
+
* would deny the coordinator's own memory writes and break `/close`. The value is
|
|
669
|
+
* emitted on the event so the ambiguity is countable (HR-105) — but countability
|
|
670
|
+
* needs TWO fail-open values, not one:
|
|
671
|
+
*
|
|
672
|
+
* - `'absent'` — NO `agent_id` key in the payload at all. The harness's own
|
|
673
|
+
* documented shape for a main-thread call; the legitimate case.
|
|
674
|
+
* - `'malformed'` — an `agent_id` key IS present and carries something this
|
|
675
|
+
* function cannot use as an identity (number, object, array,
|
|
676
|
+
* empty/whitespace string). Folding this into `'absent'` made a
|
|
677
|
+
* PRESENT-but-unusable marker byte-identical to a genuine
|
|
678
|
+
* main-thread call, so a future harness or bridge sending a
|
|
679
|
+
* numeric or object agent id would hand EVERY subagent the
|
|
680
|
+
* carve-out with nothing in the ledger to show it. Empty and
|
|
681
|
+
* whitespace-only strings belong here, not in `'absent'`: the
|
|
682
|
+
* key is present, so the sender believed it was identifying a
|
|
683
|
+
* subagent, and that is exactly the case this value separates.
|
|
684
|
+
*
|
|
685
|
+
* Only `agent_id` can produce `'malformed'`. `agent_type` is used solely in the
|
|
686
|
+
* positive coordinator direction and can never hide a subagent, so an unusable
|
|
687
|
+
* `agent_type` without an `agent_id` stays `'absent'`.
|
|
688
|
+
*
|
|
689
|
+
* Revisit if `discriminator: 'malformed'` appears at all, or if `'absent'`
|
|
690
|
+
* dominates the records on a harness that DOES dispatch subagents.
|
|
691
|
+
*
|
|
692
|
+
* @param {Record<string, unknown>} input Parsed PreToolUse stdin payload.
|
|
693
|
+
* @returns {'subagent'|'coordinator'|'malformed'|'absent'}
|
|
694
|
+
*/
|
|
695
|
+
function classifyCaller(input) {
|
|
696
|
+
const agentId = input?.agent_id;
|
|
697
|
+
if (typeof agentId === 'string' && agentId.trim() !== '') return 'subagent';
|
|
698
|
+
if (agentId !== undefined && agentId !== null) return 'malformed';
|
|
699
|
+
const agentType = input?.agent_type;
|
|
700
|
+
if (typeof agentType === 'string' && agentType.trim() !== '') return 'coordinator';
|
|
701
|
+
return 'absent';
|
|
702
|
+
}
|
|
703
|
+
|
|
503
704
|
const COORDINATOR_CARVEOUT_PATHS = Object.freeze([
|
|
504
705
|
'.claude/STATE.md',
|
|
505
706
|
'.codex/STATE.md',
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator
|
|
10
|
+
"command": "echo '🎯 Session Orchestrator v5.0.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
11
11
|
"async": false
|
|
12
12
|
},
|
|
13
13
|
{
|
package/hooks/hooks-cursor.json
CHANGED
|
@@ -161,6 +161,11 @@
|
|
|
161
161
|
"type": "command",
|
|
162
162
|
"command": "sh \"$CURSOR_PLUGIN_ROOT/hooks/run-node.sh\" \"$CURSOR_PLUGIN_ROOT/hooks/post-tool-failure-corrective-context.mjs\"",
|
|
163
163
|
"timeout": 5
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"type": "command",
|
|
167
|
+
"command": "sh \"$CURSOR_PLUGIN_ROOT/hooks/run-node.sh\" \"$CURSOR_PLUGIN_ROOT/hooks/post-bash-issue-budget-refund.mjs\"",
|
|
168
|
+
"timeout": 5
|
|
164
169
|
}
|
|
165
170
|
]
|
|
166
171
|
}
|
package/hooks/hooks.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"hooks": {
|
|
3
3
|
"SessionStart": [
|
|
4
4
|
{
|
|
5
|
-
"matcher": "startup|clear|compact",
|
|
5
|
+
"matcher": "startup|resume|clear|compact",
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "echo '🎯 Session Orchestrator
|
|
9
|
+
"command": "echo '🎯 Session Orchestrator v5.0.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
|
@@ -206,6 +206,11 @@
|
|
|
206
206
|
"type": "command",
|
|
207
207
|
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/post-tool-failure-corrective-context.mjs\"",
|
|
208
208
|
"timeout": 5
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"type": "command",
|
|
212
|
+
"command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh\" \"$CLAUDE_PLUGIN_ROOT/hooks/post-bash-issue-budget-refund.mjs\"",
|
|
213
|
+
"timeout": 5
|
|
209
214
|
}
|
|
210
215
|
]
|
|
211
216
|
}
|
|
@@ -54,6 +54,11 @@ import { detectColdStart, consumeMarker } from '../scripts/lib/cold-start-detect
|
|
|
54
54
|
import { parseSessionId } from '../scripts/lib/session-id.mjs';
|
|
55
55
|
import { readTelemetryState, resolveConsent, isCiEnv } from '../scripts/lib/telemetry/consent.mjs';
|
|
56
56
|
import { loadOwnerConfig } from '../scripts/lib/owner-yaml.mjs';
|
|
57
|
+
// SSOT for the "is this a re-entry into the same logical session?" question
|
|
58
|
+
// (#1091). Defined in the lock-bootstrap leaf module, which this hook already
|
|
59
|
+
// loads, so the preservation branch below and the lock force-refresh gate can
|
|
60
|
+
// never drift apart.
|
|
61
|
+
import { SAME_LOGICAL_SESSION_SOURCES } from './_lib/lock-bootstrap.mjs';
|
|
57
62
|
|
|
58
63
|
const execFileAsync = promisify(execFile);
|
|
59
64
|
|
|
@@ -336,6 +341,9 @@ async function readStdinJson(timeoutMs = 500) {
|
|
|
336
341
|
* Best-effort: any persistence failure is swallowed (hook must remain non-blocking).
|
|
337
342
|
*/
|
|
338
343
|
async function resolveSessionId(input, projectRoot) {
|
|
344
|
+
const nativeSource = typeof input?.source === 'string' && input.source.length > 0
|
|
345
|
+
? input.source
|
|
346
|
+
: null;
|
|
339
347
|
const fromStdin = (input && (input.session_id || input.sessionId)) ?? null;
|
|
340
348
|
const parsedStdinId = parseSessionId(fromStdin);
|
|
341
349
|
const rawStdinSessionId = parsedStdinId?.format === 'uuid' ? fromStdin : null;
|
|
@@ -364,61 +372,136 @@ async function resolveSessionId(input, projectRoot) {
|
|
|
364
372
|
source = 'generated-uuid';
|
|
365
373
|
}
|
|
366
374
|
|
|
375
|
+
const sessionFilePath = path.join(projectRoot, '.orchestrator', 'current-session.json');
|
|
376
|
+
|
|
377
|
+
// Read the PREVIOUS current-session.json BEFORE minting a semantic id: on a
|
|
378
|
+
// same-logical-session re-entry it decides whether a new id is minted at all
|
|
379
|
+
// (#1091 F1 below). Best-effort — absent/unparseable leaves prev = null.
|
|
380
|
+
let prev = null;
|
|
381
|
+
try {
|
|
382
|
+
prev = JSON.parse(await readFile(sessionFilePath, 'utf8'));
|
|
383
|
+
if (typeof prev !== 'object' || prev === null) prev = null;
|
|
384
|
+
} catch { /* absent / unparseable → no linkage, no preservation */ }
|
|
385
|
+
|
|
386
|
+
// #1091 F1 — raw-id linkage on a same-logical-session re-entry.
|
|
387
|
+
//
|
|
388
|
+
// resolveSemanticSessionId() has NO self-exclusion: it projects every active
|
|
389
|
+
// session (including OUR OWN lock and registry entry) to n and returns
|
|
390
|
+
// maxN + 1. So a second SessionStart of the SAME session mints
|
|
391
|
+
// `…-session-31` where the first minted `…-session-30`, the
|
|
392
|
+
// `prev.semantic_session_id === semanticSessionId` preservation branch below
|
|
393
|
+
// is FALSE, and last_wave / last_batch / last_wave_completed /
|
|
394
|
+
// wave_start_sha are dropped — re-opening #612, #980 and #1193. Before
|
|
395
|
+
// #1091 that path was unreachable on Claude Code because `resume` was absent
|
|
396
|
+
// from the SessionStart matcher; adding it made it live.
|
|
397
|
+
//
|
|
398
|
+
// The trustworthy linkage is the RAW id: Claude Code preserves `session_id`
|
|
399
|
+
// across a native resume, and current-session.json records the raw id the
|
|
400
|
+
// previous invocation of this hook resolved. When it matches, this is
|
|
401
|
+
// provably the same logical session and its semantic label is REUSED rather
|
|
402
|
+
// than re-minted. When it does NOT match, today's behaviour stands (fresh
|
|
403
|
+
// id, no preservation) — that is the "unverified restart" #1091 defines, and
|
|
404
|
+
// guessing continuity there would adopt a foreign session's high-water marks
|
|
405
|
+
// (`.claude/rules/identity-and-locks.md` — a working-copy artefact is not an
|
|
406
|
+
// identity witness).
|
|
407
|
+
//
|
|
408
|
+
// Sources: `clear` and `compact` are re-entries into the same logical
|
|
409
|
+
// session for exactly the reason the comment block below states (the UUID
|
|
410
|
+
// changes, the semantic label does not); `resume` is the third per #1091.
|
|
411
|
+
// `startup` is deliberately absent — a fresh process start must never
|
|
412
|
+
// inherit a predecessor's markers.
|
|
413
|
+
let resumeLinkage = 'none';
|
|
414
|
+
if (
|
|
415
|
+
nativeSource !== null
|
|
416
|
+
&& SAME_LOGICAL_SESSION_SOURCES.has(nativeSource)
|
|
417
|
+
&& prev
|
|
418
|
+
&& prev.session_id === sessionId
|
|
419
|
+
&& typeof prev.semantic_session_id === 'string'
|
|
420
|
+
&& prev.semantic_session_id.length > 0
|
|
421
|
+
) {
|
|
422
|
+
semanticSessionId = prev.semantic_session_id;
|
|
423
|
+
resumeLinkage = 'raw-id';
|
|
424
|
+
}
|
|
425
|
+
|
|
367
426
|
// Derive a descriptive semantic label for either raw-id source. Best-effort:
|
|
368
427
|
// a failure leaves it null without changing the physical raw session_id.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
428
|
+
if (semanticSessionId === null) {
|
|
429
|
+
try {
|
|
430
|
+
const semCandidate = await deriveSemanticCandidate({
|
|
431
|
+
projectRoot,
|
|
432
|
+
mode: normalizedMode,
|
|
433
|
+
});
|
|
434
|
+
if (semCandidate) semanticSessionId = semCandidate;
|
|
435
|
+
} catch { /* best effort — leave semanticSessionId = null */ }
|
|
436
|
+
}
|
|
376
437
|
|
|
377
438
|
try {
|
|
378
|
-
|
|
379
|
-
await mkdir(dir, { recursive: true });
|
|
380
|
-
const sessionFilePath = path.join(dir, 'current-session.json');
|
|
439
|
+
await mkdir(path.dirname(sessionFilePath), { recursive: true });
|
|
381
440
|
|
|
382
441
|
// High-water-mark preservation (#612 root-cause fix).
|
|
383
|
-
// SessionStart fires
|
|
384
|
-
//
|
|
385
|
-
// `
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
442
|
+
// SessionStart fires once per source in the matcher of the platform's
|
|
443
|
+
// hooks file — `startup|resume|clear|compact` on Claude Code and Codex
|
|
444
|
+
// (`resume` was ABSENT from hooks.json until #1091, so on Claude Code this
|
|
445
|
+
// hook never ran on a native resume), `startup|reload|new|resume|fork` on
|
|
446
|
+
// pi, and every source on Cursor (empty matcher). Several of those sources
|
|
447
|
+
// are re-entries into the SAME logical session. On clear/compact the UUID
|
|
448
|
+
// `session_id` changes while the `semantic_session_id`
|
|
449
|
+
// (branch+date+mode+n) stays stable; whether Claude Code PRESERVES the raw
|
|
450
|
+
// `session_id` across a native resume is what #1091 measures via
|
|
451
|
+
// `native_source` + `resume_linkage` on `orchestrator.session.started` —
|
|
452
|
+
// nothing here ASSUMES either answer, it only reacts to the raw id it can
|
|
453
|
+
// observe. A naive full overwrite of current-session.json drops the
|
|
454
|
+
// `last_wave` / `last_batch` markers written mid-session by
|
|
455
|
+
// post-tool-batch-wave-signal.mjs, which makes the next PostToolBatch
|
|
456
|
+
// re-read last_wave as absent→0 and re-emit a duplicate
|
|
457
|
+
// orchestrator.wave.started{N} with no intervening wave.completed.
|
|
458
|
+
//
|
|
459
|
+
// Three cases, exactly one of which preserves nothing:
|
|
460
|
+
// 'raw-id' — same-logical-session source AND the recorded raw
|
|
461
|
+
// session_id equals ours → the semantic label was reused
|
|
462
|
+
// above, markers PRESERVED. (#1091 F1)
|
|
463
|
+
// 'semantic' — the freshly minted label equals the recorded one →
|
|
464
|
+
// markers PRESERVED. The pre-#1091 behaviour, unchanged.
|
|
465
|
+
// 'none' — a genuinely new session, or an unverified restart
|
|
466
|
+
// (raw id changed, label re-minted) → markers RESET.
|
|
467
|
+
// Best-effort throughout: `prev` is null on a read/parse failure, which
|
|
468
|
+
// lands in 'none' and therefore on the reset path.
|
|
395
469
|
const preserved = {};
|
|
396
470
|
if (typeof semanticSessionId === 'string' && semanticSessionId.length > 0) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
// `orchestrator.wave.completed` silently omits `files_changed`.
|
|
417
|
-
if (Object.prototype.hasOwnProperty.call(prev, 'wave_start_sha')) {
|
|
418
|
-
preserved.wave_start_sha = prev.wave_start_sha;
|
|
419
|
-
}
|
|
471
|
+
// Two ways into the SAME logical session, both preserving:
|
|
472
|
+
// 1. `resumeLinkage === 'raw-id'` — the raw session_id matched on a
|
|
473
|
+
// resume/clear/compact re-entry, so the label above was REUSED
|
|
474
|
+
// (#1091 F1). Reachable since `resume` entered the matcher.
|
|
475
|
+
// 2. semantic match — the freshly minted label equals the recorded
|
|
476
|
+
// one. This is the case the branch already covered (a clear or
|
|
477
|
+
// compact whose discovery view happened to re-mint the same n),
|
|
478
|
+
// and it stays untouched.
|
|
479
|
+
// Anything else (different or absent semantic id, unparseable prior
|
|
480
|
+
// file, `startup`) is a genuinely new session and RESETS the markers.
|
|
481
|
+
if (resumeLinkage === 'none' && prev && prev.semantic_session_id === semanticSessionId) {
|
|
482
|
+
resumeLinkage = 'semantic';
|
|
483
|
+
}
|
|
484
|
+
if (prev && (resumeLinkage === 'raw-id' || prev.semantic_session_id === semanticSessionId)) {
|
|
485
|
+
if (Object.prototype.hasOwnProperty.call(prev, 'last_wave')) {
|
|
486
|
+
preserved.last_wave = prev.last_wave;
|
|
487
|
+
}
|
|
488
|
+
if (Object.prototype.hasOwnProperty.call(prev, 'last_batch')) {
|
|
489
|
+
preserved.last_batch = prev.last_batch;
|
|
420
490
|
}
|
|
421
|
-
|
|
491
|
+
// #1193 — the final-wave completion marker must survive a
|
|
492
|
+
// clear/compact too: dropping it re-arms a duplicate SessionEnd
|
|
493
|
+
// `orchestrator.wave.completed` for a wave already closed.
|
|
494
|
+
if (Object.prototype.hasOwnProperty.call(prev, 'last_wave_completed')) {
|
|
495
|
+
preserved.last_wave_completed = prev.last_wave_completed;
|
|
496
|
+
}
|
|
497
|
+
// #980 — the OPEN half of the wave-diff pair, written by
|
|
498
|
+
// post-tool-batch-wave-signal.mjs. Dropping it across a /clear leaves
|
|
499
|
+
// the running wave with no start point, so the next
|
|
500
|
+
// `orchestrator.wave.completed` silently omits `files_changed`.
|
|
501
|
+
if (Object.prototype.hasOwnProperty.call(prev, 'wave_start_sha')) {
|
|
502
|
+
preserved.wave_start_sha = prev.wave_start_sha;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
422
505
|
}
|
|
423
506
|
|
|
424
507
|
// Epic #583 W5-F1c — surface semantic_session_id (Q5 H1 / Issue #587 completion).
|
|
@@ -441,7 +524,19 @@ async function resolveSessionId(input, projectRoot) {
|
|
|
441
524
|
);
|
|
442
525
|
} catch { /* best effort */ }
|
|
443
526
|
|
|
444
|
-
return {
|
|
527
|
+
return {
|
|
528
|
+
sessionId,
|
|
529
|
+
semanticSessionId,
|
|
530
|
+
mode: normalizedMode,
|
|
531
|
+
nativeSource,
|
|
532
|
+
resumeLinkage,
|
|
533
|
+
// The raw session_id our OWN previous run of this hook recorded, or null.
|
|
534
|
+
// Surfaced (rather than kept local) because bootstrapLock() needs it as the
|
|
535
|
+
// third conjunct of the #1091 F2 force-refresh — see the call site in main().
|
|
536
|
+
predecessorSessionId: typeof prev?.session_id === 'string' && prev.session_id.length > 0
|
|
537
|
+
? prev.session_id
|
|
538
|
+
: null,
|
|
539
|
+
};
|
|
445
540
|
}
|
|
446
541
|
|
|
447
542
|
/**
|
|
@@ -711,7 +806,12 @@ async function main() {
|
|
|
711
806
|
// v3.1.0 multi-session registry (#168). All steps best-effort — failures
|
|
712
807
|
// must never break the hook, which is informational-only.
|
|
713
808
|
const input = await stdinPromise;
|
|
714
|
-
|
|
809
|
+
// #1091 — the native SessionStart `source` (Claude Code: startup|resume|
|
|
810
|
+
// clear|compact), plus the linkage decision resolveSessionId() derived from
|
|
811
|
+
// it (see SAME_LOGICAL_SESSION_SOURCES). Both are surfaced on the event so
|
|
812
|
+
// #1091 gets its measurement instead of an inference.
|
|
813
|
+
const { sessionId, semanticSessionId, mode, nativeSource, resumeLinkage, predecessorSessionId } =
|
|
814
|
+
await resolveSessionId(input, projectRoot);
|
|
715
815
|
// getPlatform() ALREADY implements the SO_PLATFORM override as step 1 of its
|
|
716
816
|
// precedence — and, unlike a bare `??`, it validates the value against the
|
|
717
817
|
// four-platform allowlist and trims it. The former `process.env.SO_PLATFORM ??
|
|
@@ -737,6 +837,13 @@ async function main() {
|
|
|
737
837
|
semanticSessionId,
|
|
738
838
|
mode,
|
|
739
839
|
ttlHours: 4,
|
|
840
|
+
nativeSource,
|
|
841
|
+
// The raw session_id our OWN previous run of this hook recorded. It is the
|
|
842
|
+
// third conjunct of the same-logical-session force-refresh (#1091 F2): a
|
|
843
|
+
// semantic-label match alone is collidable on one host (#1066), so the
|
|
844
|
+
// live lock must ALSO carry our predecessor's raw id before we take it
|
|
845
|
+
// over. `prev` is read above, before the semantic id is minted.
|
|
846
|
+
predecessorSessionId,
|
|
740
847
|
});
|
|
741
848
|
} catch { /* hook must remain non-blocking */ }
|
|
742
849
|
|
|
@@ -927,7 +1034,7 @@ async function main() {
|
|
|
927
1034
|
|
|
928
1035
|
// Phase 4 measurement probes — the mechanical caller (#1128).
|
|
929
1036
|
//
|
|
930
|
-
// `skills/session-start/SKILL.md` § Phase 4 names
|
|
1037
|
+
// `skills/session-start/SKILL.md` § Phase 4 names 19 probes with module paths
|
|
931
1038
|
// and entry functions. Measured 2026-08-23 at `4f6404e`, not one of them had
|
|
932
1039
|
// a caller anywhere in hooks/, npm scripts, .gitlab-ci.yml or .husky/ — the
|
|
933
1040
|
// only caller was the prose itself, and across 336 recorded session starts
|
|
@@ -1008,6 +1115,21 @@ async function main() {
|
|
|
1008
1115
|
peers_superseded: mechanicalPeersSuperseded,
|
|
1009
1116
|
};
|
|
1010
1117
|
|
|
1118
|
+
// #1091 — record the native SessionStart source so "does the same raw
|
|
1119
|
+
// session_id repeat under source=resume?" becomes answerable from
|
|
1120
|
+
// events.jsonl instead of guessed. OMITTED when the harness sends no source
|
|
1121
|
+
// (docs/events-schema.md optional-field convention: absent ≠ measured-empty).
|
|
1122
|
+
if (nativeSource) payload.native_source = nativeSource;
|
|
1123
|
+
|
|
1124
|
+
// #1091 F1 — WHICH linkage the source produced: 'raw-id' (prior raw
|
|
1125
|
+
// session_id matched → semantic label reused, high-water marks preserved),
|
|
1126
|
+
// 'semantic' (the freshly minted label happened to equal the recorded one),
|
|
1127
|
+
// or 'none' (a new session, or an unverified restart whose continuity is
|
|
1128
|
+
// deliberately not guessed). Same optional-field convention as
|
|
1129
|
+
// `native_source`: OMITTED when the harness sends no source at all, so a
|
|
1130
|
+
// Codex/Cursor row is never read as a measured 'none'.
|
|
1131
|
+
if (nativeSource) payload.resume_linkage = resumeLinkage;
|
|
1132
|
+
|
|
1011
1133
|
// #nnn — installed/latest plugin version on the session record.
|
|
1012
1134
|
//
|
|
1013
1135
|
// HR-105: a verdict nothing records is unfalsifiable. `installed` is stamped
|