claude-code-session-manager 0.40.2 → 0.41.0

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 (51) hide show
  1. package/dist/assets/{TiptapBody-CFCp4Mz9.js → TiptapBody-BnRle0iw.js} +1 -1
  2. package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
  3. package/dist/assets/{index-bdqOSyxG.js → index-H7rwsoKV.js} +650 -643
  4. package/dist/index.html +2 -2
  5. package/package.json +3 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -5
  7. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  8. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +2 -2
  9. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
  10. package/plugins/session-manager-dev/skills/project-status/SKILL.md +20 -34
  11. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +68 -0
  12. package/scripts/lib/watchdogHelpers.cjs +5 -5
  13. package/scripts/mint-epic.cjs +28 -0
  14. package/scripts/propose-epic.cjs +55 -0
  15. package/src/main/__tests__/epicMint.test.cjs +85 -0
  16. package/src/main/__tests__/prdCreate.test.cjs +54 -2
  17. package/src/main/__tests__/promptSessionEvents.test.cjs +56 -2
  18. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  19. package/src/main/__tests__/rcaFeedbackHook.test.cjs +43 -211
  20. package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +63 -0
  21. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +86 -0
  22. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +21 -0
  23. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +78 -0
  24. package/src/main/browserView.cjs +1 -1
  25. package/src/main/chatRunner.cjs +68 -55
  26. package/src/main/config.cjs +12 -6
  27. package/src/main/docEdit.cjs +3 -4
  28. package/src/main/health.cjs +5 -0
  29. package/src/main/index.cjs +12 -0
  30. package/src/main/ipcSchemas.cjs +51 -11
  31. package/src/main/lib/__tests__/opsOwnership.test.cjs +92 -0
  32. package/src/main/lib/__tests__/projectBriefCore.test.cjs +74 -0
  33. package/src/main/lib/epicMint.cjs +133 -59
  34. package/src/main/lib/opsOwnership.cjs +166 -0
  35. package/src/main/lib/prdCreate.cjs +9 -1
  36. package/src/main/lib/prdLocations.cjs +21 -0
  37. package/src/main/lib/projectBriefCore.cjs +70 -4
  38. package/src/main/lib/queueStore.cjs +3 -0
  39. package/src/main/lib/rcaFeedbackHook.cjs +41 -55
  40. package/src/main/projectBrief.cjs +36 -2
  41. package/src/main/promptSessionEvents.cjs +24 -2
  42. package/src/main/promptSessionTranscript.cjs +0 -0
  43. package/src/main/queueOps.cjs +1 -1
  44. package/src/main/scheduler/prdParser.cjs +8 -0
  45. package/src/main/scheduler.cjs +281 -164
  46. package/src/main/templates/PRD_AUTHORING.md +1 -1
  47. package/src/preload/api.d.ts +76 -3
  48. package/src/preload/index.cjs +21 -3
  49. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
  50. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
  51. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +0 -265
package/dist/index.html CHANGED
@@ -7,10 +7,10 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-bdqOSyxG.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-H7rwsoKV.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
- <link rel="stylesheet" crossorigin href="./assets/index-BxVBtmjA.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-CKY3mHgV.css">
14
14
  </head>
15
15
  <body class="bg-bg text-fg font-sans antialiased">
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.40.2",
3
+ "version": "0.41.0",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -13,6 +13,8 @@
13
13
  "plugins/",
14
14
  "scripts/postinstall.cjs",
15
15
  "scripts/lib/",
16
+ "scripts/propose-epic.cjs",
17
+ "scripts/mint-epic.cjs",
16
18
  "src/main/",
17
19
  "src/preload/",
18
20
  "dist/index.html",
@@ -20,7 +20,7 @@ description: >-
20
20
  **Role:** `/develop` owns the *pipeline*: it turns a development request into one or more
21
21
  self-contained PRDs, queues them, and tracks them to completion. It is the convergence point
22
22
  for both entry paths — an interactive human prompt comes straight here; an agent feedback file
23
- arrives via `/process-feedback`, which evaluates it and then calls this skill. Everything from
23
+ arrives as an approved Epic proposal (`/propose-epic`), which the user approves and then calls this skill. Everything from
24
24
  here on is identical regardless of who asked.
25
25
 
26
26
  **Never** hand-implement the work inline in chat, and never restate rules that live elsewhere:
@@ -66,7 +66,7 @@ can't load skills.
66
66
  1. **Clarify scope first.** If the prompt has genuine ambiguity (acceptance criteria, target
67
67
  repo, framework, edge cases), ask 2–4 focused questions as plain text and wait. Don't use
68
68
  the AskUserQuestion tool. Don't guess on decisions that would cost real rework. (When the
69
- caller is `/process-feedback`, scope is already established by its evaluation — don't
69
+ caller is an approved Epic proposal, scope is already established by its objective — don't
70
70
  re-ask; build from the brief it hands you.)
71
71
 
72
72
  2. **Explore the target repo — broadly, not just the obvious file.** Identify the absolute
@@ -186,7 +186,7 @@ can't load skills.
186
186
  (`title`, `cwd`, `estimateMinutes`, `goal`, `acceptanceCriteria[]`, `implementationNotes`,
187
187
  `outOfScope[]`) maps directly onto the sections below — pass them straight through. It
