claude-code-session-manager 0.38.0 → 0.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-GMk5LS9Y.js → TiptapBody-DlPTesio.js} +1 -1
- package/dist/assets/{index-CQqSHpIq.js → index-CBSyk1aI.js} +569 -569
- 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/chatRunner.cjs +34 -38
- package/src/main/exchanges.cjs +9 -3
- package/src/main/ipcSchemas.cjs +16 -0
- package/src/main/lib/classifyPromptTicket.cjs +116 -0
- package/src/main/lib/prdCreate.cjs +6 -2
- package/src/main/scheduler/prdParser.cjs +4 -0
- package/src/main/scheduler.cjs +3 -0
- package/src/preload/api.d.ts +11 -0
- package/src/preload/index.cjs +2 -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
|
+
});
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -9,15 +9,17 @@
|
|
|
9
9
|
* CONCURRENCY_CAP (SM_CHAT_CONCURRENCY overrides, clamped to [1,3]); modeled
|
|
10
10
|
* on the scheduler's serialized queue so a burst of probes can't fan out into
|
|
11
11
|
* the parallel-`claude -p` OOM that SIGKILLed a job on 2026-06-26.
|
|
12
|
-
* PRD 493
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* PRD 493 had manual, user-initiated runs bypass that lane entirely (always
|
|
13
|
+
* immediate, uncapped). A later PRD (per-tab prompt queue) folded manual runs
|
|
14
|
+
* back into the SAME FIFO lane — a per-tab queue in the renderer (chat.ts)
|
|
15
|
+
* now only ever calls run() once per tab at a time, so the cap governs
|
|
16
|
+
* cross-tab fan-out, not per-tab ordering.
|
|
15
17
|
*
|
|
16
18
|
* Public surface:
|
|
17
19
|
* run({ tabId, sessionId, prompt, cwd, resume, silent }): void — fire-and-forget enqueue.
|
|
18
|
-
*
|
|
19
|
-
* six turn-affecting broadcasts + recordExchange
|
|
20
|
-
* (
|
|
20
|
+
* Both silent (PRD 470) and manual runs go through the same CONCURRENCY_CAP FIFO lane.
|
|
21
|
+
* Silent runs additionally suppress the six turn-affecting broadcasts + recordExchange
|
|
22
|
+
* (see probeContextUsage below).
|
|
21
23
|
* cancel(tabId): Promise<void> — resolves once the cancelled run fully settles
|
|
22
24
|
* parseStopSignal(finalText): { questions: string[] } | null — exported for reuse
|
|
23
25
|
* parseContextUsageMarkdown(text): { usedTokens, totalTokens, usedPct, categories } | null
|
|
@@ -46,6 +48,7 @@ const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
|
|
|
46
48
|
const { recordExchange } = require('./exchanges.cjs');
|
|
47
49
|
const { classifyToolUse } = require('./lib/toolUseClassify.cjs');
|
|
48
50
|
const { extractJson } = require('./lib/extractJson.cjs');
|
|
51
|
+
const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
|
|
49
52
|
|
|
50
53
|
// ─── Stop-signal protocol ──────────────────────────────────────────────────
|
|
51
54
|
// Single source of truth for the sentinel and parser. The renderer (PRD 320)
|
|
@@ -267,10 +270,9 @@ const CHAT_MODE_TRUTH_INSTRUCTION =
|
|
|
267
270
|
`to continue.\n\n`;
|
|
268
271
|
|
|
269
272
|
// ─── Serial run queue (v0.34) ───────────────────────────────────────────────
|
|
270
|
-
// CONCURRENCY_CAP=2 (default) governs
|
|
271
|
-
// two
|
|
272
|
-
// overrides, clamped to [1, 3].
|
|
273
|
-
// the `waiting` FIFO — see run() below.
|
|
273
|
+
// CONCURRENCY_CAP=2 (default) governs ALL runs — silent probes and manual
|
|
274
|
+
// sends alike share one FIFO lane; two can run at once across all tabs before
|
|
275
|
+
// a 3rd queues. SM_CHAT_CONCURRENCY still overrides, clamped to [1, 3].
|
|
274
276
|
|
|
275
277
|
const DEFAULT_CAP = 2;
|
|
276
278
|
// Clamp to [1, 3]; default 2 = "two loops at a time". Read lazily (not
|
|
@@ -318,13 +320,14 @@ function broadcast(channel, payload) {
|
|
|
318
320
|
|
|
319
321
|
/**
|
|
320
322
|
* Enqueue a chat run for a tab. Fire-and-forget — results arrive via IPC.
|
|
321
|
-
*
|
|
322
|
-
* FIFO lane — extra submits queue and are
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
323
|
+
* Both silent (automated probe) and manual (user-initiated) runs share the
|
|
324
|
+
* CONCURRENCY_CAP=2 (default) FIFO lane — extra submits queue and are
|
|
325
|
+
* announced via chat:run:queued (silent runs stay invisible to the renderer;
|
|
326
|
+
* see pump() below). De-dupes a tab already in the pipeline (the renderer's
|
|
327
|
+
* per-tab prompt queue in chat.ts only ever calls this once per tab at a
|
|
328
|
+
* time, but the guard holds regardless).
|
|
326
329
|
*
|
|
327
|
-
* @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void }} opts
|
|
330
|
+
* @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void, promptId?: string }} opts
|
|
328
331
|
*/
|
|
329
332
|
function run(opts) {
|
|
330
333
|
// Per-tab exclusivity guard — unrelated to the cross-tab cap; must hold for
|
|
@@ -332,23 +335,8 @@ function run(opts) {
|
|
|
332
335
|
// for the same tab against the same --resume sessionId.
|
|
333
336
|
if (inFlight.has(opts.tabId) || waiting.some((w) => w.tabId === opts.tabId)) return;
|
|
334
337
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
// against the 2026-06-10 parallel-claude-p OOM).
|
|
338
|
-
waiting.push(opts);
|
|
339
|
-
pump();
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// PRD 493: manual/user-initiated sends are intentionally UNCAPPED — never
|
|
344
|
-
// queue behind another tab. This means the ≤3-concurrent-claude-p ceiling in
|
|
345
|
-
// CLAUDE.md no longer strictly holds for foreground sessions (accepted
|
|
346
|
-
// tradeoff). executeRun still registers in inFlight synchronously, so the
|
|
347
|
-
// per-tab guard above and cancel() keep working; no activeCount, no pump.
|
|
348
|
-
const donePromise = executor(opts);
|
|
349
|
-
const entry = inFlight.get(opts.tabId);
|
|
350
|
-
if (entry) entry.donePromise = donePromise;
|
|
351
|
-
donePromise.catch(() => { /* executeRun never rejects; defensive */ });
|
|
338
|
+
waiting.push(opts);
|
|
339
|
+
pump();
|
|
352
340
|
}
|
|
353
341
|
|
|
354
342
|
// Fill open lanes FIFO up to CONCURRENCY_CAP, then announce queue positions for
|
|
@@ -385,10 +373,10 @@ function pump() {
|
|
|
385
373
|
* for the run's lifetime so cancel() can reach it. Never rejects — the queue
|
|
386
374
|
* pump relies on the returned promise always settling so the lane frees.
|
|
387
375
|
*
|
|
388
|
-
* @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void }} opts
|
|
376
|
+
* @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void, promptId?: string }} opts
|
|
389
377
|
* @returns {Promise<void>}
|
|
390
378
|
*/
|
|
391
|
-
function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult }) {
|
|
379
|
+
function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult, promptId }) {
|
|
392
380
|
return new Promise((resolve) => {
|
|
393
381
|
let settled = false;
|
|
394
382
|
// Frees the lane exactly once: drops the cancel fn and resolves the promise
|
|
@@ -577,13 +565,14 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
577
565
|
cwd,
|
|
578
566
|
prompt,
|
|
579
567
|
result: `${signal.answerBody}\n\n[asked: ${signal.questions.join(' | ')}]`,
|
|
568
|
+
promptId,
|
|
580
569
|
}).catch((err) => {
|
|
581
570
|
console.error('[chatRunner] recordExchange failed:', err?.message ?? err);
|
|
582
571
|
});
|
|
583
572
|
} else {
|
|
584
573
|
emitTerminal('chat:run:complete', { tabId, sessionId, finalMessage: text });
|
|
585
574
|
// Record durable exchange off the hot path — UI must not wait on Haiku
|
|
586
|
-
recordExchange({ sessionId, cwd, prompt, result: text }).catch((err) => {
|
|
575
|
+
recordExchange({ sessionId, cwd, prompt, result: text, promptId }).catch((err) => {
|
|
587
576
|
console.error('[chatRunner] recordExchange failed:', err?.message ?? err);
|
|
588
577
|
});
|
|
589
578
|
}
|
|
@@ -693,11 +682,18 @@ function cancel(tabId) {
|
|
|
693
682
|
function registerChatHandlers() {
|
|
694
683
|
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
695
684
|
|
|
696
|
-
ipcMain.handle('chat:run', validated(schemas.chatRun, async ({ tabId, sessionId, prompt, cwd, resume }) => {
|
|
697
|
-
run({ tabId, sessionId, prompt, cwd, resume: !!resume });
|
|
685
|
+
ipcMain.handle('chat:run', validated(schemas.chatRun, async ({ tabId, sessionId, prompt, cwd, resume, promptId }) => {
|
|
686
|
+
run({ tabId, sessionId, prompt, cwd, resume: !!resume, promptId });
|
|
698
687
|
return { ok: true };
|
|
699
688
|
}));
|
|
700
689
|
|
|
690
|
+
// Classification for a queued PromptTicket reaching the front of its
|
|
691
|
+
// per-tab queue (PRD 749) — a direct bounded call, never a scheduler job
|
|
692
|
+
// (see classifyPromptTicket.cjs's docblock for why that would be circular).
|
|
693
|
+
ipcMain.handle('chat:classify-ticket', validated(schemas.chatClassifyTicket, async ({ text }) => {
|
|
694
|
+
return classifyPromptTicket(text);
|
|
695
|
+
}));
|
|
696
|
+
|
|
701
697
|
ipcMain.handle('chat:cancel', async (_e, payload) => {
|
|
702
698
|
let tabId;
|
|
703
699
|
try { tabId = schemas.chatCancel.parse(payload).tabId; } catch { return; }
|