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.
- package/dist/assets/{TiptapBody-CIEmzy3k.js → TiptapBody-CVFG9ufh.js} +1 -1
- package/dist/assets/index-DASEOqMP.js +3558 -0
- package/dist/assets/index-DZgKJkf3.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +3 -2
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +3 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +12 -0
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +48 -35
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +83 -0
- package/plugins/session-manager-dev/skills/memory-sanitation/SKILL.md +127 -0
- package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +20 -12
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +4 -3
- package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +33 -12
- package/src/main/__tests__/adminServer.test.cjs +175 -0
- package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +137 -0
- package/src/main/__tests__/mcpStatus.test.cjs +61 -0
- package/src/main/__tests__/runVerify.test.cjs +74 -4
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +50 -2
- package/src/main/__tests__/scheduler-autopromote.test.cjs +19 -1
- package/src/main/adminServer.cjs +157 -0
- package/src/main/browserCapture.cjs +714 -0
- package/src/main/browserView.cjs +613 -0
- package/src/main/chatRunner.cjs +60 -0
- package/src/main/config.cjs +37 -1
- package/src/main/historyAggregator.cjs +92 -6
- package/src/main/index.cjs +37 -3
- package/src/main/ipcSchemas.cjs +106 -0
- package/src/main/lib/schedulerConfig.cjs +6 -2
- package/src/main/mcpStatus.cjs +93 -0
- package/src/main/runVerify.cjs +14 -1
- package/src/main/scheduler.cjs +159 -31
- package/src/main/sessionsStore.cjs +4 -3
- package/src/preload/api.d.ts +154 -5
- package/src/preload/browserViewPreload.cjs +67 -0
- package/src/preload/index.cjs +63 -5
- package/dist/assets/index-2Om-ouz6.js +0 -3534
- package/dist/assets/index-DeIJ8SM5.css +0 -32
- 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
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
-
|
|
50
|
-
|
|
51
|
-
|
|
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** — `
|
|
66
|
-
root
|
|
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/.
|
|
12
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
61
|
-
"no feedback intake in `<project>`" and
|
|
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
|
|
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();
|