188
188
  allocates a strictly-unique `NN` atomically (no read-then-write race against another
189
- concurrent `/develop`/`/process-feedback` invocation, never reused across the project — PRD
189
+ concurrent `/develop` invocation, never reused across the project — PRD
190
190
  832), derives and collision-checks the slug, and embeds the standards pointer for you.
191
191
  `parallelGroup` is DEPRECATED and ignored — express ordering with the `dependsOn` input
192
192
  (slugs that must complete first); independent PRDs simply omit it and may run in parallel.
@@ -334,7 +334,7 @@ can't load skills.
334
334
  ## Phase 2 — Track to completion (reusable tail)
335
335
 
336
336
  The queued PRDs run headlessly and can take a while. Don't fire-and-forget, and don't block —
337
- hand off to a recurring check. `/process-feedback` delegates to this exact phase, so it is the
337
+ hand off to a recurring check. An approved proposal delegates to this exact phase, so it is the
338
338
  single definition of "tracked to done" for both entry paths.
339
339
 
340
340
  7. **Watch the scheduler every ~30 min.** Start a 30-minute monitoring loop (`/loop 30m` over
@@ -348,7 +348,7 @@ single definition of "tracked to done" for both entry paths.
348
348
  per `PRD_AUTHORING.md`). Don't silently retry forever. For `needs_review`, the scheduler
349
349
  auto-files a Root Cause Analysis into the target project's feedback inbox
350
350
  (`rcaFeedbackHook`, filename `<date>-rca-<slug>-<runId>.md`) — reference that file in
351
- your report rather than re-deriving the analysis, and let `/process-feedback` fold its
351
+ your report rather than re-deriving the analysis, and let the approving user fold its
352
352
  prevention hint back into future PRD authoring. A `rateLimited` exit-1 is the
353
353
  scheduler's benign auto-pause (auto-resumes next window) — keep waiting, don't escalate.
354
354
  - **All PRDs completed successfully** — go to step 8.
@@ -82,7 +82,7 @@ Data-driven from 400+ scheduler runs: long hangs (not bad code) are the dominant
82
82
  - **Verify before done.** Run the acceptance test command once before declaring success. If it's red, fix it or `exit 1` with the failure — never end the run on a failing test (that trips the verifier's `transcript_errors` downgrade).
83
83
  - **Fail loud, fail fast.** On any step failure, print one diagnostic line and `exit 1`; don't swallow with `|| true` or spin in a silent retry. A `rateLimited` exit-1 is the scheduler's benign auto-pause (auto-resumes next window) — not a failure to engineer around.
84
84
  - **Stay in the AC.** Do not add work past the acceptance checklist ("while we're here" generators/fixtures are the post-AC-overrun incident). Body must be clean UTF-8 — no NUL/control bytes.
85
- - **You ARE the executor — never re-queue or self-schedule.** A headless PRD run must perform its own acceptance criteria directly. Do NOT invoke `/develop`, `/process-feedback`, or any queue-authoring skill from inside a run — those are interactive main-loop skills that author a *new* PRD and return, so the run exits 0 having done nothing (no commit, no sentinel → `needs_review` with `no_verdict_sentinel`). Do NOT call `ScheduleWakeup`/set a tracking loop either — the process exits when the run ends and nothing re-invokes it. This applies just as much to spawning your own review agents and waiting on them: do NOT invoke `/code-review`, `/security-review`, `requesting-code-review`, or any other skill/subagent as a background/async step and then end your turn with something like "I'll wait for the review agents to complete" — a headless run has no next turn, so that line is the run's last output, no verdict sentinel prints, and the job parks in `needs_review` even though the actual work already landed. If a PRD's acceptance criteria call for a second review pass, run it **synchronously, inline, before the finish protocol** — call the reviewer and read its result in the same turn, don't fire-and-wait. If the PRD's work looks large, decompose and execute it inline within this run; never delegate it back to the queue. (Incidents: PRD 460 invoked `/develop`, spawned a duplicate PRD 461, and exited 0 with no work. PRD 479 landed its commit correctly but then backgrounded `/code-review --fix` + `/security-review` and called `ScheduleWakeup` to "wait" for them — same class of failure, different entry point.)
85
+ - **You ARE the executor — never re-queue or self-schedule.** A headless PRD run must perform its own acceptance criteria directly. Do NOT invoke `/develop`, `/propose-epic`, or any queue-authoring skill from inside a run — those are interactive main-loop skills that author a *new* PRD and return, so the run exits 0 having done nothing (no commit, no sentinel → `needs_review` with `no_verdict_sentinel`). Do NOT call `ScheduleWakeup`/set a tracking loop either — the process exits when the run ends and nothing re-invokes it. This applies just as much to spawning your own review agents and waiting on them: do NOT invoke `/code-review`, `/security-review`, `requesting-code-review`, or any other skill/subagent as a background/async step and then end your turn with something like "I'll wait for the review agents to complete" — a headless run has no next turn, so that line is the run's last output, no verdict sentinel prints, and the job parks in `needs_review` even though the actual work already landed. If a PRD's acceptance criteria call for a second review pass, run it **synchronously, inline, before the finish protocol** — call the reviewer and read its result in the same turn, don't fire-and-wait. If the PRD's work looks large, decompose and execute it inline within this run; never delegate it back to the queue. (Incidents: PRD 460 invoked `/develop`, spawned a duplicate PRD 461, and exited 0 with no work. PRD 479 landed its commit correctly but then backgrounded `/code-review --fix` + `/security-review` and called `ScheduleWakeup` to "wait" for them — same class of failure, different entry point.)
86
86
  - **A shared-repo `cwd` can be occupied by a concurrent job — check before you touch shared state.** When a PRD's `cwd` is a repo other headless runs may also target (a shared team repo like sigma, not a private single-purpose project), a `git checkout`/`gh pr checkout` can land you in another job's live worktree with its own uncommitted WIP. Before running `git stash`, `git reset`, or any command that discards or hides working-tree state, check `git stash list` and `git status` first, and if you must set aside pre-existing uncommitted changes that aren't yours, **stash with a descriptive message** (`git stash push -m "pre-existing WIP found by PRD <NN>, not mine"`) and **restore it before your run ends** (or, if you can't safely restore because your own commit depends on that worktree state, leave it stashed with the message and say so explicitly in your finish output — never let the run end silently dropping someone else's stash). Never `git stash drop`/`git clean -fd` on state you didn't create. (Incident: PRD 477 stashed a concurrent job's rAF-throttle-revert WIP to get its own checkout, finished, and exited without restoring it — orphaning the other job's uncommitted work in `stash@{0}` with no record of whose it was.)
