create-claude-cabinet 0.45.0 → 0.47.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 (165) hide show
  1. package/README.md +6 -10
  2. package/lib/cli.js +374 -91
  3. package/lib/copy.js +108 -6
  4. package/lib/db-setup.js +122 -17
  5. package/lib/engagement-server-setup.js +34 -9
  6. package/lib/metadata.js +51 -2
  7. package/lib/migrate-from-omega.js +13 -1
  8. package/lib/mux-setup.js +33 -9
  9. package/lib/settings-merge.js +52 -10
  10. package/lib/watchtower-setup.js +230 -0
  11. package/package.json +5 -1
  12. package/templates/cabinet/_cabinet-member-template.md +8 -3
  13. package/templates/cabinet/advisories-state-schema.md +34 -7
  14. package/templates/cabinet/checklist-stats-schema.md +15 -1
  15. package/templates/cabinet/composition-patterns.md +4 -3
  16. package/templates/cabinet/memory-lifecycle-contract.md +135 -0
  17. package/templates/cabinet/pib-db-access.md +13 -0
  18. package/templates/cabinet/skill-output-conventions.md +35 -1
  19. package/templates/cabinet/watchtower-contracts.md +503 -1
  20. package/templates/cabinet/worktree-invocation-contract.md +87 -0
  21. package/templates/engagement/OVERVIEW.md +12 -0
  22. package/templates/engagement/__tests__/engagement.test.mjs +177 -2
  23. package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
  24. package/templates/engagement/engagement-preview.mjs +100 -0
  25. package/templates/engagement/engagement-schema.md +240 -0
  26. package/templates/engagement/engagement.mjs +391 -11
  27. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
  28. package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
  29. package/templates/engagement/pib-db-patches/pib-db.mjs +5 -2
  30. package/templates/hooks/action-completion-gate.sh +9 -3
  31. package/templates/hooks/memory-index-guard.sh +17 -11
  32. package/templates/hooks/watchtower-session-start.sh +24 -2
  33. package/templates/mcp/pib-db.json +1 -4
  34. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +44 -0
  35. package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
  36. package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
  37. package/templates/mux/bin/mux +289 -56
  38. package/templates/mux/config/help.txt +1 -0
  39. package/templates/rules/acknowledge-when-corrected.md +33 -0
  40. package/templates/rules/maintainability.md +11 -0
  41. package/templates/rules/memory-capture.md +26 -4
  42. package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
  43. package/templates/rules/verify-before-asserting.md +79 -0
  44. package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
  45. package/templates/scripts/__tests__/advisories.test.mjs +262 -0
  46. package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
  47. package/templates/scripts/__tests__/batch-disposition.test.mjs +235 -0
  48. package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
  49. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
  50. package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
  51. package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
  52. package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
  53. package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
  54. package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
  55. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
  56. package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
  57. package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
  58. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -3
  59. package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
  60. package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
  61. package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
  62. package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
  63. package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
  64. package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
  65. package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
  66. package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
  67. package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
  68. package/templates/scripts/__tests__/ring3-dedup.test.mjs +467 -0
  69. package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
  70. package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
  71. package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
  72. package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
  73. package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
  74. package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
  75. package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
  76. package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
  77. package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
  78. package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
  79. package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
  80. package/templates/scripts/load-triage-history.js +5 -2
  81. package/templates/scripts/pib-db-mcp-server.mjs +5 -44
  82. package/templates/scripts/pib-db-path.mjs +61 -0
  83. package/templates/scripts/pib-db.mjs +5 -2
  84. package/templates/scripts/validate-memory.mjs +214 -16
  85. package/templates/scripts/watchtower-advisories.mjs +309 -0
  86. package/templates/scripts/watchtower-build-context.mjs +301 -18
  87. package/templates/scripts/watchtower-cross-ring-reader.mjs +700 -0
  88. package/templates/scripts/watchtower-hook-runner.mjs +422 -0
  89. package/templates/scripts/watchtower-lib.mjs +447 -2
  90. package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
  91. package/templates/scripts/watchtower-phase-shim.mjs +171 -0
  92. package/templates/scripts/watchtower-queue.mjs +484 -1
  93. package/templates/scripts/watchtower-ring1.mjs +533 -53
  94. package/templates/scripts/watchtower-ring2.mjs +787 -48
  95. package/templates/scripts/watchtower-ring3-close.mjs +1357 -99
  96. package/templates/scripts/watchtower-ring4-runner.sh +85 -0
  97. package/templates/scripts/watchtower-ring4.mjs +753 -0
  98. package/templates/scripts/watchtower-routines.mjs +358 -0
  99. package/templates/scripts/watchtower-snapshot.mjs +452 -0
  100. package/templates/scripts/watchtower-status.sh +1 -1
  101. package/templates/scripts/watchtower-sync.mjs +393 -0
  102. package/templates/skills/audit/SKILL.md +5 -1
  103. package/templates/skills/briefing/SKILL.md +584 -236
  104. package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
  105. package/templates/skills/cabinet-historian/SKILL.md +14 -11
  106. package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
  107. package/templates/skills/cabinet-security/SKILL.md +11 -0
  108. package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
  109. package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
  110. package/templates/skills/catch-up/SKILL.md +113 -0
  111. package/templates/skills/cc-publish/SKILL.md +148 -25
  112. package/templates/skills/cc-remember/SKILL.md +45 -0
  113. package/templates/skills/close/SKILL.md +107 -0
  114. package/templates/skills/collab-client/SKILL.md +22 -5
  115. package/templates/skills/collab-consultant/SKILL.md +110 -2
  116. package/templates/skills/debrief/SKILL.md +148 -16
  117. package/templates/skills/debrief-classic/SKILL.md +696 -0
  118. package/templates/skills/debrief-classic/calibration.md +44 -0
  119. package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
  120. package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
  121. package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
  122. package/templates/skills/debrief-classic/phases/close-work.md +163 -0
  123. package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
  124. package/templates/skills/debrief-classic/phases/inventory.md +40 -0
  125. package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
  126. package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
  127. package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
  128. package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
  129. package/templates/skills/debrief-classic/phases/report.md +59 -0
  130. package/templates/skills/debrief-classic/phases/update-state.md +48 -0
  131. package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
  132. package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
  133. package/templates/skills/execute/SKILL.md +77 -6
  134. package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
  135. package/templates/skills/execute-group/SKILL.md +23 -26
  136. package/templates/skills/generate-plan-groups/SKILL.md +20 -0
  137. package/templates/skills/inbox/SKILL.md +124 -7
  138. package/templates/skills/memory/SKILL.md +22 -6
  139. package/templates/skills/orient/SKILL.md +140 -52
  140. package/templates/skills/orient-classic/SKILL.md +770 -0
  141. package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
  142. package/templates/skills/orient-classic/phases/briefing.md +53 -0
  143. package/templates/skills/orient-classic/phases/cabinet.md +46 -0
  144. package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
  145. package/templates/skills/orient-classic/phases/context.md +88 -0
  146. package/templates/skills/orient-classic/phases/data-sync.md +35 -0
  147. package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
  148. package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
  149. package/templates/skills/orient-classic/phases/health-checks.md +50 -0
  150. package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
  151. package/templates/skills/orient-classic/phases/work-scan.md +69 -0
  152. package/templates/skills/plan/SKILL.md +8 -0
  153. package/templates/skills/qa-drain/SKILL.md +157 -0
  154. package/templates/skills/qa-handoff/SKILL.md +287 -46
  155. package/templates/skills/session-handoff/SKILL.md +175 -6
  156. package/templates/skills/setup-accounts/SKILL.md +38 -16
  157. package/templates/skills/threads/SKILL.md +4 -0
  158. package/templates/skills/triage-audit/SKILL.md +6 -0
  159. package/templates/skills/validate/phases/validators.md +41 -0
  160. package/templates/skills/watchtower/SKILL.md +199 -2
  161. package/templates/verify-runtime/CONVENTIONS.md +9 -0
  162. package/templates/verify-runtime/README.md +37 -0
  163. package/templates/watchtower/config.json.template +10 -2
  164. package/templates/watchtower/queue/items/item.json.schema +1 -1
  165. package/templates/workflows/execute-group-complete.js +10 -1
