claude-code-session-manager 0.35.2 → 0.35.4

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.
Files changed (39) hide show
  1. package/dist/assets/{TiptapBody-CIEmzy3k.js → TiptapBody-CVFG9ufh.js} +1 -1
  2. package/dist/assets/index-DASEOqMP.js +3558 -0
  3. package/dist/assets/index-DZgKJkf3.css +32 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +3 -2
  6. package/plugins/session-manager-dev/.claude-plugin/plugin.json +3 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +12 -0
  8. package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  9. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +48 -35
  10. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +83 -0
  11. package/plugins/session-manager-dev/skills/memory-sanitation/SKILL.md +127 -0
  12. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +20 -12
  13. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +4 -3
  14. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +33 -12
  15. package/src/main/__tests__/adminServer.test.cjs +175 -0
  16. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +137 -0
  17. package/src/main/__tests__/mcpStatus.test.cjs +61 -0
  18. package/src/main/__tests__/runVerify.test.cjs +74 -4
  19. package/src/main/__tests__/scheduler-autofix-select.test.cjs +50 -2
  20. package/src/main/__tests__/scheduler-autopromote.test.cjs +19 -1
  21. package/src/main/adminServer.cjs +157 -0
  22. package/src/main/browserCapture.cjs +714 -0
  23. package/src/main/browserView.cjs +613 -0
  24. package/src/main/chatRunner.cjs +60 -0
  25. package/src/main/config.cjs +37 -1
  26. package/src/main/historyAggregator.cjs +92 -6
  27. package/src/main/index.cjs +37 -3
  28. package/src/main/ipcSchemas.cjs +106 -0
  29. package/src/main/lib/schedulerConfig.cjs +6 -2
  30. package/src/main/mcpStatus.cjs +93 -0
  31. package/src/main/runVerify.cjs +14 -1
  32. package/src/main/scheduler.cjs +159 -31
  33. package/src/main/sessionsStore.cjs +4 -3
  34. package/src/preload/api.d.ts +154 -5
  35. package/src/preload/browserViewPreload.cjs +67 -0
  36. package/src/preload/index.cjs +63 -5
  37. package/dist/assets/index-2Om-ouz6.js +0 -3534
  38. package/dist/assets/index-DeIJ8SM5.css +0 -32
  39. package/src/main/docEditor.cjs +0 -92
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: my-feedback
3
3
  description: >-
4
- File a feedback/enhancement request FROM the current project INTO another
4
+ File a feedback or enhancement request FROM the current project INTO another
5
5
  project's inbound feedback folder, following that project's own README