87
87
  - **`gh pr edit --body` can fail on repos with legacy GitHub Projects (classic) boards** — the underlying GraphQL query fetches `repository.pullRequest.projectCards`, a field GitHub is sunsetting, and errors with `GraphQL: Projects (classic) is being deprecated ... (repository.pullRequest.projectCards)` even though the edit itself would otherwise succeed. This is a known `gh` CLI quirk, not a defect in your work. Prefer `gh api -X PATCH repos/<owner>/<repo>/pulls/<n> -f body="$(cat body.md)"` for updating a PR description headlessly — it doesn't touch the deprecated field. If you do use `gh pr edit` and it fails this way, don't leave the bare GraphQL error as the last thing in that step (it reads as an unrecovered error in the final-20%-of-transcript verifier heuristic): immediately retry with the `gh api` form and print one line noting the known-bug fallback, so the recovery is adjacent to the error.
88
88
  - **`gh pr checks`/`gh run watch` exit non-zero while CI is merely *pending*, not failed — don't let that surface as a bare error.** Polling `gh pr checks <n>` before checks finish returns a non-zero exit (e.g. 8) with output like `check pending 0 <url>` — this is normal, documented `gh` CLI behavior, not a failure. If you retry with a *differently-worded* command (e.g. dropping a `sleep N &&` prefix, or switching to `gh run watch <id> --exit-status`), the verifier's self-recovery detector pairs retries by exact command-description match and may not recognize the differently-worded retry as the same recovery, leaving the original pending-state error looking unrecovered in the transcript (incident: `745-pr188-ci-lint-docs-integrity`, a fully green, committed, pushed run flagged `needs_review` over exactly this). Prefer polling with the *same* command/description each time (e.g. loop `gh pr checks <n>` unchanged, or use `gh run watch <id> --exit-status` from the start rather than switching mid-poll) so a later success is recognized as recovering the earlier pending-state failure.
@@ -67,9 +67,9 @@ If you can't state it in the present tense without a date or a PRD, cut it.
67
67
  - **The Skill Map** — `session-manager-operations/HUMAN_LEARN/SKILL_MAP.html`, a
68
68
  **separate, dedicated** page (NOT a section of index.html) that visualizes the
69
69
  *local-development skill chain*: the two intakes (interactive human prompt;
70
- agent feedback via `/process-feedback`) converging on `/develop`, which owns
70
+ agent proposals via `/propose-epic`, approved by the user) converging on `/develop`, which owns
71
71
  PRD authoring + reads `standards.md`, queues onto the scheduler, and gates with
72
- review/verify — plus `/my-feedback` outbound. It is the "how I build on this
72
+ review/verify. It is the "how I build on this
73
73
  project" companion to index.html's "how this project works." index.html links
74
74
  to it from the nav; it links back.
75
75
 
@@ -74,7 +74,7 @@ blocking PR number — never a silent omission.
74
74
 
75
75
  - This skill only *reads* the tracker (issues/PRs) and git state — it makes no commits,
76
76
  opens nothing, and files nothing. If the loop surfaces a genuine follow-up that can't be
77
- picked now, note it in the report; do not auto-file a new issue or `/my-feedback` item
77
+ picked now, note it in the report; do not auto-file a new issue or `/propose-epic` item
78
78
  for it. Filing anything public/outward is a human decision, not this skill's default.
79
79
  - Don't rank an item you couldn't verify is actually unclaimed and non-conflicting — when
80
80
  in doubt, put it in the skip list with the uncertainty stated, rather than ranking a
@@ -134,51 +134,37 @@ A status check shouldn't dead-end at a report. Once the four dimensions are in,
134
134
  self-contained *bootstrap → evaluate → improve* loop, usable even on a brand-new
135
135
  project.
136
136
 
