claude-code-session-manager 0.38.0 → 0.38.2

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 (32) hide show
  1. package/dist/assets/{TiptapBody-GMk5LS9Y.js → TiptapBody-UWL5KIJG.js} +1 -1
  2. package/dist/assets/{index-CQqSHpIq.js → index-CZPF8Qve.js} +571 -571
  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/src/main/__tests__/classifyPromptTicket.test.cjs +101 -0
  17. package/src/main/__tests__/exchangesPromptId.test.cjs +70 -0
  18. package/src/main/__tests__/prdCreate.test.cjs +34 -0
  19. package/src/main/__tests__/prdParserSourcePromptId.test.cjs +65 -0
  20. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +99 -0
  21. package/src/main/chatRunner.cjs +78 -38
  22. package/src/main/exchanges.cjs +9 -3
  23. package/src/main/index.cjs +2 -1
  24. package/src/main/ipcSchemas.cjs +36 -0
  25. package/src/main/lib/classifyPromptTicket.cjs +116 -0
  26. package/src/main/lib/prdCreate.cjs +9 -2
  27. package/src/main/scheduler/prdParser.cjs +9 -0
  28. package/src/main/scheduler.cjs +72 -1
  29. package/src/main/webRemote.cjs +10 -0
  30. package/src/preload/api.d.ts +22 -0
  31. package/src/preload/index.cjs +9 -0
  32. package/dist/assets/index-CD_LuJZF.css +0 -32