6
6
  convention. The cross-project complement of /process-feedback (which works the
7
7
  *current* project's inbox). Use whenever the user says "/my-feedback to X",
@@ -38,17 +38,25 @@ The invocation is `/my-feedback to <<project>>` (or "send feedback to
38
38
  <project>`."* Then list candidate sibling projects that have an intake folder:
39
39
  ```bash
40
40
  for d in ~/Projects/*/; do
41
- [ -d "$d/feedback" ] && echo " - $(basename "$d") (feedback/)"
42
- [ -d "$d/external-feedback" ] && echo " - $(basename "$d") (external-feedback/)"
41
+ [ -d "$d/session-manager-operations/feedback" ] && echo " - $(basename "$d") (session-manager-operations/feedback/)"
43
42
  done
44
43
  ```
45
44
  and stop.
46
- - Resolve the path: `~/Projects/<project>/feedback/` (some projects
47
- use `external-feedback/` check both). Fuzzy-match a near miss to a real
48
- directory, but confirm the resolved name before writing.
49
- - **If the target has no feedback/external-feedback folder, STOP.** Don't invent
50
- one say the project doesn't accept feedback this way and ask how to proceed
51
- (it may take requests via issues, a different folder, or not at all).
45
+ - Resolve the path: `~/Projects/<project>/session-manager-operations/feedback/`.
46
+ This is the **one** canonical folder name do not accept or invent variants
47
+ (`external-feedback/`, `feedback-inbox/`, a root-level `feedback` folder, etc.) even
48
+ if a near-miss directory exists; a stray differently-named folder is drift,
49
+ not a valid convention (burrow's `external-feedback/` existed for ~2.5 weeks
50
+ from exactly this mistake before being merged back into `feedback` on
51
+ 2026-07-10 — don't recreate it, in burrow or anywhere else). Fuzzy-match a
52
+ near miss only to confirm it's actually named
53
+ `session-manager-operations/feedback/`, not to accept a differently-named
54
+ folder as equivalent. All session-manager per-project operations live under
55
+ `session-manager-operations/`.
56
+ - **If the target has no `session-manager-operations/feedback/` folder, STOP.**
57
+ Don't invent one under any name — say the project doesn't accept feedback
58
+ this way and ask how to proceed (it may take requests via issues, a
59
+ different folder, or not at all).
52
60
 
53
61
  ## 1. Read the target's README FIRST — it is the authority
54
62
 
@@ -57,9 +65,9 @@ unique per project** (file-naming scheme, required sections, the status-log
57
65
  table, the closing ritual). Read it before writing anything:
58
66
 
59
67
  ```bash
60
- cat ~/Projects/<project>/feedback/README.md
61
- ls ~/Projects/<project>/feedback/ # open items + numbering
62
- ls ~/Projects/<project>/feedback/processed/ 2>/dev/null # closed examples to match
68
+ cat ~/Projects/<project>/session-manager-operations/feedback/README.md
69
+ ls ~/Projects/<project>/session-manager-operations/feedback/ # open items + numbering
70
+ ls ~/Projects/<project>/session-manager-operations/feedback/processed/ 2>/dev/null # closed examples to match
63
71
  ```
64
72
 
65
73
  Read one **processed** example end-to-end to copy the house format exactly — the
@@ -62,8 +62,9 @@ files it points to:
62
62
  all, that itself is a finding — recommend adding it.
63
63
  - **The levers** — the files the section names as the knobs (tiers/config,
64
64
  selection, throughput/cadence/registry, etc.).
65
- - **The feedback intake** — `feedback/` (or `external-feedback/`) at the repo
66
- root, and its `README.md` convention.
65
+ - **The feedback intake** — `session-manager-operations/feedback/` at the repo
66
+ root (the one canonical name — do not treat a differently-named folder as
67
+ equivalent), and its `README.md` convention.
67
68
 
68
69
  **If CLAUDE.md declares no North-Star KPI section, STOP** and tell the user this
69
70
  project hasn't declared one — the KPI belongs in CLAUDE.md's mission statement
@@ -227,7 +228,7 @@ Give it the 3 temp files **and** the Step-2 in-flight/cooldown set. It must:
227
228
  failure mode this rule exists to prevent.
228
229
  - **Write ONE consolidated feedback item** (for the this-project-owned half) into
229
230
  this project's intake folder, named by the folder README's convention
230
- (`feedback/<YYYY-MM-DD>-NN-kpi-optimization.md`, next free `NN`), with: title,
231
+ (`session-manager-operations/feedback/<YYYY-MM-DD>-NN-kpi-optimization.md`, next free `NN`), with: title,
231
232
  **From:** `optimize-kpi loop`, date (PT), priority + why, **TL;DR**, **Evidence**
232
233
  (cite the Step-1 scorecard numbers, the **Step-1b usage+log findings** the root
233
234
  cause rests on, *and* the Step-2 prior-lever grade), **Why it matters**
@@ -8,8 +8,9 @@ description: >-
8
8
  execution from there), and fold lessons back into the folder's README so
9
9
  future feedback (written by other agents/projects) gets better. Use whenever the user says "/process-feedback",
10
10
  "review the feedback folder", "work through the feedback", "any open
11
- feedback?", or drops new files into feedback/. Keywords: feedback, intake,
12
- external-feedback, cross-project requests, process feedback, triage feedback.
11
+ feedback?", or drops new files into session-manager-operations/feedback/.
12
+ Keywords: feedback, intake, cross-project requests, process feedback, triage
13
+ feedback.
13
14
  model: opus
14
15
  ---
15
16
 
@@ -21,11 +22,18 @@ an interactive human prompt. It evaluates inbound feedback, dispatches the codea
21
22
  RESOLUTION, archive). It does **not** implement, and it does **not** re-specify how PRDs are
22
23
  tracked — that lives once, in `/develop` Phase 2.
23
24
 
24
- Work the project's intra-project feedback intake (`feedback/` at the repo root;
25
- some projects name it `external-feedback/`). Each file is a request from an
26
- upstream/downstream service in the same stack (e.g. Burrow ⇄ signal-builder
27
- social-signals-trader). The folder's own `README.md` is the authority on file
28
- conventions read it first; the steps below are the process.
25
+ Work the project's intra-project feedback intake
26
+ (`session-manager-operations/feedback/` at the repo root this is the **one**
27
+ canonical name; do not create or treat a differently-named folder like
28
+ `external-feedback/` as equivalent, even for cross-service requests that
29
+ split caused ~2.5 weeks of drifted duplicate tracking in burrow before being
30
+ merged back on 2026-07-10). Each file is a request from an upstream/downstream
31
+ service in the same stack (e.g. Burrow ⇄ signal-builder ⇄ social-signals-trader)
32
+ — cross-service origin does not mean a different folder, it's still just
33
+ `session-manager-operations/feedback/`. The folder's own `README.md` is the
34
+ authority on file conventions — read it first; the steps below are the
35
+ process. All session-manager per-project operations live under
36
+ `session-manager-operations/`.
29
37
 
30
38
  **Core principle:** this skill *triages and dispatches*; it does not implement.
31
39
  Anything that requires writing code for this project is decomposed and queued as
@@ -51,14 +59,27 @@ prevent.
51
59
 
52
60
  ## Steps
53
61
 
62
+ ### 0a. Self-migrate a legacy root-level folder first
63
+
64
+ Before anything else, check for the pre-migration layout: if a legacy
65
+ `feedback` folder exists at the repo root **and**
66
+ `session-manager-operations/feedback/` does not, relocate it now —
67
+ `mkdir -p session-manager-operations` (if needed), then
68
+ `git mv feedback session-manager-operations/feedback` when the repo is
69
+ git-tracked, else a plain `mv`. This converges any project this skill runs in
70
+ to the new layout even if its bulk-relocation PRD hasn't landed yet. Only then
71
+ continue with step 0.
72
+
54
73
  ### 0. Quick-exit — bail in milliseconds if there's nothing to do
55
74
 
56
75
  **Do this first, cheaply, before reading any code or spawning anything.** This
57
76
  skill is run on a schedule across many projects (the scheduler's feedback sweep),
58
77
  so the empty case must cost almost nothing:
59
78
 
60
- - If neither `feedback/` nor `external-feedback/` exists report
61
- "no feedback intake in `<project>`" and **EXIT**.
79
+ - If `session-manager-operations/feedback/` doesn't exist (after the step-0a
80
+ self-migration check) → report "no feedback intake in `<project>`" and
81
+ **EXIT**. (Don't check for or fall back to an `external-feedback/`-style
82
+ variant — `session-manager-operations/feedback/` is the only canonical name.)
62
83
  - If the folder exists but holds **no open item** (every file is in `processed/`
63
84
  or marked ✅/archived; nothing open/🆕) → report "no open feedback in
64
85
  `<project>`" and **EXIT immediately**. Do NOT read source, evaluate, or start
@@ -69,7 +90,7 @@ so the empty case must cost almost nothing:
69
90
 
70
91
  ### 1. Read the intake
71
92
 
72
- - Read `feedback/README.md` (conventions + the status log), then every open/🆕
93
+ - Read `session-manager-operations/feedback/README.md` (conventions + the status log), then every open/🆕
73
94
  file still in the inbox. Under this contract a **🛠 (queued) item has already
74
95
  been archived to `processed/`** at disposition time — so anything still sitting
75
96
  in the inbox is genuinely un-dispositioned and needs a pass. You will not find
@@ -90,7 +111,7 @@ drifted. Then classify each ask:
90
111
  RESOLUTION with the reason; never silently skip. Resolved now (no code) →
91
112
  close immediately in step 4.
92
113
  - **Theirs, forward** — the root cause lives in another service. Do NOT reach
93
- across the boundary to hack around it; file a feedback/PRD item in *that*
114
+ across the boundary to hack around it; file a feedback or PRD item in *that*
94
115
  project's intake folder and reference it (use `/my-feedback to <project>`).
95
116
  Resolved now (no code in *this* repo) → close immediately in step 4.
96
117
 
@@ -122,7 +143,7 @@ Record the emitted PRD filenames/ids — you need them for steps 4–6.
122
143
  ### 4. Disposition + archive every item now — this is the definition of done
123
144
 
124
145
  Every open item gets a disposition, a `## RESOLUTION`, and a `git mv` to
125
- `feedback/processed/` **in this pass** — archival is at disposition time, not
146
+ `session-manager-operations/feedback/processed/` **in this pass** — archival is at disposition time, not
126
147
  delivery time. Do NOT leave a queued item in the inbox "until its PRD lands":
127
148
  that is the drift this contract forbids. By disposition:
128
149
 
@@ -0,0 +1,175 @@
1
+ /**
2
+ * adminServer.test.cjs — unit tests for the loopback-only scheduler admin API.
3
+ *
4
+ * Run: timeout 120 node --test src/main/__tests__/adminServer.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const http = require('node:http');
12
+ const { createAdminServer } = require('../adminServer.cjs');
13
+
14
+ function request(port, { method = 'GET', path: reqPath, token, body }) {
15
+ return new Promise((resolve, reject) => {
16
+ const headers = {};
17
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
18
+ let payload;
19
+ if (body !== undefined) {
20
+ payload = JSON.stringify(body);
21
+ headers['Content-Type'] = 'application/json';
22
+ headers['Content-Length'] = Buffer.byteLength(payload);
23
+ }
24
+ const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
25
+ const chunks = [];
26
+ res.on('data', (c) => chunks.push(c));
27
+ res.on('end', () => {
28
+ const text = Buffer.concat(chunks).toString('utf8');
29
+ let json = null;
30
+ try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
31
+ resolve({ status: res.statusCode, json, text });
32
+ });
33
+ });
34
+ req.on('error', reject);
35
+ if (payload) req.write(payload);
36
+ req.end();
37
+ });
38
+ }
39
+
40
+ function makeFakeRemote({ jobs = [] } = {}) {
41
+ return {
42
+ async listJobs() {
43
+ return jobs.map((j) => ({ slug: j.slug, title: j.title, status: j.status, cwd: j.cwd }));
44
+ },
45
+ async resetJob(slug) {
46
+ const job = jobs.find((j) => j.slug === slug);
47
+ if (!job) return { ok: false, error: 'not found' };
48
+ job.status = 'pending';
49
+ job.runId = null;
50
+ job.startedAt = null;
51
+ job.finishedAt = null;
52
+ job.exitCode = null;
53
+ job.error = null;
54
+ delete job.runtime;
55
+ delete job.verifierVerdict;
56
+ return { ok: true, slug, status: 'pending' };
57
+ },
58
+ };
59
+ }
60
+
61
+ test('request without bearer token returns 401', async () => {
62
+ const remote = makeFakeRemote();
63
+ const admin = createAdminServer(remote);
64
+ const { port } = await admin.start();
65
+ try {
66
+ const res = await request(port, { path: '/admin/scheduler/jobs' });
67
+ assert.strictEqual(res.status, 401);
68
+ assert.strictEqual(res.json.ok, false);
69
+ } finally {
70
+ await admin.stop();
71
+ }
72
+ });
73
+
74
+ test('request with wrong bearer token returns 401', async () => {
75
+ const remote = makeFakeRemote();
76
+ const admin = createAdminServer(remote);
77
+ const { port } = await admin.start();
78
+ try {
79
+ const res = await request(port, { path: '/admin/scheduler/jobs', token: 'not-the-token' });
80
+ assert.strictEqual(res.status, 401);
81
+ } finally {
82
+ await admin.stop();
83
+ }
84
+ });
85
+
86
+ test('GET /admin/scheduler/jobs with correct token returns 200 + array', async () => {
87
+ const remote = makeFakeRemote({
88
+ jobs: [{ slug: '10-foo', title: 'Foo', status: 'completed', cwd: '/tmp/foo' }],
89
+ });
90
+ const admin = createAdminServer(remote);
91
+ const { port, token } = await admin.start();
92
+ try {
93
+ const res = await request(port, { path: '/admin/scheduler/jobs', token });
94
+ assert.strictEqual(res.status, 200);
95
+ assert.ok(Array.isArray(res.json));
96
+ assert.strictEqual(res.json.length, 1);
97
+ assert.strictEqual(res.json[0].slug, '10-foo');
98
+ } finally {
99
+ await admin.stop();
100
+ }
101
+ });
102
+
103
+ test('POST /admin/scheduler/reset-job with unknown slug returns ok:false', async () => {
104
+ const remote = makeFakeRemote({ jobs: [] });
105
+ const admin = createAdminServer(remote);
106
+ const { port, token } = await admin.start();
107
+ try {
108
+ const res = await request(port, {
109
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: 'does-not-exist' },
110
+ });
111
+ assert.strictEqual(res.status, 200);
112
+ assert.strictEqual(res.json.ok, false);
113
+ } finally {
114
+ await admin.stop();
115
+ }
116
+ });
117
+
118
+ test('POST /admin/scheduler/reset-job with known slug flips job to pending and clears run fields', async () => {
119
+ const job = {
120
+ slug: '10-foo',
121
+ title: 'Foo',
122
+ status: 'completed',
123
+ cwd: '/tmp/foo',
124
+ runId: 'run-1',
125
+ startedAt: 100,
126
+ finishedAt: 200,
127
+ exitCode: 0,
128
+ error: null,
129
+ runtime: { pid: 1234 },
130
+ verifierVerdict: 'PASS',
131
+ };
132
+ const remote = makeFakeRemote({ jobs: [job] });
133
+ const admin = createAdminServer(remote);
134
+ const { port, token } = await admin.start();
135
+ try {
136
+ const res = await request(port, {
137
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: '10-foo' },
138
+ });
139
+ assert.strictEqual(res.status, 200);
140
+ assert.deepStrictEqual(res.json, { ok: true, slug: '10-foo', status: 'pending' });
141
+ assert.strictEqual(job.status, 'pending');
142
+ assert.strictEqual(job.runId, null);
143
+ assert.strictEqual(job.startedAt, null);
144
+ assert.strictEqual(job.finishedAt, null);
145
+ assert.strictEqual(job.exitCode, null);
146
+ assert.strictEqual(job.error, null);
147
+ assert.strictEqual('runtime' in job, false);
148
+ assert.strictEqual('verifierVerdict' in job, false);
149
+ } finally {
150
+ await admin.stop();
151
+ }
152
+ });
153
+
154
+ test('unknown path/method returns 404', async () => {
155
+ const remote = makeFakeRemote();
156
+ const admin = createAdminServer(remote);
157
+ const { port, token } = await admin.start();
158
+ try {
159
+ const res = await request(port, { path: '/admin/scheduler/nope', token });
160
+ assert.strictEqual(res.status, 404);
161
+ } finally {
162
+ await admin.stop();
163
+ }
164
+ });
165
+
166
+ test('server binds only to 127.0.0.1', async () => {
167
+ const remote = makeFakeRemote();
168
+ const admin = createAdminServer(remote);
169
+ await admin.start();
170
+ try {
171
+ assert.strictEqual(admin.server.address().address, '127.0.0.1');
172
+ } finally {
173
+ await admin.stop();
174
+ }
175
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * chat-mcp-consent-notice.test.cjs — regression for the headless MCP-consent
3
+ * hang: a chat run with stdin closed can never answer an MCP server's own
4
+ * interactive consent flow (e.g. `/design consent`). This drives the real
5
+ * stdout parser with a synthetic stream-json `tool_result` denial line and
6
+ * asserts a `chat:run:notice` event is broadcast — informational, not a
7
+ * terminal event.
8
+ *
9
+ * Run: timeout 120 node --test src/main/__tests__/chat-mcp-consent-notice.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ delete process.env.SM_CHAT_CONCURRENCY;
15
+
16
+ const { test } = require('node:test');
17
+ const assert = require('node:assert/strict');
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+
22
+ function writeStub(lines) {
23
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
24
+ const body = lines.map((l) => `process.stdout.write(${JSON.stringify(JSON.stringify(l))} + "\\n");`).join('\n');
25
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\nprocess.exit(0);\n`, { mode: 0o755 });
26
+ return stubPath;
27
+ }
28
+
29
+ function isTerminal(channel) {
30
+ return (
31
+ channel === 'chat:run:complete' ||
32
+ channel === 'chat:run:needs-input' ||
33
+ channel === 'chat:run:error'
34
+ );
35
+ }
36
+
37
+ test('MCP consent denial in a tool_result surfaces a chat:run:notice event', async () => {
38
+ const stubPath = writeStub([
39
+ {
40
+ type: 'user',
41
+ message: {
42
+ content: [
43
+ {
44
+ type: 'tool_result',
45
+ tool_use_id: 'toolu_1',
46
+ is_error: true,
47
+ content: [
48
+ {
49
+ type: 'text',
50
+ text:
51
+ "Claude Design: write_files The user hasn't granted this — run /design consent " +
52
+ "to grant it (it can't be approved automatically in this permission mode).",
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ },
58
+ },
59
+ { type: 'result', subtype: 'success', result: 'done anyway' },
60
+ ]);
61
+ process.env.SM_CLAUDE_BIN = stubPath;
62
+ delete require.cache[require.resolve('../chatRunner.cjs')];
63
+ const cr = require('../chatRunner.cjs');
64
+
65
+ const events = [];
66
+ cr.attachWindow({
67
+ isDestroyed: () => false,
68
+ webContents: {
69
+ isDestroyed: () => false,
70
+ send: (channel, payload) => events.push({ channel, payload }),
71
+ },
72
+ });
73
+
74
+ cr.run({ tabId: 'T-notice', sessionId: 'S-notice', prompt: 'do a design thing', cwd: process.cwd(), resume: false });
75
+
76
+ for (let i = 0; i < 60 && !events.some((e) => isTerminal(e.channel)); i++) {
77
+ await new Promise((r) => setTimeout(r, 25));
78
+ }
79
+
80
+ const notice = events.find((e) => e.channel === 'chat:run:notice');
81
+ assert.ok(notice, 'chat:run:notice should be broadcast');
82
+ assert.match(notice.payload.message, /consent/i, 'message mentions consent');
83
+ assert.match(notice.payload.message, /terminal/i, 'message mentions the raw terminal session');
84
+
85
+ // Notice is informational, not terminal — the normal result still fires.
86
+ const terminal = events.filter((e) => isTerminal(e.channel));
87
+ assert.equal(terminal.length, 1, 'exactly one terminal event still fires');
88
+ assert.equal(terminal[0].channel, 'chat:run:complete');
89
+
90
+ try { fs.unlinkSync(stubPath); } catch { /* already gone */ }
91
+ delete process.env.SM_CLAUDE_BIN;
92
+ });
93
+
94
+ test('a normal tool_result with no consent marker does not fire chat:run:notice', async () => {
95
+ const stubPath = writeStub([
96
+ {
97
+ type: 'user',
98
+ message: {
99
+ content: [
100
+ {
101
+ type: 'tool_result',
102
+ tool_use_id: 'toolu_2',
103
+ is_error: false,
104
+ content: [{ type: 'text', text: 'Wrote 3 files successfully.' }],
105
+ },
106
+ ],
107
+ },
108
+ },
109
+ { type: 'result', subtype: 'success', result: 'all good' },
110
+ ]);
111
+ process.env.SM_CLAUDE_BIN = stubPath;
112
+ delete require.cache[require.resolve('../chatRunner.cjs')];
113
+ const cr = require('../chatRunner.cjs');
114
+
115
+ const events = [];
116
+ cr.attachWindow({
117
+ isDestroyed: () => false,
118
+ webContents: {
119
+ isDestroyed: () => false,
120
+ send: (channel, payload) => events.push({ channel, payload }),
121
+ },
122
+ });
123
+
124
+ cr.run({ tabId: 'T-clean', sessionId: 'S-clean', prompt: 'do a normal thing', cwd: process.cwd(), resume: false });
125
+
126
+ for (let i = 0; i < 60 && !events.some((e) => isTerminal(e.channel)); i++) {
127
+ await new Promise((r) => setTimeout(r, 25));
128
+ }
129
+
130
+ assert.ok(
131
+ !events.some((e) => e.channel === 'chat:run:notice'),
132
+ 'no chat:run:notice should fire for a clean tool_result (no false positive)',
133
+ );
134
+
135
+ try { fs.unlinkSync(stubPath); } catch { /* already gone */ }
136
+ delete process.env.SM_CLAUDE_BIN;
137
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * mcpStatus.test.cjs — unit tests for the `claude mcp list` output parser.
3
+ *
4
+ * Run: timeout 120 node --test src/main/__tests__/mcpStatus.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const { parseMcpList } = require('../mcpStatus.cjs');
12
+
13
+ const SAMPLE = `Checking MCP server health…
14
+
15
+ claude.ai Gmail: https://gmailmcp.googleapis.com/mcp/v1 - ✔ Connected
16
+ fetch: uvx mcp-server-fetch - ✔ Connected
17
+ n8n: bash /home/bilko/.config/n8n-mcp/run.sh - ✘ Failed to connect
18
+ session-manager-scheduler: node scripts/scheduler-mcp-server.cjs - ⏸ Pending approval (run \`claude\` to approve)
19
+ claude_design: https://api.anthropic.com/v1/design/mcp (HTTP) - ✔ Connected
20
+ claude.ai Google Drive: https://drivemcp.googleapis.com/mcp/v1 - ! Needs authentication
21
+ `;
22
+
23
+ test('parses the captured claude mcp list sample', () => {
24
+ const servers = parseMcpList(SAMPLE);
25
+ assert.ok(servers.length >= 6, `expected >=6 servers, got ${servers.length}`);
26
+
27
+ const byName = Object.fromEntries(servers.map((s) => [s.name, s]));
28
+
29
+ assert.equal(byName['claude.ai Gmail'].status, 'connected');
30
+ assert.equal(byName['fetch'].status, 'connected');
31
+ assert.equal(byName['n8n'].status, 'failed');
32
+ assert.equal(byName['session-manager-scheduler'].status, 'pending');
33
+ assert.equal(byName['claude_design'].status, 'connected');
34
+ assert.equal(byName['claude_design'].transport, 'http');
35
+ assert.equal(byName['claude.ai Google Drive'].status, 'needs-auth');
36
+ });
37
+
38
+ test('ignores the header and blank lines', () => {
39
+ const servers = parseMcpList(SAMPLE);
40
+ assert.ok(!servers.some((s) => /Checking MCP server health/i.test(s.name)));
41
+ });
42
+
43
+ test('defaults transport to stdio for non-HTTP targets', () => {
44
+ const servers = parseMcpList(SAMPLE);
45
+ const byName = Object.fromEntries(servers.map((s) => [s.name, s]));
46
+ assert.equal(byName['n8n'].transport, 'stdio');
47
+ assert.equal(byName['fetch'].transport, 'stdio');
48
+ });
49
+
50
+ test('returns [] (not a throw) on empty input', () => {
51
+ assert.deepEqual(parseMcpList(''), []);
52
+ });
53
+
54
+ test('returns [] (not a throw) on garbage input', () => {
55
+ assert.deepEqual(parseMcpList('this is not\na valid mcp list output\n12345'), []);
56
+ });
57
+
58
+ test('returns [] on non-string input without throwing', () => {
59
+ assert.deepEqual(parseMcpList(null), []);
60
+ assert.deepEqual(parseMcpList(undefined), []);
61
+ });
@@ -285,7 +285,7 @@ test('Truly clean run (no error markers) → clean/null', async () => {
285
285
  {
286
286
  type: 'result',
287
287
  subtype: 'success',
288
- result: 'Feature complete. All acceptance criteria satisfied.',
288
+ result: 'Feature complete. All acceptance criteria satisfied.\nSCHEDULER_VERDICT: PASS',
289
289
  },
290
290
  ];
291
291
 
@@ -367,7 +367,7 @@ test('FAIL recovered within 30 events → clean', async () => {
367
367
  {
368
368
  type: 'result',
369
369
  subtype: 'success',
370
- result: 'Fixed the failing test and verified all pass.',
370
+ result: 'Fixed the failing test and verified all pass.\nSCHEDULER_VERDICT: PASS',
371
371
  },
372
372
  ];
373
373
 
@@ -416,7 +416,7 @@ function bashRunEvents(content, { toolName = 'Bash', resultSubtype = 'success' }
416
416
  }],
417
417
  },
418
418
  },
419
- { type: 'result', subtype: resultSubtype, result: 'All acceptance criteria verified.' },
419
+ { type: 'result', subtype: resultSubtype, result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' },
420
420
  ];
421
421
  }
