claude-code-session-manager 0.58.0 → 0.60.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 (41) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/{TiptapBody-BEBLdJl_.js → TiptapBody-BtVPGBaq.js} +1 -1
  3. package/dist/assets/{index-DwUffaDq.css → index-CPMP2XZ_.css} +1 -1
  4. package/dist/assets/{index-DEQzGYa6.js → index-DtNip-Zx.js} +1076 -1081
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
  8. package/src/main/__tests__/epicContextDigest.test.cjs +41 -1
  9. package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
  10. package/src/main/__tests__/prdMigration.test.cjs +160 -0
  11. package/src/main/__tests__/projectPages.test.cjs +151 -0
  12. package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
  13. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
  14. package/src/main/__tests__/scheduler-epic-digest.test.cjs +9 -2
  15. package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
  16. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
  17. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
  18. package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
  19. package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
  20. package/src/main/health.cjs +5 -1
  21. package/src/main/index.cjs +1 -1
  22. package/src/main/ipcSchemas.cjs +26 -3
  23. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
  24. package/src/main/lib/classifyTranscriptLine.cjs +131 -48
  25. package/src/main/lib/epicContextDigest.cjs +36 -0
  26. package/src/main/lib/epicMint.cjs +6 -1
  27. package/src/main/lib/epicValidationHook.cjs +192 -0
  28. package/src/main/lib/prdMigration.cjs +76 -5
  29. package/src/main/lib/promptSessionSchema.cjs +16 -0
  30. package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
  31. package/src/main/lib/schedulerBatch.cjs +70 -111
  32. package/src/main/lib/schedulerConfig.cjs +0 -1
  33. package/src/main/otel.cjs +3 -1
  34. package/src/main/projectPages.cjs +60 -14
  35. package/src/main/promptSessionEvents.cjs +16 -1
  36. package/src/main/scheduler.cjs +228 -49
  37. package/src/main/templates/project-pages-default-home.html +123 -0
  38. package/src/main/transcripts.cjs +191 -32
  39. package/src/main/webRemote.cjs +8 -7
  40. package/src/preload/api.d.ts +75 -9
  41. package/src/preload/index.cjs +3 -0
@@ -18,15 +18,29 @@ const os = require('node:os');
18
18
  const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
19
19
 
