claude-code-session-manager 0.86.0 → 0.87.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/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
- package/dist/assets/{DataModel-Q4jhl24R.js → DataModel--mISIJ6h.js} +1 -1
- package/dist/assets/{History-Cj2FejEo.js → History-C2ahUXTg.js} +2 -2
- package/dist/assets/{Hooks-CaelQI6t.js → Hooks-BiC6oyR2.js} +3 -3
- package/dist/assets/{HostBilko--v7cMR8I.js → HostBilko-BPleEOld.js} +1 -1
- package/dist/assets/{Library-DgI9oCCZ.js → Library-Dc8Qst1R.js} +1 -1
- package/dist/assets/{ListDetail-DYUZN-x-.js → ListDetail-DIXh-OLX.js} +1 -1
- package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
- package/dist/assets/{McpServers-ypCYURh3.js → McpServers-DqcbLOLZ.js} +2 -2
- package/dist/assets/{Memory-C2qYp-3M.js → Memory-CW62MXlh.js} +4 -4
- package/dist/assets/{Panel-Cj2kw-Zv.js → Panel-Bw1FhRuF.js} +1 -1
- package/dist/assets/Permissions-BcUC-5y8.js +3 -0
- package/dist/assets/{Plugins-C1Vj8_dU.js → Plugins-BnKx9flD.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DczPNM5U.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
- package/dist/assets/{SaveBar-Cd_7U6Gb.js → SaveBar-CWr0O_w-.js} +1 -1
- package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
- package/dist/assets/{ScopeSwitcher-DVSyI44-.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
- package/dist/assets/Settings-DluB-vN1.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CUv1_Q2c.js → SkillReferenceGraph-CHLSseay.js} +1 -1
- package/dist/assets/{Skills-C_YHkAy-.js → Skills-gNdo_HNK.js} +2 -2
- package/dist/assets/{SystemPrompt-B8R7T9xn.js → SystemPrompt-Cru05-Ia.js} +1 -1
- package/dist/assets/{TagLibrary-dj9YHWyy.js → TagLibrary-DNHY0xou.js} +1 -1
- package/dist/assets/{TiptapBody-DnSBUjHE.js → TiptapBody-I4lmbCgP.js} +1 -1
- package/dist/assets/{Toggle-CjV_BJn6.js → Toggle-bWMHjmRh.js} +1 -1
- package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
- package/dist/assets/{index-CXFQIPhO.js → index-fc_JjdxL.js} +724 -724
- package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
- package/dist/index.html +2 -2
- package/package.json +14 -14
- package/plugins/CLAUDE.md +61 -0
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
- package/scripts/scheduler-mcp-server.cjs +7 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
- package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
- package/src/main/__tests__/epicMint.test.cjs +2 -2
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
- package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
- package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
- package/src/main/__tests__/prdLocations.test.cjs +100 -2
- package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
- package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
- package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +14 -2
- package/src/main/__tests__/queueHistory.test.cjs +63 -0
- package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
- package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
- package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
- package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
- package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
- package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
- package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +14 -4
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
- package/src/main/chatRunner.cjs +8 -5
- package/src/main/health.cjs +1 -1
- package/src/main/historyAggregator.cjs +5 -0
- package/src/main/index.cjs +95 -44
- package/src/main/ipcSchemas.cjs +47 -0
- package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
- package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
- package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
- package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
- package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
- package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
- package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
- package/src/main/lib/agentModelResolve.cjs +65 -27
- package/src/main/lib/bootSelfHeal.cjs +88 -0
- package/src/main/lib/delegationReadiness.cjs +290 -225
- package/src/main/lib/effectiveModelInfo.cjs +333 -0
- package/src/main/lib/ephemeralCwd.cjs +1 -1
- package/src/main/lib/epicMint.cjs +3 -3
- package/src/main/lib/gitWorktree.cjs +42 -12
- package/src/main/lib/guardShims.cjs +156 -0
- package/src/main/lib/jobDirtFilter.cjs +7 -2
- package/src/main/lib/launchFailure.cjs +2 -1
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/needsReviewLedger.cjs +205 -0
- package/src/main/lib/opsErrorLog.cjs +1 -1
- package/src/main/lib/opsOwnership.cjs +1 -1
- package/src/main/lib/prdCreate.cjs +56 -1
- package/src/main/lib/prdDisposition.cjs +199 -0
- package/src/main/lib/prdFrontmatter.cjs +8 -2
- package/src/main/lib/prdLocations.cjs +167 -45
- package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
- package/src/main/lib/projectPageSummarySchema.cjs +1 -1
- package/src/main/lib/projectRootResolve.cjs +1 -1
- package/src/main/lib/queueHistory.cjs +19 -1
- package/src/main/lib/queueStore.cjs +6 -1
- package/src/main/lib/reaperHelpers.cjs +181 -15
- package/src/main/lib/scheduleJobSchema.cjs +8 -0
- package/src/main/lib/scheduleJobTransitions.cjs +33 -0
- package/src/main/lib/schedulerConfig.cjs +24 -0
- package/src/main/lib/usageCircuit.cjs +159 -0
- package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
- package/src/main/scheduler/prdParser.cjs +13 -0
- package/src/main/scheduler.cjs +1285 -143
- package/src/main/templates/PRD_AUTHORING.md +50 -0
- package/src/main/templates/project-pages-catalog.json +1 -1
- package/src/main/usage.cjs +21 -3
- package/src/preload/api.d.ts +92 -1
- package/src/preload/index.cjs +10 -0
- package/web/README.md +41 -0
- package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
- package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
- package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
- package/dist/assets/AgentLibrary-DTFL7y8G.js +0 -3
- package/dist/assets/MarkdownEditor-DCIubYWf.js +0 -1
- package/dist/assets/Permissions-BiYZNGYW.js +0 -3
- package/dist/assets/Scheduler-DcLBiJBq.js +0 -14
- package/dist/assets/Settings-Cv-pRyms.js +0 -3
- package/dist/assets/settingsSchema-BJVciriw.js +0 -3
- /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
package/dist/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-fc_JjdxL.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-DV3PorRY.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="bg-bg text-fg font-sans antialiased">
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.87.0",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
|
@@ -12,13 +12,12 @@
|
|
|
12
12
|
".claude-plugin/",
|
|
13
13
|
"plugins/",
|
|
14
14
|
"scripts/postinstall.cjs",
|
|
15
|
-
"scripts/lib/",
|
|
16
15
|
"scripts/mint-epic.cjs",
|
|
17
16
|
"scripts/scheduler-mcp-server.cjs",
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
17
|
+
"web/project-pages/render.cjs",
|
|
18
|
+
"web/project-pages/renderer/dist/",
|
|
19
|
+
"web/project-pages/logic/dist/",
|
|
20
|
+
"web/project-pages/validate-summary.cjs",
|
|
22
21
|
"scripts/hooks/guard-prd-writes.cjs",
|
|
23
22
|
"scripts/hooks/guard-destructive-git.cjs",
|
|
24
23
|
"scripts/hooks/guard-inline-implementation.cjs",
|
|
@@ -36,10 +35,10 @@
|
|
|
36
35
|
"dev:renderer": "vite",
|
|
37
36
|
"dev:electron": "wait-on http://localhost:5173 && SM_DEV=1 electron .",
|
|
38
37
|
"build": "vite build",
|
|
39
|
-
"build:project-pages": "node
|
|
40
|
-
"build:project-pages-logic": "node
|
|
41
|
-
"build:project-pages-assets": "node
|
|
42
|
-
"project-pages:gate": "node
|
|
38
|
+
"build:project-pages": "node web/project-pages/build-renderer.mjs",
|
|
39
|
+
"build:project-pages-logic": "node web/project-pages/build-logic.mjs",
|
|
40
|
+
"build:project-pages-assets": "node web/project-pages/assets.cjs",
|
|
41
|
+
"project-pages:gate": "node web/project-pages/publish-gate.cjs",
|
|
43
42
|
"typecheck": "tsc --noEmit",
|
|
44
43
|
"start": "electron .",
|
|
45
44
|
"postinstall": "node scripts/postinstall.cjs",
|
|
@@ -51,13 +50,14 @@
|
|
|
51
50
|
"test:e2e:gen-fixture": "espeak-ng -w /tmp/sm-raw.wav -s 140 'testing one two three four five' && ffmpeg -y -i /tmp/sm-raw.wav -ar 48000 -ac 2 -sample_fmt s16 e2e/fixtures/speech.wav",
|
|
52
51
|
"refresh:vad-assets": "cp node_modules/@ricky0123/vad-web/dist/silero_vad_v5.onnx node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js src/renderer/public/vad/ && cp node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.wasm node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.mjs node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.wasm node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.mjs src/renderer/public/vad/",
|
|
53
52
|
"health": "node src/main/health.cjs",
|
|
54
|
-
"manual:check": "node
|
|
55
|
-
"manual:build": "node
|
|
56
|
-
"manual:figures": "xvfb-run -a node
|
|
53
|
+
"manual:check": "node web/manual/build.mjs --check",
|
|
54
|
+
"manual:build": "node web/manual/build.mjs",
|
|
55
|
+
"manual:figures": "xvfb-run -a node web/manual/capture-figures.mjs",
|
|
57
56
|
"lint:selectors": "node scripts/check-unstable-selectors.cjs",
|
|
58
57
|
"lint:hooks": "node scripts/check-conditional-hooks.cjs",
|
|
59
58
|
"lint:unregistered-tests": "node scripts/check-unregistered-tests.cjs",
|
|
60
|
-
"lint": "npm run lint:selectors && npm run lint:hooks && npm run lint:unregistered-tests"
|
|
59
|
+
"lint": "npm run lint:selectors && npm run lint:hooks && npm run lint:unregistered-tests",
|
|
60
|
+
"sync:settings-schema": "node scripts/sync-settings-schema.cjs"
|
|
61
61
|
},
|
|
62
62
|
"keywords": [
|
|
63
63
|
"claude",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# plugins/ — scoped context
|
|
2
|
+
|
|
3
|
+
Part of the **AGENT LAYER** partition — see
|
|
4
|
+
[`project-partition.md`](../session-manager-operations/architecture/project-partition.md).
|
|
5
|
+
|
|
6
|
+
## What's here
|
|
7
|
+
|
|
8
|
+
`session-manager-dev/skills/` — 15 skills (`blog-for-project-feature`, `builder`, `develop`,
|
|
9
|
+
`discover-features`, `explain-to-me`, `find-opportunity`, `issue-address`,
|
|
10
|
+
`local-project-health`, `memory-sanitation`, `ops-sweep`, `project-status`, `pr-review-sweep`,
|
|
11
|
+
`pr-signal`, `requesting-code-review`, `send-feedback`) — verified against `ls
|
|
12
|
+
plugins/session-manager-dev/skills` 2026-09-13; keep in sync with `project-partition.md` and
|
|
13
|
+
`seedDevPlugin.cjs`'s header count.
|
|
14
|
+
|
|
15
|
+
Two manifests: root [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json)
|
|
16
|
+
declares the one plugin (`source: "./plugins/session-manager-dev"`); the plugin's own
|
|
17
|
+
[`session-manager-dev/.claude-plugin/plugin.json`](session-manager-dev/.claude-plugin/plugin.json)
|
|
18
|
+
carries the `description` shown in the marketplace UI — list only shipped skills.
|
|
19
|
+
|
|
20
|
+
`src/main/seedDevPlugin.cjs` is the first-boot offline installer — installs this plugin from its
|
|
21
|
+
bundled marketplace (no GitHub/registry call) as a default on a fresh machine, then marks
|
|
22
|
+
`~/.claude/session-manager/.dev-plugin-seeded`.
|
|
23
|
+
|
|
24
|
+
**`skills/develop/standards.md` is a frozen absolute-path contract.** Every scheduler PRD, in
|
|
25
|
+
every consuming repo, points its headless executor at it by resolved absolute path, baked
|
|
26
|
+
in at authoring time — including PRDs already archived in OTHER repos. Renaming or moving it
|
|
27
|
+
breaks them all, undetectably from here.
|
|
28
|
+
|
|
29
|
+
**Path literals inside shipped skills.** A skill runs in OTHER repos, so a
|
|
30
|
+
session-manager-internal path (`src/main/...`, `src/renderer/...`, bare `scripts/...`) inside one
|
|
31
|
+
is a portability defect unless `$SM_ROOT`-qualified (see `ops-sweep/SKILL.md`'s resolution) or
|
|
32
|
+
removed. Current offenders, not precedent: `memory-sanitation/SKILL.md`, `builder/SKILL.md` +
|
|
33
|
+
`builder/0-diff/SKILL.md`, `develop/SKILL.md`, `develop/standards.md`, `ops-sweep/SKILL.md`.
|
|
34
|
+
|
|
35
|
+
## Who consumes this
|
|
36
|
+
|
|
37
|
+
**Other repos**, not (only) this one. A project installs `claude-code-session-manager` from npm,
|
|
38
|
+
or references its skills/hooks directly, for the same dev workflow session-manager uses on itself.
|
|
39
|
+
Editing a skill here changes behavior in every consuming repo. See
|
|
40
|
+
[`../session-manager-operations/reviews/2026-09-12-agent-layer-consumers.md`](../session-manager-operations/reviews/2026-09-12-agent-layer-consumers.md)
|
|
41
|
+
for live sibling consumers.
|
|
42
|
+
|
|
43
|
+
## What must NOT be assumed
|
|
44
|
+
|
|
45
|
+
- Treat every skill edit here as a cross-repo API change, not a local-only one.
|
|
46
|
+
- Do not vendor `scripts/hooks/guard-*.cjs` into a skill/repo — adopt **by reference** via the
|
|
47
|
+
stable shim (`src/main/lib/guardShims.cjs`) at `~/.claude/session-manager/hooks/guard-*.cjs`,
|
|
48
|
+
never this repo's own absolute path or a copy.
|
|
49
|
+
- Do not confuse this with `.claude/` at the repo root — this repo's own unshipped local dev
|
|
50
|
+
config. `.claude/agents/*.md` are per-project OVERLAYS that win over
|
|
51
|
+
`~/.claude/agents/*.md` (Claude Code's own precedence, per `src/main/agentLibrary.cjs`);
|
|
52
|
+
`src/seed/agents/` is the shipped source for exactly the three personas
|
|
53
|
+
`src/main/seedAgentPersonas.cjs` copies into `~/.claude/agents/` on first boot (`architect`,
|
|
54
|
+
`dev-lead`, `project-home-builder`); `builder` and `bilko-host-publisher` have no seed source
|
|
55
|
+
and exist only as this repo's overlays. Tracked on purpose: `.claude/settings.json` (guard
|
|
56
|
+
hooks) plus the `builder.md`/`project-home-builder.md` overlays (auditable edits) —
|
|
57
|
+
`bilko-host-publisher.md` is untracked despite being in use; nothing else under `.claude/` is.
|
|
58
|
+
- `.mcp.json` at the repo root registers only `bilko-host` — the scheduler MCP server
|
|
59
|
+
(`session-manager-scheduler`) is registered at USER scope in `~/.claude.json`, not here.
|
|
60
|
+
- A skill may run headless via a scheduled PRD in another project — don't rely on
|
|
61
|
+
interactive-session-only features; check `/develop`'s scheduler flow first.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-manager-dev",
|
|
3
|
-
"description": "The default end-to-end development skillset for the Claude Code Session Manager: decompose work into scheduler PRDs (/develop), hand a finding to another project on this machine (/send-feedback), rank what to work on next in a shared repo (/find-opportunity), roll up operational status
|
|
3
|
+
"description": "The default end-to-end development skillset for the Claude Code Session Manager: decompose work into scheduler PRDs (/develop), hand a finding to another project on this machine (/send-feedback), rank what to work on next in a shared repo (/find-opportunity), roll up operational status (/project-status, /local-project-health), gate with review (/requesting-code-review), document how the project works (/explain-to-me), and keep the auto-memory store clean (/memory-sanitation). Together with the session-manager scheduler these form a closed develop → queue → track → review → status → improve loop.",
|
|
4
4
|
"version": "0.1.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stanislav Georgiev",
|
|
@@ -54,7 +54,7 @@ Hard rules — these are the ones that protect a paying customer:
|
|
|
54
54
|
the one thing people get wrong, the workflow that pays off. If a paragraph would be equally
|
|
55
55
|
at home in `CLAUDE.md`, cut it.
|
|
56
56
|
- **Preserve the free chapter.** At least one chapter must stay `free: true` — it is the
|
|
57
|
-
marketing sample, and `
|
|
57
|
+
marketing sample, and `web/manual/build.mjs` refuses to build without one.
|
|
58
58
|
|
|
59
59
|
### 3. Bump the manual's own version
|
|
60
60
|
|
|
@@ -133,5 +133,5 @@ id(s) or PRD(s) filed.
|
|
|
133
133
|
directly; don't run the full sweep for a question with a known answer.
|
|
134
134
|
- Cron/scheduled sweeping across every active project cwd is an explicit
|
|
135
135
|
stretch goal, not implemented here — this skill is manual, single-project,
|
|
136
|
-
invoked per run. `
|
|
136
|
+
invoked per run. `src/main/lib/watchdogHelpers.cjs`'s `activeProjectCwds()`
|
|
137
137
|
is the reusable piece if that's ever built.
|
|
@@ -164,6 +164,11 @@ const TOOLS = [
|
|
|
164
164
|
slug: { type: 'string', description: 'Optional kebab-case slug; derived from title if omitted' },
|
|
165
165
|
parallelGroup: { type: 'number', description: 'DEPRECATED and ignored (PRD 832): numbers are strictly unique per project; use dependsOn for ordering' },
|
|
166
166
|
dependsOn: { type: 'array', items: { type: 'string' }, description: 'Optional: PRD slugs that must complete before this one becomes eligible (replaces the retired shared-NN-parallel convention)' },
|
|
167
|
+
disposition: {
|
|
168
|
+
type: 'string',
|
|
169
|
+
enum: ['append', 'new-head'],
|
|
170
|
+
description: 'Required when this PRD would land as a new root (no dependsOn given) in an Epic that already has incomplete PRDs: "append" depends this wave on the existing chain\'s current terminal PRD(s); "new-head" leaves it independent, eligible to run in parallel. Omit when the Epic has no incomplete PRDs yet, or when dependsOn already places this PRD explicitly.',
|
|
171
|
+
},
|
|
167
172
|
sourcePromptId: { type: 'string', description: "Effectively required: an EXISTING, already-human-approved Epic's promptSessionId (the id shown in the Epics list / active-index.json sessions key) to join — NOT a PromptTicket.id (those are distinct fields). The server never mints a new Epic; if this is omitted and no Epic can be resolved (via the SM_CHAT_SESSION_ID fallback below), the write is refused with an error telling the caller to create/approve an Epic first." },
|
|
168
173
|
sourceTabId: { type: 'string', description: 'Optional: tab id (claudeSessionId) this PRD was queued from, so the scheduler can route a completion status prompt back to it' },
|
|
169
174
|
tag: {
|
|
@@ -231,6 +236,8 @@ const TOOLS = [
|
|
|
231
236
|
tag: { type: 'string', enum: PRD_WORK_TYPES },
|
|
232
237
|
agentType: { type: 'string', description: 'WHO executes this PRD (persona name) — patchable after creation too, see scheduler_create_prd' },
|
|
233
238
|
quietMachine: { type: 'boolean', description: 'Optional: the exclusive machine-wide quiet lease flag (see scheduler_create_prd) — patchable after creation too' },
|
|
239
|
+
dependsOn: { type: 'array', items: { type: 'string' }, description: 'Optional: replaces the dependsOn list; an explicit empty array CLEARS it — see scheduler_create_prd' },
|
|
240
|
+
disposition: { type: 'string', enum: ['append', 'new-head'], description: 'Optional: patches the recorded wave-authoring decision — see scheduler_create_prd' },
|
|
234
241
|
},
|
|
235
242
|
},
|
|
236
243
|
body: { type: 'string', description: 'Optional: full replacement body (everything after the frontmatter)' },
|
|
@@ -17,7 +17,7 @@ const {
|
|
|
17
17
|
FALLBACK_MODEL,
|
|
18
18
|
resolveEpicModel,
|
|
19
19
|
findAgentTypeByClaudeSessionId,
|
|
20
|
-
|
|
20
|
+
readOverlayAwarePersonaModel,
|
|
21
21
|
resolvePrdPersonaForSpawn,
|
|
22
22
|
} = require('../lib/agentModelResolve.cjs');
|
|
23
23
|
|
|
@@ -103,37 +103,39 @@ test('findAgentTypeByClaudeSessionId returns null for no match', async () => {
|
|
|
103
103
|
expect(findAgentTypeByClaudeSessionId(cwd, 'missing')).toBeNull();
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
test('
|
|
106
|
+
test('readOverlayAwarePersonaModel returns null for a persona with no model field', async () => {
|
|
107
|
+
const cwd = await mkTmpDir('sm-agentmodel-cwd-');
|
|
107
108
|
const globalDir = await mkTmpDir('sm-agentmodel-agents-');
|
|
108
109
|
writePersona(globalDir, 'no-model-persona', []);
|
|
109
|
-
expect(
|
|
110
|
+
expect(readOverlayAwarePersonaModel('no-model-persona', { cwd, globalDir, validatePath: noopValidatePath })).toBeNull();
|
|
110
111
|
});
|
|
111
112
|
|
|
112
|
-
test('
|
|
113
|
+
test('readOverlayAwarePersonaModel returns null (never throws) for a path-traversal agentType rejected by validatePath', async () => {
|
|
114
|
+
const cwd = await mkTmpDir('sm-agentmodel-cwd-');
|
|
113
115
|
const globalDir = await mkTmpDir('sm-agentmodel-agents-');
|
|
114
116
|
const realValidatePath = (p) => {
|
|
115
117
|
const real = path.resolve(p);
|
|
116
118
|
if (!real.startsWith(path.resolve(globalDir) + path.sep)) throw new Error('outside allowed boundaries');
|
|
117
119
|
return real;
|
|
118
120
|
};
|
|
119
|
-
expect(
|
|
121
|
+
expect(readOverlayAwarePersonaModel('../../../etc/passwd', { cwd, globalDir, validatePath: realValidatePath })).toBeNull();
|
|
120
122
|
});
|
|
121
123
|
|
|
122
124
|
// READ side of the agentType FK: a persona that was valid when the Epic was
|
|
123
125
|
// created but got deleted afterward (rename/deletion in Agent Library) must
|
|
124
126
|
// still resolve to null, not throw — the WRITE side (epicMint.cjs's
|
|
125
127
|
// ensureEpic) is what refuses a bad reference at creation time.
|
|
126
|
-
test('
|
|
128
|
+
test('readOverlayAwarePersonaModel returns null (does not throw) for a persona deleted after the Epic was created, and logs it once via opsErrorLog', async () => {
|
|
127
129
|
const cwd = await mkTmpDir('sm-agentmodel-cwd-');
|
|
128
130
|
const globalDir = await mkTmpDir('sm-agentmodel-agents-'); // never written to — simulates a deleted persona
|
|
129
131
|
|
|
130
|
-
expect(() =>
|
|
131
|
-
expect(
|
|
132
|
+
expect(() => readOverlayAwarePersonaModel('deleted-persona', { globalDir, validatePath: noopValidatePath, cwd })).not.toThrow();
|
|
133
|
+
expect(readOverlayAwarePersonaModel('deleted-persona', { globalDir, validatePath: noopValidatePath, cwd })).toBeNull();
|
|
132
134
|
|
|
133
135
|
const { todayFile } = require('../lib/opsErrorLog.cjs');
|
|
134
136
|
const lines = fs.readFileSync(todayFile(cwd), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
135
137
|
const matches = lines.filter((l) => l.message.includes('deleted-persona'));
|
|
136
|
-
// Logged once despite two
|
|
138
|
+
// Logged once despite two readOverlayAwarePersonaModel calls above — dedup key is (cwd, agentType).
|
|
137
139
|
expect(matches).toHaveLength(1);
|
|
138
140
|
expect(matches[0].level).toBe('warn');
|
|
139
141
|
});
|
|
@@ -190,6 +192,95 @@ test('resolvePrdPersonaForSpawn returns the fallback (no persona applied) when a
|
|
|
190
192
|
expect(result).toEqual({ model: FALLBACK_MODEL, systemPrompt: null, personaPath: null });
|
|
191
193
|
});
|
|
192
194
|
|
|
195
|
+
// Overlay-aware resolution (unify-epic-model-resolution): resolveEpicModel
|
|
196
|
+
// must use the SAME project-overlay-then-global precedence
|
|
197
|
+
// resolvePrdPersonaForSpawn already uses — so a project's
|
|
198
|
+
// `.claude/agents/<name>.md` wins for a launched Chat/Terminal session
|
|
199
|
+
// exactly like it already does for a scheduled PRD.
|
|
200
|
+
|
|
201
|
+
test('resolveEpicModel prefers a project-overlay persona over the global one with the same name', async () => {
|
|
202
|
+
const cwd = await mkTmpDir('sm-agentmodel-cwd-');
|
|
203
|
+
const globalDir = await mkTmpDir('sm-agentmodel-agents-');
|
|
204
|
+
writeIndex(cwd, { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-overlay', agentType: 'shared-persona' } });
|
|
205
|
+
writePersona(globalDir, 'shared-persona', ['model: opus']);
|
|
206
|
+
const projectAgentsDir = path.join(cwd, '.claude', 'agents');
|
|
207
|
+
writePersona(projectAgentsDir, 'shared-persona', ['model: haiku']);
|
|
208
|
+
|
|
209
|
+
const model = resolveEpicModel({
|
|
210
|
+
cwd,
|
|
211
|
+
claudeSessionId: 'sess-overlay',
|
|
212
|
+
deps: { globalDir, validatePath: noopValidatePath },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(model).toBe('haiku');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('resolveEpicModel falls through to the global persona when no project overlay exists', async () => {
|
|
219
|
+
const cwd = await mkTmpDir('sm-agentmodel-cwd-');
|
|
220
|
+
const globalDir = await mkTmpDir('sm-agentmodel-agents-');
|
|
221
|
+
writeIndex(cwd, { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-global-only', agentType: 'global-only-persona' } });
|
|
222
|
+
writePersona(globalDir, 'global-only-persona', ['model: opus']);
|
|
223
|
+
|
|
224
|
+
const model = resolveEpicModel({
|
|
225
|
+
cwd,
|
|
226
|
+
claudeSessionId: 'sess-global-only',
|
|
227
|
+
deps: { globalDir, validatePath: noopValidatePath },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(model).toBe('opus');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('resolveEpicModel falls back to FALLBACK_MODEL (never throws) for a dangling agentType with no persona file anywhere, and logs it once', async () => {
|
|
234
|
+
const cwd = await mkTmpDir('sm-agentmodel-cwd-');
|
|
235
|
+
const globalDir = await mkTmpDir('sm-agentmodel-agents-'); // never written to
|
|
236
|
+
writeIndex(cwd, { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-dangling', agentType: 'ghost-persona' } });
|
|
237
|
+
|
|
238
|
+
const call = () => resolveEpicModel({
|
|
239
|
+
cwd,
|
|
240
|
+
claudeSessionId: 'sess-dangling',
|
|
241
|
+
deps: { globalDir, validatePath: noopValidatePath },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(call).not.toThrow();
|
|
245
|
+
expect(call()).toBe(FALLBACK_MODEL);
|
|
246
|
+
call(); // second call must not log a second line (dedup per (cwd, agentType))
|
|
247
|
+
|
|
248
|
+
const { todayFile } = require('../lib/opsErrorLog.cjs');
|
|
249
|
+
const lines = fs.readFileSync(todayFile(cwd), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
250
|
+
const matches = lines.filter((l) => l.message.includes('ghost-persona'));
|
|
251
|
+
expect(matches).toHaveLength(1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Chat (chatRunner.cjs, in-process) and Terminal (EpicTerminalPane.tsx, via
|
|
255
|
+
// the agents:resolve-epic-model IPC added in this change) both call this
|
|
256
|
+
// exact function with the same { cwd, claudeSessionId } shape — so "the same
|
|
257
|
+
// Epic resolves identically in both views" is structural (one function, one
|
|
258
|
+
// call signature), not a coincidence of two implementations agreeing. This
|
|
259
|
+
// asserts the function is a pure, deterministic read of that Epic's overlay-
|
|
260
|
+
// resolved persona model, which is what makes that guarantee hold.
|
|
261
|
+
test('resolveEpicModel resolves identically for the same Epic across repeated calls (the guarantee Chat and Terminal both rely on)', async () => {
|
|
262
|
+
const cwd = await mkTmpDir('sm-agentmodel-cwd-');
|
|
263
|
+
const globalDir = await mkTmpDir('sm-agentmodel-agents-');
|
|
264
|
+
writeIndex(cwd, { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-shared', agentType: 'shared-epic-persona' } });
|
|
265
|
+
writePersona(globalDir, 'shared-epic-persona', ['model: haiku']);
|
|
266
|
+
|
|
267
|
+
const callArgs = { cwd, claudeSessionId: 'sess-shared', deps: { globalDir, validatePath: noopValidatePath } };
|
|
268
|
+
const chatResult = resolveEpicModel(callArgs);
|
|
269
|
+
const terminalResult = resolveEpicModel(callArgs); // simulates the IPC-forwarded call Terminal makes
|
|
270
|
+
|
|
271
|
+
expect(chatResult).toBe('haiku');
|
|
272
|
+
expect(terminalResult).toBe(chatResult);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// --model must never be left unpinned (CLAUDE.md model-pinning rule) on any
|
|
276
|
+
// of the three launch paths. resolvePrdPersonaForSpawn's own miss-path tests
|
|
277
|
+
// above already cover the scheduler call site; this covers the shared
|
|
278
|
+
// Chat/Terminal resolver.
|
|
279
|
+
test('resolveEpicModel never returns an empty/falsy --model value, even on every miss path', async () => {
|
|
280
|
+
expect(resolveEpicModel({})).toBeTruthy();
|
|
281
|
+
expect(resolveEpicModel({ cwd: '/nonexistent-cwd-xyz', claudeSessionId: 'no-such-session' })).toBeTruthy();
|
|
282
|
+
});
|
|
283
|
+
|
|
193
284
|
test('resolvePrdPersonaForSpawn caps the persona body at 6000 characters with a truncation notice naming the persona path', async () => {
|
|
194
285
|
const longBody = 'x'.repeat(6500);
|
|
195
286
|
const result = await resolvePrdPersonaForSpawn({
|
|
@@ -101,4 +101,22 @@ test('flush() with no pending timer still sends (no-arm case)', async () => {
|
|
|
101
101
|
expect(sent).toEqual(['state-1']);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
test('a getPayload() that throws does not wedge the coalescer — the next schedule() still sends', async () => {
|
|
105
|
+
const sent = [];
|
|
106
|
+
const send = vi.fn((payload) => sent.push(payload));
|
|
107
|
+
const getPayload = vi.fn()
|
|
108
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
109
|
+
.mockResolvedValue('state-recovered');
|
|
110
|
+
const coalescer = createBroadcastCoalescer({ delayMs: 200, send, getPayload });
|
|
111
|
+
|
|
112
|
+
coalescer.schedule();
|
|
113
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
114
|
+
expect(sent).toEqual([]);
|
|
115
|
+
|
|
116
|
+
coalescer.schedule();
|
|
117
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
118
|
+
expect(sent).toEqual(['state-recovered']);
|
|
119
|
+
expect(getPayload).toHaveBeenCalledTimes(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
104
122
|
console.log('broadcastCoalescer tests: PASS');
|
|
@@ -287,8 +287,8 @@ test('ensureEpic refuses the mint and appends an epic_mint_refused audit event w
|
|
|
287
287
|
// "throw on write, report on read" — a mint whose agentType names a persona
|
|
288
288
|
// that doesn't resolve is refused outright; a persona that goes dangling
|
|
289
289
|
// LATER (renamed/deleted after the Epic was minted) is legitimate history,
|
|
290
|
-
// left to agentModelResolve.cjs's
|
|
291
|
-
// ops-hygiene sweep to report.
|
|
290
|
+
// left to agentModelResolve.cjs's readOverlayAwarePersonaModel (stays
|
|
291
|
+
// permissive) and the ops-hygiene sweep to report.
|
|
292
292
|
|
|
293
293
|
test('minting with an unknown agentType throws and names the two paths checked', async () => {
|
|
294
294
|
const cwd = await mkCwd();
|
|
@@ -86,7 +86,7 @@ test('failing check with no fix omits the "(fix: ...)" suffix', () => {
|
|
|
86
86
|
test('end-to-end: check() reports delegation_chain for this repo, expected to pass here', async () => {
|
|
87
87
|
const status = await check();
|
|
88
88
|
expect(status.components.delegation_chain).toBeDefined();
|
|
89
|
-
expect(status.components.delegation_chain.checks).toHaveLength(
|
|
89
|
+
expect(status.components.delegation_chain.checks).toHaveLength(9);
|
|
90
90
|
const ids = status.components.delegation_chain.checks.map((c) => c.id);
|
|
91
91
|
expect(ids).toEqual([
|
|
92
92
|
'scheduler-mcp',
|
|
@@ -97,6 +97,7 @@ test('end-to-end: check() reports delegation_chain for this repo, expected to pa
|
|
|
97
97
|
'prd-write-guard',
|
|
98
98
|
'destructive-git-guard',
|
|
99
99
|
'inline-implementation-guard',
|
|
100
|
+
'self-schedule-guard',
|
|
100
101
|
]);
|
|
101
102
|
// This repo registers the scheduler MCP at user scope (~/.claude.json) and
|
|
102
103
|
// wires guard-prd-writes in .claude/settings.json, and no longer carries a
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* needsReviewLedger.test.cjs — unit tests for src/main/lib/needsReviewLedger.cjs's
|
|
3
|
+
* pure ladder-rung classification, line builders, and rollup reduction.
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/needsReviewLedger.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
import { test, expect } from 'vitest';
|
|
10
|
+
const {
|
|
11
|
+
LADDER_RUNGS,
|
|
12
|
+
classifyLadderRung,
|
|
13
|
+
buildNeedsReviewEntryLine,
|
|
14
|
+
buildNeedsReviewResolutionLine,
|
|
15
|
+
reduceNeedsReviewLedger,
|
|
16
|
+
} = require('../lib/needsReviewLedger.cjs');
|
|
17
|
+
|
|
18
|
+
// ---------- classifyLadderRung ----------
|
|
19
|
+
|
|
20
|
+
test('classifyLadderRung maps every known automated source to its named rung', () => {
|
|
21
|
+
expect(classifyLadderRung({ source: 'scheduler:mechanicalRecovery' })).toBe('mechanical-recovery');
|
|
22
|
+
expect(classifyLadderRung({ source: 'spawnJob:dispatch', reason: 'dispatched for resume-recovery' })).toBe('resume-recovery');
|
|
23
|
+
expect(classifyLadderRung({ source: 'spawnInvestigation:start' })).toBe('auto-fix');
|
|
24
|
+
expect(classifyLadderRung({ source: 'spawnJob:auto-promote' })).toBe('auto-fix');
|
|
25
|
+
expect(classifyLadderRung({ source: 'reverifyNeedsReview:auto-promote' })).toBe('auto-fix');
|
|
26
|
+
expect(classifyLadderRung({ source: 'reverifyNeedsReview:heal' })).toBe('reverify');
|
|
27
|
+
expect(classifyLadderRung({ source: 'needsReviewAutoResolve' })).toBe('reverify');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('classifyLadderRung: ordinary spawnJob:dispatch (no resume-recovery reason) is NOT resume-recovery', () => {
|
|
31
|
+
// 'spawnJob:dispatch' is shared with the everyday pending->running dispatch
|
|
32
|
+
// — only the resume-recovery REASON text distinguishes the two.
|
|
33
|
+
expect(classifyLadderRung({ source: 'spawnJob:dispatch', reason: 'dispatched for execution' })).toBe('manual-reset');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('classifyLadderRung: unrecognized/explicit-reset sources default to manual-reset', () => {
|
|
37
|
+
expect(classifyLadderRung({ source: 'ipc:schedule:reset-job' })).toBe('manual-reset');
|
|
38
|
+
expect(classifyLadderRung({ source: 'remote:resetJob' })).toBe('manual-reset');
|
|
39
|
+
expect(classifyLadderRung({ source: 'clear-queue' })).toBe('manual-reset');
|
|
40
|
+
expect(classifyLadderRung({})).toBe('manual-reset');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('LADDER_RUNGS names exactly the 6 documented rungs', () => {
|
|
44
|
+
expect(LADDER_RUNGS).toEqual(['mechanical-recovery', 'resume-recovery', 'quarantine', 'auto-fix', 'reverify', 'manual-reset']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ---------- buildNeedsReviewEntryLine / buildNeedsReviewResolutionLine ----------
|
|
48
|
+
|
|
49
|
+
test('buildNeedsReviewEntryLine: park reason prefers verifierVerdict, then heldReason, then error', () => {
|
|
50
|
+
const entry = { to: 'needs_review', source: 'spawnJob:finalize', at: '2026-09-13T00:00:00.000Z' };
|
|
51
|
+
expect(buildNeedsReviewEntryLine({ slug: 's1', verifierVerdict: 'uncommitted_changes', heldReason: 'held', error: 'err' }, entry).reason).toBe('uncommitted_changes');
|
|
52
|
+
expect(buildNeedsReviewEntryLine({ slug: 's1', heldReason: 'held', error: 'err' }, entry).reason).toBe('held');
|
|
53
|
+
expect(buildNeedsReviewEntryLine({ slug: 's1', error: 'err' }, entry).reason).toBe('err');
|
|
54
|
+
expect(buildNeedsReviewEntryLine({ slug: 's1' }, entry).reason).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('buildNeedsReviewEntryLine: carries slug/cwd/epicId/runId/at/source verbatim', () => {
|
|
58
|
+
const job = { slug: '42-x', cwd: '/proj', epicId: 'epic-1', runId: 'run-9' };
|
|
59
|
+
const entry = { to: 'needs_review', source: 'spawnJob:finalize', at: '2026-09-13T00:00:00.000Z' };
|
|
60
|
+
const line = buildNeedsReviewEntryLine(job, entry);
|
|
61
|
+
expect(line).toEqual({
|
|
62
|
+
kind: 'needs_review_entry',
|
|
63
|
+
slug: '42-x',
|
|
64
|
+
cwd: '/proj',
|
|
65
|
+
epicId: 'epic-1',
|
|
66
|
+
runId: 'run-9',
|
|
67
|
+
at: '2026-09-13T00:00:00.000Z',
|
|
68
|
+
reason: null,
|
|
69
|
+
source: 'spawnJob:finalize',
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('buildNeedsReviewResolutionLine: references the EPISODE runId, not job.runId, and computes dwellMs', () => {
|
|
74
|
+
const job = { slug: '42-x', cwd: '/proj', epicId: 'epic-1', runId: 'run-LATER' };
|
|
75
|
+
const entry = { to: 'completed', source: 'reverifyNeedsReview:heal', at: '2026-09-13T00:10:00.000Z' };
|
|
76
|
+
const episode = { runId: 'run-ORIGINAL', enteredAt: '2026-09-13T00:00:00.000Z' };
|
|
77
|
+
const line = buildNeedsReviewResolutionLine(job, entry, episode);
|
|
78
|
+
expect(line.runId).toBe('run-ORIGINAL');
|
|
79
|
+
expect(line.resolvedTo).toBe('completed');
|
|
80
|
+
expect(line.ladderRung).toBe('reverify');
|
|
81
|
+
expect(line.dwellMs).toBe(10 * 60_000);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('buildNeedsReviewResolutionLine: dwellMs is null when the episode has no enteredAt (defensive — should not happen in practice)', () => {
|
|
85
|
+
const job = { slug: '42-x', runId: 'run-1' };
|
|
86
|
+
const entry = { to: 'failed', source: 'ipc:schedule:reset-job', at: '2026-09-13T00:00:00.000Z' };
|
|
87
|
+
const line = buildNeedsReviewResolutionLine(job, entry, { runId: 'run-1', enteredAt: null });
|
|
88
|
+
expect(line.dwellMs).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---------- reduceNeedsReviewLedger ----------
|
|
92
|
+
|
|
93
|
+
function entryLine({ slug, runId, at, reason = 'uncommitted_changes', source = 'spawnJob:finalize' }) {
|
|
94
|
+
return { kind: 'needs_review_entry', slug, cwd: null, epicId: null, runId, at, reason, source };
|
|
95
|
+
}
|
|
96
|
+
function resolutionLine({ slug, runId, at, resolvedTo = 'completed', ladderRung = 'reverify', source = 'reverifyNeedsReview:heal', dwellMs = 1000 }) {
|
|
97
|
+
return { kind: 'needs_review_resolution', slug, cwd: null, epicId: null, runId, at, resolvedTo, ladderRung, source, dwellMs };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test('reduceNeedsReviewLedger: entry-only (unresolved) episode counts toward byReason and unresolvedCount, not byLadderRung', () => {
|
|
101
|
+
const lines = [entryLine({ slug: 'a', runId: 'r1', at: '2026-09-13T00:00:00.000Z', reason: 'silent_no_op' })];
|
|
102
|
+
const rollup = reduceNeedsReviewLedger(lines);
|
|
103
|
+
expect(rollup.byReason).toEqual({ silent_no_op: 1 });
|
|
104
|
+
expect(rollup.byLadderRung).toEqual({});
|
|
105
|
+
expect(rollup.unresolvedCount).toBe(1);
|
|
106
|
+
expect(rollup.dwellMsP50).toBeNull();
|
|
107
|
+
expect(rollup.dwellMsP90).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('reduceNeedsReviewLedger: entry+resolution pair resolves and contributes dwell', () => {
|
|
111
|
+
const lines = [
|
|
112
|
+
entryLine({ slug: 'a', runId: 'r1', at: '2026-09-13T00:00:00.000Z' }),
|
|
113
|
+
resolutionLine({ slug: 'a', runId: 'r1', at: '2026-09-13T00:00:05.000Z', dwellMs: 5000, ladderRung: 'mechanical-recovery' }),
|
|
114
|
+
];
|
|
115
|
+
const rollup = reduceNeedsReviewLedger(lines);
|
|
116
|
+
expect(rollup.unresolvedCount).toBe(0);
|
|
117
|
+
expect(rollup.byLadderRung).toEqual({ 'mechanical-recovery': 1 });
|
|
118
|
+
expect(rollup.dwellMsP50).toBe(5000);
|
|
119
|
+
expect(rollup.dwellMsP90).toBe(5000);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('reduceNeedsReviewLedger: repeated entries for the same slug (different runIds) count as independent episodes', () => {
|
|
123
|
+
const lines = [
|
|
124
|
+
entryLine({ slug: 'a', runId: 'r1', at: '2026-09-13T00:00:00.000Z', reason: 'uncommitted_changes' }),
|
|
125
|
+
resolutionLine({ slug: 'a', runId: 'r1', at: '2026-09-13T00:00:10.000Z', dwellMs: 10_000, ladderRung: 'manual-reset' }),
|
|
126
|
+
entryLine({ slug: 'a', runId: 'r2', at: '2026-09-13T01:00:00.000Z', reason: 'uncommitted_changes' }),
|
|
127
|
+
// r2 never resolves.
|
|
128
|
+
];
|
|
129
|
+
const rollup = reduceNeedsReviewLedger(lines);
|
|
130
|
+
expect(rollup.byReason).toEqual({ uncommitted_changes: 2 });
|
|
131
|
+
expect(rollup.unresolvedCount).toBe(1);
|
|
132
|
+
expect(rollup.byLadderRung).toEqual({ 'manual-reset': 1 });
|
|
133
|
+
expect(rollup.dwellMsP50).toBe(10_000);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('reduceNeedsReviewLedger: p50/p90 over several resolved episodes', () => {
|
|
137
|
+
const dwells = [1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10_000];
|
|
138
|
+
const lines = dwells.flatMap((d, i) => [
|
|
139
|
+
entryLine({ slug: `s${i}`, runId: `r${i}`, at: '2026-09-13T00:00:00.000Z' }),
|
|
140
|
+
resolutionLine({ slug: `s${i}`, runId: `r${i}`, at: '2026-09-13T00:00:00.000Z', dwellMs: d }),
|
|
141
|
+
]);
|
|
142
|
+
const rollup = reduceNeedsReviewLedger(lines);
|
|
143
|
+
expect(rollup.dwellMsP50).toBe(5000);
|
|
144
|
+
expect(rollup.dwellMsP90).toBe(9000);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('reduceNeedsReviewLedger: ignores unrelated (terminal) rows mixed into the same lines array', () => {
|
|
148
|
+
const lines = [
|
|
149
|
+
{ kind: 'terminal', slug: 'a', status: 'completed', runId: 'r1' },
|
|
150
|
+
{ slug: 'b', status: 'failed', runId: 'r2' }, // legacy row, no kind at all
|
|
151
|
+
entryLine({ slug: 'c', runId: 'r3', at: '2026-09-13T00:00:00.000Z' }),
|
|
152
|
+
];
|
|
153
|
+
const rollup = reduceNeedsReviewLedger(lines);
|
|
154
|
+
expect(rollup.unresolvedCount).toBe(1);
|
|
155
|
+
expect(Object.keys(rollup.byReason)).toEqual(['uncommitted_changes']);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('reduceNeedsReviewLedger: empty/non-array input returns a zeroed rollup, never throws', () => {
|
|
159
|
+
expect(reduceNeedsReviewLedger([])).toEqual({ byReason: {}, byLadderRung: {}, unresolvedCount: 0, dwellMsP50: null, dwellMsP90: null });
|
|
160
|
+
expect(reduceNeedsReviewLedger(null)).toEqual({ byReason: {}, byLadderRung: {}, unresolvedCount: 0, dwellMsP50: null, dwellMsP90: null });
|
|
161
|
+
expect(reduceNeedsReviewLedger(undefined)).toEqual({ byReason: {}, byLadderRung: {}, unresolvedCount: 0, dwellMsP50: null, dwellMsP90: null });
|
|
162
|
+
});
|
|
@@ -24,7 +24,7 @@ afterEach(async () => {
|
|
|
24
24
|
if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
|
|
25
25
|
const telemetryPath = require.resolve('../lib/telemetryClient.cjs');
|
|
26
26
|
delete require.cache[telemetryPath];
|
|
27
|
-
const activeSessionsPath = require.resolve('
|
|
27
|
+
const activeSessionsPath = require.resolve('../lib/activeSessions.cjs');
|
|
28
28
|
delete require.cache[activeSessionsPath];
|
|
29
29
|
while (tmpDirs.length) {
|
|
30
30
|
const d = tmpDirs.pop();
|
|
@@ -89,8 +89,8 @@ test('an ephemeral cwd yields zero local lines but one telemetry record, with a
|
|
|
89
89
|
const worktreeCwd = path.join(KIND_CONFIG.epic.root, 'fakehash', 'fake-epic-id');
|
|
90
90
|
const realRoot = '/home/bilko/Projects/real-project';
|
|
91
91
|
|
|
92
|
-
const activeSessionsPath = require.resolve('
|
|
93
|
-
const original = require('
|
|
92
|
+
const activeSessionsPath = require.resolve('../lib/activeSessions.cjs');
|
|
93
|
+
const original = require('../lib/activeSessions.cjs');
|
|
94
94
|
require.cache[activeSessionsPath] = {
|
|
95
95
|
id: activeSessionsPath,
|
|
96
96
|
filename: activeSessionsPath,
|
|
@@ -73,10 +73,24 @@ function mkProject() {
|
|
|
73
73
|
return { cwd, slug };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// dependsOn deliberately references a slug that can never resolve (no such
|
|
77
|
+
// row, no history/archive entry) so pickNextBatch's dep gate holds this job
|
|
78
|
+
// forever instead of ever handing it to spawnJob. tickQueue() still reaches
|
|
79
|
+
// the picker and stamps lastDispatchAttemptAt (that stamp is unconditional —
|
|
80
|
+
// see lastDispatchAttemptAt's own header comment), which is the only thing
|
|
81
|
+
// these tests assert on. Without this, the picker actually dispatches the
|
|
82
|
+
// job: spawnJob() is fire-and-forget (scheduler.cjs's own comment: "spawnJob
|
|
83
|
+
// is fire-and-forget; it calls tickQueue() on completion") and its real
|
|
84
|
+
// child-process exit handler later calls mutate() independently of the
|
|
85
|
+
// tickTail chain flushPendingTick() drains — round-tripping whatever
|
|
86
|
+
// lastDispatchAttemptAt was in memory at ITS read time. Reproduced outside
|
|
87
|
+
// vitest: that stray write landed ~2s after this suite's own reset-to-null,
|
|
88
|
+
// clobbering the NEXT test's "still null" assertion with the previous test's
|
|
89
|
+
// stamp. A held (never-dispatched) job has no such background continuation.
|
|
76
90
|
async function seedOnePendingJob() {
|
|
77
91
|
const { cwd, slug } = mkProject();
|
|
78
92
|
await scheduler.writeQueue({
|
|
79
|
-
jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
|
|
93
|
+
jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: ['unresolvable-dep-never-completes'] }],
|
|
80
94
|
config: {},
|
|
81
95
|
paused: null,
|
|
82
96
|
});
|