137
- **First, work the inbox — triage before generating new work.** Right after the
138
- audit, clear the inbound queue via **`/process-feedback`**: it evaluates any
139
- pending feedback (cross-project asks others filed to us, plus the prior cycle's
140
- `/optimize-kpi` item) and routes each — ours-do-it → `/develop`, theirs →
141
- `/my-feedback`, decline-with-reason. Don't generate new levers on top of an
142
- unprocessed backlog. (If the inbox is empty, say so and move on.)
143
-
144
- **Reconcile queued feedback — this audit owns it, because `/process-feedback`
145
- hands off at queue time.** Under the resilience contract, `/process-feedback` is
146
- done the moment an item is queued as a PRD and archived to `processed/` — it does
147
- NOT wait for the PRD to land. So the ongoing tracking is **this skill's job**: as
148
- part of the chores/scheduler audit, cross-reference the feedback status-log
149
- against `queue.json` and (a) flip any **🛠** row whose PRD now shows `completed`
150
- to **✅** with the landing evidence, and (b) surface any `failed` / `needs_review`
151
- / stuck Burrow PRD behind a still-🛠 row as a finding to route. The file is
152
- already in `processed/`; only the row's status is reconciled here. A `-local` may
153
- supply the exact cross-reference command (grep 🛠 rows → look up their PRD id in
154
- the queue); if it doesn't, do it inline. Never expect `/process-feedback` to have
155
- delivery-gated the archive — that's the anti-pattern this split removes.
137
+ **First, work the pending proposals — decide before generating new work.** Right
138
+ after the audit, look at the project's **proposed Epics** (status `proposed` in
139
+ `session-manager-operations/prompt-sessions/active-index.json`): these are the
140
+ items agents filed for a human decision. Report them so the user can approve
141
+ or discard; do not
142
+ approve them yourself, and do not stack new levers on top of an undecided
143
+ backlog. (If there are none, say so and move on.)
144
+
145
+ There is no inbox to reconcile and no archive convention to keep in step: an
146
+ approved proposal becomes an active Epic whose PRDs and runs are already visible
147
+ in the Scheduler, so its status is read from the queue directly rather than
148
+ tracked in a parallel status log.
156
149
 
157
150
  Then route **this audit's own findings**, by ownership:
158
151
 
159
152
  1. **A finding that belongs to another project** (an upstream/downstream service
160
153
  in the stack — e.g. the data source is stale, a contract drifted): do NOT
161
- reach across the boundary. File it with **`/my-feedback <project>`** into that
162
- project's intake, and note it in the report. (Same rule as everywhere: service
163
- boundaries outrank convenience.)
154
+ reach across the boundary. File it with **`/propose-epic <that project's cwd>`**,
155
+ and note it in the report. (Same rule as everywhere: service boundaries
156
+ outrank convenience.)
164
157
  2. **A finding that belongs to THIS project** (a real bug, a missing guard, an
165
158
  enhancement the audit revealed — a cold sold-surface, a recurring error in the
166
159
  logs, a usage gap): queue it as a scheduled PRD via **`/develop`** — never
167
160
  implement it inline here. Record the queued PRD id in the report.
168
161
  3. **Nothing actionable** — just report.
169
162
 
170
- Then, the improvement gate: **if and only if the full health verdict is GREEN**
171
- (every check passing — not degraded, not down) **and** the KPI/crons are clean,
172
- invoke **`/optimize-kpi`** to drive the next North-Star improvement. The green gate
173
- is load-bearing: optimizing the KPI on a broken or degraded base is wasted
174
- motion — *fix health first* (via routes 1/2 above), and let a later run, once
175
- green, trigger the optimizer. State explicitly in the report whether the gate
176
- opened (green → optimize queued) or stayed shut (degraded/down → health findings
177
- routed first).
178
-
179
- This sequence — audit → route findings (my-feedback / develop) → optimize-when-green
180
- — is enough to bootstrap, evaluate, and continuously improve a project from a
181
- single `/project-status` invocation. The `-local` owns the *specifics* of each
163
+ Then stop. There is no automatic improvement gate: findings are routed as
164
+ proposals and PRDs above, and the user decides what gets worked on next.
165
+
166
+ This sequence — audit → route findings (propose-epic / develop) — is enough to
167
+ bootstrap and evaluate a project from a single `/project-status` invocation. The `-local` owns the *specifics* of each
182
168
  step; this framework owns the *loop*.
183
169
 