20
20
  /**
21
- * Per-project batch picker. Applies group-ordering rules scoped to a single
22
- * project (all jobs sharing one cwd).
21
+ * Per-project batch picker. `dependsOn` is the ONLY ordering primitive.
23
22
  *
24
- * Rules (same as original global pickNextBatch, but scoped):
25
- * 1. Find the lowest parallelGroup with pending jobs not already running.
26
- * 2. Failure gate: if an earlier group has failed jobs, hold this project.
27
- * 3. If that group has jobs in flight (backfill), fire more from SAME group.
28
- * 4. If a lower-numbered group arrives late (late-arrival), fire it now.
29
- * 5. If no group is in flight, start the lowest pending group fresh.
23
+ * Rules:
24
+ * 1. Eligible = pending, not already running, and no blocking dependency.
25
+ * 2. A dep is blocking while a queue row for it exists non-completed; a
26
+ * FAILED dep holds its dependents with an explicit reason. Transitive
27
+ * holds fall out of this for free (a held job stays `pending`, which is
28
+ * itself a blocking state for anything depending on IT).
29
+ * 3. Everything eligible fires, up to `slots`, ordered by parallelGroup
30
+ * ascending as a PRIORITY HINT only — never as a barrier.
31
+ *
32
+ * `parallelGroup` used to be a WAVE number and this function used to fire at
33
+ * most one wave per tick, holding every higher wave while a lower one was in
34
+ * flight. PRD 832 made the number strictly UNIQUE per PRD and moved ordering
35
+ * to `dependsOn` — `prdCreate.cjs` now warns that an explicit parallelGroup is
36
+ * "deprecated and ignored" — but this picker was never updated to match. With
37
+ * unique numbers every wave is a singleton, so the batch was always exactly
38
+ * ONE job, and because numbers are allocated monotonically upward, each newly
39
+ * queued PRD always sorted ABOVE the in-flight one and was held behind it.
40
+ * Measured effect: max concurrency 1 across 25 recorded runs, against a
41
+ * 5-slot pool. The group logic is gone; the real limits are the machine-wide
42
+ * `sessionSlots` pool and the memory gate, both enforced by the caller in
43
+ * scheduler.cjs's tickQueue.
30
44
  *
31
45
  * @param {object[]} projectJobs - All jobs for this project (all statuses).
32
46
  * @param {Set<string>} runningSlugsInProject - Slugs from the global
@@ -79,14 +93,24 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
79
93
  const allPending = projectJobs.filter(
80
94
  (j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
81
95
  );
82
- if (allPending.length === 0) return { batch: [], reason: null };
96
+ if (allPending.length === 0) return { batch: [], reason: null, holds: [] };
83
97
 
84
98
  const pending = [];
85
99
  const heldByFailedDep = [];
100
+ // Per-job hold records, surfaced to the UI so a `pending` row can say WHICH
101
+ // dep is holding it instead of leaving the reason in console.log.
102
+ const holds = [];
86
103
  for (const j of allPending) {
87
104
  const dep = blockingDep(j);
88
105
  if (!dep) { pending.push(j); continue; }
89
- if (rowsForDep(dep).some((d) => d.status === 'failed')) heldByFailedDep.push({ job: j, dep });
106
+ const depRows = rowsForDep(dep);
107
+ const failed = depRows.some((d) => d.status === 'failed');
108
+ if (failed) heldByFailedDep.push({ job: j, dep });
109
+ holds.push({
110
+ slug: j.slug,
111
+ dep,
112
+ depStatus: depRows.find((d) => d.status !== 'completed')?.status ?? 'unknown',
113
+ });
90
114
  // running/pending/needs_review dep — simply not eligible this tick.
91
115
  }
92
116
  if (pending.length === 0) {
@@ -94,136 +118,69 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
94
118
  const detail = heldByFailedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
95
119
  const reason = `[scheduler] depends-gate [${projectCwd}]: holding ${heldByFailedDep.length} job(s) behind failed dependencies [${detail}]. Reset or archive the dep to unblock.`;
96
120
  console.log(reason);
97
- return { batch: [], reason };
121
+ return { batch: [], reason, holds };
98
122
  }
99
- return { batch: [], reason: null };
123
+ return { batch: [], reason: null, holds };
100
124
  }
101
125
 
102
- // Lowest pending group (computed up-front for the failure-gate check).
103
- const lowestPendingGroup = pending.reduce(
104
- (min, j) => Math.min(min, j.parallelGroup ?? 99),
105
- Infinity,
106
- );
107
-
108
- // Cross-group failure gate: refuse to advance past a group with failed jobs.
109
- // A failed foundation PRD should not allow later groups to run and
110
- // silently corrupt project state. needs_review is NOT a blocker.
111
- const blockingFailures = projectJobs.filter(
112
- (j) => j.status === 'failed' && (j.parallelGroup ?? 99) < lowestPendingGroup,
113
- );
114
- if (blockingFailures.length > 0) {
115
- const slugs = blockingFailures.map((j) => j.slug).join(', ');
116
- const reason = `[scheduler] failure-gate [${projectCwd}]: holding g${lowestPendingGroup} — ` +
117
- `${blockingFailures.length} failed job(s) in earlier groups [${slugs}]. ` +
118
- `Reset to pending or archive to unblock.`;
119
- console.log(reason);
120
- return { batch: [], reason };
121
- }
122
-
123
- // Groups with at least one job in flight: either tracked in runningSlugsInProject
124
- // (this process spawned it) or still marked 'running' in queue.json
125
- // (persisted from a previous session that hasn't been orphan-reset yet).
126
- const jobBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
127
- const activeGroups = new Set();
128
- for (const slug of runningSlugsInProject) {
129
- const job = jobBySlug.get(slug);
130
- if (job) activeGroups.add(job.parallelGroup ?? 99);
131
- }
132
- for (const j of projectJobs) {
133
- if (j.status === 'running' && !runningSlugsInProject.has(j.slug)) {
134
- activeGroups.add(j.parallelGroup ?? 99);
135
- }
136
- }
137
-
138
- if (activeGroups.size > 0) {
139
- const lowestActive = Math.min(...activeGroups);
140
- if (lowestPendingGroup > lowestActive) {
141
- // Earlier group still running — wait for it to drain before advancing.
142
- const reason = `[scheduler] concurrency [${projectCwd}]: g${lowestActive} in flight, holding g${lowestPendingGroup}`;
143
- console.log(reason);
144
- return { batch: [], reason };
145
- }
146
- if (lowestPendingGroup < lowestActive) {
147
- // Late-arrival: a lower-numbered (higher-priority) PRD reconciled AFTER
148
- // a higher-numbered group was already picked. Fire it now in parallel
149
- // with the active group rather than starving it until drain.
150
- if (slots <= 0) {
151
- const reason = `[scheduler] concurrency [${projectCwd}]: no slots for late-arrival g${lowestPendingGroup}`;
152
- console.log(reason);
153
- return { batch: [], reason };
154
- }
155
- const batch = pending
156
- .filter((j) => (j.parallelGroup ?? 99) === lowestPendingGroup)
157
- .slice(0, slots);
158
- console.log(
159
- `[scheduler] concurrency [${projectCwd}]: firing late-arrival g${lowestPendingGroup} ` +
160
- `(${batch.length} job(s)) alongside active g${lowestActive}`,
161
- );
162
- return { batch, reason: null };
163
- }
164
- // Backfill slots remaining in the current group.
165
- if (slots <= 0) {
166
- const reason = `[scheduler] concurrency [${projectCwd}]: cap reached, no slots`;
167
- console.log(reason);
168
- return { batch: [], reason };
169
- }
170
- const batch = pending
171
- .filter((j) => (j.parallelGroup ?? 99) === lowestActive)
172
- .slice(0, slots);
173
- if (batch.length > 0) {
174
- console.log(
175
- `[scheduler] concurrency [${projectCwd}]: backfilling ${batch.length} into g${lowestActive}`,
176
- );
177
- }
178
- return { batch, reason: null };
179
- }
180
-
181
- // No active group — start the next group fresh.
182
126
  if (slots <= 0) {
183
- const reason = `[scheduler] concurrency [${projectCwd}]: cap reached, no slots`;
127
+ const reason = `[scheduler] concurrency [${projectCwd}]: no slots free, holding ${pending.length} eligible job(s)`;
184
128
  console.log(reason);
185
- return { batch: [], reason };
129
+ return { batch: [], reason, holds };
186
130
  }
131
+
132
+ // parallelGroup is a PRIORITY HINT, not a barrier: when there are more
133
+ // eligible jobs than slots, the lower (earlier-authored) PRD numbers go
134
+ // first. Everything else about the number is display-only.
187
135
  const batch = pending
188
- .filter((j) => (j.parallelGroup ?? 99) === lowestPendingGroup)
136
+ .slice()
137
+ .sort((a, b) => (a.parallelGroup ?? 99) - (b.parallelGroup ?? 99))
189
138
  .slice(0, slots);
190
139
  console.log(
191
- `[scheduler] concurrency [${projectCwd}]: starting g${lowestPendingGroup} with ${batch.length} job(s)`,
140
+ `[scheduler] concurrency [${projectCwd}]: firing ${batch.length} of ${pending.length} ` +
141
+ `eligible job(s) [${batch.map((j) => j.slug).join(', ')}]`,
192
142
  );
193
- return { batch, reason: null };
143
+ return { batch, reason: null, holds };
194
144
  }
195
145
 
196
146
  /**
197
147
  * Pick the next batch of jobs to spawn this tick.
198
148
  *
199
- * Group-ordering gates are evaluated PER PROJECT (keyed by cwd), so jobs in
200
- * different projects are not serialized by each other's groups. Within a
201
- * single project, the existing sequential-group semantics are fully preserved.
149
+ * Dependency gates are evaluated PER PROJECT (keyed by cwd), so jobs in
150
+ * different projects never serialize each other.
202
151
  *
203
152
  * O(N) where N = allJobs.length.
204
153
  *
205
154
  * @param {object[]} allJobs - Full queue.json job list.
206
155
  * @param {Set<string>} running - In-process running slugs (runningSet).
207
- * @param {number} cap - concurrencyCap.
156
+ * @param {number} freeSlots - Slots free RIGHT NOW in the machine-wide
157
+ * `sessionSlots` pool (`sessionSlots.available()`). This is already
158
+ * net of every held slot — scheduler jobs AND chat runs — so it must not
159
+ * have the running count subtracted from it again. The scheduler no longer
160
+ * keeps a private concurrency cap of its own: per sessionSlots.cjs's own
161
+ * charter, caps belong to Session-Manager's one pool, not to each consumer.
208
162
  * @returns {{ batch: object[], reason: string | null }} Jobs to spawn this
209
163
  * tick, plus (when batch is empty because a gate held it) the human-readable
210
164
  * hold reason that would otherwise only reach console.log.
211
165
  */