@@ -84,11 +84,24 @@ export function loadEngagement(config) {
84
84
  : { enabled: false, timelog: null, rate: 190, currency: 'USD', period: 'engagement' };
85
85
 
86
86
  const transport = config.transport || { type: 'email', consultant: null };
87
+
88
+ // invoicing: optional block controlling the local invoice document
89
+ // (`/collab-consultant invoice`). NO bank/remit/From fields live here —
90
+ // Relay supplies the sender from the operator's account and assigns the
91
+ // invoice number; the document only carries bill-to + line items + total.
92
+ const invoicing = config.invoicing
93
+ ? {
94
+ mode: config.invoicing.mode === 'itemized' ? 'itemized' : 'consolidated',
95
+ scope_label: config.invoicing.scope_label || 'Consulting services',
96
+ terms_days: Number.isFinite(config.invoicing.terms_days) ? config.invoicing.terms_days : 30,
97
+ }
98
+ : { mode: 'consolidated', scope_label: 'Consulting services', terms_days: 30 };
99
+
87
100
  const sections = Array.isArray(config.sections) ? config.sections : [];
88
101
  const engagement_notes = config.engagement_notes || null;
89
102
  const scoped_notes = Array.isArray(config.scoped_notes) ? config.scoped_notes : [];
90
103
 
91
- return { engagement, billing, recipients, roles, transport, sections, engagement_notes, scoped_notes };
104
+ return { engagement, billing, recipients, roles, transport, invoicing, sections, engagement_notes, scoped_notes };
92
105
  }
93
106
 
