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.
Files changed (53) hide show
  1. package/dist/assets/{TiptapBody-BtrSXTRp.js → TiptapBody-DlPTesio.js} +1 -1
  2. package/dist/assets/{index-uVGdpAGF.js → index-CBSyk1aI.js} +626 -626
  3. package/dist/assets/index-DyOGjslF.css +32 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/plugins/session-manager-dev/skills/blog-for-project-feature/0-select/SKILL.md +39 -0
  7. package/plugins/session-manager-dev/skills/blog-for-project-feature/1-research/SKILL.md +37 -0
  8. package/plugins/session-manager-dev/skills/blog-for-project-feature/2-storyboard/SKILL.md +38 -0
  9. package/plugins/session-manager-dev/skills/blog-for-project-feature/3-capture/SKILL.md +43 -0
  10. package/plugins/session-manager-dev/skills/blog-for-project-feature/4-draft-copy/SKILL.md +37 -0
  11. package/plugins/session-manager-dev/skills/blog-for-project-feature/5-illustrate/SKILL.md +50 -0
  12. package/plugins/session-manager-dev/skills/blog-for-project-feature/6-compose/SKILL.md +48 -0
  13. package/plugins/session-manager-dev/skills/blog-for-project-feature/7-review/SKILL.md +51 -0
  14. package/plugins/session-manager-dev/skills/blog-for-project-feature/SKILL.md +140 -0
  15. package/plugins/session-manager-dev/skills/blog-for-project-feature/feature.config.yaml +74 -0
  16. package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  17. package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +38 -0
  18. package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +64 -0
  19. package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +71 -0
  20. package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +39 -0
  21. package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +75 -0
  22. package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +39 -0
  23. package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +57 -0
  24. package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +40 -0
  25. package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +33 -0
  26. package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +40 -0
  27. package/plugins/session-manager-dev/skills/issue-address/SKILL.md +120 -0
  28. package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +41 -0
  29. package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +45 -0
  30. package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +49 -0
  31. package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +50 -0
  32. package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +60 -0
  33. package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +124 -0
  34. package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +67 -0
  35. package/src/main/__tests__/chat-cancel-terminal.test.cjs +31 -0
  36. package/src/main/__tests__/chat-exit-close-race.test.cjs +140 -0
  37. package/src/main/__tests__/classifyPromptTicket.test.cjs +101 -0
  38. package/src/main/__tests__/exchangesPromptId.test.cjs +70 -0
  39. package/src/main/__tests__/prdCreate.test.cjs +34 -0
  40. package/src/main/__tests__/prdParserSourcePromptId.test.cjs +65 -0
  41. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +64 -0
  42. package/src/main/chatRunner.cjs +67 -46
  43. package/src/main/exchanges.cjs +9 -3
  44. package/src/main/ipcSchemas.cjs +18 -3
  45. package/src/main/lib/classifyPromptTicket.cjs +116 -0
  46. package/src/main/lib/prdCreate.cjs +6 -2
  47. package/src/main/scheduler/prdParser.cjs +4 -0
  48. package/src/main/scheduler.cjs +30 -4
  49. package/src/main/sessionsStore.cjs +4 -5
  50. package/src/main/webRemote.cjs +3 -3
  51. package/src/preload/api.d.ts +15 -5
  52. package/src/preload/index.cjs +5 -2
  53. 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
+ });