claude-code-session-manager 0.37.2 → 0.38.1
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-BtrSXTRp.js → TiptapBody-DlPTesio.js} +1 -1
- package/dist/assets/{index-uVGdpAGF.js → index-CBSyk1aI.js} +626 -626
- package/dist/assets/index-DyOGjslF.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/blog-for-project-feature/0-select/SKILL.md +39 -0
- package/plugins/session-manager-dev/skills/blog-for-project-feature/1-research/SKILL.md +37 -0
- package/plugins/session-manager-dev/skills/blog-for-project-feature/2-storyboard/SKILL.md +38 -0
- package/plugins/session-manager-dev/skills/blog-for-project-feature/3-capture/SKILL.md +43 -0
- package/plugins/session-manager-dev/skills/blog-for-project-feature/4-draft-copy/SKILL.md +37 -0
- package/plugins/session-manager-dev/skills/blog-for-project-feature/5-illustrate/SKILL.md +50 -0
- package/plugins/session-manager-dev/skills/blog-for-project-feature/6-compose/SKILL.md +48 -0
- package/plugins/session-manager-dev/skills/blog-for-project-feature/7-review/SKILL.md +51 -0
- package/plugins/session-manager-dev/skills/blog-for-project-feature/SKILL.md +140 -0
- package/plugins/session-manager-dev/skills/blog-for-project-feature/feature.config.yaml +74 -0
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +38 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +64 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +71 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +39 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +75 -0
- package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +39 -0
- package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +57 -0
- package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +40 -0
- package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +33 -0
- package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +40 -0
- package/plugins/session-manager-dev/skills/issue-address/SKILL.md +120 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +41 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +45 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +49 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +50 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +60 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +124 -0
- package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +67 -0
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +31 -0
- package/src/main/__tests__/chat-exit-close-race.test.cjs +140 -0
- package/src/main/__tests__/classifyPromptTicket.test.cjs +101 -0
- package/src/main/__tests__/exchangesPromptId.test.cjs +70 -0
- package/src/main/__tests__/prdCreate.test.cjs +34 -0
- package/src/main/__tests__/prdParserSourcePromptId.test.cjs +65 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +64 -0
- package/src/main/chatRunner.cjs +67 -46
- package/src/main/exchanges.cjs +9 -3
- package/src/main/ipcSchemas.cjs +18 -3
- package/src/main/lib/classifyPromptTicket.cjs +116 -0
- package/src/main/lib/prdCreate.cjs +6 -2
- package/src/main/scheduler/prdParser.cjs +4 -0
- package/src/main/scheduler.cjs +30 -4
- package/src/main/sessionsStore.cjs +4 -5
- package/src/main/webRemote.cjs +3 -3
- package/src/preload/api.d.ts +15 -5
- package/src/preload/index.cjs +5 -2
- package/dist/assets/index-CD_LuJZF.css +0 -32
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pr-signal
|
|
3
|
+
description: >-
|
|
4
|
+
Request or re-request a reviewer on one PR on the current project's repo — the ONLY
|
|
5
|
+
step in the PR flow that contacts a person. Deliberately separate from pr-review-sweep
|
|
6
|
+
and meant to be invoked rarely — most projects don't tag reviewers often. Requires the
|
|
7
|
+
user's explicit, fresh, per-PR approval every time — a prior approval never carries
|
|
8
|
+
forward, and CI must be green at the moment of signaling, not just when approval was
|
|
9
|
+
given. Use only when the user explicitly names a PR number and says to
|
|
10
|
+
request/ping/notify the reviewer, or approves signaling in response to a
|
|
11
|
+
pr-review-sweep report.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# pr-signal — the one step that contacts a reviewer (rare, gated)
|
|
15
|
+
|
|
16
|
+
Global, project-agnostic — works against whatever repo the current project's `gh` remote
|
|
17
|
+
points to. Input: a PR whose queued PRDs have all landed and whose threads
|
|
18
|
+
`pr-review-sweep:land-and-resolve` has replied to and resolved (CI green) — plus the user's
|
|
19
|
+
explicit approval for this specific PR number, given in this conversation, not inherited
|
|
20
|
+
from an earlier one. If the project has its own reviewer-contact policy doc (e.g. sigma's
|
|
21
|
+
`docs/pr-review-workflow.local.md`), read it for repo-specific nuance — reviewer identity,
|
|
22
|
+
cadence norms — but the approval-per-PR and CI-green-at-signal-time gates below are hard
|
|
23
|
+
constants regardless of what a project's doc says.
|
|
24
|
+
|
|
25
|
+
**This skill runs far less often than the rest of the flow.** Most PRs on this repo don't need
|
|
26
|
+
an explicit re-request — a push alone re-triggers review for an already-requested reviewer, and
|
|
27
|
+
this team doesn't like being tagged unnecessarily. Only invoke this when the user actually asks
|
|
28
|
+
for it, by PR number.
|
|
29
|
+
|
|
30
|
+
## Steps
|
|
31
|
+
|
|
32
|
+
1. **Verify the gate, don't assume it from a report given earlier in the conversation:**
|
|
33
|
+
- The PR is still open (`gh pr view <n> --json state,mergedAt`) — a merged/closed PR gets
|
|
34
|
+
no signal; report the state change instead.
|
|
35
|
+
- Every review thread is resolved (re-check now via GraphQL `reviewThreads.isResolved` — a
|
|
36
|
+
`pr-review-sweep:land-and-resolve` report from even a few minutes ago can be stale if
|
|
37
|
+
anything else touched the PR, and a new comment that arrived since means the PR goes back
|
|
38
|
+
through `pr-review-sweep:classify`, not to a signal).
|
|
39
|
+
- CI is green **right now** — re-run `gh pr checks <n> --repo "$(gh repo view --json nameWithOwner -q .nameWithOwner)"` immediately
|
|
40
|
+
before acting. An approval given while CI was green does not survive CI going red in
|
|
41
|
+
between (a later push, a flaky rerun). If CI is red at signal-time, stop and report it —
|
|
42
|
+
never signal on a stale approval, and never solicit approval on a PR that's currently
|
|
43
|
+
CI-red in the first place.
|
|
44
|
+
- No review is already outstanding (requested, not yet dismissed by a later push) — if one
|
|
45
|
+
is, the push itself was already the signal; do not re-request
|
|
46
|
+
([[no-reviewer-ping-without-approval]] / [[reviewers-dont-spam]]).
|
|
47
|
+
- The user's approval is for **this PR number, in this conversation** — a blanket "yes,
|
|
48
|
+
signal when ready" said about a different PR, or said before this session, does not count.
|
|
49
|
+
|
|
50
|
+
2. **If any gate check fails**, stop and report exactly which one — don't ask "should I signal
|
|
51
|
+
now?" as a throwaway line and proceed anyway.
|
|
52
|
+
|
|
53
|
+
3. **If every gate check passes**, request the reviewer:
|
|
54
|
+
```bash
|
|
55
|
+
gh pr edit <number> --repo "$(gh repo view --json nameWithOwner -q .nameWithOwner)" --add-reviewer <reviewer>
|
|
56
|
+
```
|
|
57
|
+
If `gh pr edit` fails with the known "Projects (classic)" GraphQL deprecation error on this
|
|
58
|
+
repo, fall back to
|
|
59
|
+
`gh api repos/$(gh repo view --json nameWithOwner -q .nameWithOwner)/pulls/<n>/requested_reviewers -f "reviewers[]=<reviewer>"` —
|
|
60
|
+
and send it exactly once; verify via `gh pr view <n> --json reviewRequests` rather than
|
|
61
|
+
retrying blind (a retry after an ambiguous failure is how a double-ping happens).
|
|
62
|
+
Do not add a "ready for re-review" comment or @-mention on top of the request — the request
|
|
63
|
+
itself is the signal; a comment on top of it is the over-notification this skill exists to
|
|
64
|
+
avoid.
|
|
65
|
+
|
|
66
|
+
4. **Report** the PR as signaled, and loop back to `project-status-local` to pick up the new
|
|
67
|
+
state on its next run.
|
|
@@ -75,6 +75,37 @@ test('cancelling an ACTIVE run broadcasts a terminal event (unsticks the UI)', a
|
|
|
75
75
|
assert.match(terminal[0].payload.message, /cancel/i, 'message names the cancellation');
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
test('cancel() resolves only AFTER the terminal broadcast fires, not immediately on SIGTERM (PRD 718)', async () => {
|
|
79
|
+
// Order-of-events assertion (not timing-based — a real SIGTERM can be fast
|
|
80
|
+
// enough on a lightweight stub child that a fixed-delay race would flake).
|
|
81
|
+
const order = [];
|
|
82
|
+
cr.attachWindow({
|
|
83
|
+
isDestroyed: () => false,
|
|
84
|
+
webContents: {
|
|
85
|
+
isDestroyed: () => false,
|
|
86
|
+
send: (channel, payload) => {
|
|
87
|
+
if (isTerminal(channel) && payload.tabId === 'T2') order.push('terminal');
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
cr.run({ tabId: 'T2', sessionId: 'S2', prompt: 'hello', cwd: process.cwd(), resume: false });
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < 6; i++) await tick();
|
|
95
|
+
|
|
96
|
+
await cr.cancel('T2').then(() => order.push('resolved'));
|
|
97
|
+
|
|
98
|
+
assert.deepEqual(order, ['terminal', 'resolved'], 'cancel() resolves strictly after the terminal broadcast');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('cancel() on a tab with no in-flight or waiting run resolves immediately (no-op)', async () => {
|
|
102
|
+
cr.attachWindow({
|
|
103
|
+
isDestroyed: () => false,
|
|
104
|
+
webContents: { isDestroyed: () => false, send: () => {} },
|
|
105
|
+
});
|
|
106
|
+
await cr.cancel('no-such-tab'); // must resolve, not hang
|
|
107
|
+
});
|
|
108
|
+
|
|
78
109
|
function isTerminal(channel) {
|
|
79
110
|
return (
|
|
80
111
|
channel === 'chat:run:complete' ||
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chat-exit-close-race.test.cjs — regression for the exit/close race that
|
|
3
|
+
* silently dropped a pure-text-only final turn (PRD 716).
|
|
4
|
+
*
|
|
5
|
+
* Bug: the terminal-event fallback was wired to `child.on('exit', ...)`
|
|
6
|
+
* instead of `child.on('close', ...)`. Node only guarantees all buffered
|
|
7
|
+
* stdout 'data' events have been delivered by 'close', not 'exit' — 'exit'
|
|
8
|
+
* can fire before the final stdout chunk (last assistant text + terminating
|
|
9
|
+
* `result` line) reaches the parent's 'data' handler. When that happened,
|
|
10
|
+
* the exit fallback ran first, set the one-shot terminalSent latch, and
|
|
11
|
+
* broadcast a misleading chat:run:error. The real result line then parsed
|
|
12
|
+
* moments later, but emitTerminal silently no-op'd and the genuine
|
|
13
|
+
* chat:run:complete was dropped.
|
|
14
|
+
*
|
|
15
|
+
* Fix: use `child.on('close', ...)` for the fallback so it only fires once
|
|
16
|
+
* Node guarantees stdout is fully drained — a real result event that arrives
|
|
17
|
+
* (even "late", right up against process exit) always wins.
|
|
18
|
+
*
|
|
19
|
+
* This test mocks node:child_process.spawn so it can deterministically
|
|
20
|
+
* reproduce the exact race ordering (exit fires, THEN the final stdout chunk
|
|
21
|
+
* arrives, THEN close fires) rather than relying on real OS/pipe timing,
|
|
22
|
+
* which is not reliably reproducible from a real child process.
|
|
23
|
+
*
|
|
24
|
+
* Run: timeout 120 node --test src/main/__tests__/chat-exit-close-race.test.cjs
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
delete process.env.SM_CHAT_CONCURRENCY;
|
|
30
|
+
|
|
31
|
+
const { test } = require('node:test');
|
|
32
|
+
const assert = require('node:assert/strict');
|
|
33
|
+
const EventEmitter = require('node:events');
|
|
34
|
+
const cp = require('node:child_process');
|
|
35
|
+
|
|
36
|
+
// Replace spawn BEFORE chatRunner.cjs is first required, so the module's
|
|
37
|
+
// top-level `const { spawn } = require('node:child_process')` destructuring
|
|
38
|
+
// captures this mock instead of the real implementation.
|
|
39
|
+
const originalSpawn = cp.spawn;
|
|
40
|
+
let nextChild = null;
|
|
41
|
+
cp.spawn = () => {
|
|
42
|
+
const child = new EventEmitter();
|
|
43
|
+
child.stdout = new EventEmitter();
|
|
44
|
+
child.stderr = new EventEmitter();
|
|
45
|
+
child.pid = 424242;
|
|
46
|
+
child.kill = () => {};
|
|
47
|
+
nextChild = child;
|
|
48
|
+
return child;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const cr = require('../chatRunner.cjs');
|
|
52
|
+
|
|
53
|
+
const tick = () => new Promise((r) => setImmediate(r));
|
|
54
|
+
|
|
55
|
+
test('a final-text-only turn whose stdout chunk lands after exit still surfaces chat:run:complete', async () => {
|
|
56
|
+
nextChild = null;
|
|
57
|
+
const events = [];
|
|
58
|
+
cr.attachWindow({
|
|
59
|
+
isDestroyed: () => false,
|
|
60
|
+
webContents: {
|
|
61
|
+
isDestroyed: () => false,
|
|
62
|
+
send: (channel, payload) => events.push({ channel, payload }),
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
cr.run({ tabId: 'T2', sessionId: 'S2', prompt: 'show me a sample post', cwd: process.cwd(), resume: false });
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < 20 && !nextChild; i++) await tick();
|
|
69
|
+
const child = nextChild;
|
|
70
|
+
assert.ok(child, 'spawn should have been invoked');
|
|
71
|
+
|
|
72
|
+
const text = 'Here is your sample post, all in one go.';
|
|
73
|
+
const lines =
|
|
74
|
+
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } }) +
|
|
75
|
+
'\n' +
|
|
76
|
+
JSON.stringify({ type: 'result', subtype: 'success', result: text }) +
|
|
77
|
+
'\n';
|
|
78
|
+
|
|
79
|
+
// The exact race: 'exit' fires first (process already gone from the OS's
|
|
80
|
+
// perspective), the final stdout chunk is delivered to the 'data' handler
|
|
81
|
+
// only after that, and 'close' — which Node guarantees fires after all
|
|
82
|
+
// stdout data has been delivered — fires last.
|
|
83
|
+
child.emit('exit', 0, null);
|
|
84
|
+
child.stdout.emit('data', Buffer.from(lines));
|
|
85
|
+
child.emit('close', 0, null);
|
|
86
|
+
|
|
87
|
+
await tick();
|
|
88
|
+
await tick();
|
|
89
|
+
|
|
90
|
+
const terminal = events.filter((e) => isTerminal(e.channel));
|
|
91
|
+
assert.equal(terminal.length, 1, 'exactly one terminal event is emitted');
|
|
92
|
+
assert.equal(
|
|
93
|
+
terminal[0].channel,
|
|
94
|
+
'chat:run:complete',
|
|
95
|
+
'the real result event must win over the generic exit-fallback error',
|
|
96
|
+
);
|
|
97
|
+
assert.equal(terminal[0].payload.finalMessage, text);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a crashed process with no result event ever emitted still fires the fallback chat:run:error', async () => {
|
|
101
|
+
nextChild = null;
|
|
102
|
+
const events = [];
|
|
103
|
+
cr.attachWindow({
|
|
104
|
+
isDestroyed: () => false,
|
|
105
|
+
webContents: {
|
|
106
|
+
isDestroyed: () => false,
|
|
107
|
+
send: (channel, payload) => events.push({ channel, payload }),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
cr.run({ tabId: 'T3', sessionId: 'S3', prompt: 'anything', cwd: process.cwd(), resume: false });
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < 20 && !nextChild; i++) await tick();
|
|
114
|
+
const child = nextChild;
|
|
115
|
+
assert.ok(child, 'spawn should have been invoked');
|
|
116
|
+
|
|
117
|
+
// Crashes with no stdout at all — 'close' is the only event that fires.
|
|
118
|
+
child.emit('close', 1, null);
|
|
119
|
+
|
|
120
|
+
await tick();
|
|
121
|
+
await tick();
|
|
122
|
+
|
|
123
|
+
const terminal = events.filter((e) => isTerminal(e.channel));
|
|
124
|
+
assert.equal(terminal.length, 1, 'exactly one terminal event is emitted');
|
|
125
|
+
assert.equal(terminal[0].channel, 'chat:run:error', 'a crash with no result surfaces as an error turn');
|
|
126
|
+
assert.match(terminal[0].payload.message, /process exited without a result event/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function isTerminal(channel) {
|
|
130
|
+
return (
|
|
131
|
+
channel === 'chat:run:complete' ||
|
|
132
|
+
channel === 'chat:run:needs-input' ||
|
|
133
|
+
channel === 'chat:run:error'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
test.after(() => {
|
|
138
|
+
cp.spawn = originalSpawn;
|
|
139
|
+
delete process.env.SM_CHAT_CONCURRENCY;
|
|
140
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* classifyPromptTicket.test.cjs — unit tests for the PromptTicket
|
|
3
|
+
* inline-vs-develop classifier (PRD 749).
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/classifyPromptTicket.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach } from 'vitest';
|
|
11
|
+
const { EventEmitter } = require('node:events');
|
|
12
|
+
const {
|
|
13
|
+
classifyPromptTicket,
|
|
14
|
+
parseVerdict,
|
|
15
|
+
__setSpawnForTest,
|
|
16
|
+
} = require('../lib/classifyPromptTicket.cjs');
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
__setSpawnForTest(null); // restore real spawn between tests
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('parseVerdict returns "develop" only when the raw text contains the word develop', () => {
|
|
23
|
+
expect(parseVerdict('develop')).toBe('develop');
|
|
24
|
+
expect(parseVerdict(' Develop ')).toBe('develop');
|
|
25
|
+
expect(parseVerdict('inline')).toBe('inline');
|
|
26
|
+
expect(parseVerdict('')).toBe('inline');
|
|
27
|
+
expect(parseVerdict(undefined)).toBe('inline');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function fakeChild() {
|
|
31
|
+
const child = new EventEmitter();
|
|
32
|
+
child.stdout = new EventEmitter();
|
|
33
|
+
child.stderr = new EventEmitter();
|
|
34
|
+
child.kill = () => {};
|
|
35
|
+
return child;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('classifyPromptTicket resolves "develop" for a complex sample prompt (stubbed spawn)', async () => {
|
|
39
|
+
__setSpawnForTest(() => {
|
|
40
|
+
const child = fakeChild();
|
|
41
|
+
setTimeout(() => {
|
|
42
|
+
child.stdout.emit('data', Buffer.from('develop'));
|
|
43
|
+
child.emit('close', 0);
|
|
44
|
+
}, 0);
|
|
45
|
+
return child;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const verdict = await classifyPromptTicket('Build a full multi-tab prompt-queue feature with PRD decomposition');
|
|
49
|
+
expect(verdict).toBe('develop');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('classifyPromptTicket resolves "inline" for a quick sample prompt (stubbed spawn)', async () => {
|
|
53
|
+
__setSpawnForTest(() => {
|
|
54
|
+
const child = fakeChild();
|
|
55
|
+
setTimeout(() => {
|
|
56
|
+
child.stdout.emit('data', Buffer.from('inline'));
|
|
57
|
+
child.emit('close', 0);
|
|
58
|
+
}, 0);
|
|
59
|
+
return child;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const verdict = await classifyPromptTicket('what time is it');
|
|
63
|
+
expect(verdict).toBe('inline');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('classifyPromptTicket fails safe to "inline" when the child process errors', async () => {
|
|
67
|
+
__setSpawnForTest(() => {
|
|
68
|
+
const child = fakeChild();
|
|
69
|
+
setTimeout(() => child.emit('error', new Error('spawn failed')), 0);
|
|
70
|
+
return child;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const verdict = await classifyPromptTicket('anything');
|
|
74
|
+
expect(verdict).toBe('inline');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('classifyPromptTicket spawns with a pinned model, argv array (no shell string), and closed stdin', async () => {
|
|
78
|
+
let capturedBin;
|
|
79
|
+
let capturedArgs;
|
|
80
|
+
let capturedOpts;
|
|
81
|
+
__setSpawnForTest((bin, args, opts) => {
|
|
82
|
+
capturedBin = bin;
|
|
83
|
+
capturedArgs = args;
|
|
84
|
+
capturedOpts = opts;
|
|
85
|
+
const child = fakeChild();
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
child.stdout.emit('data', Buffer.from('inline'));
|
|
88
|
+
child.emit('close', 0);
|
|
89
|
+
}, 0);
|
|
90
|
+
return child;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await classifyPromptTicket('hello');
|
|
94
|
+
|
|
95
|
+
expect(typeof capturedBin).toBe('string');
|
|
96
|
+
expect(Array.isArray(capturedArgs)).toBe(true);
|
|
97
|
+
expect(capturedArgs).toContain('--model');
|
|
98
|
+
expect(capturedArgs[capturedArgs.indexOf('--model') + 1]).toBe('sonnet');
|
|
99
|
+
expect(capturedOpts.stdio[0]).toBe('ignore');
|
|
100
|
+
expect(capturedOpts.shell).toBeFalsy();
|
|
101
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exchangesPromptId.test.cjs — recordExchange() round-trips the optional
|
|
3
|
+
* promptId field (PRD 749). Separate file from exchanges.test.cjs (which
|
|
4
|
+
* runs under node:test, not vitest) so this is picked up by
|
|
5
|
+
* `npx vitest run` per this repo's test:unit convention.
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/exchangesPromptId.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const fsp = require('node:fs/promises');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
|
|
18
|
+
let tmpHome;
|
|
19
|
+
let origHome;
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'exchanges-promptid-test-'));
|
|
23
|
+
origHome = process.env.HOME;
|
|
24
|
+
process.env.HOME = tmpHome;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
process.env.HOME = origHome;
|
|
29
|
+
await fsp.rm(tmpHome, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
// Stub summarize.cjs to avoid any network calls, and clear the modules
|
|
34
|
+
// that cache HOME-derived paths so each test sees the tmp HOME.
|
|
35
|
+
const summarizePath = require.resolve('../lib/summarize.cjs');
|
|
36
|
+
delete require.cache[summarizePath];
|
|
37
|
+
require.cache[summarizePath] = {
|
|
38
|
+
id: summarizePath,
|
|
39
|
+
filename: summarizePath,
|
|
40
|
+
loaded: true,
|
|
41
|
+
exports: { summarize: async () => ({ summary: 'ok', model: 'claude-haiku-4-5' }) },
|
|
42
|
+
};
|
|
43
|
+
delete require.cache[require.resolve('../exchanges.cjs')];
|
|
44
|
+
delete require.cache[require.resolve('../config.cjs')];
|
|
45
|
+
delete require.cache[require.resolve('../lib/encodeCwd.cjs')];
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('recordExchange persists promptId when supplied', async () => {
|
|
49
|
+
const { recordExchange } = require('../exchanges.cjs');
|
|
50
|
+
const { encodeCwd } = require('../lib/encodeCwd.cjs');
|
|
51
|
+
|
|
52
|
+
const cwd = '/home/user/promptid-project';
|
|
53
|
+
await recordExchange({ sessionId: 's1', cwd, prompt: 'do X', result: 'done', promptId: 'ticket-abc-123' });
|
|
54
|
+
|
|
55
|
+
const logPath = path.join(tmpHome, '.claude', 'knowledge-log', 'exchanges', `${encodeCwd(cwd)}.jsonl`);
|
|
56
|
+
const record = JSON.parse((await fsp.readFile(logPath, 'utf8')).trim());
|
|
57
|
+
expect(record.promptId).toBe('ticket-abc-123');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('recordExchange omits promptId when not supplied (no backfill/synthesis)', async () => {
|
|
61
|
+
const { recordExchange } = require('../exchanges.cjs');
|
|
62
|
+
const { encodeCwd } = require('../lib/encodeCwd.cjs');
|
|
63
|
+
|
|
64
|
+
const cwd = '/home/user/no-promptid-project';
|
|
65
|
+
await recordExchange({ sessionId: 's2', cwd, prompt: 'do Y', result: 'done' });
|
|
66
|
+
|
|
67
|
+
const logPath = path.join(tmpHome, '.claude', 'knowledge-log', 'exchanges', `${encodeCwd(cwd)}.jsonl`);
|
|
68
|
+
const record = JSON.parse((await fsp.readFile(logPath, 'utf8')).trim());
|
|
69
|
+
expect('promptId' in record).toBe(false);
|
|
70
|
+
});
|
|
@@ -85,6 +85,23 @@ test('buildPrdBody omits parallelGroup frontmatter key when not supplied', () =>
|
|
|
85
85
|
expect(!/parallelGroup:/.test(body)).toBeTruthy();
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
test('buildPrdBody writes sourcePromptId into frontmatter when supplied (PRD 749 traceability)', () => {
|
|
89
|
+
const body = buildPrdBody({
|
|
90
|
+
title: 't', cwd: '~/x', estimateMinutes: 5, goal: 'g',
|
|
91
|
+
acceptanceCriteria: ['a'], implementationNotes: 'n',
|
|
92
|
+
sourcePromptId: 'ticket-abc-123',
|
|
93
|
+
});
|
|
94
|
+
expect(body).toMatch(/sourcePromptId: ticket-abc-123/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('buildPrdBody omits sourcePromptId frontmatter key when not supplied', () => {
|
|
98
|
+
const body = buildPrdBody({
|
|
99
|
+
title: 't', cwd: '~/x', estimateMinutes: 5, goal: 'g',
|
|
100
|
+
acceptanceCriteria: ['a'], implementationNotes: 'n',
|
|
101
|
+
});
|
|
102
|
+
expect(!/sourcePromptId:/.test(body)).toBeTruthy();
|
|
103
|
+
});
|
|
104
|
+
|
|
88
105
|
test('readStandards result is byte-identical to the on-disk file (single source of truth)', async () => {
|
|
89
106
|
const onDisk = fs.readFileSync(
|
|
90
107
|
path.join(__dirname, '..', '..', '..', 'plugins', 'session-manager-dev', 'skills', 'develop', 'standards.md'),
|
|
@@ -320,3 +337,20 @@ test('POST /admin/scheduler/create-prd honors an explicit slug + parallelGroup i
|
|
|
320
337
|
await admin.stop();
|
|
321
338
|
}
|
|
322
339
|
});
|
|
340
|
+
|
|
341
|
+
test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the created PRD frontmatter', async () => {
|
|
342
|
+
const prdsDir = await mkTmpPrdsDir();
|
|
343
|
+
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
344
|
+
const { admin, port, token } = await startWithRemote(remote);
|
|
345
|
+
try {
|
|
346
|
+
const res = await request(port, {
|
|
347
|
+
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
348
|
+
body: validCreateBody({ slug: 'from-a-ticket', parallelGroup: 888, sourcePromptId: 'ticket-xyz-789' }),
|
|
349
|
+
});
|
|
350
|
+
expect(res.status).toBe(200);
|
|
351
|
+
const written = fs.readFileSync(path.join(prdsDir, '888-from-a-ticket.md'), 'utf8');
|
|
352
|
+
expect(written).toMatch(/sourcePromptId: ticket-xyz-789/);
|
|
353
|
+
} finally {
|
|
354
|
+
await admin.stop();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdParserSourcePromptId.test.cjs — parsePrdRaw() tolerates both presence
|
|
3
|
+
* and absence of the optional `sourcePromptId` frontmatter key (PRD 749).
|
|
4
|
+
* Additive-only: PRDs written before this field existed must still parse
|
|
5
|
+
* identically (no required-field regression).
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdParserSourcePromptId.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect } from 'vitest';
|
|
13
|
+
const fsp = require('node:fs/promises');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
|
|
17
|
+
const { parsePrdRaw, _resetCache } = require('../scheduler/prdParser.cjs');
|
|
18
|
+
|
|
19
|
+
function mkTmpPrdsDir() {
|
|
20
|
+
return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prd-sourceprompt-'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('parsePrdRaw extracts sourcePromptId when present in frontmatter', async () => {
|
|
24
|
+
const dir = await mkTmpPrdsDir();
|
|
25
|
+
_resetCache();
|
|
26
|
+
const filePath = path.join(dir, '1-with-source.md');
|
|
27
|
+
await fsp.writeFile(filePath, [
|
|
28
|
+
'---',
|
|
29
|
+
'title: Has a source ticket',
|
|
30
|
+
'cwd: ~/Projects/session-manager',
|
|
31
|
+
'estimateMinutes: 10',
|
|
32
|
+
'sourcePromptId: ticket-xyz-789',
|
|
33
|
+
'---',
|
|
34
|
+
'# Goal',
|
|
35
|
+
'',
|
|
36
|
+
'Do the thing.',
|
|
37
|
+
'',
|
|
38
|
+
].join('\n'));
|
|
39
|
+
|
|
40
|
+
const parsed = await parsePrdRaw(filePath);
|
|
41
|
+
expect(parsed.sourcePromptId).toBe('ticket-xyz-789');
|
|
42
|
+
expect(parsed.title).toBe('Has a source ticket');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('parsePrdRaw returns null sourcePromptId for a PRD authored before this field existed', async () => {
|
|
46
|
+
const dir = await mkTmpPrdsDir();
|
|
47
|
+
_resetCache();
|
|
48
|
+
const filePath = path.join(dir, '2-legacy.md');
|
|
49
|
+
await fsp.writeFile(filePath, [
|
|
50
|
+
'---',
|
|
51
|
+
'title: Legacy PRD, no sourcePromptId',
|
|
52
|
+
'cwd: ~/Projects/session-manager',
|
|
53
|
+
'estimateMinutes: 5',
|
|
54
|
+
'---',
|
|
55
|
+
'# Goal',
|
|
56
|
+
'',
|
|
57
|
+
'Do the other thing.',
|
|
58
|
+
'',
|
|
59
|
+
].join('\n'));
|
|
60
|
+
|
|
61
|
+
const parsed = await parsePrdRaw(filePath);
|
|
62
|
+
expect(parsed.sourcePromptId).toBe(null);
|
|
63
|
+
expect(parsed.title).toBe('Legacy PRD, no sourcePromptId');
|
|
64
|
+
expect(parsed.cwd).toBe(path.join(os.homedir(), 'Projects', 'session-manager'));
|
|
65
|
+
});
|
|
@@ -61,3 +61,67 @@ test('committedInWindow returns false when the window covers no commit', async (
|
|
|
61
61
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
62
62
|
}
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
// Reproduces the sigma PRD 713 incident: a commit made+pushed from an
|
|
66
|
+
// isolated worktree checkout that shares the run's remote but not job.cwd's
|
|
67
|
+
// local refs — e.g. `git worktree add /tmp/foo` followed by `git worktree
|
|
68
|
+
// remove` clears the worktree's local branch, leaving the commit reachable
|
|
69
|
+
// in job.cwd only via a remote-tracking ref that hasn't been fetched yet.
|
|
70
|
+
// Modeled here as a second, independent clone of the shared remote (rather
|
|
71
|
+
// than a literal `git worktree add`, which shares job.cwd's .git store and
|
|
72
|
+
// so opportunistically updates job.cwd's remote-tracking ref on push,
|
|
73
|
+
// masking the staleness this test needs to exercise) — the observable
|
|
74
|
+
// symptom is identical: job.cwd's remote-tracking ref is stale until fetched.
|
|
75
|
+
test('committedInWindow sees a commit pushed from an isolated checkout, only reachable via a stale remote-tracking ref', async () => {
|
|
76
|
+
const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-committed-in-window-bare-'));
|
|
77
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-committed-in-window-cwd-'));
|
|
78
|
+
const worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-committed-in-window-wt-'));
|
|
79
|
+
try {
|
|
80
|
+
git(bareDir, ['init', '-q', '--bare']);
|
|
81
|
+
|
|
82
|
+
const seedDir = makeRepo();
|
|
83
|
+
try {
|
|
84
|
+
git(seedDir, ['push', bareDir, 'HEAD:refs/heads/main']);
|
|
85
|
+
// Without an explicit default branch, the bare repo's symbolic HEAD can
|
|
86
|
+
// point at a nonexistent ref (e.g. 'master'), which makes the clone
|
|
87
|
+
// below check out no local branch at all — set it explicitly.
|
|
88
|
+
git(bareDir, ['symbolic-ref', 'HEAD', 'refs/heads/main']);
|
|
89
|
+
} finally {
|
|
90
|
+
fs.rmSync(seedDir, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
git(cwd, ['clone', '-q', bareDir, '.']);
|
|
94
|
+
git(cwd, ['config', 'user.email', 'test@example.com']);
|
|
95
|
+
git(cwd, ['config', 'user.name', 'Test']);
|
|
96
|
+
|
|
97
|
+
const startedAt = new Date().toISOString();
|
|
98
|
+
|
|
99
|
+
// Simulate the PRD's own isolated-checkout workflow: a separate clone of
|
|
100
|
+
// the same shared remote, a commit there, a push back to that remote —
|
|
101
|
+
// job.cwd never touches this checkout and never fetches after the push.
|
|
102
|
+
git(worktreeDir, ['clone', '-q', bareDir, '.']);
|
|
103
|
+
git(worktreeDir, ['config', 'user.email', 'test@example.com']);
|
|
104
|
+
git(worktreeDir, ['config', 'user.name', 'Test']);
|
|
105
|
+
git(worktreeDir, ['checkout', '-q', '-b', 'temp-merge-branch']);
|
|
106
|
+
fs.writeFileSync(path.join(worktreeDir, 'feature.txt'), 'feature\n');
|
|
107
|
+
git(worktreeDir, ['add', '.']);
|
|
108
|
+
git(worktreeDir, ['commit', '-q', '-m', 'feature commit from isolated worktree']);
|
|
109
|
+
git(worktreeDir, ['push', 'origin', 'temp-merge-branch']);
|
|
110
|
+
|
|
111
|
+
const finishedAt = new Date(Date.now() + 1000).toISOString();
|
|
112
|
+
|
|
113
|
+
// Precondition: without a fetch, job.cwd genuinely cannot see the commit —
|
|
114
|
+
// this is what made committedInWindow's old bare `git log --all` blind to it.
|
|
115
|
+
expect(() => git(cwd, ['log', '--all', '--format=%H', '--grep=feature commit from isolated worktree']))
|
|
116
|
+
.not.toThrow();
|
|
117
|
+
const beforeFetch = git(cwd, ['log', '--all', '--format=%H', '--grep=feature commit from isolated worktree']);
|
|
118
|
+
expect(beforeFetch).toBe('');
|
|
119
|
+
|
|
120
|
+
const result = await committedInWindow(cwd, startedAt, finishedAt);
|
|
121
|
+
expect(result).toBe(true);
|
|
122
|
+
} finally {
|
|
123
|
+
fs.rmSync(bareDir, { recursive: true, force: true });
|
|
124
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
125
|
+
fs.rmSync(worktreeDir, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
});
|