212
- function pickNextBatch(allJobs, running, cap) {
166
+ function pickNextBatch(allJobs, running, freeSlots) {
213
167
  if (!allJobs.some((j) => j.status === 'pending' && !running.has(j.slug))) {
214
- return { batch: [], reason: null };
168
+ return { batch: [], reason: null, holds: [] };
215
169
  }
216
170
 
217
- // Global slot accounting: take the higher of in-process running count and
218
- // queue.json running count (handles orphaned running entries from a previous
219
- // session not yet reaped).
171
+ // Orphan correction: a queue.json row still marked `running` that this
172
+ // process did NOT spawn holds no pool slot (it is a leftover from a crashed
173
+ // prior session, pending boot reconciliation). The pool cannot see it, so
174
+ // discount it here rather than starting a job into a slot a soon-to-be-
175
+ // reaped process may still be occupying.
220
176
  const queueRunningCount = allJobs.filter((j) => j.status === 'running').length;
221
- const effectiveRunning = Math.max(running.size, queueRunningCount);
222
- let slots = cap - effectiveRunning;
177
+ const untrackedRunning = Math.max(0, queueRunningCount - running.size);
178
+ let slots = freeSlots - untrackedRunning;
223
179
  if (slots <= 0) {
224
- const reason = `[scheduler] concurrency: cap ${cap} reached (${effectiveRunning} running), no slots`;
180
+ const reason = `[scheduler] concurrency: no session slots free ` +
181
+ `(${freeSlots} free in pool, ${untrackedRunning} untracked running row(s))`;
225
182
  console.log(reason);
226
- return { batch: [], reason };
183
+ return { batch: [], reason, holds: [] };
227
184
  }
228
185
 
229
186
  // Group all jobs by project cwd.
@@ -259,15 +216,17 @@ function pickNextBatch(allJobs, running, cap) {
259
216
  // Aggregate batch across projects, consuming global slots as we go. Track
260
217
  // the first hold reason seen so callers can explain an empty overall batch.
261
218
  const batch = [];
219
+ const holds = [];
262
220
  let heldReason = null;
263
221
  for (const { projectJobs, runningSlugsInProject } of projectCandidates) {
264
222
  if (slots <= 0) break;
265
223
  const projectResult = pickForProject(projectJobs, runningSlugsInProject, slots);
266
224
  batch.push(...projectResult.batch);
225
+ holds.push(...(projectResult.holds ?? []));
267
226
  slots -= projectResult.batch.length;
268
227
  if (heldReason === null && projectResult.reason) heldReason = projectResult.reason;
269
228
  }
270
- return { batch, reason: batch.length === 0 ? heldReason : null };
229
+ return { batch, reason: batch.length === 0 ? heldReason : null, holds };
271
230
  }
272
231
 
273
232
  module.exports = { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD };
@@ -17,7 +17,6 @@ module.exports = {
17
17
  HTTP_TIMEOUT_MS: 30_000,
18
18
  HTTP_RETRY_DELAY_MS: 1_000,
19
19
  OFFSET_MINUTES_MAX: 180,
20
- CONCURRENCY_CAP_MAX: 20,
21
20
  MAX_JOB_DURATION_MS: 4 * 60 * 60_000,
22
21
  SUPERVISOR_INTERVAL_MS: 15 * 60_000,
23
22
  SUPERVISOR_PROBE_STALE_MS: 10 * 60_000,
package/src/main/otel.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * otel — mirrors transcripts.cjs classified events as OpenTelemetry spans.
3
3
  *
4
- * Spans are 0-duration "events": one span per classified transcript line,
4
+ * Spans are 0-duration "events": one span per classified transcript EVENT
5
+ * (a single JSONL line can classify to several events — e.g. a usage rollup
6
+ * alongside real content, or several tool calls in one assistant turn),
5
7
  * named `transcript.<kind>`, with structural attributes only. Tool inputs,
6
8
  * plan text, and agent prompts are excluded unless `includeContent` is on
7
9
  * (matches upstream OTEL_LOG_USER_PROMPTS opt-in).
@@ -11,9 +11,19 @@
11
11
  * session-manager-operations/project-pages/README.md. Structure mirrors
12
12
  * projectBrief.cjs's `get()`: validatePath first, config.cjs read helpers,
13
13
  * one ipcMain.handle registered from index.cjs's registerXHandlers() pattern.
14
+ *
15
+ * Shipped default (PRD "project-home-hosted-html-spec"): when a project has
16
+ * never generated its own output, `get()` no longer returns the bare
17
+ * `{ output: null }` empty-state signal — it falls back to a build-time
18
+ * default `home.html` shipped with the app (`templates/`), returned with
19
+ * `isDefault: true` and `generatedAt: null` so the renderer can label
20
+ * provenance. That default is read from a fixed app-relative path, never
21
+ * written into any project's own `session-manager-operations/` tree — this
22
+ * module stays read-only.
14
23
  */
15
24
 
16
25
  const { ipcMain } = require('electron');
26
+ const fs = require('node:fs');
17
27
  const path = require('node:path');
18
28
  const config = require('./config.cjs');
19
29
 
@@ -21,38 +31,72 @@ function outputDir(cwd) {
21
31
  return path.join(cwd, 'session-manager-operations', 'project-pages', 'output');
22
32
  }
23
33
 
24
- const LENSES = ['home', 'marketing', 'feature', 'architecture'];
34
+ // Shipped build-time asset (see PRD "project-home-hosted-html-spec"'s
35
+ // default-document requirement) — a fixed app-relative path with no
36
+ // user-controlled segment, so it deliberately does NOT go through
37
+ // config.cjs's validatePath (whose allowedRoots is the user's home dir and
38
+ // would reject/mis-resolve a path under the app's own install directory,
39
+ // e.g. when installed via npx/global npm outside $HOME). Read once and
40
+ // cached in-process; this file never changes at runtime.
41
+ const DEFAULT_HOME_PATH = path.join(__dirname, 'templates', 'project-pages-default-home.html');
42
+ let cachedDefaultHomeHtml = null;
43
+ function readDefaultHomeHtml() {
44
+ if (cachedDefaultHomeHtml === null) {
45
+ cachedDefaultHomeHtml = fs.readFileSync(DEFAULT_HOME_PATH, 'utf8');
46
+ }
47
+ return cachedDefaultHomeHtml;
48
+ }
49
+ function defaultOutput() {
50
+ return { home: readDefaultHomeHtml(), generatedAt: null, isDefault: true };
51
+ }
52
+
53
+ // The original 4 lenses stay required — their absence still means "no
54
+ // Project Pages generated yet" (now the shipped-default fallback rather
55
+ // than a bare null). 'brief' was added later (5th lens): a project that
56
+ // generated its output BEFORE 'brief'
57
+ // existed has home/marketing/feature/architecture.html but no brief.html, and
58
+ // that must keep reading as "has output" rather than suddenly regressing to
59
+ // the empty state for every pre-existing project. So brief.html is read
60
+ // tolerantly — present, its text is returned; absent, the field is simply
61
+ // omitted from the payload rather than failing the whole read.
62
+ const REQUIRED_LENSES = ['home', 'marketing', 'feature', 'architecture'];
63
+ const OPTIONAL_LENSES = ['brief'];
64
+ const LENSES = [...REQUIRED_LENSES, ...OPTIONAL_LENSES];
25
65
 
26
66
  async function get({ cwd }) {
27
67
  const realCwd = config.validatePath(cwd);
28
68
  const dir = outputDir(realCwd);
29
69
  const manifestResult = await config.readJson(path.join(dir, 'manifest.json'));
30
70
  if (!manifestResult.exists || !manifestResult.data || manifestResult.parseError) {
31
- return { output: null };
71
+ return { output: defaultOutput() };
32
72
  }
33
73
 
34
74
  const htmlResults = await Promise.all(
35
75
  LENSES.map((lens) => config.readText(path.join(dir, `${lens}.html`))),
36
76
  );
37
- if (htmlResults.some((r) => !r.exists)) {
38
- return { output: null };
77
+ const byLens = Object.fromEntries(LENSES.map((lens, i) => [lens, htmlResults[i]]));
78
+ if (REQUIRED_LENSES.some((lens) => !byLens[lens].exists)) {
79
+ return { output: defaultOutput() };
39
80
  }
40
81
 
41
- const [home, marketing, feature, architecture] = htmlResults;
42
82
  const generatedAt = typeof manifestResult.data.generatedAt === 'string' ? manifestResult.data.generatedAt : null;
43
83
  if (!generatedAt) {
44
- return { output: null };
84
+ return { output: defaultOutput() };
45
85
  }
46
86
 
47
- return {
48
- output: {
49
- home: home.text,
50
- marketing: marketing.text,
51
- feature: feature.text,
52
- architecture: architecture.text,
53
- generatedAt,
54
- },
87
+ const output = {
88
+ home: byLens.home.text,
89
+ marketing: byLens.marketing.text,
90
+ feature: byLens.feature.text,
91
+ architecture: byLens.architecture.text,
92
+ generatedAt,
93
+ isDefault: false,
55
94
  };
95
+ if (byLens.brief.exists) {
96
+ output.brief = byLens.brief.text;
97
+ }
98
+
99
+ return { output };
56
100
  }
57
101
 
58
102
  function registerProjectPagesIpc() {
@@ -63,4 +107,6 @@ function registerProjectPagesIpc() {
63
107
  module.exports = {
64
108
  registerProjectPagesIpc,
65
109
  get,
110
+ DEFAULT_HOME_PATH,
111
+ readDefaultHomeHtml,
66
112
  };
@@ -51,8 +51,20 @@ function mintEventId() {
51
51
  * unknown id, a completed session, an empty event chain, or any I/O error —
52
52
  * so notifyOriginatingTab's existing tab-external-ticket path stays the
53
53
  * safety net, not a special case.
54
+ *
55
+ * The optional 4th argument stamps `prdSlug`/`outcome` onto the appended
56
+ * event (PRD 976) — which PRD checked in and whether it completed, failed,
57
+ * or needs review — so the Epic's own event chain keeps that signal even
58
+ * after the job is archived out of queue.json. PRD 986 extends the same
59
+ * meta arg (not a fifth positional parameter) with `validation`: a
60
+ * scheduler check-in is born `'unvalidated'` — never `'verified'` —
61
+ * regardless of the job's self-reported outcome; the authoring Epic's own
62
+ * validation pass is what may later move it to 'verified'/'refuted'. All
63
+ * keys are added to the event object only when present in `meta`, never as
64
+ * `undefined`-valued keys, so events appended without them serialize
65
+ * identically to before.
54
66
  */
55
- async function appendResponseEventIfKnown(cwd, sourcePromptId, text) {
67
+ async function appendResponseEventIfKnown(cwd, sourcePromptId, text, meta = {}) {
56
68
  if (!cwd || !sourcePromptId) return false;
57
69
  const path = promptSessionActiveIndexPath(cwd);
58
70
  try {
@@ -73,6 +85,9 @@ async function appendResponseEventIfKnown(cwd, sourcePromptId, text) {
73
85
  at: new Date().toISOString(),
74
86
  text,
75
87
  };
88
+ if (meta && meta.prdSlug) event.prdSlug = meta.prdSlug;
89
+ if (meta && meta.outcome) event.outcome = meta.outcome;
90
+ if (meta && meta.validation) event.validation = meta.validation;
76
91
  data.events[sourcePromptId] = [...events, event];
77
92
  await config.writeJson(path, data, { writer: 'scheduler' });
78
93
  broadcast(EVENT_APPENDED_CHANNEL, { cwd, promptSessionId: sourcePromptId, event });