create-claude-cabinet 0.34.1 → 0.35.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.34.1",
3
+ "version": "0.35.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -187,6 +187,14 @@ One or more "why this matters" lines for the client.
187
187
  blocks dispatch until it is supplied (the internal `text` is never
188
188
  shown to a client verbatim).
189
189
 
190
+ **Literal `\n` normalization.** When notes are written via MCP tools
191
+ that JSON-encode the string, newlines sometimes arrive as literal
192
+ two-character `\n` sequences (backslash + n) instead of real newlines.
193
+ `parseClientCopy` normalizes these before parsing — titles won't contain
194
+ stray `\n` characters. Status suffixes like "— complete" or "— shipped"
195
+ are also stripped from titles since the bucket placement already
196
+ communicates status.
197
+
190
198
  The copy block may be human-written or generated-then-approved.
191
199
  `/engagement-sync` step 3.5 can auto-draft copy from structured action
192
200
  metadata (via `buildGenerationSource`), with the consultant reviewing
@@ -240,6 +248,9 @@ transport:
240
248
  type: email # the CHANNEL (email | mcp | file)
241
249
  consultant: "oren@example.com" # where client feedback is sent
242
250
 
251
+ engagement_notes: # persistent notes shown at top of every packet
252
+ - "All email logic is built and tested on staging — real delivery activates once Postmark is configured."
253
+
243
254
  sections: # the credential checklist = one section
244
255
  - key: go_live
245
256
  title: "Go-Live Credentials"
@@ -274,8 +285,10 @@ A **packet** is a per-recipient projection of engagement state. Fields:
274
285
  engagement, packet_id, recipient, role, generated_at,
275
286
  needs_you: [ { ref, title, why, kind, options } ],
276
287
  in_flight, shipped, delegated,
