create-claude-cabinet 0.46.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.
- package/README.md +2 -6
- package/lib/cli.js +348 -91
- package/lib/copy.js +108 -6
- package/lib/db-setup.js +122 -17
- package/lib/metadata.js +51 -2
- package/lib/settings-merge.js +52 -10
- package/lib/watchtower-setup.js +22 -2
- package/package.json +1 -1
- package/templates/cabinet/advisories-state-schema.md +1 -1
- package/templates/cabinet/checklist-stats-schema.md +15 -1
- package/templates/cabinet/memory-lifecycle-contract.md +135 -0
- package/templates/cabinet/pib-db-access.md +13 -0
- package/templates/cabinet/watchtower-contracts.md +414 -0
- package/templates/cabinet/worktree-invocation-contract.md +87 -0
- package/templates/engagement/OVERVIEW.md +12 -0
- package/templates/engagement/__tests__/engagement.test.mjs +177 -2
- package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
- package/templates/engagement/engagement-preview.mjs +100 -0
- package/templates/engagement/engagement-schema.md +240 -0
- package/templates/engagement/engagement.mjs +391 -11
- package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
- package/templates/engagement/pib-db-patches/pib-db.mjs +5 -2
- package/templates/hooks/action-completion-gate.sh +9 -3
- package/templates/hooks/memory-index-guard.sh +17 -11
- package/templates/hooks/watchtower-session-start.sh +24 -2
- package/templates/mcp/pib-db.json +1 -4
- package/templates/mux/bin/mux +8 -1
- package/templates/mux/config/help.txt +1 -0
- package/templates/rules/acknowledge-when-corrected.md +33 -0
- package/templates/rules/maintainability.md +11 -0
- package/templates/rules/memory-capture.md +26 -4
- package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
- package/templates/rules/verify-before-asserting.md +79 -0
- package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
- package/templates/scripts/__tests__/batch-disposition.test.mjs +98 -0
- package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
- package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
- package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
- package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
- package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
- package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
- package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
- package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
- package/templates/scripts/__tests__/ring-state-ownership.test.mjs +120 -0
- package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
- package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
- package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
- package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
- package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
- package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
- package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
- package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
- package/templates/scripts/__tests__/ring3-dedup.test.mjs +92 -12
- package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
- package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
- package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
- package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
- package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
- package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
- package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
- package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
- package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
- package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
- package/templates/scripts/load-triage-history.js +5 -2
- package/templates/scripts/pib-db-mcp-server.mjs +5 -44
- package/templates/scripts/pib-db-path.mjs +61 -0
- package/templates/scripts/pib-db.mjs +5 -2
- package/templates/scripts/validate-memory.mjs +214 -16
- package/templates/scripts/watchtower-advisories.mjs +7 -3
- package/templates/scripts/watchtower-build-context.mjs +192 -8
- package/templates/scripts/watchtower-cross-ring-reader.mjs +700 -0
- package/templates/scripts/watchtower-hook-runner.mjs +422 -0
- package/templates/scripts/watchtower-lib.mjs +273 -4
- package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
- package/templates/scripts/watchtower-phase-shim.mjs +171 -0
- package/templates/scripts/watchtower-queue.mjs +341 -3
- package/templates/scripts/watchtower-ring1.mjs +405 -45
- package/templates/scripts/watchtower-ring2.mjs +710 -68
- package/templates/scripts/watchtower-ring3-close.mjs +988 -147
- package/templates/scripts/watchtower-ring4-runner.sh +85 -0
- package/templates/scripts/watchtower-ring4.mjs +753 -0
- package/templates/scripts/watchtower-routines.mjs +1 -1
- package/templates/scripts/watchtower-snapshot.mjs +452 -0
- package/templates/scripts/watchtower-sync.mjs +393 -0
- package/templates/skills/briefing/SKILL.md +281 -41
- package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
- package/templates/skills/cabinet-security/SKILL.md +11 -0
- package/templates/skills/catch-up/SKILL.md +113 -0
- package/templates/skills/cc-publish/SKILL.md +53 -16
- package/templates/skills/cc-remember/SKILL.md +45 -0
- package/templates/skills/close/SKILL.md +107 -0
- package/templates/skills/collab-client/SKILL.md +22 -5
- package/templates/skills/collab-consultant/SKILL.md +110 -2
- package/templates/skills/debrief/SKILL.md +21 -4
- package/templates/skills/debrief-classic/SKILL.md +696 -0
- package/templates/skills/debrief-classic/calibration.md +44 -0
- package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
- package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
- package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
- package/templates/skills/debrief-classic/phases/close-work.md +163 -0
- package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
- package/templates/skills/debrief-classic/phases/inventory.md +40 -0
- package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
- package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
- package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
- package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
- package/templates/skills/debrief-classic/phases/report.md +59 -0
- package/templates/skills/debrief-classic/phases/update-state.md +48 -0
- package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
- package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
- package/templates/skills/execute/SKILL.md +71 -6
- package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
- package/templates/skills/execute-group/SKILL.md +23 -26
- package/templates/skills/generate-plan-groups/SKILL.md +20 -0
- package/templates/skills/inbox/SKILL.md +84 -28
- package/templates/skills/memory/SKILL.md +22 -6
- package/templates/skills/orient/SKILL.md +71 -5
- package/templates/skills/orient-classic/SKILL.md +770 -0
- package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
- package/templates/skills/orient-classic/phases/briefing.md +53 -0
- package/templates/skills/orient-classic/phases/cabinet.md +46 -0
- package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
- package/templates/skills/orient-classic/phases/context.md +88 -0
- package/templates/skills/orient-classic/phases/data-sync.md +35 -0
- package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
- package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
- package/templates/skills/orient-classic/phases/health-checks.md +50 -0
- package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
- package/templates/skills/orient-classic/phases/work-scan.md +69 -0
- package/templates/skills/qa-drain/SKILL.md +38 -0
- package/templates/skills/qa-handoff/SKILL.md +287 -46
- package/templates/skills/setup-accounts/SKILL.md +38 -16
- package/templates/skills/threads/SKILL.md +4 -0
- package/templates/skills/validate/phases/validators.md +41 -0
- package/templates/skills/watchtower/SKILL.md +160 -8
- package/templates/verify-runtime/CONVENTIONS.md +9 -0
- package/templates/verify-runtime/README.md +37 -0
- package/templates/watchtower/config.json.template +8 -2
- package/templates/workflows/execute-group-complete.js +10 -1
|
@@ -117,6 +117,17 @@ timelog. The running total appears only in a principal's update — never
|
|
|
117
117
|
in a delegate's. Billing can be scoped by period: the full engagement
|
|
118
118
|
total, the current month, or only hours since the last sync.
|
|
119
119
|
|
|
120
|
+
**Invoicing.** `/collab-consultant invoice [YYYY-MM]` renders a clean
|
|
121
|
+
invoice document from a month's tracked hours (defaults to the last full
|
|
122
|
+
calendar month; `--itemized` for one line per timelog row instead of a
|
|
123
|
+
single consolidated line). The document is built for upload to the
|
|
124
|
+
operator's bank (Relay), whose "Create Invoice → Autofill from document"
|
|
125
|
+
extracts the customer, line items, and total — so the document carries
|
|
126
|
+
no invoice number and no From/bank block (Relay supplies those). It's
|
|
127
|
+
written to `invoices/<YYYY-MM>.html` with a best-effort PDF conversion.
|
|
128
|
+
Relay defaults the due date to today, so the skill reminds you to set the
|
|
129
|
+
Net-30 due date by hand after autofill.
|
|
130
|
+
|
|
120
131
|
### Credential handoff
|
|
121
132
|
|
|
122
133
|
When a client needs to provide a secret (API key, token, password):
|
|
@@ -142,6 +153,7 @@ When a client needs to provide a secret (API key, token, password):
|
|
|
142
153
|
| `/engagement-message` | Consultant | Send a free-text note to a recipient |
|
|
143
154
|
| `/engagement-sync` | Consultant | Push updates to recipients (with preview + confirm) + pull feedback |
|
|
144
155
|
| `/engagement-status` | Consultant | Dashboard: previews, feedback inbox, billing, tag warnings |
|
|
156
|
+
| `/collab-consultant invoice` | Consultant | Render a month's invoice document for upload to Relay's autofill |
|
|
145
157
|
| `/engagement` | Client | Review the latest update and respond |
|
|
146
158
|
| `/engagement-progress` | Client | Quick glance — what's new, counts, read-only |
|
|
147
159
|
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
validateFeedbackEnvelope,
|
|
28
28
|
mapResponseToEvent,
|
|
29
29
|
resolveFeedbackRef,
|
|
30
|
+
decisionFid,
|
|
31
|
+
normalizeOption,
|
|
30
32
|
} from '../engagement.mjs';
|
|
31
33
|
|
|
32
34
|
// --- Fixtures ---------------------------------------------------------------
|
|
@@ -1106,7 +1108,7 @@ test('2D: options declared in notes but NOT client_body do not reach the client
|
|
|
1106
1108
|
assert.deepStrictEqual(item.options, [], 'notes-based options must not leak to the client');
|
|
1107
1109
|
});
|
|
1108
1110
|
|
|
1109
|
-
test('2D: options declared in client_body DO reach the client packet', () => {
|
|
1111
|
+
test('2D: options declared in client_body DO reach the client packet (structured §5.1)', () => {
|
|
1110
1112
|
const a = action({
|
|
1111
1113
|
fid: 'act:00000011',
|
|
1112
1114
|
tags: 'client-visible, audience:ed, needs:decision',
|
|
@@ -1115,7 +1117,9 @@ test('2D: options declared in client_body DO reach the client packet', () => {
|
|
|
1115
1117
|
});
|
|
1116
1118
|
const { packet } = renderPacket({ ...CONFIG, __now: '2026-05-31T12:00:00Z' }, [a], [], null, ED);
|
|
1117
1119
|
const item = packet.needs_you.find(i => i.title === 'Pick a vendor');
|
|
1118
|
-
|
|
1120
|
+
// Structured options objects (comma-safe value field), not a string array.
|
|
1121
|
+
assert.deepStrictEqual(item.options, [{ value: 'Acme', label: 'Acme' }, { value: 'Globex', label: 'Globex' }]);
|
|
1122
|
+
assert.strictEqual(item.decision_type, 'choice');
|
|
1119
1123
|
});
|
|
1120
1124
|
|
|
1121
1125
|
// --- Plan 2B: status fallback never leaks raw enum --------------------------
|
|
@@ -1171,3 +1175,174 @@ test('2C: renderPacket still works with a plain array (not double-unwrapped)', (
|
|
|
1171
1175
|
const { packet } = renderPacket({ ...CONFIG, __now: '2026-05-31T12:00:00Z' }, [a], [], null, ED);
|
|
1172
1176
|
assert.ok(packet.in_flight.find(i => i.title === 'Plain'));
|
|
1173
1177
|
});
|
|
1178
|
+
|
|
1179
|
+
// ===========================================================================
|
|
1180
|
+
// §5.1 — internalized decision projection: structured options, free-text,
|
|
1181
|
+
// fail-closed, dedup, deterministic fid. (P1.1 + P1.2)
|
|
1182
|
+
// ===========================================================================
|
|
1183
|
+
|
|
1184
|
+
// A config carrying `decide` sections, principal=ed, delegate=sydney.
|
|
1185
|
+
function decideConfig(items, over = {}) {
|
|
1186
|
+
return loadEngagement({
|
|
1187
|
+
engagement: { slug: 'maginnis', title: 'Maginnis', project_fid: 'prj:11111111', consultant: 'Oren' },
|
|
1188
|
+
recipients: [
|
|
1189
|
+
{ id: 'ed', name: 'Ed', email: 'ed@x.com', role: 'principal', scopes: ['all'] },
|
|
1190
|
+
{ id: 'sydney', name: 'Sydney', email: 'syd@x.com', role: 'delegate', scopes: ['marketing'] },
|
|
1191
|
+
],
|
|
1192
|
+
roles: { principal: { sees: 'all', billing: true }, delegate: { sees: 'assigned', billing: false } },
|
|
1193
|
+
transport: { type: 'email', consultant: 'oren@x.com' },
|
|
1194
|
+
sections: [{ key: 'go_live', title: 'Go-Live', items }],
|
|
1195
|
+
...over,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
const EDR = { id: 'ed', name: 'Ed', email: 'ed@x.com', role: 'principal', scopes: ['all'], has_claude_code: true };
|
|
1199
|
+
const SYDR = { id: 'sydney', name: 'Sydney', email: 'syd@x.com', role: 'delegate', scopes: ['marketing'], has_claude_code: false };
|
|
1200
|
+
|
|
1201
|
+
test('§5.1 decisionFid: deterministic and FID_PATTERN-valid (act:<8hex> — passes ingest validateFid)', () => {
|
|
1202
|
+
assert.strictEqual(decisionFid('maginnis', 'go_live', 'domain'), decisionFid('maginnis', 'go_live', 'domain'));
|
|
1203
|
+
assert.notStrictEqual(decisionFid('maginnis', 'go_live', 'domain'), decisionFid('maginnis', 'go_live', 'mail_from'));
|
|
1204
|
+
assert.notStrictEqual(decisionFid('maginnis', 'go_live', 'domain'), decisionFid('other', 'go_live', 'domain'));
|
|
1205
|
+
// Must match the pib-db FID_PATTERN so addEngagementEvent accepts it as a
|
|
1206
|
+
// target_fid (the production answer-ingest path the loop test can't reach).
|
|
1207
|
+
assert.ok(/^act:[0-9a-f]{8}$/.test(decisionFid('maginnis', 'go_live', 'domain')), 'matches act:<8 lowercase hex>');
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
test('§5.1 normalizeOption: string ⇒ {value,label}; object passes through; comma-safe; empties drop', () => {
|
|
1211
|
+
assert.deepStrictEqual(normalizeOption('Acme'), { value: 'Acme', label: 'Acme' });
|
|
1212
|
+
assert.deepStrictEqual(normalizeOption({ value: 'B', label: 'Option B' }), { value: 'B', label: 'Option B' });
|
|
1213
|
+
assert.deepStrictEqual(normalizeOption({ value: 'B' }), { value: 'B', label: 'B' });
|
|
1214
|
+
assert.deepStrictEqual(normalizeOption('A, B, and C together'), { value: 'A, B, and C together', label: 'A, B, and C together' });
|
|
1215
|
+
assert.strictEqual(normalizeOption(' '), null);
|
|
1216
|
+
assert.strictEqual(normalizeOption(null), null);
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
test('§5.1 synthesis: a yaml decide item renders into needs_you with structured options + kind=decision', () => {
|
|
1220
|
+
const cfg = decideConfig([
|
|
1221
|
+
{ key: 'domain', kind: 'decide', prompt: 'Which domain?', help: 'Pick one', tradeoff: 'Keystone', options: ['holdthemtoit.com', 'A different domain'] },
|
|
1222
|
+
]);
|
|
1223
|
+
const { packet, refmap } = renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [], [], null, EDR);
|
|
1224
|
+
assert.strictEqual(packet.needs_you.length, 1);
|
|
1225
|
+
const d = packet.needs_you[0];
|
|
1226
|
+
assert.strictEqual(d.kind, 'decision');
|
|
1227
|
+
assert.strictEqual(d.decision_type, 'choice');
|
|
1228
|
+
assert.strictEqual(d.title, 'Which domain?');
|
|
1229
|
+
assert.ok(/Pick one/.test(d.why) && /Trade-off: Keystone/.test(d.why), 'why merges help + tradeoff');
|
|
1230
|
+
assert.deepStrictEqual(d.options, [
|
|
1231
|
+
{ value: 'holdthemtoit.com', label: 'holdthemtoit.com' },
|
|
1232
|
+
{ value: 'A different domain', label: 'A different domain' },
|
|
1233
|
+
]);
|
|
1234
|
+
assert.strictEqual(refmap[d.ref], decisionFid('maginnis', 'go_live', 'domain'), 'ref resolves to the deterministic fid');
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
test('§5.1 synthesis: a comma-bearing option survives intact (no comma-split damage)', () => {
|
|
1238
|
+
const cfg = decideConfig([
|
|
1239
|
+
{ key: 'k', kind: 'decide', prompt: 'P', options: ['Marriott uses AAA, Hilton uses NAF', 'One forum per theory'] },
|
|
1240
|
+
]);
|
|
1241
|
+
const { packet } = renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [], [], null, EDR);
|
|
1242
|
+
assert.deepStrictEqual(packet.needs_you[0].options.map(o => o.value), [
|
|
1243
|
+
'Marriott uses AAA, Hilton uses NAF', 'One forum per theory',
|
|
1244
|
+
]);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
test('§5.1 synthesis: a decide item with no options is free_text (exempt from fail-closed)', () => {
|
|
1248
|
+
const cfg = decideConfig([{ key: 'mail_from', kind: 'decide', prompt: 'Which From address?', help: 'open' }]);
|
|
1249
|
+
const { packet } = renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [], [], null, EDR);
|
|
1250
|
+
assert.strictEqual(packet.needs_you[0].decision_type, 'free_text');
|
|
1251
|
+
assert.deepStrictEqual(packet.needs_you[0].options, []);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
test('§5.1 synthesis: free_text:true forces free-text even with options present', () => {
|
|
1255
|
+
const cfg = decideConfig([{ key: 'k', kind: 'decide', prompt: 'P', free_text: true, options: ['a', 'b'] }]);
|
|
1256
|
+
const { packet } = renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [], [], null, EDR);
|
|
1257
|
+
assert.strictEqual(packet.needs_you[0].decision_type, 'free_text');
|
|
1258
|
+
assert.deepStrictEqual(packet.needs_you[0].options, []);
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
test('§5.1 fail-closed: a choice decision with <2 options throws at render', () => {
|
|
1262
|
+
const cfg = decideConfig([{ key: 'k', kind: 'decide', prompt: 'P', options: ['only one'] }]);
|
|
1263
|
+
assert.throws(
|
|
1264
|
+
() => renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [], [], null, EDR),
|
|
1265
|
+
/choice with 1 option/,
|
|
1266
|
+
);
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
test('§5.1 audience: a no-audience decision reaches the principal but not the delegate', () => {
|
|
1270
|
+
const cfg = decideConfig([{ key: 'k', kind: 'decide', prompt: 'P', options: ['a', 'b'] }]);
|
|
1271
|
+
const ed = renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [], [], null, EDR).packet;
|
|
1272
|
+
const syd = renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [], [], null, SYDR).packet;
|
|
1273
|
+
assert.strictEqual(ed.needs_you.length, 1);
|
|
1274
|
+
assert.strictEqual(syd.needs_you.length, 0, 'delegate sees only assigned items');
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
test('§5.1 audience: audience:[sydney] narrows a decision to that recipient only', () => {
|
|
1278
|
+
const cfg = decideConfig([{ key: 'k', kind: 'decide', prompt: 'P', audience: ['sydney'], options: ['a', 'b'] }]);
|
|
1279
|
+
const ed = renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [], [], null, EDR).packet;
|
|
1280
|
+
// sydney is a delegate; audience alone does not grant a delegate visibility
|
|
1281
|
+
// (assignee is the sole delegate key), so audience narrows the principal out:
|
|
1282
|
+
assert.strictEqual(ed.needs_you.length, 0, 'audience:sydney narrows ed (principal) out');
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
test('§5.1 dedup: a live needs:decision action carrying the engagement_key marker renders the decision once', () => {
|
|
1286
|
+
const cfg = decideConfig([{ key: 'domain', kind: 'decide', prompt: 'Which domain?', options: ['a', 'b'] }]);
|
|
1287
|
+
// A surviving projected action for the SAME key (transition state).
|
|
1288
|
+
const projected = action({
|
|
1289
|
+
fid: 'act:abc12345',
|
|
1290
|
+
tags: 'client-visible, audience:ed, needs:decision',
|
|
1291
|
+
client_title: 'Which domain? (projected)',
|
|
1292
|
+
client_body: 'why\nOptions: a, b',
|
|
1293
|
+
notes: 'engagement_key: domain\ntier: blocks_launch',
|
|
1294
|
+
});
|
|
1295
|
+
const { packet } = renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [projected], [], null, EDR);
|
|
1296
|
+
const domainItems = packet.needs_you.filter(i => /domain/i.test(i.title));
|
|
1297
|
+
assert.strictEqual(domainItems.length, 1, 'exactly one render — synthesis skipped the covered key');
|
|
1298
|
+
assert.strictEqual(domainItems[0].title, 'Which domain? (projected)', 'the live action rendered it, not the synthesis');
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
test('§5.1 cross-render: the same decide key keeps the same ref across re-renders', () => {
|
|
1302
|
+
const cfg = decideConfig([{ key: 'domain', kind: 'decide', prompt: 'Which domain?', options: ['a', 'b'] }]);
|
|
1303
|
+
const p1 = renderPacket({ ...cfg, __now: '2026-06-18T09:00:00Z' }, [], [], null, EDR).packet;
|
|
1304
|
+
const p2 = renderPacket({ ...cfg, __now: '2026-06-18T15:00:00Z' }, [], [], null, EDR).packet;
|
|
1305
|
+
assert.strictEqual(p1.needs_you[0].ref, p2.needs_you[0].ref, 'deterministic fid ⇒ stable ref');
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
test('§5.1 synthesized decision passes the leak gate (no fid/notes/tags on the item)', () => {
|
|
1309
|
+
const cfg = decideConfig([{ key: 'k', kind: 'decide', prompt: 'P', options: ['a', 'b'] }]);
|
|
1310
|
+
const { packet } = renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [], [], null, EDR);
|
|
1311
|
+
// renderPacket already runs assertPacketInvariants; re-assert explicitly.
|
|
1312
|
+
assert.doesNotThrow(() => assertPacketInvariants(packet, EDR, cfg.roles));
|
|
1313
|
+
for (const k of ['action_fid', 'fid', 'notes', 'tags']) {
|
|
1314
|
+
assert.ok(!(k in packet.needs_you[0]), `synthesized item must not carry ${k}`);
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
test('§5.1 checklist_summary: a type-less section labels by key, never the literal "unknown"', () => {
|
|
1319
|
+
const cfg = decideConfig([], { sections: [{ key: 'misc', items: [{ key: 'k1', kind: 'confirm' }] }] });
|
|
1320
|
+
const { packet } = renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [], [], null, EDR);
|
|
1321
|
+
assert.ok(Array.isArray(packet.checklist_summary));
|
|
1322
|
+
assert.strictEqual(packet.checklist_summary[0].type, 'misc');
|
|
1323
|
+
assert.notStrictEqual(packet.checklist_summary[0].type, 'unknown');
|
|
1324
|
+
assert.strictEqual(packet.checklist_summary[0].count, 1);
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
test('§5.1 dedup: a marker-bearing projected action with NO client copy does not suppress synthesis', () => {
|
|
1328
|
+
const cfg = decideConfig([{ key: 'domain', kind: 'decide', prompt: 'Which domain?', options: ['a', 'b'] }]);
|
|
1329
|
+
const noCopy = action({
|
|
1330
|
+
fid: 'act:abc12399',
|
|
1331
|
+
tags: 'client-visible, audience:ed, needs:decision',
|
|
1332
|
+
notes: 'engagement_key: domain', // marker present, but NO client copy
|
|
1333
|
+
});
|
|
1334
|
+
const { packet, notClientReady } = renderPacket({ ...cfg, __now: '2026-06-18T00:00:00Z' }, [noCopy], [], null, EDR);
|
|
1335
|
+
const domainItems = packet.needs_you.filter((i) => /domain/i.test(i.title));
|
|
1336
|
+
assert.strictEqual(domainItems.length, 1, 'synthesis fills the gap when the projected action lacks copy');
|
|
1337
|
+
assert.ok(notClientReady.includes('act:abc12399'), 'the no-copy action is reported, not silently swallowed');
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
test('§5.1 fail-closed: a REAL action with exactly 1 option throws (not only yaml decide items)', () => {
|
|
1341
|
+
const a = action({
|
|
1342
|
+
fid: 'act:00000091',
|
|
1343
|
+
tags: 'client-visible, audience:ed, needs:decision',
|
|
1344
|
+
client_title: 'Pick one',
|
|
1345
|
+
client_body: 'why\nOptions: OnlyChoice',
|
|
1346
|
+
});
|
|
1347
|
+
assert.throws(() => renderPacket({ ...CONFIG, __now: '2026-06-18T00:00:00Z' }, [a], [], null, ED), /choice with 1 option/);
|
|
1348
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Unit tests for renderInvoiceDoc — the pure invoice-document renderer that
|
|
2
|
+
// backs `/collab-consultant invoice`. The document is uploaded to Relay's
|
|
3
|
+
// "Create Invoice → Autofill from document"; these tests pin the ground-truth
|
|
4
|
+
// constraints from the live Relay experiment (2026-06-16):
|
|
5
|
+
// - line items are Description / Quantity (hrs) / Unit price / Amount
|
|
6
|
+
// - NO invoice number, NO From/remit/bank block (Relay supplies them)
|
|
7
|
+
// - Total due is billingResult.total VERBATIM (never a re-sum)
|
|
8
|
+
// - terms live in a memo footer (Relay defaults the due date to today)
|
|
9
|
+
// Run: node --test templates/engagement/__tests__/invoice-doc.test.mjs
|
|
10
|
+
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert';
|
|
13
|
+
import { renderInvoiceDoc, loadEngagement, renderBilling } from '../engagement.mjs';
|
|
14
|
+
|
|
15
|
+
// --- Fixtures ---------------------------------------------------------------
|
|
16
|
+
const CONFIG = loadEngagement({
|
|
17
|
+
engagement: { slug: 'maginnis', title: 'Maginnis Howard', project_fid: 'prj:11111111' },
|
|
18
|
+
billing: { enabled: true, rate: 190, currency: 'USD' },
|
|
19
|
+
invoicing: { mode: 'consolidated', scope_label: 'Consulting services', terms_days: 30 },
|
|
20
|
+
recipients: [
|
|
21
|
+
{ id: 'ed', name: 'Ed Maginnis', org: 'Maginnis Howard', email: 'ed@example.com', role: 'principal', scopes: ['all'] },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
const ED = CONFIG.recipients.find(r => r.id === 'ed');
|
|
25
|
+
|
|
26
|
+
// A hand-built billingResult (the shape renderBilling returns) — keeps the
|
|
27
|
+
// unit test independent of timelog parsing.
|
|
28
|
+
function billing(over = {}) {
|
|
29
|
+
return {
|
|
30
|
+
hours: 7.25,
|
|
31
|
+
rate: 190,
|
|
32
|
+
currency: 'USD',
|
|
33
|
+
period: 'month',
|
|
34
|
+
total: 1377.5,
|
|
35
|
+
lineItems: [
|
|
36
|
+
{ date: '2026-05-02', hours: 2.5, work: 'Platform setup' },
|
|
37
|
+
{ date: '2026-05-10', hours: 1.75, work: 'Campaign config' },
|
|
38
|
+
{ date: '2026-05-20', hours: 3, work: 'Pipeline review' },
|
|
39
|
+
],
|
|
40
|
+
...over,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const OPTS = { mode: 'consolidated', invoiceDate: '2026-06-01', dueDate: '2026-07-01', period: '2026-05' };
|
|
45
|
+
|
|
46
|
+
// Count <tr> rows inside the line-item <tbody> only (excludes thead + totals).
|
|
47
|
+
function tbodyRowCount(html) {
|
|
48
|
+
const m = html.match(/<tbody>([\s\S]*?)<\/tbody>/);
|
|
49
|
+
if (!m) return 0;
|
|
50
|
+
return (m[1].match(/<tr>/g) || []).length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// (a) consolidated mode → exactly one line, Quantity = total hours, Total verbatim
|
|
54
|
+
test('consolidated mode: one line item, quantity = total hours, total verbatim', () => {
|
|
55
|
+
const bill = billing();
|
|
56
|
+
const html = renderInvoiceDoc(CONFIG, ED, bill, OPTS);
|
|
57
|
+
assert.strictEqual(tbodyRowCount(html), 1, 'consolidated renders exactly one line');
|
|
58
|
+
// Quantity column shows the total hours
|
|
59
|
+
assert.match(html, /7\.25/);
|
|
60
|
+
// The stated Total === billingResult.total EXACTLY ($1,377.50)
|
|
61
|
+
assert.ok(html.includes('Total due'));
|
|
62
|
+
assert.ok(html.includes('$1,377.50'), 'total rendered verbatim');
|
|
63
|
+
// consolidated description = "<scope_label> — <Month YYYY>"
|
|
64
|
+
assert.ok(html.includes('Consulting services — May 2026'));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// (b) itemized mode from a 3-row fixture → 3 line rows, Total verbatim
|
|
68
|
+
test('itemized mode: one line per row, total verbatim (not a re-sum)', () => {
|
|
69
|
+
const bill = billing();
|
|
70
|
+
const html = renderInvoiceDoc(CONFIG, ED, bill, { ...OPTS, mode: 'itemized' });
|
|
71
|
+
assert.strictEqual(tbodyRowCount(html), 3, 'itemized renders one row per lineItem');
|
|
72
|
+
assert.ok(html.includes('Platform setup'));
|
|
73
|
+
assert.ok(html.includes('Campaign config'));
|
|
74
|
+
assert.ok(html.includes('Pipeline review'));
|
|
75
|
+
// Total is billingResult.total verbatim, NOT a re-sum of per-line amounts.
|
|
76
|
+
assert.ok(html.includes('$1,377.50'), 'total is the honest billingResult.total');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// (b2) itemized total stays verbatim even when per-line rounding would diverge
|
|
80
|
+
test('itemized mode: total is billingResult.total even if per-line amounts re-sum differently', () => {
|
|
81
|
+
// 3 rows of 0.333h × 190 = 63.27 each → re-sum 189.81; but billingResult.total
|
|
82
|
+
// is the authoritative figure (here deliberately 190.00).
|
|
83
|
+
const bill = billing({
|
|
84
|
+
hours: 0.999,
|
|
85
|
+
total: 190.0,
|
|
86
|
+
lineItems: [
|
|
87
|
+
{ date: '2026-05-02', hours: 0.333, work: 'A' },
|
|
88
|
+
{ date: '2026-05-03', hours: 0.333, work: 'B' },
|
|
89
|
+
{ date: '2026-05-04', hours: 0.333, work: 'C' },
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
const html = renderInvoiceDoc(CONFIG, ED, bill, { ...OPTS, mode: 'itemized' });
|
|
93
|
+
assert.ok(html.includes('Total due'));
|
|
94
|
+
assert.ok(html.includes('$190.00'), 'stated total is the verbatim billingResult.total');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// (b3) itemized: empty work falls back to the row date
|
|
98
|
+
test('itemized mode: empty work falls back to the row date', () => {
|
|
99
|
+
const bill = billing({
|
|
100
|
+
hours: 1,
|
|
101
|
+
total: 190,
|
|
102
|
+
lineItems: [{ date: '2026-05-09', hours: 1, work: '' }],
|
|
103
|
+
});
|
|
104
|
+
const html = renderInvoiceDoc(CONFIG, ED, bill, { ...OPTS, mode: 'itemized' });
|
|
105
|
+
assert.ok(html.includes('2026-05-09'), 'falls back to the date when work is empty');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// (c) due date = invoiceDate + terms_days (rendered Net N in the memo)
|
|
109
|
+
test('memo reflects the passed due date (Net N — due <dueDate>)', () => {
|
|
110
|
+
const bill = billing();
|
|
111
|
+
// invoiceDate 2026-06-01, dueDate 2026-07-01 → 30 days
|
|
112
|
+
const html = renderInvoiceDoc(CONFIG, ED, bill, OPTS);
|
|
113
|
+
assert.match(html, /Payment terms: Net 30 — due 2026-07-01\./);
|
|
114
|
+
// A shorter term renders the right N.
|
|
115
|
+
const html15 = renderInvoiceDoc(CONFIG, ED, bill, { ...OPTS, dueDate: '2026-06-16' });
|
|
116
|
+
assert.match(html15, /Net 15 — due 2026-06-16/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// (d) period label renders "Month YYYY"
|
|
120
|
+
test('period label renders the right Month YYYY', () => {
|
|
121
|
+
const bill = billing();
|
|
122
|
+
const html = renderInvoiceDoc(CONFIG, ED, bill, OPTS);
|
|
123
|
+
assert.ok(html.includes('May 2026'), 'period 2026-05 → "May 2026"');
|
|
124
|
+
assert.ok(html.includes('Period: May 2026.'), 'memo carries the period label too');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// (e) NO invoice number, NO bank/From block
|
|
128
|
+
test('document carries no invoice number and no bank/From/remit block', () => {
|
|
129
|
+
const bill = billing();
|
|
130
|
+
const html = renderInvoiceDoc(CONFIG, ED, bill, OPTS);
|
|
131
|
+
assert.ok(!/Invoice\s*#|Invoice\s*No\.?\b/i.test(html), 'no "Invoice #" / "Invoice No" sender-number label');
|
|
132
|
+
assert.ok(!/routing/i.test(html), 'no routing number');
|
|
133
|
+
assert.ok(!/account\s*(number|no\.?)/i.test(html), 'no account number');
|
|
134
|
+
assert.ok(!/\bACH\b/i.test(html), 'no ACH details');
|
|
135
|
+
assert.ok(!/\bremit\b/i.test(html), 'no remit-to block');
|
|
136
|
+
// No "From:" sender block — Relay pulls the sender from the account.
|
|
137
|
+
assert.ok(!/>\s*From\b/i.test(html), 'no From sender block');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// (f) bill-to shows the recipient name + email (and org when present)
|
|
141
|
+
test('bill-to shows recipient name, org, and email', () => {
|
|
142
|
+
const bill = billing();
|
|
143
|
+
const html = renderInvoiceDoc(CONFIG, ED, bill, OPTS);
|
|
144
|
+
assert.ok(html.includes('Bill to'));
|
|
145
|
+
assert.ok(html.includes('Ed Maginnis'), 'bill-to name');
|
|
146
|
+
assert.ok(html.includes('Maginnis Howard'), 'bill-to org');
|
|
147
|
+
assert.ok(html.includes('ed@example.com'), 'bill-to email');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// (g) refuses on empty lineItems / zero hours
|
|
151
|
+
test('throws on empty lineItems', () => {
|
|
152
|
+
assert.throws(
|
|
153
|
+
() => renderInvoiceDoc(CONFIG, ED, billing({ lineItems: [], hours: 0, total: 0 }), OPTS),
|
|
154
|
+
/no lineItems/,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
test('throws on zero hours even with a stray line', () => {
|
|
158
|
+
assert.throws(
|
|
159
|
+
() => renderInvoiceDoc(CONFIG, ED, billing({ hours: 0, total: 0, lineItems: [{ date: '2026-05-01', hours: 0, work: 'x' }] }), OPTS),
|
|
160
|
+
/zero hours/,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// --- integration with renderBilling -----------------------------------------
|
|
165
|
+
test('renderBilling → renderInvoiceDoc end to end (month period, consolidated)', () => {
|
|
166
|
+
const timelog = [
|
|
167
|
+
'| Date | Hours | Work |',
|
|
168
|
+
'| ---------- | ----- | --------------- |',
|
|
169
|
+
'| 2026-05-02 | 2.5 | Platform setup |',
|
|
170
|
+
'| 2026-04-30 | 9 | (prior month) |',
|
|
171
|
+
'| 2026-05-20 | 3 | Pipeline review |',
|
|
172
|
+
].join('\n');
|
|
173
|
+
const bill = renderBilling(timelog, 190, 'USD', 'month', { monthCutoff: '2026-05-01' });
|
|
174
|
+
assert.strictEqual(bill.period, 'month');
|
|
175
|
+
// Only the two May rows count: 5.5h × 190 = 1045.00 (the April row is excluded).
|
|
176
|
+
assert.strictEqual(bill.hours, 5.5);
|
|
177
|
+
assert.strictEqual(bill.total, 1045);
|
|
178
|
+
const html = renderInvoiceDoc(CONFIG, ED, bill, OPTS);
|
|
179
|
+
assert.strictEqual(tbodyRowCount(html), 1);
|
|
180
|
+
assert.ok(html.includes('$1,045.00'));
|
|
181
|
+
assert.ok(!html.includes('(prior month)'), 'April row excluded by the month filter');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// --- mode default + __now seam ----------------------------------------------
|
|
185
|
+
test('mode defaults to consolidated and dates default via config.__now', () => {
|
|
186
|
+
const cfg = loadEngagement({
|
|
187
|
+
engagement: {}, billing: { enabled: true, rate: 190 }, invoicing: {},
|
|
188
|
+
recipients: [{ id: 'ed', name: 'Ed', role: 'principal' }],
|
|
189
|
+
});
|
|
190
|
+
cfg.__now = '2026-06-16T10:00:00.000Z';
|
|
191
|
+
const html = renderInvoiceDoc(cfg, cfg.recipients[0], billing(), {});
|
|
192
|
+
assert.strictEqual(tbodyRowCount(html), 1, 'default mode is consolidated (one line)');
|
|
193
|
+
assert.ok(html.includes('2026-06-16'), 'issued date defaults from config.__now');
|
|
194
|
+
assert.match(html, /Net 0 — due 2026-06-16/, 'due defaults to issued when not passed');
|
|
195
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// READ-ONLY dispatch preview — renders EXACTLY what `/collab-consultant sync`
|
|
2
|
+
// would send to a recipient, WITHOUT generating copy, writing pib-db, or POSTing
|
|
3
|
+
// anything. The guarded `--dry-run` half of sync, upstreamed (P1.3). It drives
|
|
4
|
+
// the same render path the real sync uses (loadEngagement → renderPacket) against
|
|
5
|
+
// the live pib-db, so the leak gate (assertPacketInvariants, run inside
|
|
6
|
+
// renderPacket) is exercised before any real dispatch.
|
|
7
|
+
//
|
|
8
|
+
// node .claude/engagement/engagement-preview.mjs [recipientId]
|
|
9
|
+
//
|
|
10
|
+
// recipientId defaults to the first principal (a role that `sees: all`). The
|
|
11
|
+
// command makes ZERO writes and ZERO network calls — safe to run any time.
|
|
12
|
+
// PIB_DB_PATH (if set) is inherited by the pib-db query, so it is correct from
|
|
13
|
+
// a worktree too.
|
|
14
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
15
|
+
import { execFileSync } from 'node:child_process'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import { createRequire } from 'node:module'
|
|
19
|
+
import { loadEngagement, renderPacket, renderBilling } from './engagement.mjs'
|
|
20
|
+
import { toAbstractActions, toAbstractProjects, toEvents } from './pibdb-adapter.mjs'
|
|
21
|
+
|
|
22
|
+
// .claude/engagement/ → project root is two levels up.
|
|
23
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
24
|
+
const require = createRequire(import.meta.url)
|
|
25
|
+
|
|
26
|
+
// YAML is needed only to parse engagement.yaml. Resolve it through node's own
|
|
27
|
+
// module resolution (walks up node_modules) rather than a brittle deep path,
|
|
28
|
+
// and degrade with a clear message if the consumer hasn't installed it — the
|
|
29
|
+
// engagement module deliberately adds no hard dependency.
|
|
30
|
+
let parseYaml
|
|
31
|
+
try {
|
|
32
|
+
parseYaml = require('yaml').parse
|
|
33
|
+
} catch {
|
|
34
|
+
console.error('engagement-preview needs the `yaml` package to read engagement.yaml.\n Install it in this project: npm install yaml');
|
|
35
|
+
process.exit(2)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const pib = (sql) => JSON.parse(execFileSync('node', [join(root, 'scripts/pib-db.mjs'), 'query', sql], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }))
|
|
39
|
+
|
|
40
|
+
const config = loadEngagement(parseYaml(readFileSync(join(root, 'engagement.yaml'), 'utf8')))
|
|
41
|
+
const wantId = process.argv[2]
|
|
42
|
+
const recipient = (config.recipients || []).find((r) => r.id === wantId)
|
|
43
|
+
|| (config.recipients || []).find((r) => config.roles?.[r.role]?.sees === 'all')
|
|
44
|
+
|| (config.recipients || [])[0]
|
|
45
|
+
if (!recipient) {
|
|
46
|
+
console.error('engagement-preview: no recipients in engagement.yaml');
|
|
47
|
+
process.exit(2)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const actions = toAbstractActions(pib('SELECT * FROM actions WHERE deleted_at IS NULL'))
|
|
51
|
+
const projects = toAbstractProjects(pib('SELECT * FROM projects WHERE deleted_at IS NULL'))
|
|
52
|
+
let events = []
|
|
53
|
+
try { events = toEvents(pib(`SELECT * FROM engagement_events WHERE engagement = '${config.engagement.project_fid}'`)) } catch { events = [] }
|
|
54
|
+
const billing = renderBilling(readFileSync(join(root, 'timelog.md'), 'utf8'), config.billing?.rate || 190, config.billing?.currency || 'USD', 'engagement')
|
|
55
|
+
const prevPath = join(root, 'engagement-packets', `${recipient.id}-sent.json`)
|
|
56
|
+
const prevPacket = existsSync(prevPath) ? JSON.parse(readFileSync(prevPath, 'utf8')) : null
|
|
57
|
+
|
|
58
|
+
let out
|
|
59
|
+
try {
|
|
60
|
+
out = renderPacket(config, actions, events, billing, recipient, prevPacket, projects)
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.error('LEAK GATE / RENDER FAILED:', e.message)
|
|
63
|
+
process.exit(1)
|
|
64
|
+
}
|
|
65
|
+
const { packet, notClientReady, allDropped } = out
|
|
66
|
+
|
|
67
|
+
const B = '\x1b[1m', X = '\x1b[0m', C = '\x1b[36m', Y = '\x1b[33m', G = '\x1b[32m'
|
|
68
|
+
console.log(`\n${B}══════ DISPATCH PREVIEW — what ${recipient.name || recipient.id} would receive ══════${X}`)
|
|
69
|
+
console.log(`${B}${packet.generated_label || packet.packet_id}${X} (prev packet: ${prevPacket ? prevPacket.generated_label || prevPacket.packet_id : 'none'})`)
|
|
70
|
+
console.log(`\nBuckets: needs_you ${C}${(packet.needs_you || []).length}${X} · in_flight ${C}${(packet.in_flight || []).length}${X} · shipped ${C}${(packet.shipped || []).length}${X} · delegated ${C}${(packet.delegated || []).length}${X} · messages ${C}${(packet.messages || []).length}${X}`)
|
|
71
|
+
console.log(`New since last: ${C}${(packet.new_since_last || []).length}${X} Checklist: ${JSON.stringify(packet.checklist_summary)}`)
|
|
72
|
+
console.log(`Billing: ${G}${packet.billing ? packet.billing.hours + 'h $' + packet.billing.total : '(not included)'}${X}`)
|
|
73
|
+
console.log(`${G}✓ leak gate (assertPacketInvariants) passed — no _refmap / internal fid in projection${X}`)
|
|
74
|
+
|
|
75
|
+
console.log(`\n${B}── NEEDS YOU (${(packet.needs_you || []).length}) — decisions/requests answered in /collab-client ──${X}`)
|
|
76
|
+
for (const it of packet.needs_you || []) {
|
|
77
|
+
if (it.kind === 'decision') {
|
|
78
|
+
const detail = it.decision_type === 'free_text'
|
|
79
|
+
? ` ${C}[free-text answer]${X}`
|
|
80
|
+
: ((it.options || []).length ? ` ${C}[${it.options.map((o) => o.value).join(' · ')}]${X}` : ` ${Y}[NO OPTIONS]${X}`)
|
|
81
|
+
console.log(` • ${(it.title || '').slice(0, 90)} (decision)${detail}`)
|
|
82
|
+
} else {
|
|
83
|
+
console.log(` • ${(it.title || '').slice(0, 90)} (${it.kind || 'item'})`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(`\n${B}── IN FLIGHT (${(packet.in_flight || []).length}) ──${X}`)
|
|
88
|
+
for (const it of packet.in_flight || []) console.log(` • ${(it.title || '').slice(0, 90)} — ${it.status || ''}`)
|
|
89
|
+
|
|
90
|
+
console.log(`\n${B}── SHIPPED (${(packet.shipped || []).length}) ──${X}`)
|
|
91
|
+
for (const it of packet.shipped || []) console.log(` ✓ ${(it.title || '').slice(0, 90)}`)
|
|
92
|
+
|
|
93
|
+
console.log(`\n${B}── MESSAGES FEED (${(packet.messages || []).length}) ──${X}`)
|
|
94
|
+
for (const m of packet.messages || []) console.log(` » ${(m.body || m.text || JSON.stringify(m)).slice(0, 100)}`)
|
|
95
|
+
|
|
96
|
+
console.log(`\n${B}── MISSING CLIENT COPY (would be auto-generated by sync step 6) ──${X}`)
|
|
97
|
+
if (!notClientReady || notClientReady.length === 0) console.log(` ${G}none — every client-visible item/project already has copy${X}`)
|
|
98
|
+
else { console.log(` ${Y}${notClientReady.length} item(s)/project(s) lack copy:${X}`); for (const fid of notClientReady) console.log(` - ${fid}`) }
|
|
99
|
+
if (allDropped) console.log(` ${Y}⚠ allDropped=true — every visible item lacked copy${X}`)
|
|
100
|
+
console.log(`\n${G}(read-only preview — no pib-db writes, no network calls)${X}\n`)
|