@@ -0,0 +1,140 @@
1
+ ---
2
+ name: blog-for-project-feature
3
+ description: Produce a self-contained, interactive HTML "feature showcase" for one project + one feature — Project Mission & Description, Feature Description (with an optional generated companion illustration), and a Click-Through Demo built from REAL screenshots (never mockups) — one aspect revealed at a time. Output is a single portable HTML file uploadable anywhere (no server, no build step). Distinct from blog-from-git (which drafts narrative bilko.run changelog posts from git history) — this is an evergreen, per-feature product page, not a dated post. Orchestrates 8 nested sub-skills (blog-for-project-feature:select, :research, :storyboard, :capture, :draft-copy, :illustrate, :compose, :review) so each step is independently invokable and inspectable. Use for "make a feature page for X", "showcase this feature", "/blog-for-project-feature", "interactive demo of X", or turning a shipped feature in Session Manager / Social Signal Builder (or any project) into something you can hand someone a link to.
4
+ ---
5
+
6
+ # blog-for-project-feature (orchestrator)
7
+
8
+ Project-agnostic. Runs against whatever project the current working directory (or a named
9
+ project path) points to — built first for **Session Manager** and **Social Signal Builder**,
10
+ but nothing here is hardcoded to either.
11
+
12
+ **Not `blog-from-git`.** That skill drafts a dated, narrative bilko.run post from git activity
13
+ ("here's what shipped this week"). This skill produces an **evergreen, single-feature product
14
+ page** — no date, no changelog framing, not tied to a publishing cadence. One project, one
15
+ feature, three things: what the project is for, what this feature does, and a real click-through
16
+ demo of it working. The two skills never share output or ledger state.
17
+
18
+ **`feature.config.yaml` in this folder is the grounding authority** for output conventions
19
+ (where the HTML lands, image size/compression limits, single-file-vs-folder default, theme
20
+ tokens). Read it first, before any sub-skill file. If prose here ever disagrees with the config,
21
+ the config wins.
22
+
23
+ ## Pipeline DAG
24
+
25
+ ```
26
+ project (+ optional feature name)
27
+
28
+
29
+ ┌───────────────────────────────┐
30
+ │ 0. blog-for-project-feature:select │──▶ one confirmed project + one confirmed feature
31
+ └───────────────────────────────┘
32
+
33
+
34
+ ┌───────────────────────────────┐
35
+ │ 1. :research │──▶ mission, feature description, key files/entry points
36
+ └───────────────────────────────┘
37
+
38
+
39
+ ┌───────────────────────────────┐
40
+ │ 2. :storyboard │──▶ ordered demo steps (action, expected screen, caption)
41
+ └───────────────────────────────┘
42
+
43
+
44
+ ┌───────────────────────────────┐
45
+ │ 3. :capture │──▶ real screenshots per step (+ storyboard corrections)
46
+ └───────────────────────────────┘ │ no real screenshot obtainable for a step
47
+ │ all steps captured ▼
48
+ ▼ STOP — report which step blocked, don't fabricate
49
+ ┌───────────────────────────────┐
50
+ │ 4. :draft-copy │──▶ FINAL mission/feature text + one-sentence "essence" line
51
+ └───────────────────────────────┘
52
+
53
+
54
+ ┌───────────────────────────────┐
55
+ │ 5. :illustrate │──▶ one companion illustration from the essence line
56
+ └───────────────────────────────┘ (or a clean "omitted" note — never blocks the pipeline)
57
+
58
+
59
+ ┌───────────────────────────────┐
60
+ │ 6. :compose │──▶ one self-contained interactive HTML file
61
+ └───────────────────────────────┘
62
+
63
+
64
+ ┌───────────────────────────────┐
65
+ │ 7. :review │──── fails a check ────┐
66
+ └───────────────────────────────┘ │
67
+ │ passes │
68
+ ▼ │
69
+ report path, wait for explicit │
70
+ "publish/upload" instruction loop back to :compose / :capture / :illustrate
71
+ ```
72
+
73
+ | Step | Input | Output | On failure |
74
+ |---|---|---|---|
75
+ | 0. `:select` | project (cwd or named) + optional feature hint | one confirmed project + one confirmed feature name + why it's demo-worthy | **STOP** — report if no clear feature can be confirmed (don't guess) |
76
+ | 1. `:research` | confirmed project + feature | mission/description (project-level), feature description, key files, how it actually works | n/a — always produces notes, flags gaps explicitly rather than inventing claims |
77
+ | 2. `:storyboard` | mission + feature notes | ordered list of `{step, action, expected_screen, caption}` — one aspect per step | n/a — storyboard is a draft, `:capture` is allowed to correct it |
78
+ | 3. `:capture` | storyboard | one real screenshot file per step + any corrected captions | **STOP** on that step — report what's missing; never substitute a placeholder/mockup image |
79
+ | 4. `:draft-copy` | research notes + captured captions | final Mission & Feature Description prose + one-sentence essence line | n/a — always produces final text, gaps stay explicit |
80
+ | 5. `:illustrate` | the essence line (text must exist first) | one companion illustration, tagged distinct from screenshots — or an explicit omission note | never blocks — skips cleanly if no image generator is configured, or after one off-brief retry |
81
+ | 6. `:compose` | final copy + captured steps + optional illustration | one self-contained HTML file (or html+images folder per config) | loop back to `:capture` if a required image is missing, else fix and retry |
82
+ | 7. `:review` | the composed HTML | pass/fail against the gate below | loop back to `:compose` (content), `:capture` (screenshot), or `:illustrate` (mislabeled companion image) — never ship on a failed gate |
83
+
84
+ ## Golden rule: screenshots are real or the step doesn't exist
85
+
86
+ This skill exists specifically because "feature demo" content is worthless if the images are
87
+ invented, stale, or from the wrong build. `:capture` uses `mcp__playwright__*` (or the project's
88
+ own screenshot tooling if `run`/a project skill already drives it — check for one first) against
89
+ a running instance of the actual project. If a step can't be captured for real (app won't start,
90
+ feature is behind auth you don't have, flow requires data that doesn't exist locally), **that
91
+ step is dropped and reported, not faked.** A shorter, honest demo beats a complete, fabricated one.
92
+
93
+ ## Output
94
+
95
+ Default: one self-contained `.html` file (inline CSS/JS, base64-embedded images) written to
96
+ `docs/feature-showcase/<feature-slug>.html` inside the target project — see
97
+ `feature.config.yaml` for the exact path convention, size ceiling, and the single-file vs.
98
+ folder fallback (used when embedded images would blow past the size ceiling). This is what
99
+ "uploadable anywhere" means: no server, no relative asset paths, no build step — open the file
100
+ or drop it on any static host and it works.
101
+
102
+ **Only push, deploy, or upload when explicitly asked.** Report the local path and a one-line
103
+ description of what the page shows, and wait — same convention as `blog-from-git`'s seed gate
104
+ and `issue-address`'s "only push when asked" rule.
105
+
106
+ ## Why nested skills instead of one inline pass
107
+
108
+ Same reasoning as `issue-address` and `pr-review-sweep`: each phase is a real, independently
109
+ invokable `SKILL.md` so progress is visible step by step ("storyboard produced 5 steps, capture
110
+ got 4 real screenshots, 1 dropped") instead of one opaque generation pass, and a single phase
111
+ (e.g. swapping the screenshot tool, changing the HTML theme) can be edited without touching the
112
+ others. If a step is still doing too much once this ships, decompose it further the same way
113
+ `issue-address:select` was (nested sub-DAG) — continuously, wherever one file covers too much to
114
+ inspect as one unit.
115
+
116
+ ## Folder map
117
+
118
+ ```
119
+ blog-for-project-feature/
120
+ feature.config.yaml ← output path convention, size ceiling, theme tokens — read first
121
+ SKILL.md ← this file: pipeline, DAG, output contract
122
+ 0-select/SKILL.md
123
+ 1-research/SKILL.md
124
+ 2-storyboard/SKILL.md
125
+ 3-capture/SKILL.md
126
+ 4-draft-copy/SKILL.md
127
+ 5-illustrate/SKILL.md
128
+ 6-compose/SKILL.md
129
+ 7-review/SKILL.md
130
+ ```
131
+
132
+ ## What this skill is not
133
+
134
+ - Not `blog-from-git` — no dated post, no rotation ledger, no git-diff scan. This is
135
+ feature-scoped and evergreen; that skill is time-scoped and narrative.
136
+ - Not a marketing copy generator — every claim in `:research`'s output must trace to real code,
137
+ docs, or a live screenshot, same evidentiary bar as `blog-from-git`'s `ground.md`.
138
+ - Not a full site generator — one feature, one page, per run. A project with N features worth
139
+ showcasing gets N runs (and, if wanted later, a separate index/landing skill — not built yet,
140
+ don't invent one inline).
@@ -0,0 +1,74 @@
1
+ # Grounding authority for blog-for-project-feature. Read this before any sub-skill file.
2
+ # If prose in a SKILL.md ever disagrees with this file, this file wins.
3
+
4
+ output:
5
+ # Path is relative to the TARGET project's repo root (the project being showcased),
6
+ # not this plugin's own repo.
7
+ path_template: "docs/feature-showcase/{feature-slug}.html"
8
+ feature_slug_rule: "kebab-case, derived from the confirmed feature name in :select"
9
+ # Prefer one self-contained file. Only fall back to a folder when the size ceiling
10
+ # below would be exceeded by inlining images.
11
+ default_mode: single_file # single_file | folder
12
+ folder_fallback_layout:
13
+ html: "docs/feature-showcase/{feature-slug}/index.html"
14
+ images: "docs/feature-showcase/{feature-slug}/screenshots/"
15
+ single_file_size_ceiling_mb: 8 # switch to folder_fallback_layout above this
16
+ never_upload_or_deploy_without_explicit_ask: true
17
+
18
+ images:
19
+ format: png
20
+ max_width_px: 1600 # downscale before embedding; demo clarity, not pixel-perfect
21
+ compression: "reasonable lossy ok for PNG->WebP if it keeps text legible; never so lossy captions become unreadable"
22
+ source_of_truth: "mcp__playwright__browser_take_screenshot against a real running instance"
23
+ placeholder_or_mockup_images: forbidden # a dropped step beats a fabricated one
24
+
25
+ illustration:
26
+ # The ONE companion image, generated by :illustrate — a distinct image class from the
27
+ # click-through screenshots above. Optional: omitted cleanly if no generator is configured.
28
+ runs_after: draft-copy # never generate from anything but finalized copy
29
+ generator: "project-local generator if the target project has one, else Gemini
30
+ (gemini-2.5-flash-image via google-genai — same provider/pattern as Burrow's
31
+ app/shared/image_gen.py) using GEMINI_API_KEY / GOOGLE_API_KEY from the environment"
32
+ count: 1
33
+ placement: "Feature Description section header only — never inside the Click-Through Demo"
34
+ format: png
35
+ max_width_px: 1200
36
+ style: "conceptual/editorial illustration — mood/palette should follow the project's own
37
+ theme tokens above if it has brand colors, otherwise a clean flat/geometric style"
38
+ must_not_resemble: "a UI mockup, screenshot, or contain invented interface text/window chrome
39
+ — must be visually distinguishable from a real captured screen at a glance"
40
+ max_regeneration_attempts: 1
41
+ on_no_generator_available: "skip silently, no page-visible note, report to the operator only"
42
+
43
+ content:
44
+ sections_required:
45
+ - "Project Mission & Description" # 2-4 sentences, from :research, project-level not feature-level
46
+ - "Feature Description" # what it does, why it matters, 1-2 short paragraphs
47
+ - "Click-Through Demo" # the storyboard steps, one aspect revealed at a time
48
+ demo_interaction: "one-step-at-a-time reveal (next/prev controls or numbered tabs) — never a single long scroll dump of every screenshot at once"
49
+ tone: "same plain, non-salesy, show-don't-tell bar as blog-from-git/voice.md bot-tell blocklist — no 'excited to share', no grand-implication filler"
50
+ caption_rule: "every screenshot has a one-sentence caption naming what changed on screen, not what the code does internally"
51
+
52
+ theme:
53
+ # Minimal neutral tokens so every showcase looks consistent without importing a design system.
54
+ # Swap these per-project only if the project has its own documented brand tokens.
55
+ font_stack: "system-ui, -apple-system, 'Segoe UI', sans-serif"
56
+ bg: "#0b0d10"
57
+ surface: "#15181d"
58
+ text: "#e8eaed"
59
+ text_muted: "#9aa0a6"
60
+ accent: "#5b9dff"
61
+ radius_px: 10
62
+ max_width_px: 900
63
+
64
+ gates:
65
+ # Checked in step 7 (:review). All must pass before reporting the page ready.
66
+ - "every image referenced actually loads (no broken src, no 404 in a local file:// open)"
67
+ - "zero placeholder/mockup images — every screenshot traces to a real :capture run"
68
+ - "if present, the companion illustration sits only in the Feature Description header and is
69
+ visually distinguishable from a real screenshot (no UI chrome/invented interface text)"
70
+ - "all three required sections present and non-empty (illustration is optional, never gates)"
71
+ - "demo is click-through (has working next/prev or tab controls), not a flat scroll"
72
+ - "file opens correctly via file:// with no console errors (network calls, external CDN fonts/JS forbidden — must be fully offline-capable)"
73
+ - "single_file_size_ceiling_mb respected, or folder fallback used instead"
74
+ - "no invented claims — every sentence in Mission/Feature sections traces to :research notes"
@@ -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
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * scheduler-notify-originating-tab.test.cjs — unit tests for
3
+ * notifyOriginatingTab (PRD 761): on a job's terminal completed/failed
4
+ * transition, push a short status prompt into the chat tab that originated
5
+ * it via enqueueExternalPrompt (PRD 753).
6
+ *
7
+ * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-notify-originating-tab.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, vi } from 'vitest';
13
+ const { notifyOriginatingTab, isNotifiableTerminalStatus } = require('../scheduler.cjs');
14
+
15
+ test('isNotifiableTerminalStatus: completed and failed are notifiable', () => {
16
+ expect(isNotifiableTerminalStatus('completed')).toBe(true);
17
+ expect(isNotifiableTerminalStatus('failed')).toBe(true);
18
+ });
19
+
20
+ test('isNotifiableTerminalStatus: needs_review is not notifiable (may still auto-fix)', () => {
21
+ expect(isNotifiableTerminalStatus('needs_review')).toBe(false);
22
+ });
23
+
24
+ test('isNotifiableTerminalStatus: a rateLimited exit never reaches an effectiveStatus (undefined) and is not notifiable', () => {
25
+ // spawnJob's treatAsPending branch (rateLimited || paused for rate_limit)
26
+ // resets the job to pending and returns before effectiveStatus is ever
27
+ // computed — so this hook is only ever evaluated with undefined/pending,
28
+ // never with a real status, on that path.
29
+ expect(isNotifiableTerminalStatus(undefined)).toBe(false);
30
+ expect(isNotifiableTerminalStatus('pending')).toBe(false);
31
+ });
32
+
33
+ test('resolves via sourceTabId when present on the PRD frontmatter', async () => {
34
+ const sendPrompt = vi.fn();
35
+ const parsePrdRaw = vi.fn(async () => ({ sourceTabId: 'tab-from-frontmatter' }));
36
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
37
+
38
+ await notifyOriginatingTab(
39
+ { slug: '761-notify', status: 'completed', cwd: '/some/cwd' },
40
+ { parsePrdRaw, loadSessions, sendPrompt },
41
+ );
42
+
43
+ expect(loadSessions).not.toHaveBeenCalled();
44
+ expect(sendPrompt).toHaveBeenCalledTimes(1);
45
+ expect(sendPrompt).toHaveBeenCalledWith(
46
+ 'tab-from-frontmatter',
47
+ expect.stringContaining('761-notify'),
48
+ );
49
+ });
50
+
51
+ test('falls back to the first open tab whose cwd matches the job cwd', async () => {
52
+ const sendPrompt = vi.fn();
53
+ const parsePrdRaw = vi.fn(async () => ({ sourceTabId: null }));
54
+ const loadSessions = vi.fn(async () => ({
55
+ tabs: [
56
+ { id: 'tab-other', cwd: '/other/cwd' },
57
+ { id: 'tab-match', cwd: '/some/cwd' },
58
+ { id: 'tab-second-match', cwd: '/some/cwd' },
59
+ ],
60
+ }));
61
+
62
+ await notifyOriginatingTab(
63
+ { slug: '761-notify', status: 'failed', cwd: '/some/cwd' },
64
+ { parsePrdRaw, loadSessions, sendPrompt },
65
+ );
66
+
67
+ expect(sendPrompt).toHaveBeenCalledTimes(1);
68
+ expect(sendPrompt).toHaveBeenCalledWith('tab-match', expect.stringContaining('761-notify'));
69
+ });
70
+
71
+ test('no-ops without throwing when neither sourceTabId nor a cwd-matching tab resolves', async () => {
72
+ const sendPrompt = vi.fn();
73
+ const parsePrdRaw = vi.fn(async () => ({ sourceTabId: null }));
74
+ const loadSessions = vi.fn(async () => ({ tabs: [{ id: 'tab-other', cwd: '/unrelated' }] }));
75
+
76
+ await expect(
77
+ notifyOriginatingTab(
78
+ { slug: '761-notify', status: 'completed', cwd: '/some/cwd' },
79
+ { parsePrdRaw, loadSessions, sendPrompt },
80
+ ),
81
+ ).resolves.not.toThrow();
82
+
83
+ expect(sendPrompt).not.toHaveBeenCalled();
84
+ });
85
+
86
+ test('no-ops when parsing the PRD frontmatter throws', async () => {
87
+ const sendPrompt = vi.fn();
88
+ const parsePrdRaw = vi.fn(async () => { throw new Error('ENOENT'); });
89
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
90
+
91
+ await expect(
92
+ notifyOriginatingTab(
93
+ { slug: '761-notify', status: 'completed', cwd: '/some/cwd' },
94
+ { parsePrdRaw, loadSessions, sendPrompt },
95
+ ),
96
+ ).resolves.not.toThrow();
97
+
98
+ expect(sendPrompt).not.toHaveBeenCalled();
99
+ });