94
107
  export function normalizeRecipient(r) {
@@ -112,6 +125,8 @@ export function normalizeRecipient(r) {
112
125
  id: r.id,
113
126
  name: r.name || r.id,
114
127
  email: r.email || null,
128
+ // org (optional) rides through so the invoice bill-to block can show it.
129
+ org: r.org || r.organization || null,
115
130
  role,
116
131
  scopes: Array.isArray(r.scopes) ? r.scopes : [],
117
132
  has_claude_code,
@@ -327,6 +342,217 @@ function extractDate(cell) {
327
342
  return m ? m[0] : null;
328
343
  }
329
344
 
345
+ // ---------------------------------------------------------------------------
346
+ // renderInvoiceDoc — PURE. A clean, print-friendly invoice HTML document
347
+ // the operator uploads to Relay's "Create Invoice → Autofill from document".
348
+ // ---------------------------------------------------------------------------
349
+ // Relay (the operator's bank) does the actual invoicing — it has no API but
350
+ // extracts fields from an uploaded PDF/PNG. So this is the ONLY job: render a
351
+ // flat, self-contained invoice document. Validated against Relay's autofill
352
+ // (live experiment, 2026-06-16):
353
+ // - Relay parses line items as Description / Quantity / Unit price / Amount
354
+ // and computes its OWN totals (Quantity = hours, Unit price = the rate).
355
+ // - Relay assigns its OWN invoice number and pulls the From/sender (business
356
+ // name, address, email) from the operator's account → the document must
357
+ // NOT include an invoice number or any From/remit/bank block (ignored or
358
+ // overridden). No bank/ACH details anywhere.
359
+ // - Relay autofills the customer (bill-to name + email) and the line items
360
+ // + total correctly.
361
+ // - Relay does NOT map a net-30 due date — it defaults the due date to
362
+ // today. The terms only survive in Relay's free-text Memo → terms go in a
363
+ // memo-friendly footer line, and the skill tells the operator to set the
364
+ // due date in Relay by hand.
365
+ //
366
+ // Signature: renderInvoiceDoc(config, recipient, billingResult,
367
+ // { mode, invoiceDate, dueDate, period }) → HTML string.
368
+ // - PURE: invoiceDate/dueDate/period are passed in. A default for missing
369
+ // dates uses the `config.__now` injectable-time seam (never new Date()).
370
+ // - mode 'consolidated' (default): ONE line item — description
371
+ // "<scope_label> — <Month YYYY>", Quantity = total hours, Unit price =
372
+ // rate, Amount = billingResult.total.
373
+ // - mode 'itemized': one line per billingResult.lineItems row.
374
+ // - Total due is billingResult.total VERBATIM (the honest figure; for
375
+ // itemized it is NOT a re-sum of the per-line amounts).
376
+ export function renderInvoiceDoc(config, recipient, billingResult, { mode = 'consolidated', invoiceDate = null, dueDate = null, period = null } = {}) {
377
+ if (!billingResult || !Array.isArray(billingResult.lineItems) || billingResult.lineItems.length === 0) {
378
+ throw new Error('renderInvoiceDoc: billingResult has no lineItems (nothing to invoice)');
379
+ }
380
+ if (!(billingResult.hours > 0)) {
381
+ throw new Error('renderInvoiceDoc: billingResult has zero hours (nothing to invoice)');
382
+ }
383
+
384
+ const now = config?.__now || isoNow();
385
+ const issued = invoiceDate || now.slice(0, 10);
386
+ const due = dueDate || issued;
387
+ const rate = billingResult.rate;
388
+ const currency = billingResult.currency || 'USD';
389
+
390
+ // Period label: prefer the explicit period (YYYY-MM), else the issue month.
391
+ const periodMonth = (period || issued).slice(0, 7);
392
+ const periodLabel = formatMonthYear(periodMonth);
393
+
394
+ const invoicing = config?.invoicing || {};
395
+ const scopeLabel = invoicing.scope_label || 'Consulting services';
396
+
397
+ // Build the line rows. Each: { description, quantity (hours), unitPrice, amount }.
398
+ let rows;
399
+ if (mode === 'itemized') {
400
+ rows = billingResult.lineItems.map(li => {
401
+ const qty = Math.round((li.hours || 0) * 100) / 100;
402
+ return {
403
+ description: (li.work && li.work.trim()) || li.date || scopeLabel,
404
+ quantity: qty,
405
+ unitPrice: rate,
406
+ amount: Math.round(qty * rate * 100) / 100,
407
+ };
408
+ });
409
+ } else {
410
+ rows = [{
411
+ description: `${scopeLabel} — ${periodLabel}`,
412
+ quantity: billingResult.hours,
413
+ unitPrice: rate,
414
+ amount: billingResult.total,
415
+ }];
416
+ }
417
+
418
+ // The stated Total is billingResult.total VERBATIM — never a re-sum.
419
+ const total = billingResult.total;
420
+
421
+ // Bill-to from the recipient object (autofilled by Relay as the customer).
422
+ const billToName = recipient?.name || recipient?.id || '';
423
+ const billToOrg = recipient?.org || recipient?.organization || null;
424
+ const billToEmail = recipient?.email || null;
425
+
426
+ const billToLines = [esc(billToName)];
427
+ if (billToOrg) billToLines.push(esc(billToOrg));
428
+ if (billToEmail) billToLines.push(esc(billToEmail));
429
+
430
+ const rowHtml = rows.map(r => ` <tr>
431
+ <td class="desc">${esc(r.description)}</td>
432
+ <td class="num">${fmtQty(r.quantity)}</td>
433
+ <td class="num">${fmtMoney(r.unitPrice, currency)}</td>
434
+ <td class="num">${fmtMoney(r.amount, currency)}</td>
435
+ </tr>`).join('\n');
436
+
437
+ const memoLine = `Payment terms: Net ${daysBetween(issued, due)} — due ${due}. Period: ${periodLabel}.`;
438
+
439
+ return `<!DOCTYPE html>
440
+ <html lang="en">
441
+ <head>
442
+ <meta charset="utf-8">
443
+ <meta name="viewport" content="width=device-width, initial-scale=1">
444
+ <title>Invoice — ${esc(periodLabel)}</title>
445
+ <style>
446
+ * { box-sizing: border-box; }
447
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; color: #1a1a1a; margin: 0; padding: 48px; font-size: 14px; line-height: 1.5; }
448
+ .invoice { max-width: 720px; margin: 0 auto; }
449
+ h1 { font-size: 28px; letter-spacing: 0.06em; margin: 0 0 24px; font-weight: 600; }
450
+ .meta { display: flex; justify-content: space-between; gap: 32px; margin-bottom: 32px; }
451
+ .meta .block { flex: 1; }
452
+ .label { text-transform: uppercase; font-size: 11px; letter-spacing: 0.08em; color: #888; margin-bottom: 4px; }
453
+ .billto-name { font-weight: 600; }
454
+ table { width: 100%; border-collapse: collapse; margin: 8px 0 0; }
455
+ thead th { text-align: left; border-bottom: 2px solid #1a1a1a; padding: 8px 10px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #555; }
456
+ thead th.num { text-align: right; }
457
+ tbody td { padding: 10px; border-bottom: 1px solid #e2e2e2; vertical-align: top; }
458
+ tbody td.num { text-align: right; white-space: nowrap; }
459
+ tbody td.desc { width: 55%; }
460
+ .totals { margin-top: 16px; display: flex; justify-content: flex-end; }
461
+ .totals table { width: auto; min-width: 280px; }
462
+ .totals td { padding: 6px 10px; border: none; }
463
+ .totals td.num { text-align: right; white-space: nowrap; }
464
+ .totals tr.grand td { border-top: 2px solid #1a1a1a; font-weight: 700; font-size: 16px; padding-top: 12px; }
465
+ .memo { margin-top: 40px; padding-top: 16px; border-top: 1px solid #e2e2e2; color: #555; font-size: 13px; }
466
+ @media print { body { padding: 0; } }
467
+ </style>
468
+ </head>
469
+ <body>
470
+ <div class="invoice">
471
+ <h1>INVOICE</h1>
472
+ <div class="meta">
473
+ <div class="block">
474
+ <div class="label">Bill to</div>
475
+ <div class="billto-name">${billToLines.join('</div>\n <div>')}</div>
476
+ </div>
477
+ <div class="block">
478
+ <div class="label">Issued</div>
479
+ <div>${esc(issued)}</div>
480
+ <div class="label" style="margin-top:12px;">Period</div>
481
+ <div>${esc(periodLabel)}</div>
482
+ </div>
483
+ </div>
484
+ <table>
485
+ <thead>
486
+ <tr>
487
+ <th class="desc">Description</th>
488
+ <th class="num">Quantity (hrs)</th>
489
+ <th class="num">Unit price</th>
490
+ <th class="num">Amount</th>
491
+ </tr>
492
+ </thead>
493
+ <tbody>
494
+ ${rowHtml}
495
+ </tbody>
496
+ </table>
497
+ <div class="totals">
498
+ <table>
499
+ <tr class="grand">
500
+ <td>Total due</td>
501
+ <td class="num">${fmtMoney(total, currency)}</td>
502
+ </tr>
503
+ </table>
504
+ </div>
505
+ <div class="memo">${esc(memoLine)}</div>
506
+ </div>
507
+ </body>
508
+ </html>
509
+ `;
510
+ }
511
+
512
+ // HTML-escape a value for safe interpolation into the invoice document.
513
+ function esc(v) {
514
+ return String(v == null ? '' : v)
515
+ .replace(/&/g, '&amp;')
516
+ .replace(/</g, '&lt;')
517
+ .replace(/>/g, '&gt;')
518
+ .replace(/"/g, '&quot;');
519
+ }
520
+
521
+ // "2026-06" → "June 2026". Falls back to the raw input on a malformed value.
522
+ function formatMonthYear(yyyymm) {
523
+ const m = String(yyyymm || '').match(/^(\d{4})-(\d{2})/);
524
+ if (!m) return String(yyyymm || '');
525
+ const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
526
+ const idx = parseInt(m[2], 10) - 1;
527
+ if (idx < 0 || idx > 11) return String(yyyymm);
528
+ return `${months[idx]} ${m[1]}`;
529
+ }
530
+
531
+ // Whole-day gap between two ISO dates (used to label the memo's "Net N").
532
+ // Defaults to 30 if either date is unparseable.
533
+ function daysBetween(fromIso, toIso) {
534
+ const a = Date.parse(`${fromIso}T00:00:00Z`);
535
+ const b = Date.parse(`${toIso}T00:00:00Z`);
536
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return 30;
537
+ return Math.round((b - a) / 86400000);
538
+ }
539
+
540
+ function fmtQty(n) {
541
+ // Trim trailing zeros: 2.50 → "2.5", 3.00 → "3".
542
+ const r = Math.round((Number(n) || 0) * 100) / 100;
543
+ return String(r);
544
+ }
545
+
546
+ function fmtMoney(n, currency = 'USD') {
547
+ const amount = (Number(n) || 0).toFixed(2);
548
+ const symbols = { USD: '$', CAD: '$', AUD: '$', GBP: '£', EUR: '€' };
549
+ const sym = symbols[currency];
550
+ // Group thousands with commas.
551
+ const [whole, frac] = amount.split('.');
552
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
553
+ return sym ? `${sym}${grouped}.${frac}` : `${grouped}.${frac} ${currency}`;
554
+ }
555
+
330
556
  // ---------------------------------------------------------------------------
331
557
  // renderPacket — build a per-recipient projection + consultant-side refmap
332
558
  // ---------------------------------------------------------------------------
@@ -403,7 +629,14 @@ export function renderPacket(config, actions, events, billing, recipient, prevPa
403
629
  // Completed wins over needs: a done item is never shown as pending.
404
630
  shipped.push({ ...item, status: STATUS_LABELS.done });
405
631
  } else if (parsed.needs.length > 0) {
406
- needs_you.push({ ...item, kind: parsed.needs[0], options: extractClientOptions(a) });
632
+ const kind = parsed.needs[0];
633
+ if (kind === 'decision') {
634
+ // Structured options + decision_type (§5.1). A real (projected) action
635
+ // declares free-text by carrying no options.
636
+ needs_you.push({ ...item, kind, ...buildDecisionFields(a) });
637
+ } else {
638
+ needs_you.push({ ...item, kind, options: extractClientOptions(a) });
639
+ }
407
640
  } else if (parsed.assignees.length > 0 && !parsed.assignees.includes(recipient.id)) {
408
641
  // Delegated = assigned to others and NOT to this recipient. An item the
409
642
  // recipient is also an assignee on is their own work (in_flight), not delegated.
@@ -458,22 +691,51 @@ export function renderPacket(config, actions, events, billing, recipient, prevPa
458
691
  }
459
692
  }
460
693
 
694
+ // Internalized decision projection (§5.1, P1.1): synthesize a needs_you
695
+ // decision per engagement.yaml `decide` item that no LIVE action already
696
+ // covers. The synthesized item carries a deterministic backing fid so its
697
+ // ref/refmap entry is stable and an inbound answer resolves to it — exactly
698
+ // as for a real action. Structural dedup (invariant #3): a live, visible
699
+ // needs:decision action declaring `engagement_key: <key>` in its notes
700
+ // renders that decision instead, so it is never emitted twice.
701
+ const coveredKeys = new Set();
702
+ for (const a of visible) {
703
+ // Only a projected action that ACTUALLY RENDERS as a decision (i.e. has
704
+ // client copy) covers its key. A marker-bearing action with no copy is
705
+ // dropped to notClientReady and renders nowhere; if it also suppressed the
706
+ // synthesis the decision would vanish silently. Require copy so synthesis
707
+ // still fills that gap.
708
+ if (parseActionTags(a.tags).needs.includes('decision') && parseClientCopy(a) !== null) {
709
+ const k = parseEngagementKey(a.notes);
710
+ if (k) coveredKeys.add(k);
711
+ }
712
+ }
713
+ for (const synth of synthesizeDecisions(config, recipient, roles, coveredKeys, refmap, liveFids)) {
714
+ needs_you.push(synth);
715
+ }
716
+
461
717
  // Silent-empty signal: if there were visible actions but ALL of them landed
462
718
  // in notClientReady, the caller likely used a query that omits the notes
463
719
  // column (e.g. pib_list_actions instead of pib_query). Signal so the
464
720
  // caller can halt dispatch instead of silently sending an empty packet.
465
721
  const allDropped = visible.length > 0 && notClientReady.length === visible.length;
466
722
 
467
- // Checklist summary: sections parsed from engagement.yaml but NOT rendered as
468
- // bucket items (those are interactive via /engagement). Include a summary so
469
- // the preview shows checklist coverage.
723
+ // Checklist summary: NON-decide checklist items only (credentials/confirms
724
+ // handled interactively via /setup-accounts). `decide` items now render as
725
+ // needs_you decisions (the internalized projection, §5.1), so counting them
726
+ // here too would double-surface every decision — they are EXCLUDED. The type
727
+ // label is derived from a real section field (type → kind → label → key),
728
+ // never the literal 'unknown'; a section with zero non-decide items is
729
+ // omitted entirely (no empty / 'unknown' rows leak into the packet).
470
730
  const sections = config.sections || [];
471
- const checklistSummary = sections.length > 0
472
- ? sections.map(s => {
473
- const items = Array.isArray(s.items) ? s.items : [];
474
- return { type: s.type || s.label || 'unknown', count: items.length };
475
- })
476
- : null;
731
+ const checklistRows = [];
732
+ for (const s of sections) {
733
+ const items = Array.isArray(s.items) ? s.items : [];
734
+ const nonDecide = items.filter(it => it && it.kind !== 'decide');
735
+ if (nonDecide.length === 0) continue;
736
+ checklistRows.push({ type: s.type || s.kind || s.label || s.key || 'checklist', count: nonDecide.length });
737
+ }
738
+ const checklistSummary = checklistRows.length > 0 ? checklistRows : null;
477
739
 
478
740
  // Engagement-wide notes: persistent messages from the consultant that appear
479
741
  // at the top of every packet (e.g. caveats, disclaimers, contextual notes).
@@ -553,6 +815,111 @@ export function extractClientOptions(item) {
553
815
  return extractOptions(item);
554
816
  }
555
817
 
818
+ // ---------------------------------------------------------------------------
819
+ // Decision items — structured options & the internalized projection (§5.1)
820
+ // ---------------------------------------------------------------------------
821
+
822
+ // Deterministic backing fid for a yaml-authored decision. MUST satisfy the
823
+ // pib-db FID_PATTERN (`act:<8 lowercase hex>`) so an inbound answer's
824
+ // target_fid passes addEngagementEvent's validateFid — the production ingest
825
+ // path the loop test's in-memory refmap can't exercise. Deterministic FNV-1a
826
+ // over (slug, section, key) → 8 hex: a stable ref across renders, the same
827
+ // identity any materialized backing row derives. A collision with a real
828
+ // pib-db-generated fid is ~1e-6 and is hard-guarded in synthesizeDecisions
829
+ // (an unrelated live-fid match throws loudly rather than mis-attributing).
830
+ export function decisionFid(engagementSlug, sectionKey, itemKey) {
831
+ let h = 0x811c9dc5;
832
+ const s = `${engagementSlug}${sectionKey}${itemKey}`;
833
+ for (let i = 0; i < s.length; i++) {
834
+ h ^= s.charCodeAt(i);
835
+ h = Math.imul(h, 0x01000193) >>> 0;
836
+ }
837
+ return 'act:' + (h >>> 0).toString(16).padStart(8, '0');
838
+ }
839
+
840
+ // Normalize one option (yaml entry OR a legacy comma-split string) → { value,
841
+ // label }. `value` is the canonical, comma-safe answer text; `label` defaults
842
+ // to it. Empty/whitespace-only values drop out.
843
+ export function normalizeOption(opt) {
844
+ if (opt == null) return null;
845
+ if (typeof opt === 'object' && !Array.isArray(opt)) {
846
+ const value = String(opt.value ?? '').trim();
847
+ if (!value) return null;
848
+ const lab = opt.label != null ? String(opt.label).trim() : '';
849
+ return { value, label: lab || value };
850
+ }
851
+ const value = String(opt).trim();
852
+ return value ? { value, label: value } : null;
853
+ }
854
+
855
+ // { decision_type, options } for a yaml decide item. free_text when the item
856
+ // declares free_text:true OR carries no options. Choice options are the
857
+ // structured array — never a comma-joined string.
858
+ function decisionShape(item) {
859
+ const options = Array.isArray(item.options) ? item.options.map(normalizeOption).filter(Boolean) : [];
860
+ const freeText = item.free_text === true || options.length === 0;
861
+ return { decision_type: freeText ? 'free_text' : 'choice', options: freeText ? [] : options };
862
+ }
863
+
864
+ // { decision_type, options } for a REAL (projected) needs:decision action.
865
+ // Sources options from the legacy comma-split path and normalizes them to the
866
+ // structured shape so every decision item is uniform. No options ⇒ free_text.
867
+ function buildDecisionFields(action) {
868
+ const options = (extractClientOptions(action) || []).map(normalizeOption).filter(Boolean);
869
+ const freeText = options.length === 0;
870
+ return { decision_type: freeText ? 'free_text' : 'choice', options: freeText ? [] : options };
871
+ }
872
+
873
+ // The dedup key: a backing/projected action declares which decide item it
874
+ // covers with an `engagement_key: <itemKey>` marker on its own notes line.
875
+ function parseEngagementKey(notes) {
876
+ const m = (notes || '').match(/^engagement_key:\s*(\S+)\s*$/m);
877
+ return m ? m[1] : null;
878
+ }
879
+
880
+ // Synthesize needs_you decision items from the config's `decide` sections,
881
+ // skipping any key a live action already covers (coveredKeys). Pure: builds an
882
+ // in-memory action shape per decide item and reuses visibleTo (audience-aware)
883
+ // + stableRef, then emits the structured decision item and records its refmap
884
+ // entry. Returns the items to append to needs_you.
885
+ function synthesizeDecisions(config, recipient, roles, coveredKeys, refmap, liveFids) {
886
+ const out = [];
887
+ const slug = config.engagement?.slug || config.engagement?.project_fid || 'engagement';
888
+ for (const sec of config.sections || []) {
889
+ const sectionKey = sec.key || sec.label || 'section';
890
+ for (const it of (sec.items || [])) {
891
+ if (!it || it.kind !== 'decide' || !it.key) continue;
892
+ if (coveredKeys.has(it.key)) continue; // a live action renders this one
893
+ const audience = Array.isArray(it.audience) ? it.audience : (it.audience ? [it.audience] : []);
894
+ const tags = ['client-visible', 'needs:decision', ...audience.map(a => `audience:${a}`)].join(',');
895
+ const synthAction = { fid: decisionFid(slug, sectionKey, it.key), tags, status: 'open', notes: `engagement_key: ${it.key}` };
896
+ if (!visibleTo(synthAction, recipient, roles)) continue;
897
+ // Collision guard: this key was NOT covered by a marker-bearing action
898
+ // (coveredKeys skip above), so a live-fid match here is an UNRELATED
899
+ // action. Throw loudly rather than mis-attribute the answer to it.
900
+ if (liveFids && liveFids.has(synthAction.fid)) {
901
+ throw new Error(`renderPacket: synthesized decision fid ${synthAction.fid} (key "${it.key}") collides with an unrelated live action fid`);
902
+ }
903
+ const ref = stableRef(synthAction.fid);
904
+ if (refmap[ref] && refmap[ref] !== synthAction.fid) {
905
+ throw new Error(`renderPacket: stableRef collision (synthesized decision) — ${refmap[ref]} and ${synthAction.fid} both hash to ${ref}`);
906
+ }
907
+ refmap[ref] = synthAction.fid;
908
+ const title = String(it.prompt || it.key).replace(STATUS_SUFFIX_RE, '').trim();
909
+ const why = [it.help, it.tradeoff ? `Trade-off: ${it.tradeoff}` : '']
910
+ .map(x => (x == null ? '' : String(x).trim())).filter(Boolean).join('\n\n');
911
+ const { decision_type, options } = decisionShape(it);
912
+ const item = { ref, title, why, kind: 'decision', decision_type, options };
913
+ const itemTags = tags.split(',').map(t => t.trim()).filter(Boolean);
914
+ const scopedNotes = (config.scoped_notes || [])
915
+ .filter(sn => sn.tag && itemTags.includes(sn.tag)).map(sn => sn.note).filter(Boolean);
916
+ if (scopedNotes.length) item.context_notes = scopedNotes;
917
+ out.push(item);
918
+ }
919
+ }
920
+ return out;
921
+ }
922
+
556
923
  function collectRefs(packet) {
557
924
  if (!packet) return [];
558
925
  const buckets = ['needs_you', 'in_flight', 'shipped', 'delegated'];
@@ -596,6 +963,19 @@ export function assertPacketInvariants(packet, recipient, roles) {
596
963
  }
597
964
  }
598
965
 
966
+ // Fail-closed (§5.1, P1.2): an answerable choice decision must carry >=2
967
+ // options. An unanswerable choice can never ship as an empty client dialog —
968
+ // the failure is loud at render time. free_text decisions (0 options) and
969
+ // display-only roll-ups (no ref) are exempt.
970
+ for (const item of packet.needs_you || []) {
971
+ if (item.kind === 'decision' && item.ref && item.decision_type === 'choice') {
972
+ const n = Array.isArray(item.options) ? item.options.length : 0;
973
+ if (n < 2) {
974
+ throw new Error(`assertPacketInvariants: decision "${item.title}" (${item.ref}) is a choice with ${n} option(s) — a choice decision needs >=2 options. Fix: add a second option; OR declare free_text (a yaml decide item); OR drop the needs:decision tag (a projected action). The whole packet is held until this is resolved.`);
975
+ }
976
+ }
977
+ }
978
+
599
979
  const hasBilling = Object.prototype.hasOwnProperty.call(packet, 'billing') && packet.billing != null;
600
980
  if (hasBilling && role.billing !== true) {
601
981
  throw new Error(`assertPacketInvariants: role "${recipient.role}" must not receive billing`);
@@ -8,10 +8,19 @@
8
8
  // None of them do console.log — callers decide how to present output.
9
9
 
10
10
  import { existsSync, readFileSync } from 'node:fs';
11
- import { ENGAGEMENT_EVENTS_CREATE, ENGAGEMENT_EVENTS_INDEXES } from '../.claude/engagement/sql-constants.mjs';
12
11
  import { join } from 'node:path';
13
12
  import { randomUUID } from 'node:crypto';
14
13
 
14
+ // sql-constants lives in the installed engagement runtime (.claude/engagement/),
15
+ // which is gitignored in the CC source repo — absent on every fresh checkout/CI,
16
+ // where npm test must still pass (act:d64feaac). Fall back to the committed
17
+ // template source of truth. Same code in template and installed copies — no fork.
18
+ const { ENGAGEMENT_EVENTS_CREATE, ENGAGEMENT_EVENTS_INDEXES } =
19
+ await import('../.claude/engagement/sql-constants.mjs').catch((err) => {
20
+ if (err.code !== 'ERR_MODULE_NOT_FOUND') throw err;
21
+ return import('../templates/engagement/sql-constants.mjs');
22
+ });
23
+
15
24
  // ---------------------------------------------------------------------------
16
25
  // Helpers
17
26
  // ---------------------------------------------------------------------------
@@ -5,59 +5,20 @@
5
5
  // for Claude Code to call directly instead of shelling out to the CLI.
6
6
  //
7
7
  // Environment:
8
- // PIB_DB_PATH — path to SQLite file (default: ./pib.db)
8
+ // PIB_DB_PATH — path to SQLite file. Overrides everything. Otherwise the
9
+ // path resolver (pib-db-path.mjs) picks main's pib.db when in
10
+ // a linked worktree, else cwd/pib.db.
9
11
 
10
12
  import { join, dirname } from 'node:path';
11
13
  import { fileURLToPath } from 'node:url';
12
14
  import { createRequire } from 'node:module';
13
15
  import { createInterface } from 'node:readline';
14
- import { existsSync, readFileSync, statSync } from 'node:fs';
15
16
  import * as lib from './pib-db-lib.mjs';
17
+ import { resolvePibDbPath } from './pib-db-path.mjs';
16
18
 
17
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
20
 
19
- // Resolve pib.db path, handling git worktrees where cwd may not contain
20
- // the canonical database. Worktrees have a .git *file* (not directory)
21
- // pointing to the main repo's .git/worktrees/<name>/ directory.
22
- function resolveDbPath() {
23
- if (process.env.PIB_DB_PATH) return process.env.PIB_DB_PATH;
24
-
25
- const cwd = process.cwd();
26
- const localDb = join(cwd, 'pib.db');
27
-
28
- // If local pib.db exists and is non-trivial (>8KB), use it
29
- if (existsSync(localDb)) {
30
- try {
31
- const stats = statSync(localDb);
32
- if (stats.size > 8192) return localDb;
33
- } catch { /* fall through to worktree detection */ }
34
- }
35
-
36
- // Check if we're in a git worktree (.git is a file, not a directory)
37
- const gitPath = join(cwd, '.git');
38
- if (existsSync(gitPath)) {
39
- try {
40
- const stats = statSync(gitPath);
41
- if (stats.isFile()) {
42
- // .git file contains: "gitdir: /path/to/main/.git/worktrees/<name>"
43
- const content = readFileSync(gitPath, 'utf-8').trim();
44
- const match = content.match(/^gitdir:\s*(.+)/);
45
- if (match) {
46
- // Walk up from .git/worktrees/<name> to find the main repo
47
- const gitdir = match[1];
48
- const mainGitDir = join(gitdir, '..', '..');
49
- const mainRepoDir = dirname(mainGitDir);
50
- const mainDb = join(mainRepoDir, 'pib.db');
51
- if (existsSync(mainDb)) return mainDb;
52
- }
53
- }
54
- } catch { /* fall through to default */ }
55
- }
56
-
57
- return localDb;
58
- }
59
-
60
- const DB_PATH = resolveDbPath();
21
+ const DB_PATH = resolvePibDbPath();
61
22
 
62
23
  // ---------------------------------------------------------------------------
63
24
  // SQLite setup
@@ -24,15 +24,18 @@
24
24
  // node scripts/pib-db.mjs triage-history # Suppression list JSON
25
25
  //
26
26
  // Environment:
27
- // PIB_DB_PATH — path to SQLite file (default: ./pib.db)
27
+ // PIB_DB_PATH — path to SQLite file. Overrides everything. Otherwise the
28
+ // path resolver (pib-db-path.mjs) picks main's pib.db when in
29
+ // a linked worktree, else cwd/pib.db.
28
30
 
29
31
  import { join, dirname } from 'node:path';
30
32
  import { fileURLToPath } from 'node:url';
31
33
  import { createRequire } from 'node:module';
32
34
  import * as lib from './pib-db-lib.mjs';
35
+ import { resolvePibDbPath } from './pib-db-path.mjs';
33
36
 
34
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
35
- const DB_PATH = process.env.PIB_DB_PATH || join(process.cwd(), 'pib.db');
38
+ const DB_PATH = resolvePibDbPath();
36
39
 
37
40
  // ---------------------------------------------------------------------------
38
41
  // SQLite setup — try better-sqlite3
@@ -68,10 +68,16 @@ fi
68
68
  #
69
69
  # Tag lookup is best-effort: if pib.db can't be read, GRP_LABEL is empty and
70
70
  # this gate is skipped — the base breadcrumb gate above still applies.
71
- # Same worktree resolution for the db: a worktree without its own pib.db
72
- # falls back to the main checkout's, so the grp gate doesn't silently skip.
71
+ # Worktree resolution mirrors scripts/pib-db-path.mjs (the JS resolver) and the
72
+ # VERIFY_DIR logic above: an explicit PIB_DB_PATH wins; otherwise, in a linked
73
+ # worktree whose main checkout HAS a pib.db, ALWAYS use main's db — never the
74
+ # worktree's own copy (work-tracking is project-level; one main db is the
75
+ # source of truth). Earlier this only redirected when the local pib.db was
76
+ # absent, which silently read a stale/empty worktree-local db whenever one
77
+ # existed (the symlink mux creates usually hid it) — diverging from the JS
78
+ # resolver that dropped exactly that "local db wins" heuristic. Keep in step.
73
79
  DB_PATH="${PIB_DB_PATH:-pib.db}"
74
- if [ ! -f "$DB_PATH" ] && [ -z "$PIB_DB_PATH" ] && [ -n "$MAIN_CHECKOUT" ] && [ -f "$MAIN_CHECKOUT/pib.db" ]; then
80
+ if [ -z "$PIB_DB_PATH" ] && [ -n "$MAIN_CHECKOUT" ] && [ -f "$MAIN_CHECKOUT/pib.db" ]; then
75
81
  DB_PATH="$MAIN_CHECKOUT/pib.db"
76
82
  fi
77
83
  TAGS=$(python3 -c "
@@ -5,9 +5,14 @@
5
5
  # Triggers: PostToolUse on Write/Edit when the target path is under
6
6
  # ~/.claude/projects/*/memory/*.md (Claude Code's auto-memory dir).
7
7
  #
8
- # Behavior: runs scripts/validate-memory.mjs against the memory dir.
9
- # If the validator reports orphan files (memory file written but not
10
- # indexed), emits a system reminder pointing Claude at /cc-remember.
8
+ # Behavior: runs scripts/validate-memory.mjs in --check-file mode against
9
+ # the file just written. If THAT file is not reachable from MEMORY.md
10
+ # (no index line and no region pointer covers it — i.e. it was written
11
+ # straight to disk and isn't in the working set), emits a system reminder
12
+ # pointing Claude at /cc-remember. Keys on the written file's reachability,
13
+ # NOT on the whole-dir validation — so an unrelated pre-existing violation
14
+ # (e.g. a stale region pointer) never makes the guard cry wolf on every
15
+ # write, and the relaxed enumeration rule doesn't silently retire it.
11
16
  #
12
17
  # This is the post-omega-winddown replacement for omega-memory-guard.sh.
13
18
  # Where the omega guard BLOCKED markdown writes (redirecting them to
@@ -45,8 +50,9 @@ if [ ! -f "$VALIDATOR" ]; then
45
50
  exit 0
46
51
  fi
47
52
 
48
- # Run validator quietly. If it reports orphans, emit guidance.
49
- OUTPUT=$(node "$VALIDATOR" --memory-dir "$MEMORY_DIR" --quiet 2>&1)
53
+ # Check only THIS file's reachability. Exits non-zero with a one-line
54
+ # message when the just-written file isn't reachable from MEMORY.md.
55
+ OUTPUT=$(node "$VALIDATOR" --memory-dir "$MEMORY_DIR" --check-file "$FILE_PATH" --quiet 2>&1)
50
56
  STATUS=$?
51
57
 
52
58
  if [ $STATUS -eq 0 ]; then
@@ -56,15 +62,15 @@ fi
56
62
  # Print the failure to stdout so it surfaces back to Claude as
57
63
  # additional context. Frame as a suggestion, not a block.
58
64
  cat <<REMINDER
59
- [memory-index-guard] $(basename "$FILE_PATH") was written to a memory directory,
60
- but validate-memory.mjs reports an issue:
65
+ [memory-index-guard] $(basename "$FILE_PATH") was written to a memory directory
66
+ but is not reachable from MEMORY.md:
61
67
 
62
68
  $OUTPUT
63
69
 
64
- To fix the most common case (orphan memory file not indexed in
65
- MEMORY.md), use /cc-remember next time it updates MEMORY.md
66
- automatically. Or manually add this file to MEMORY.md's
67
- "## Curated entries (hand-authored)" section.
70
+ The file is on disk but nothing makes it findable next session — it has no
71
+ working-set index line and no region pointer covers it. Use /cc-remember
72
+ (it indexes into the working set automatically), or add a covering region
73
+ pointer in MEMORY.md, or add a "## Curated entries (hand-authored)" line.
68
74
  REMINDER
69
75
 
70
76
  exit 0