277
- billing, # present ONLY if roles[role].billing === true
278
- messages, new_since_last, generated_label
288
+ billing, # present ONLY if roles[role].billing === true
289
+ messages, new_since_last, generated_label,
290
+ engagement_notes, # persistent consultant-authored notes (from config)
291
+ checklist_summary # [ { type, count } ] when engagement.yaml has sections
279
292
  ```
280
293
 
281
294
  - The client **never** sees `action_fid` — items are referenced by an
@@ -306,13 +319,15 @@ The pure render engine (`engagement.mjs`, imports nothing — the adapter
306
319
  boundary) pins these behaviors so Phase 3/4 can build on them without
307
320
  reopening Phase 1:
308
321
 
309
- - **`renderPacket` returns `{ packet, refmap, notClientReady }`.**
322
+ - **`renderPacket` returns `{ packet, refmap, notClientReady, allDropped }`.**
310
323
  `packet` is the client projection, built without `action_fid`/`_refmap`
311
324
  ever added (leaking is impossible by construction, not by a strip step).
312
325
  `refmap` is the consultant-side `ref → action_fid` map. The caller writes
313
326
  `{ ...packet, _refmap: refmap }` to `<recipient>-sent.json` and delivers
314
327
  `packet` alone. `notClientReady` lists fids of client-visible actions
315
- dropped because they lacked a client-facing copy block.
328
+ dropped because they lacked a client-facing copy block. `allDropped` is
329
+ `true` when ALL visible items were dropped (likely a missing-notes-column
330
+ query bug) — the caller should halt dispatch, not send an empty packet.
316
331
  - **Not-client-ready items are dropped, never rendered with internal text.**
317
332
  A `client-visible` action with no `<!-- client-facing -->` block is
318
333
  excluded from the packet (and reported in `notClientReady`) — the engine
@@ -16,6 +16,9 @@
16
16
  const KNOWN_TAG_PREFIXES = ['audience', 'scope', 'assignee', 'needs'];
17
17
  const KNOWN_BARE_TAGS = ['client-visible'];
18
18
 
19
+ // Trailing status phrases that are redundant with bucket placement.
20
+ const STATUS_SUFFIX_RE = /\s*[—–-]\s*(?:complete|completed|shipped|done|planned|in progress|started|delivered)\s*(?:\(.*\))?\s*$/i;
21
+
19
22
  // pib-db status → plain English shown to clients (never the raw enum).
20
23
  const STATUS_LABELS = {
21
24
  open: 'Not started',
@@ -82,8 +85,9 @@ export function loadEngagement(config) {
82
85
 
83
86
  const transport = config.transport || { type: 'email', consultant: null };
84
87
  const sections = Array.isArray(config.sections) ? config.sections : [];
88
+ const engagement_notes = config.engagement_notes || null;
85
89
 
86
- return { engagement, billing, recipients, roles, transport, sections };
90
+ return { engagement, billing, recipients, roles, transport, sections, engagement_notes };
87
91
  }
88
92
 
89
93
  export function normalizeRecipient(r) {
@@ -171,8 +175,11 @@ export function parseClientCopy(notes) {
171
175
  const close = notes.indexOf('-->', bodyStart);
172
176
  if (close === -1) return null;
173
177
 
174
- const inner = notes.slice(bodyStart, close).trim();
175
- if (!inner) return null;
178
+ const raw = notes.slice(bodyStart, close).trim();
179
+ if (!raw) return null;
180
+ // Normalize literal two-char \n sequences (from double-escaping during
181
+ // JSON-encoded MCP writes) to real newlines before splitting.
182
+ const inner = raw.replace(/\\n/g, '\n');
176
183
  const lines = inner.split('\n').map(l => l.trim()).filter(Boolean);
177
184
  if (lines.length === 0) return null;
178
185
 
@@ -361,7 +368,8 @@ export function renderPacket(config, actions, events, billing, recipient, prevPa
361
368
  throw new Error(`renderPacket: stableRef collision — ${refmap[ref]} and ${a.fid} both hash to ${ref}`);
362
369
  }
363
370
  refmap[ref] = a.fid;
364
- const item = { ref, title: copy.client_title, why: copy.client_why };
371
+ const title = copy.client_title.replace(STATUS_SUFFIX_RE, '');
372
+ const item = { ref, title, why: copy.client_why };
365
373
 
366
374
  if (a.completed || a.status === 'done') {
367
375
  // Completed wins over needs: a done item is never shown as pending.
@@ -408,7 +416,8 @@ export function renderPacket(config, actions, events, billing, recipient, prevPa
408
416
  }
409
417
  // Construction-not-strip: explicit field listing only. Never spread from
410
418
  // project row. fid/project_fid/name/notes/tags are never assigned.
411
- const rollupItem = { title: copy.client_title, why: copy.client_why, status: rollupStatus, feedbackable: false };
419
+ const rollupTitle = copy.client_title.replace(STATUS_SUFFIX_RE, '');
420
+ const rollupItem = { title: rollupTitle, why: copy.client_why, status: rollupStatus, feedbackable: false };
412
421
  const parsedProjectTags = parseActionTags(p.tags);
413
422
  if (p.status === 'done' || (children.length > 0 && children.every(c => c.completed || c.status === 'done'))) {
414
423
  shipped.push(rollupItem);
@@ -419,6 +428,29 @@ export function renderPacket(config, actions, events, billing, recipient, prevPa
419
428
  }
420
429
  }
421
430
 
431
+ // Silent-empty signal: if there were visible actions but ALL of them landed
432
+ // in notClientReady, the caller likely used a query that omits the notes
433
+ // column (e.g. pib_list_actions instead of pib_query). Signal so the
434
+ // caller can halt dispatch instead of silently sending an empty packet.
435
+ const allDropped = visible.length > 0 && notClientReady.length === visible.length;
436
+
437
+ // Checklist summary: sections parsed from engagement.yaml but NOT rendered as
438
+ // bucket items (those are interactive via /engagement). Include a summary so
439
+ // the preview shows checklist coverage.
440
+ const sections = config.sections || [];
441
+ const checklistSummary = sections.length > 0
442
+ ? sections.map(s => {
443
+ const items = Array.isArray(s.items) ? s.items : [];
444
+ return { type: s.type || s.label || 'unknown', count: items.length };
445
+ })
446
+ : null;
447
+
448
+ // Engagement-wide notes: persistent messages from the consultant that appear
449
+ // at the top of every packet (e.g. caveats, disclaimers, contextual notes).
450
+ const engagementNotes = Array.isArray(config.engagement_notes) ? config.engagement_notes
451
+ : typeof config.engagement_notes === 'string' ? [config.engagement_notes]
452
+ : null;
453
+
422
454
  // Messages: engagement-level events (target_fid null) plus events whose
423
455
  // target action is still live. Events for soft-deleted / unknown actions
424
456
  // are dropped (defensive — even if the caller passed them through).
@@ -452,6 +484,9 @@ export function renderPacket(config, actions, events, billing, recipient, prevPa
452
484
  new_since_last,
453
485
  };
454
486
 
487
+ if (engagementNotes) packet.engagement_notes = engagementNotes;
488
+ if (checklistSummary) packet.checklist_summary = checklistSummary;
489
+
455
490
  // billing only for roles configured to receive it, and only when billing is
456
491
  // actually enabled (a config.billing with enabled:false must not produce a
457
492
  // "$0.00" section; a computed renderBilling block has no `enabled` field, so
@@ -465,7 +500,7 @@ export function renderPacket(config, actions, events, billing, recipient, prevPa
465
500
  // roles, defined role). Defense-in-depth so the dispatch path can't skip it.
466
501
  assertPacketInvariants(packet, recipient, roles);
467
502
 
468
- return { packet, refmap, notClientReady };
503
+ return { packet, refmap, notClientReady, allDropped };
469
504
  }
470
505
 
471
506
  function extractOptions(action) {
@@ -66,7 +66,14 @@ note before anything else:
66
66
  Always show the `generated_label` ("as of <date>") as a header so
67
67
  staleness is visible even under the threshold.
68
68
 
69
- ### 3. Messages first, then the work
69
+ ### 3. Engagement notes, messages, then work
70
+
71
+ If the packet carries `engagement_notes` (persistent consultant-authored
72
+ notes), render them first in a distinct block before any messages or items.
73
+
74
+ If the packet carries `checklist_summary`, show a single summary line
75
+ after engagement notes: "Plus N checklist items (X decisions, Y credentials,
76
+ ...) — these appear when you run `/engagement` interactively."
70
77
 
71
78
  Render the **messages** feed distinctly from action items — each with an
72
79
  explicit sender and timestamp, in order:
@@ -74,16 +81,23 @@ explicit sender and timestamp, in order:
74
81
  > **Oren · May 30** — "Quick note: the hosting migration is scheduled
75
82
  > for next week."
76
83
 
77
- Then render the work, **needs_you first**:
78
- - **Needs your attention** (`needs_you`) — lead with these.
79
- - **In progress** (`in_flight`).
80
- - **Done** (`shipped`).
84
+ Then render the work with **visual structure**:
85
+
86
+ - **Needs your attention (N)** (`needs_you`) — lead with these. Use a
87
+ clear heading with the count.
88
+ - **In progress (N)** (`in_flight`) — each item gets an arrow indicator.
89
+ - **Done (N)** (`shipped`) — each item gets a checkmark indicator.
81
90
  - **Handled for you** (`delegated`) — place LAST, collapsed to a one-line
82
91
  summary ("Sydney is handling 3 items (marketing)"); offer to expand on
83
92
  request. It's reassurance, not a to-do.
84
93
 
85
- Show `billing` only if the packet carries it (principals only). Highlight
86
- `new_since_last` items by title.
94
+ **Formatting rules:**
95
+ - Every section gets a header with a count: "**Done (12)**" not just a
96
+ bare list.
97
+ - Items in `new_since_last` are marked with "(new)" after the title.
98
+ - Show `billing` only if the packet carries it (principals only). Format
99
+ billing as a clear table (Date | Hours | Work), with rate and total at
100
+ the bottom — not a raw JSON dump.
87
101
 
88
102
  ### 4. Collect responses (respectful batch review)
89
103
 
@@ -180,12 +180,19 @@ For each recipient: load their previous packet from
180
180
  `engagement-packets/<recipient>-sent.json` (if any) as `prevPacket`,
181
181
  then:
182
182
  ```
183
- const { packet, refmap, notClientReady } = renderPacket(config, actions, events, billing, recipient, prevPacket, projects)
183
+ const { packet, refmap, notClientReady, allDropped } = renderPacket(config, actions, events, billing, recipient, prevPacket, projects)
184
184
  ```
185
185
  `notClientReady` reconfirms (defensively) the not-client-ready items the
186
186
  engine dropped — they never reach the client (no internal text leaks).
187
187
  Fold these into the step-3 block report.
188
188
 
189
+ **`allDropped` guard:** If `allDropped` is true, **STOP** — do not
190
+ proceed to dispatch. Every visible item was dropped because it lacked
191
+ client-facing copy. This almost always means the query used
192
+ `pib_list_actions` (which omits `notes`) instead of `pib_query` with
193
+ `SELECT *`. Surface: "Empty packet — all N client-visible items are
194
+ missing client-facing copy. Did you use pib_query with SELECT *?"
195
+
189
196
  ### 5. Hard gate
190
197
 
191
198
  `assertPacketInvariants(packet, recipient, config.roles)`. If it throws