184
170
  ## Model expectation (every local must mirror this)
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: propose-epic
3
+ description: >-
4
+ File work you think should happen as a PROPOSED Epic — an Epic that does not
5
+ start until a human presses Approve. Replaces the retired /process-feedback
6
+ and /my-feedback intake pipeline: there is no feedback folder, no triage
7
+ pass, and no archiving convention, because the proposal IS the work item.
8
+ Use whenever you would previously have written a feedback file — for this
9
+ project or another one — or whenever the user says "/propose-epic",
10
+ "propose this", "file this for later", "suggest we do X", "send feedback to
11
+ X". Keywords: propose, proposal, feedback, enhancement request, file for
12
+ approval, cross-project request, backlog.
13
+ ---
14
+
15
+ # propose-epic
16
+
17
+ **Role:** the one way an agent asks for work to happen that it is not doing right now.
18
+
19
+ You do not implement, and you do not queue anything that runs. You file a **proposed
20
+ Epic** and stop. A human approves it in the Epics workspace, at which point its objective
21
+ becomes the first prompt of its own claude session — the same start path every hand-created
22
+ Epic takes.
23
+
24
+ ## Why this replaced the feedback folder
25
+
26
+ The old flow was: write a markdown file into `session-manager-operations/feedback/` →
27
+ `/process-feedback` later reads it, evaluates it, dedupes it, queues PRDs via `/develop`,
28
+ archives the file to `processed/`, and updates a README with lessons. Four moving parts and
29
+ ~400 lines of instructions existed to move an idea from "written down" to "being worked on".
30
+
31
+ An Epic already is that thing. It has a goal, an intent tag, its own session, its own PRD
32
+ directory, and a place in the UI. The only piece it was missing was "don't start yet" —
33
+ that is the `proposed` status. So the intake pipeline collapses into one command.
34
+
35
+ ## How to file one
36
+
37
+ ```bash
38
+ node "$SM_ROOT/scripts/propose-epic.cjs" <project-cwd> "<one-line title>" [feature|bug|discussion] <<'BODY'
39
+ <the full objective — this is sent verbatim as the first prompt on approval>
40
+ BODY
41
+ ```
42
+
43
+ `$SM_ROOT` is session-manager's own package root. Resolve it once, in this order:
44
+ 1. You are working inside the session-manager repo → `.` (use `scripts/propose-epic.cjs`).
45
+ 2. Otherwise the installed package — this skill file lives at
46
+ `<SM_ROOT>/plugins/session-manager-dev/skills/propose-epic/SKILL.md`, so
47
+ `SM_ROOT` is four directories up from this file's own path.
48
+
49
+ - **`<project-cwd>`** — the project the work belongs to. Cross-project requests are just a
50
+ proposal filed into *that* project's cwd; there is no separate outbound skill.
51
+ - **title** — one line. It is what the Epics list shows. Keep it specific: `Scheduler rows
52
+ don't show their Epic`, not `Scheduler bug`.
53
+ - **body** — write it as an instruction to the agent that will pick it up, not as a report
54
+ to a human. Include what you observed, where in the code it lives (file:line if you know
55
+ it), and what done looks like. It is the first prompt, so it should be enough to act on.
56
+ - Re-proposing the same title into the same project **joins** the existing proposal and
57
+ enriches it rather than duplicating it. Say more in the body and re-run; that is the
58
+ update mechanism.
59
+
60
+ Report back the Epic id and the title you filed. Do not queue PRDs, do not edit code, and
61
+ do not ask whether you should file it — filing is the default action, and the approval gate
62
+ is the user's control, not your prompt.
63
+
64
+ ## When NOT to propose
65
+
66
+ - The user asked you to do the work now → do it (or `/develop` it), don't propose it.
67
+ - It is a one-line fix you are already in the file for → just fix it.
68
+ - Nothing actionable → say so plainly and file nothing.
@@ -294,7 +294,7 @@ function hasOpenFeedback(cwd) {
294
294
  *
295
295
  * Complexity: O(P) over PRD files in prdsDir + O(J) over queue jobs.
296
296
  */
297
- function emitFeedbackPRD(cwd, {
297
+ async function emitFeedbackPRD(cwd, {
298
298
  prdsDir,
299
299
  queuePath,
300
300
  skillPath,
@@ -313,7 +313,7 @@ function emitFeedbackPRD(cwd, {
313
313
  // Every PRD belongs to an Epic (CLAUDE.md domain model). The sweep joins
314
314
  // one standing "Inbound feedback" Epic per project (reuseByGoal) so
315
315
  // recurring ticks chain into a single Epic rather than minting one each.
316
- const epic = ensureEpic(cwd, {
316
+ const epic = await ensureEpic(cwd, {
317
317
  goalText: 'Inbound feedback processing',
318
318
  tag: 'discussion',
319
319
  reuseByGoal: true,
@@ -474,7 +474,7 @@ function emitFeedbackPRD(cwd, {
474
474
  if (epicId !== null) {
475
475
  // Trace the dispatch on the Epic's event chain (prompt → prd_created →
476
476
  // ...) so the Epic conversation shows the PRD it spawned.
477
- try { appendPrdCreatedEvent(cwd, epicId, slug, `Feedback sweep emitted ${slug}`); } catch { /* trace is best-effort */ }
477
+ try { await appendPrdCreatedEvent(cwd, epicId, slug, `Feedback sweep emitted ${slug}`); } catch { /* trace is best-effort */ }
478
478
  }
479
479
  } catch (e) {
480
480
  try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
@@ -655,7 +655,7 @@ function maybeRelaunchApp({
655
655
  * projectsDir — forwarded to activeProjectCwds for testing
656
656
  * prdsDir, queuePath, skillPath, standardsPath — forwarded to emitFeedbackPRD
657
657
  */
658
- function sweep({
658
+ async function sweep({
659
659
  projectsDir,
660
660
  prdsDir,
661
661
  queuePath,
@@ -679,7 +679,7 @@ function sweep({
679
679
  // dir); an explicit override (tests) is forwarded verbatim to every cwd.
680
680
  let result;
681
681
  try {
682
- result = emitFeedbackPRD(cwd, { prdsDir, queuePath, skillPath, standardsPath });
682
+ result = await emitFeedbackPRD(cwd, { prdsDir, queuePath, skillPath, standardsPath });
683
683
  } catch (e) {
684
684
  process.stderr.write(`[sweep] error emitting PRD for ${cwd}: ${e?.message}\n`);
685
685
  continue;
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mint-epic.cjs — CLI wrapper over lib/epicMint.cjs for PRD authors working
4
+ * without the app's admin API (the /develop skill's manual-write fallback).
5
+ *
6
+ * Usage:
7
+ * node scripts/mint-epic.cjs <cwd> "<goal text>" [feature|bug|discussion]
8
+ *
9
+ * Prints the Epic's prds/ write directory on stdout (last line), minting the
10
+ * Epic (and registering it in prompt-sessions/active-index.json) if no active
11
+ * Epic with the same goal text exists — reuse-by-goal, so re-running for the
12
+ * same goal is idempotent and returns the same directory.
13
+ */
14
+ 'use strict';
15
+
16
+ const { ensureEpic } = require('../src/main/lib/epicMint.cjs');
17
+
18
+ const [cwd, goalText, tag] = process.argv.slice(2);
19
+ if (!cwd || !goalText) {
20
+ process.stderr.write('usage: mint-epic.cjs <cwd> "<goal text>" [feature|bug|discussion]\n');
21
+ process.exit(1);
22
+ }
23
+
24
+ (async () => {
25
+ const { epicId, prdDir, created } = await ensureEpic(cwd, { goalText, tag, reuseByGoal: true });
26
+ process.stderr.write(`${created ? 'minted' : 'joined'} epic ${epicId}\n`);
27
+ process.stdout.write(`${prdDir}\n`);
28
+ })();
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * propose-epic.cjs — file an Epic that does NOT start.
4
+ *
5
+ * This is the replacement for the feedback-folder intake. Instead of writing
6
+ * a markdown file into `session-manager-operations/feedback/` for a separate
7
+ * skill to later parse, triage, dedupe and re-queue, an agent files a
8
+ * `proposed` Epic: it appears in the Epics workspace with an Approve & start
9
+ * gate, and nothing runs or is spent until a human presses it.
10
+ *
11
+ * One mechanism replaces the whole intake pipeline — the proposal IS the
12
+ * work item, already in the place work is done, already carrying its own
13
+ * session and PRD directory.
14
+ *
15
+ * Usage:
16
+ * node scripts/propose-epic.cjs <cwd> "<one-line title>" [feature|bug|discussion]
17
+ * # full body (the first prompt sent on approval) is read from stdin;
18
+ * # when stdin is empty the title is used as the body.
19
+ *
20
+ * Prints the Epic id on stdout. Reuse-by-title: re-proposing the same title
21
+ * into the same project joins the existing proposal instead of duplicating it.
22
+ */
23
+ 'use strict';
24
+
25
+ const { ensureEpic } = require('../src/main/lib/epicMint.cjs');
26
+
27
+ const [cwd, title, tag] = process.argv.slice(2);
28
+ if (!cwd || !title) {
29
+ process.stderr.write('usage: propose-epic.cjs <cwd> "<title>" [feature|bug|discussion] (body on stdin)\n');
30
+ process.exit(1);
31
+ }
32
+
33
+ function readStdin() {
34
+ return new Promise((resolve) => {
35
+ if (process.stdin.isTTY) { resolve(''); return; }
36
+ let buf = '';
37
+ process.stdin.setEncoding('utf8');
38
+ process.stdin.on('data', (d) => { buf += d; });
39
+ process.stdin.on('end', () => resolve(buf));
40
+ process.stdin.on('error', () => resolve(''));
41
+ });
42
+ }
43
+
44
+ (async () => {
45
+ const body = (await readStdin()).trim();
46
+ const { epicId, created } = await ensureEpic(cwd, {
47
+ goalText: title,
48
+ tag: tag || 'feature',
49
+ status: 'proposed',
50
+ reuseByGoal: true,
51
+ openingPrompt: body || title,
52
+ });
53
+ process.stderr.write(`${created ? 'proposed' : 'joined existing proposal'} epic ${epicId}\n`);
54
+ process.stdout.write(`${epicId}\n`);
55
+ })();
@@ -0,0 +1,85 @@
1
+ /**
2
+ * epicMint.test.cjs — unit tests for ensureEpic's sourcePromptId join
3
+ * semantics (see PRD authoring notes: sourcePromptId must be an existing
4
+ * Epic's promptSessionId, i.e. its active-index.json sessions key — NOT a
5
+ * PromptTicket.id).
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/epicMint.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, afterEach } from 'vitest';
13
+ const fs = require('node:fs');
14
+ const fsp = require('node:fs/promises');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const { ensureEpic, removeEpic, readActiveIndex } = require('../lib/epicMint.cjs');
18
+
19
+ const tmpDirs = [];
20
+ afterEach(async () => {
21
+ while (tmpDirs.length) {
22
+ const d = tmpDirs.pop();
23
+ await fsp.rm(d, { recursive: true, force: true });
24
+ }
25
+ });
26
+
27
+ async function mkCwd() {
28
+ const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-epicmint-cwd-'));
29
+ tmpDirs.push(cwd);
30
+ return cwd;
31
+ }
32
+
33
+ test('ensureEpic mints a new Epic when explicit epicId matches no existing Epic (documents current behavior for a stray PromptTicket.id)', async () => {
34
+ const cwd = await mkCwd();
35
+ const notAPromptSessionId = 'ticket-abc123';
36
+
37
+ const result = await ensureEpic(cwd, { goalText: 'do the thing', epicId: notAPromptSessionId });
38
+
39
+ expect(result.created).toBe(true);
40
+ expect(result.epicId).not.toBe(notAPromptSessionId);
41
+
42
+ const index = readActiveIndex(cwd);
43
+ expect(index.sessions[notAPromptSessionId]).toBeUndefined();
44
+ expect(index.sessions[result.epicId]).toBeDefined();
45
+ expect(Object.keys(index.sessions)).toHaveLength(1);
46
+ });
47
+
48
+ test('ensureEpic joins the existing Epic when explicit epicId equals its promptSessionId', async () => {
49
+ const cwd = await mkCwd();
50
+
51
+ const first = await ensureEpic(cwd, { goalText: 'initial epic goal' });
52
+ expect(first.created).toBe(true);
53
+
54
+ const second = await ensureEpic(cwd, { goalText: 'a different PRD title', epicId: first.epicId });
55
+
56
+ expect(second.created).toBe(false);
57
+ expect(second.epicId).toBe(first.epicId);
58
+ expect(second.prdDir).toBe(first.prdDir);
59
+
60
+ const index = readActiveIndex(cwd);
61
+ expect(Object.keys(index.sessions)).toHaveLength(1);
62
+ });
63
+
64
+ test('removeEpic deletes a minted Epic from both sessions and events maps', async () => {
65
+ const cwd = await mkCwd();
66
+ const minted = await ensureEpic(cwd, { goalText: 'to be rolled back' });
67
+ expect(readActiveIndex(cwd).sessions[minted.epicId]).toBeDefined();
68
+
69
+ const removed = removeEpic(cwd, minted.epicId);
70
+
71
+ expect(removed).toBe(true);
72
+ const index = readActiveIndex(cwd);
73
+ expect(index.sessions[minted.epicId]).toBeUndefined();
74
+ expect(index.events[minted.epicId]).toBeUndefined();
75
+ });
76
+
77
+ test('removeEpic is a no-op (returns false) for an unknown epicId', async () => {
78
+ const cwd = await mkCwd();
79
+ await ensureEpic(cwd, { goalText: 'unrelated epic' });
80
+
81
+ const removed = removeEpic(cwd, 'nonexistent-epic-id');
82
+
83
+ expect(removed).toBe(false);
84
+ expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(1);
85
+ });
@@ -171,7 +171,7 @@ function makeFakeRemoteWithPrdsDir(prdsDir) {
171
171
  async writePrd(slug, body) {
172
172
  try {
173
173
  const filePath = path.join(prdsDir, `${slug}.md`);
174
- await config.writeTextAtomic(filePath, body);
174
+ await config.writeTextAtomic(filePath, body, { writer: 'scheduler' });
175
175
  return { ok: true };
176
176
  } catch (e) {
177
177
  return { ok: false, error: e?.message };
@@ -413,7 +413,8 @@ test('validateWrite allows writes under <root>/session-manager-operations/schedu
413
413
  await fsp.mkdir(prdsDir, { recursive: true });
414
414
  const filePath = path.join(prdsDir, '1-do-thing.md');
415
415
 
416
- await config.writeTextAtomic(filePath, 'PRD body\n');
416
+ // Writes into scheduler/ must declare the owning surface (single-writer law).
417
+ await config.writeTextAtomic(filePath, 'PRD body\n', { writer: 'scheduler' });
417
418
 
418
419
  expect(fs.existsSync(filePath)).toBeTruthy();
419
420
  expect(await fsp.readFile(filePath, 'utf8')).toBe('PRD body\n');
@@ -437,6 +438,57 @@ test('createPrd normalizes a `~`-prefixed cwd via expandHome before calling remo
437
438
  }
438
439
  });
439
440
 
441
+ // ──────────────────────────────────────────── PRD 862: register cwd as an
442
+ // allowed write root at createPrd() time, not only at pty.spawn() time
443
+ //
444
+ // Before this fix, config.cjs's allowedRoots (the write-boundary allowlist)
445
+ // only ever grew via pty.cjs's addAllowedRoot call inside pty.spawn(). A
446
+ // chat-only Epic (headless `claude -p --resume`, no Terminal PTY ever
447
+ // spawned for its project cwd) reached createPrd() -> remote.writePrd() ->
448
+ // config.writeTextAtomic() -> config.validateWrite() and failed with "Write
449
+ // outside allowed write boundaries", purely because no PTY had registered
450
+ // that cwd yet in this process's lifetime. This test reproduces that exact
451
+ // scenario: a brand-new project root that has NEVER had config.addAllowedRoot
452
+ // called for it (mirroring pty.spawn() never having run), writing through the
453
+ // real config.writeTextAtomic into the real session-manager-operations/
454
+ // scheduler/ subtree (not the mocked-remote temp dirs the other tests here
455
+ // use, which pre-register their root via addAllowedRoot in mkTmpPrdsDir).
456
+ test('createPrd registers cwd as an allowed write root itself, so a chat-only Epic (no Terminal PTY ever spawned for this cwd) can still write its PRD', async () => {
457
+ // Must live under $HOME: validatePath's read boundary is home-dir-wide
458
+ // regardless of allowedRoots registration (matching every real project
459
+ // cwd, which checkInsideHome forces inside $HOME) — the bug this test
460
+ // reproduces is specifically about the *write* boundary (validateWrite),
461
+ // not the read one.
462
+ const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-chat-only-epic-'));
463
+ // Deliberately do NOT call config.addAllowedRoot(root) here — pty.spawn()
464
+ // is the only other caller of addAllowedRoot, and it never runs for a
465
+ // chat-only Epic. createPrd() itself must perform this registration.
466
+ const prdsEpicDir = path.join(root, 'session-manager-operations', 'scheduler', 'epics', 'chat-only-epic', 'prds');
467
+
468
+ try {
469
+ const remote = {
470
+ async allocateParallelGroup() { return 1; },
471
+ async readPrd() { return { ok: false }; },
472
+ async writePrd(slug, body) {
473
+ await fsp.mkdir(prdsEpicDir, { recursive: true });
474
+ const filePath = path.join(prdsEpicDir, `${slug}.md`);
475
+ // Real config.cjs write path — this is what threw
476
+ // "Write outside allowed write boundaries" before the fix.
477
+ await config.writeTextAtomic(filePath, body, { writer: 'scheduler' });
478
+ return { ok: true };
479
+ },
480
+ };
481
+
482
+ const result = await createPrd(validCreateBody({ cwd: root }), remote);
483
+
484
+ expect(result.ok).toBe(true);
485
+ const written = await fsp.readFile(path.join(prdsEpicDir, result.filename), 'utf8');
486
+ expect(written).toMatch(/title: Add widget frobnication/);
487
+ } finally {
488
+ await fsp.rm(root, { recursive: true, force: true });
489
+ }
490
+ });
491
+
440
492
  test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the created PRD frontmatter', async () => {
441
493
  const prdsDir = await mkTmpPrdsDir();
442
494
  const remote = makeFakeRemoteWithPrdsDir(prdsDir);
@@ -9,13 +9,18 @@
9
9
 
10
10
  'use strict';
11
11
 
12
- import { test, expect, beforeEach } from 'vitest';
12
+ import { test, expect, beforeEach, vi } from 'vitest';
13
13
  const fs = require('node:fs');
14
14
  const fsp = require('node:fs/promises');
15
15
  const os = require('node:os');
16
16
  const path = require('node:path');
17
17
  const config = require('../config.cjs');
18
- const { appendResponseEventIfKnown, promptSessionActiveIndexPath } = require('../promptSessionEvents.cjs');
18
+ const {
19
+ appendResponseEventIfKnown,
20
+ promptSessionActiveIndexPath,
21
+ attachWindow,
22
+ EVENT_APPENDED_CHANNEL,
23
+ } = require('../promptSessionEvents.cjs');
19
24
 
20
25
  let cwd;
21
26
 
@@ -104,3 +109,52 @@ test('returns false (never throws) when cwd or sourcePromptId is missing', async
104
109
  await expect(appendResponseEventIfKnown(null, 'psess-1', 'hi')).resolves.toBe(false);
105
110
  await expect(appendResponseEventIfKnown(cwd, null, 'hi')).resolves.toBe(false);
106
111
  });
112
+
113
+ test('broadcasts EVENT_APPENDED_CHANNEL to the attached window on a successful append', async () => {
114
+ await writeIndex({
115
+ sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
116
+ events: {
117
+ 'psess-1': [
118
+ { id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' },
119
+ ],
120
+ },
121
+ });
122
+
123
+ const send = vi.fn();
124
+ const fakeWindow = { isDestroyed: () => false, webContents: { isDestroyed: () => false, send } };
125
+ attachWindow(fakeWindow);
126
+ try {
127
+ const routed = await appendResponseEventIfKnown(cwd, 'psess-1', 'PRD finished');
128
+ expect(routed).toBe(true);
129
+ expect(send).toHaveBeenCalledTimes(1);
130
+ const [channel, payload] = send.mock.calls[0];
131
+ expect(channel).toBe(EVENT_APPENDED_CHANNEL);
132
+ expect(payload.cwd).toBe(cwd);
133
+ expect(payload.promptSessionId).toBe('psess-1');
134
+ expect(payload.event.kind).toBe('response');
135
+ expect(payload.event.text).toBe('PRD finished');
136
+ expect(payload.event.causedByEventId).toBe('pevt-1');
137
+ } finally {
138
+ attachWindow(null);
139
+ }
140
+ });
141
+
142
+ test('does not throw broadcasting before a window is attached, or after it is destroyed', async () => {
143
+ await writeIndex({
144
+ sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
145
+ events: {
146
+ 'psess-1': [
147
+ { id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' },
148
+ ],
149
+ },
150
+ });
151
+
152
+ attachWindow(null);
153
+ await expect(appendResponseEventIfKnown(cwd, 'psess-1', 'no window yet')).resolves.toBe(true);
154
+
155
+ const destroyedWindow = { isDestroyed: () => true, webContents: { isDestroyed: () => false, send: vi.fn() } };
156
+ attachWindow(destroyedWindow);
157
+ await expect(appendResponseEventIfKnown(cwd, 'psess-1', 'window destroyed')).resolves.toBe(true);
158
+ expect(destroyedWindow.webContents.send).not.toHaveBeenCalled();
159
+ attachWindow(null);
160
+ });