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.
- package/dist/assets/{TiptapBody-GMk5LS9Y.js → TiptapBody-UWL5KIJG.js} +1 -1
- package/dist/assets/{index-CQqSHpIq.js → index-CZPF8Qve.js} +571 -571
- 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/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-notify-originating-tab.test.cjs +99 -0
- package/src/main/chatRunner.cjs +78 -38
- package/src/main/exchanges.cjs +9 -3
- package/src/main/index.cjs +2 -1
- package/src/main/ipcSchemas.cjs +36 -0
- package/src/main/lib/classifyPromptTicket.cjs +116 -0
- package/src/main/lib/prdCreate.cjs +9 -2
- package/src/main/scheduler/prdParser.cjs +9 -0
- package/src/main/scheduler.cjs +72 -1
- package/src/main/webRemote.cjs +10 -0
- package/src/preload/api.d.ts +22 -0
- package/src/preload/index.cjs +9 -0
- 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
|
+
});
|