422
422
 
@@ -568,7 +568,7 @@ test('harness tool error (<tool_use_error>) in final 20% → clean', async () =>
568
568
  events.push({ type: 'user', message: { role: 'user', content: [
569
569
  { type: 'tool_result', tool_use_id: 'tbad',
570
570
  content: '<tool_use_error>Error: No such tool available: bash</tool_use_error>', is_error: true }] } });
571
- events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.' });
571
+ events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' });
572
572
 
573
573
  writeLog(tmp, slug, events);
574
574
  const prdPath = writePrd(tmp, slug, '# Correctness batch');
@@ -809,6 +809,76 @@ test('no sentinel + committed + allowPreSentinelHeal=false (default) → transcr
809
809
  }
810
810
  });
811
811
 
812
+ // ─── no_verdict_sentinel: no commit + no sentinel is not "clean" by default ──
813
+
814
+ /** A run with zero tool_result pattern hits — e.g. the agent asked a
815
+ * clarifying question and stopped, ending its turn without touching tools. */
816
+ function noOpRunEvents(resultText) {
817
+ return [
818
+ { type: 'result', subtype: 'success', result: resultText },
819
+ ];
820
+ }
821
+
822
+ test('no_verdict_sentinel: no tool_result issues, no sentinel, no commit → needs_review', async () => {
823
+ const tmp = makeTmpDir();
824
+ try {
825
+ const slug = '406-browser-capture-panel-ui';
826
+ writeLog(tmp, slug, noOpRunEvents('I need clarification on which capture API to target before proceeding.'));
827
+ const prdPath = writePrd(tmp, slug, '# Browser capture panel UI');
828
+ const verdict = await verifyRun({
829
+ runDir: tmp,
830
+ prdPath,
831
+ queueEntry: { slug, status: 'running' },
832
+ allJobs: [],
833
+ committedDuringRun: false,
834
+ });
835
+ assert.equal(verdict.verdict, 'no_verdict_sentinel', `expected no_verdict_sentinel, got ${verdict.verdict}: ${verdict.reason}`);
836
+ assert.equal(verdict.downgradeTo, 'needs_review');
837
+ } finally {
838
+ rmdir(tmp);
839
+ }
840
+ });
841
+
842
+ test('no_verdict_sentinel guard: same run but committedDuringRun:true → stays clean', async () => {
843
+ const tmp = makeTmpDir();
844
+ try {
845
+ const slug = '406-browser-capture-panel-ui-committed';
846
+ writeLog(tmp, slug, noOpRunEvents('I need clarification on which capture API to target before proceeding.'));
847
+ const prdPath = writePrd(tmp, slug, '# Browser capture panel UI');
848
+ const verdict = await verifyRun({
849
+ runDir: tmp,
850
+ prdPath,
851
+ queueEntry: { slug, status: 'running' },
852
+ allJobs: [],
853
+ committedDuringRun: true,
854
+ });
855
+ assert.equal(verdict.verdict, 'clean', `commit landed should stay clean, got ${verdict.verdict}: ${verdict.reason}`);
856
+ assert.equal(verdict.downgradeTo, null);
857
+ } finally {
858
+ rmdir(tmp);
859
+ }
860
+ });
861
+
862
+ test('no_verdict_sentinel guard: same run but with SCHEDULER_VERDICT: PASS sentinel → stays clean', async () => {
863
+ const tmp = makeTmpDir();
864
+ try {
865
+ const slug = '406-browser-capture-panel-ui-sentinel';
866
+ writeLog(tmp, slug, noOpRunEvents('All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS'));
867
+ const prdPath = writePrd(tmp, slug, '# Browser capture panel UI');
868
+ const verdict = await verifyRun({
869
+ runDir: tmp,
870
+ prdPath,
871
+ queueEntry: { slug, status: 'running' },
872
+ allJobs: [],
873
+ committedDuringRun: false,
874
+ });
875
+ assert.equal(verdict.verdict, 'clean', `PASS sentinel should stay clean, got ${verdict.verdict}: ${verdict.reason}`);
876
+ assert.equal(verdict.downgradeTo, null);
877
+ } finally {
878
+ rmdir(tmp);
879
+ }
880
+ });
881
+
812
882
  // (e) halt + sentinel PASS → still halt (override must not apply to halt)
813
883
  test('halt result + sentinel PASS + committedDuringRun:true → still halt', async () => {
814
884
  const tmp = makeTmpDir();