@productbrain/cli 0.1.0-beta.10904 → 0.1.0-beta.10914

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 (47) hide show
  1. package/dist/__tests__/capture-receipt-role-hint.test.js +2 -5
  2. package/dist/__tests__/capture-receipt-role-hint.test.js.map +1 -1
  3. package/dist/__tests__/capture.test.js +83 -621
  4. package/dist/__tests__/capture.test.js.map +1 -1
  5. package/dist/__tests__/rate-limit-resilience.test.js +2 -36
  6. package/dist/__tests__/rate-limit-resilience.test.js.map +1 -1
  7. package/dist/__tests__/removed-commands.test.js +8 -2
  8. package/dist/__tests__/removed-commands.test.js.map +1 -1
  9. package/dist/__tests__/session-touch.test.js +48 -112
  10. package/dist/__tests__/session-touch.test.js.map +1 -1
  11. package/dist/__tests__/workspaceNotice.test.js +3 -15
  12. package/dist/__tests__/workspaceNotice.test.js.map +1 -1
  13. package/dist/commands/capture.d.ts +4 -9
  14. package/dist/commands/capture.d.ts.map +1 -1
  15. package/dist/commands/capture.js +30 -151
  16. package/dist/commands/capture.js.map +1 -1
  17. package/dist/handleCliError.d.ts +0 -2
  18. package/dist/handleCliError.d.ts.map +1 -1
  19. package/dist/handleCliError.js +3 -0
  20. package/dist/handleCliError.js.map +1 -1
  21. package/dist/index.js +1 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/lib/client.d.ts +9 -10
  24. package/dist/lib/client.d.ts.map +1 -1
  25. package/dist/lib/client.js +19 -62
  26. package/dist/lib/client.js.map +1 -1
  27. package/dist/lib/rate-limit.js +1 -1
  28. package/dist/registerEntryCommands.d.ts.map +1 -1
  29. package/dist/registerEntryCommands.js +1 -2
  30. package/dist/registerEntryCommands.js.map +1 -1
  31. package/package.json +1 -1
  32. package/dist/__tests__/collection-registry-seed-drift.test.d.ts +0 -2
  33. package/dist/__tests__/collection-registry-seed-drift.test.d.ts.map +0 -1
  34. package/dist/__tests__/collection-registry-seed-drift.test.js +0 -102
  35. package/dist/__tests__/collection-registry-seed-drift.test.js.map +0 -1
  36. package/dist/lib/captureGrounding.d.ts +0 -48
  37. package/dist/lib/captureGrounding.d.ts.map +0 -1
  38. package/dist/lib/captureGrounding.js +0 -54
  39. package/dist/lib/captureGrounding.js.map +0 -1
  40. package/dist/lib/captureGrounding.test.d.ts +0 -2
  41. package/dist/lib/captureGrounding.test.d.ts.map +0 -1
  42. package/dist/lib/captureGrounding.test.js +0 -74
  43. package/dist/lib/captureGrounding.test.js.map +0 -1
  44. package/dist/lib/collectionRegistry.d.ts +0 -73
  45. package/dist/lib/collectionRegistry.d.ts.map +0 -1
  46. package/dist/lib/collectionRegistry.js +0 -135
  47. package/dist/lib/collectionRegistry.js.map +0 -1
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * pb capture extensions — --link, --json flags, WP-319 grounding.
3
3
  * TEN-341 test contract: capture --link calls createEntryRelation, capture --json outputs JSON.
4
- * WP-319 S3: grounding fires in parallel, fail-open, --strict, PB_GROUNDING_MODE.
4
+ * TASK-894 (DEC-2113): one chain.createEntry per capture; the kernel's verdict rides the reply.
5
5
  */
6
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
7
  import { runCapture } from '../commands/capture.js';
8
8
  import { formatCaptureReceipt } from '../formatters/capture.js';
9
9
  import { CLIError, ErrorCode } from '../lib/errors.js';
@@ -31,8 +31,6 @@ describe('runCapture --link', () => {
31
31
  // list the source entry's legal verbs in the refusal message.
32
32
  it('--link without --link-type refuses (no default), preserving the created entry', async () => {
33
33
  kernelCallWithSessionMock.mockImplementation((fn, args) => {
34
- if (fn === 'chain.capture')
35
- return Promise.resolve(null);
36
34
  if (fn === 'chain.createEntry')
37
35
  return Promise.resolve({ docId: 'doc-1', entryId: 'TEN-710' });
38
36
  if (fn === 'chain.getEntry') {
@@ -51,10 +49,9 @@ describe('runCapture --link', () => {
51
49
  });
52
50
  const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
53
51
  await expect(runCapture({ text: 'TEN: missing validation', link: 'BET-151' })).rejects.toThrow(CLIError);
54
- // The entry was created for real — only the LINK is refused.
52
+ // The entry was created for real — only the LINK is refused. TASK-894: the text goes as typed; the kernel routes the prefix.
55
53
  expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.objectContaining({
56
- collectionSlug: 'tensions',
57
- name: 'missing validation',
54
+ name: 'TEN: missing validation',
58
55
  }));
59
56
  // Legal verbs are READ by scanning the semantic-types collection (chain.listEntries)
60
57
  // and matching data.canonical_key — the same resolution loadTypeMap itself uses,
@@ -67,8 +64,6 @@ describe('runCapture --link', () => {
67
64
  });
68
65
  it('--link without --link-type: refusal message lists the source entry\'s declared relation types', async () => {
69
66
  kernelCallWithSessionMock.mockImplementation((fn, args) => {
70
- if (fn === 'chain.capture')
71
- return Promise.resolve(null);
72
67
  if (fn === 'chain.createEntry')
73
68
  return Promise.resolve({ docId: 'doc-1b', entryId: 'TEN-711' });
74
69
  if (fn === 'chain.getEntry') {
@@ -105,8 +100,6 @@ describe('runCapture --link', () => {
105
100
  // the entry still gets created, but the link itself must be explicitly refused.
106
101
  it('--link " " (whitespace-only) refuses instead of silently dropping the link', async () => {
107
102
  kernelCallWithSessionMock.mockImplementation((fn) => {
108
- if (fn === 'chain.capture')
109
- return Promise.resolve(null);
110
103
  if (fn === 'chain.createEntry')
111
104
  return Promise.resolve({ docId: 'doc-ws', entryId: 'TEN-720' });
112
105
  return Promise.resolve({});
@@ -129,8 +122,6 @@ describe('runCapture --link', () => {
129
122
  // must check `!== undefined` instead, or this whole block (and the bcRUML refusal) never runs.
130
123
  it('--link "" (empty string) refuses instead of silently dropping the link', async () => {
131
124
  kernelCallWithSessionMock.mockImplementation((fn) => {
132
- if (fn === 'chain.capture')
133
- return Promise.resolve(null);
134
125
  if (fn === 'chain.createEntry')
135
126
  return Promise.resolve({ docId: 'doc-empty', entryId: 'TEN-722' });
136
127
  return Promise.resolve({});
@@ -152,8 +143,6 @@ describe('runCapture --link', () => {
152
143
  // guidance must say so rather than recommending `pb relate <verb> <missing-id>`.
153
144
  it('--link <missing-id> without --link-type reports the target was not found, not a verb-recovery command', async () => {
154
145
  kernelCallWithSessionMock.mockImplementation((fn, args) => {
155
- if (fn === 'chain.capture')
156
- return Promise.resolve(null);
157
146
  if (fn === 'chain.createEntry')
158
147
  return Promise.resolve({ docId: 'doc-miss', entryId: 'TEN-721' });
159
148
  if (fn === 'chain.getEntry') {
@@ -186,8 +175,6 @@ describe('runCapture --link', () => {
186
175
  // `pb relate` would name a guaranteed-to-fail re-run.
187
176
  it('excludes replaces and do_not_confuse_with from the refusal\'s legal-verb list (both are door-blocked)', async () => {
188
177
  kernelCallWithSessionMock.mockImplementation((fn, args) => {
189
- if (fn === 'chain.capture')
190
- return Promise.resolve(null);
191
178
  if (fn === 'chain.createEntry')
192
179
  return Promise.resolve({ docId: 'doc-1c', entryId: 'DEC-1' });
193
180
  if (fn === 'chain.getEntry') {
@@ -218,8 +205,6 @@ describe('runCapture --link', () => {
218
205
  // so it must never be listed as a `pb relate`-usable recovery verb.
219
206
  it('excludes a declared verb outside the kernel allowlist from the refusal\'s legal-verb list', async () => {
220
207
  kernelCallWithSessionMock.mockImplementation((fn, args) => {
221
- if (fn === 'chain.capture')
222
- return Promise.resolve(null);
223
208
  if (fn === 'chain.createEntry')
224
209
  return Promise.resolve({ docId: 'doc-1d', entryId: 'GOAL-1' });
225
210
  if (fn === 'chain.getEntry') {
@@ -250,8 +235,6 @@ describe('runCapture --link', () => {
250
235
  // supply (guards.ts:349-363) — never recommend them as recovery verbs for such a target.
251
236
  it('excludes domain-tag verbs from the refusal\'s legal-verb list when the target is a domain', async () => {
252
237
  kernelCallWithSessionMock.mockImplementation((fn, args) => {
253
- if (fn === 'chain.capture')
254
- return Promise.resolve(null);
255
238
  if (fn === 'chain.createEntry')
256
239
  return Promise.resolve({ docId: 'doc-1e', entryId: 'INS-1' });
257
240
  if (fn === 'chain.getEntry') {
@@ -281,10 +264,7 @@ describe('runCapture --link', () => {
281
264
  expect(caught?.message).not.toContain('authority_domain');
282
265
  });
283
266
  it('uses the supplied relation type when --link-type provided (no probe, real write)', async () => {
284
- // Prefix "INS:" is extracted → skips classification, routes directly to insights
285
267
  kernelCallWithSessionMock.mockImplementation((fn) => {
286
- if (fn === 'chain.capture')
287
- return Promise.resolve(null);
288
268
  if (fn === 'chain.createEntry')
289
269
  return Promise.resolve({ docId: 'doc-2', entryId: 'INS-50' });
290
270
  if (fn === 'chain.createEntryRelation')
@@ -293,7 +273,6 @@ describe('runCapture --link', () => {
293
273
  });
294
274
  const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
295
275
  await runCapture({ text: 'INS: discovered X', link: 'DEC-264', linkType: 'informed_by' });
296
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.resolveCollection', expect.anything());
297
276
  // A REAL (non-preview) write — the exact verb supplied, echoed verbatim, no probe call.
298
277
  expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntryRelation', {
299
278
  fromEntryId: 'INS-50',
@@ -310,8 +289,6 @@ describe('runCapture --link', () => {
310
289
  // --link-without---link-type branch above).
311
290
  it('a relate-door refusal during --link is deferred, naming the persisted entry — never an unhandled rejection', async () => {
312
291
  kernelCallWithSessionMock.mockImplementation((fn) => {
313
- if (fn === 'chain.capture')
314
- return Promise.resolve(null);
315
292
  if (fn === 'chain.createEntry')
316
293
  return Promise.resolve({ docId: 'doc-wp', entryId: 'WP-6' });
317
294
  if (fn === 'chain.createEntryRelation') {
@@ -337,8 +314,6 @@ describe('runCapture --link', () => {
337
314
  // real error's identity.
338
315
  it('a non-validation relate-door failure during --link preserves its own code/category, not a relabeled VALIDATION_FAILED', async () => {
339
316
  kernelCallWithSessionMock.mockImplementation((fn) => {
340
- if (fn === 'chain.capture')
341
- return Promise.resolve(null);
342
317
  if (fn === 'chain.createEntry')
343
318
  return Promise.resolve({ docId: 'doc-wp7', entryId: 'WP-7' });
344
319
  if (fn === 'chain.createEntryRelation') {
@@ -366,8 +341,6 @@ describe('runCapture --link', () => {
366
341
  // the "was refused, re-run" template, which would prompt a blind duplicate-risking retry.
367
342
  it('a NETWORK_TIMEOUT relate-door failure during --link says outcome-unknown, never "was refused"', async () => {
368
343
  kernelCallWithSessionMock.mockImplementation((fn) => {
369
- if (fn === 'chain.capture')
370
- return Promise.resolve(null);
371
344
  if (fn === 'chain.createEntry')
372
345
  return Promise.resolve({ docId: 'doc-wp9', entryId: 'WP-9' });
373
346
  if (fn === 'chain.createEntryRelation') {
@@ -396,8 +369,6 @@ describe('runCapture --link', () => {
396
369
  // scheduled.
397
370
  it('the quality re-eval still runs after a relate-door refusal — the entry is not left permanently deferred', async () => {
398
371
  kernelCallWithSessionMock.mockImplementation((fn) => {
399
- if (fn === 'chain.capture')
400
- return Promise.resolve(null);
401
372
  if (fn === 'chain.createEntry')
402
373
  return Promise.resolve({ docId: 'doc-wp8', entryId: 'WP-8' });
403
374
  if (fn === 'chain.createEntryRelation') {
@@ -423,8 +394,6 @@ describe('runCapture --link', () => {
423
394
  // fallback (formatters/capture.ts:105).
424
395
  it('the human-readable receipt prints the real relation type, never the dead surfaces_tension_in fallback', async () => {
425
396
  kernelCallWithSessionMock.mockImplementation((fn) => {
426
- if (fn === 'chain.capture')
427
- return Promise.resolve(null);
428
397
  if (fn === 'chain.createEntry')
429
398
  return Promise.resolve({ docId: 'doc-2b', entryId: 'INS-51' });
430
399
  if (fn === 'chain.createEntryRelation')
@@ -453,8 +422,6 @@ describe('runCapture --link', () => {
453
422
  // WITHOUT a relationId — only a real, persisted write may claim `linkedTo`/`relationType`.
454
423
  it('an auto-link-gate diversion (no relationId) is never reported as a persisted link', async () => {
455
424
  kernelCallWithSessionMock.mockImplementation((fn) => {
456
- if (fn === 'chain.capture')
457
- return Promise.resolve(null);
458
425
  if (fn === 'chain.createEntry')
459
426
  return Promise.resolve({ docId: 'doc-3', entryId: 'WPKG-1' });
460
427
  if (fn === 'chain.createEntryRelation') {
@@ -493,8 +460,6 @@ describe('runCapture --link', () => {
493
460
  // deferred-then-never-run schedule would leave the entry permanently stuck.
494
461
  it('--link without --link-type does NOT defer the LLM schedule (nothing will ever un-defer it)', async () => {
495
462
  kernelCallWithSessionMock.mockImplementation((fn) => {
496
- if (fn === 'chain.capture')
497
- return Promise.resolve(null);
498
463
  if (fn === 'chain.createEntry')
499
464
  return Promise.resolve({ docId: 'doc-4', entryId: 'TEN-20' });
500
465
  if (fn === 'chain.createEntryRelation')
@@ -513,8 +478,6 @@ describe('runCapture --link', () => {
513
478
  // refusal is the only error.
514
479
  it('prints the dropped-field warning AND throws the link-type refusal when they co-occur', async () => {
515
480
  kernelCallWithSessionMock.mockImplementation((fn) => {
516
- if (fn === 'chain.capture')
517
- return Promise.resolve(null);
518
481
  if (fn === 'chain.createEntry') {
519
482
  return Promise.resolve({
520
483
  docId: 'doc-5', entryId: 'TEN-21',
@@ -547,8 +510,6 @@ describe('runCapture --link', () => {
547
510
  // error code must still see the timeout, not a generic validation failure.
548
511
  it('the link-refused error beside a dropped field keeps the link failure\'s own code (NETWORK_TIMEOUT), not VALIDATION_FAILED', async () => {
549
512
  kernelCallWithSessionMock.mockImplementation((fn) => {
550
- if (fn === 'chain.capture')
551
- return Promise.resolve(null);
552
513
  if (fn === 'chain.createEntry') {
553
514
  return Promise.resolve({
554
515
  docId: 'doc-10', entryId: 'TEN-22',
@@ -585,8 +546,6 @@ describe('runCapture --entry-type', () => {
585
546
  });
586
547
  it('forwards --entry-type as canonicalKey on the chain.createEntry payload', async () => {
587
548
  kernelCallWithSessionMock.mockImplementation((fn) => {
588
- if (fn === 'chain.capture')
589
- return Promise.resolve(null);
590
549
  if (fn === 'chain.createEntry')
591
550
  return Promise.resolve({ docId: 'doc-3', entryId: 'OBJ-1' });
592
551
  return Promise.resolve({});
@@ -601,8 +560,6 @@ describe('runCapture --entry-type', () => {
601
560
  });
602
561
  it('omits canonicalKey entirely when --entry-type is absent (byte-identical default path)', async () => {
603
562
  kernelCallWithSessionMock.mockImplementation((fn) => {
604
- if (fn === 'chain.capture')
605
- return Promise.resolve(null);
606
563
  if (fn === 'chain.createEntry')
607
564
  return Promise.resolve({ docId: 'doc-4', entryId: 'OBJ-1' });
608
565
  return Promise.resolve({});
@@ -613,110 +570,19 @@ describe('runCapture --entry-type', () => {
613
570
  expect(call?.[1]).not.toHaveProperty('canonicalKey');
614
571
  writeSpy.mockRestore();
615
572
  });
616
- // Review round 2 (bcI42): --entry-type's help text promises it "bypasses classification",
617
- // but without an ALSO-explicit --collection, the resolution chain fell through to
618
- // chain.resolveCollection anyway — objective/key_result text could classify into the wrong
619
- // collection (e.g. strategy) and then fail the placement guard. --entry-type alone must route
620
- // straight to the shared Objectives drawer with no classification call at all.
621
- it('--entry-type objective alone (no --collection) skips classification and routes to the Objectives drawer (bcI42)', async () => {
622
- kernelCallWithSessionMock.mockImplementation((fn) => {
623
- if (fn === 'chain.capture')
624
- return Promise.resolve(null);
625
- if (fn === 'chain.createEntry')
626
- return Promise.resolve({ docId: 'doc-5', entryId: 'OBJ-2' });
627
- return Promise.resolve({});
628
- });
573
+ // TASK-894 (DEC-2113): --entry-type alone routes in the kernel (getTypeHintMap), so the CLI sends
574
+ // one request with the key and no collection: no listCollections, no resolveCollection.
575
+ it('--entry-type alone sends one chain.createEntry with canonicalKey and no collectionSlug', async () => {
576
+ kernelCallWithSessionMock.mockImplementation((fn) => (fn === 'chain.createEntry' ? Promise.resolve({ docId: 'doc-5', entryId: 'OBJ-2', collection: 'objectives' }) : Promise.resolve({})));
629
577
  const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
578
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
630
579
  await runCapture({ text: 'another objective', entryType: 'objective' });
631
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.resolveCollection', expect.anything());
632
- // PR #713 post-merge review (PRRT_kwDORS4ils6cQP_I, P1): 'key-results' is the RETIRED slug —
633
- // the fix derives the drawer's current slug from the live registry (OBJ idPrefix), which is
634
- // 'objectives' post-WP-686.
635
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.objectContaining({
636
- collectionSlug: 'objectives',
637
- canonicalKey: 'objective',
638
- }));
639
- writeSpy.mockRestore();
640
- });
641
- // WP-707 S1 round 2 (DEC-1622, Codex PRRT_kwDORS4ils6dUxaz): key_result now files into its
642
- // OWN 1:1 drawer (key-results, idPrefix KR) — it no longer shares the Objectives drawer.
643
- it('--entry-type key_result alone (no --collection) routes to the Key Results drawer (DEC-1622)', async () => {
644
- kernelCallWithSessionMock.mockImplementation((fn) => {
645
- if (fn === 'chain.capture')
646
- return Promise.resolve(null);
647
- if (fn === 'chain.createEntry')
648
- return Promise.resolve({ docId: 'doc-6', entryId: 'KR-2' });
649
- return Promise.resolve({});
650
- });
651
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
652
- await runCapture({ text: 'another key result', entryType: 'key_result' });
653
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.resolveCollection', expect.anything());
654
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.objectContaining({
655
- collectionSlug: 'key-results',
656
- canonicalKey: 'key_result',
657
- }));
658
- writeSpy.mockRestore();
659
- });
660
- // Review round 3 (bclZ9): --entry-type Objective (mixed case) or " objective " (whitespace)
661
- // must route to the drawer too -- the server normalizes explicit keys with
662
- // toLowerCase().trim() downstream, so client-side routing must match or it falls through to
663
- // classification (often landing in 'strategy') and the placement guard then rejects it.
664
- it('--entry-type with mixed case/whitespace still routes to the Objectives drawer, no classification call (bclZ9)', async () => {
665
- kernelCallWithSessionMock.mockImplementation((fn) => {
666
- if (fn === 'chain.capture')
667
- return Promise.resolve(null);
668
- if (fn === 'chain.createEntry')
669
- return Promise.resolve({ docId: 'doc-7', entryId: 'OBJ-3' });
670
- return Promise.resolve({});
671
- });
672
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
673
- await runCapture({ text: 'a padded objective', entryType: ' Objective ' });
674
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.resolveCollection', expect.anything());
675
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.objectContaining({
676
- collectionSlug: 'objectives',
677
- }));
678
- writeSpy.mockRestore();
679
- });
680
- // WP-707 S1 round 3 (operator ruling #13): the fast path is now a GENERAL derivation off the
681
- // registry's defaultCanonicalKey (getCanonicalKeyToCollection), not two hardcoded
682
- // objective/key_result branches — this proves a THIRD kind that was never one of those two
683
- // branches (strategy) still skips classification and routes to its own drawer.
684
- it('--entry-type strategy alone (no --collection) skips classification and routes to the Strategy drawer — proves the derivation is general, not a hardcoded pair', async () => {
685
- kernelCallWithSessionMock.mockImplementation((fn) => {
686
- if (fn === 'chain.capture')
687
- return Promise.resolve(null);
688
- if (fn === 'chain.createEntry')
689
- return Promise.resolve({ docId: 'doc-8', entryId: 'STR-1' });
690
- return Promise.resolve({});
691
- });
692
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
693
- await runCapture({ text: 'our approach to winning', entryType: 'strategy' });
694
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.resolveCollection', expect.anything());
695
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.objectContaining({
696
- collectionSlug: 'strategy',
697
- canonicalKey: 'strategy',
698
- }));
699
- writeSpy.mockRestore();
700
- });
701
- // WP-707 S1 round 3.1 (Codex post-round-3 P2): vision/purpose are brand-new drawers this same
702
- // PR adds — the offline SEED fallback (collectionRegistry.ts) must carry them too, or the
703
- // --entry-type derivation silently can't resolve them during a chain.listCollections outage.
704
- it('--entry-type vision alone (no --collection) skips classification and routes to the Vision drawer, even offline', async () => {
705
- kernelCallWithSessionMock.mockImplementation((fn) => {
706
- if (fn === 'chain.capture')
707
- return Promise.resolve(null);
708
- if (fn === 'chain.createEntry')
709
- return Promise.resolve({ docId: 'doc-9', entryId: 'VIS-1' });
710
- return Promise.resolve({});
711
- });
712
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
713
- await runCapture({ text: 'a world where knowledge compounds', entryType: 'vision' });
714
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.resolveCollection', expect.anything());
715
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.objectContaining({
716
- collectionSlug: 'vision',
717
- canonicalKey: 'vision',
718
- }));
580
+ expect(kernelCallWithSessionMock.mock.calls.map(([fn]) => fn)).toEqual(['chain.createEntry']);
581
+ const args = kernelCallWithSessionMock.mock.calls[0][1];
582
+ expect(args).toMatchObject({ canonicalKey: 'objective', name: 'another objective' });
583
+ expect(args).not.toHaveProperty('collectionSlug');
719
584
  writeSpy.mockRestore();
585
+ stderrSpy.mockRestore();
720
586
  });
721
587
  });
722
588
  describe('runCapture --json', () => {
@@ -725,12 +591,10 @@ describe('runCapture --json', () => {
725
591
  mockSession = { sessionId: 'sess-test' };
726
592
  });
727
593
  it('outputs valid JSON with id field and no other stdout', async () => {
728
- // Prefix "TEN:" is extracted → skips classification
594
+ // TASK-894: the kernel routes the "TEN:" prefix and names the slug on the reply.
729
595
  kernelCallWithSessionMock.mockImplementation((fn) => {
730
- if (fn === 'chain.capture')
731
- return Promise.resolve(null);
732
596
  if (fn === 'chain.createEntry')
733
- return Promise.resolve({ docId: 'doc-1', entryId: 'TEN-711' });
597
+ return Promise.resolve({ docId: 'doc-1', entryId: 'TEN-711', collection: 'tensions' });
734
598
  return Promise.resolve({});
735
599
  });
736
600
  let stdoutOutput = '';
@@ -779,8 +643,6 @@ describe('runCapture coaching passthrough (WP-475 E1)', () => {
779
643
  };
780
644
  it('--json output includes the raw coaching object from the write response', async () => {
781
645
  kernelCallWithSessionMock.mockImplementation((fn) => {
782
- if (fn === 'chain.capture')
783
- return Promise.resolve(null);
784
646
  if (fn === 'chain.createEntry')
785
647
  return Promise.resolve({ docId: 'doc-c1', entryId: 'DEC-77', coaching });
786
648
  return Promise.resolve({});
@@ -807,8 +669,6 @@ describe('runCapture coaching passthrough (WP-475 E1)', () => {
807
669
  });
808
670
  it('--json output omits coaching when the write response has none (backward compat)', async () => {
809
671
  kernelCallWithSessionMock.mockImplementation((fn) => {
810
- if (fn === 'chain.capture')
811
- return Promise.resolve(null);
812
672
  if (fn === 'chain.createEntry')
813
673
  return Promise.resolve({ docId: 'doc-c2', entryId: 'DEC-78' });
814
674
  return Promise.resolve({});
@@ -848,8 +708,6 @@ describe('runCapture coaching passthrough (WP-475 E1)', () => {
848
708
  };
849
709
  it('--link: re-evaluates after the relation lands and renders the post-relation verdict', async () => {
850
710
  kernelCallWithSessionMock.mockImplementation((fn) => {
851
- if (fn === 'chain.capture')
852
- return Promise.resolve(null);
853
711
  if (fn === 'chain.createEntry')
854
712
  return Promise.resolve({ docId: 'doc-c3', entryId: 'DEC-79', coaching });
855
713
  if (fn === 'chain.createEntryRelation')
@@ -892,8 +750,6 @@ describe('runCapture coaching passthrough (WP-475 E1)', () => {
892
750
  });
893
751
  it('--link: keeps the create-response verdict when the re-eval fails (fail-open, BR-144)', async () => {
894
752
  kernelCallWithSessionMock.mockImplementation((fn) => {
895
- if (fn === 'chain.capture')
896
- return Promise.resolve(null);
897
753
  if (fn === 'chain.createEntry')
898
754
  return Promise.resolve({ docId: 'doc-c4', entryId: 'DEC-80', coaching });
899
755
  if (fn === 'chain.createEntryRelation')
@@ -923,8 +779,6 @@ describe('runCapture coaching passthrough (WP-475 E1)', () => {
923
779
  });
924
780
  it('no --link: does NOT call the re-evaluation and does NOT defer the LLM schedule (plain capture unchanged)', async () => {
925
781
  kernelCallWithSessionMock.mockImplementation((fn) => {
926
- if (fn === 'chain.capture')
927
- return Promise.resolve(null);
928
782
  if (fn === 'chain.createEntry')
929
783
  return Promise.resolve({ docId: 'doc-c5', entryId: 'DEC-81', coaching });
930
784
  return Promise.resolve({});
@@ -952,8 +806,6 @@ describe('runCapture --field passthrough', () => {
952
806
  });
953
807
  it('merges --field pairs into data', async () => {
954
808
  kernelCallWithSessionMock.mockImplementation((fn) => {
955
- if (fn === 'chain.capture')
956
- return Promise.resolve(null);
957
809
  if (fn === 'chain.createEntry')
958
810
  return Promise.resolve({ docId: 'doc-6', entryId: 'STD-1' });
959
811
  return Promise.resolve({});
@@ -965,17 +817,13 @@ describe('runCapture --field passthrough', () => {
965
817
  field: ['rationale=signed specs define scope but agents need permission'],
966
818
  });
967
819
  expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.objectContaining({
968
- data: expect.objectContaining({
969
- description: 'touched-code stewardship',
970
- rationale: 'signed specs define scope but agents need permission',
971
- }),
820
+ description: 'touched-code stewardship', // TASK-894: the capture text; the kernel files it when the main field is empty
821
+ data: { rationale: 'signed specs define scope but agents need permission' },
972
822
  }));
973
823
  writeSpy.mockRestore();
974
824
  });
975
825
  it('--field description=X overrides the derived description', async () => {
976
826
  kernelCallWithSessionMock.mockImplementation((fn) => {
977
- if (fn === 'chain.capture')
978
- return Promise.resolve(null);
979
827
  if (fn === 'chain.createEntry')
980
828
  return Promise.resolve({ docId: 'doc-7', entryId: 'TEN-1' });
981
829
  return Promise.resolve({});
@@ -994,20 +842,15 @@ describe('runCapture --field passthrough', () => {
994
842
  });
995
843
  // TASK-584 (WP-802, TEN-4312): the CLI sends only the fields the caller sent. The default
996
844
  // description (the capture text) exists to be remapped into the collection's primary field;
997
- // when -f already set that field and no -d was passed, it is left out.
845
+ // TASK-894: data carries only what the caller sent; the kernel fills the main text field when it is empty.
998
846
  describe('runCapture sends only caller fields (TASK-584)', () => {
999
847
  beforeEach(() => {
1000
848
  kernelCallWithSessionMock.mockReset();
1001
849
  mockSession = { sessionId: 'sess-test' };
1002
850
  });
1003
- const bodyContract = { governed: false, whyField: null, minChars: 0, descriptionFieldKey: 'body' };
1004
851
  const createData = () => kernelCallWithSessionMock.mock.calls.find(([fn]) => fn === 'chain.createEntry')?.[1]?.data;
1005
852
  it('omits description when -f sets the primary field and no -d is given', async () => {
1006
853
  kernelCallWithSessionMock.mockImplementation((fn) => {
1007
- if (fn === 'chain.capture')
1008
- return Promise.resolve(null);
1009
- if (fn === 'chain.getCollectionFields')
1010
- return Promise.resolve(bodyContract);
1011
854
  if (fn === 'chain.createEntry')
1012
855
  return Promise.resolve({ docId: 'd', entryId: 'INS-1', status: 'draft' });
1013
856
  return Promise.resolve({});
@@ -1019,10 +862,6 @@ describe('runCapture sends only caller fields (TASK-584)', () => {
1019
862
  });
1020
863
  it('keeps description when -d is given, even with -f body', async () => {
1021
864
  kernelCallWithSessionMock.mockImplementation((fn) => {
1022
- if (fn === 'chain.capture')
1023
- return Promise.resolve(null);
1024
- if (fn === 'chain.getCollectionFields')
1025
- return Promise.resolve(bodyContract);
1026
865
  if (fn === 'chain.createEntry')
1027
866
  return Promise.resolve({ docId: 'd', entryId: 'INS-2', status: 'draft' });
1028
867
  return Promise.resolve({});
@@ -1032,12 +871,8 @@ describe('runCapture sends only caller fields (TASK-584)', () => {
1032
871
  writeSpy.mockRestore();
1033
872
  expect(createData()).toEqual({ description: 'explicit', body: 'b' });
1034
873
  });
1035
- it('keeps description on a bare capture so the server can remap it into body', async () => {
874
+ it('a bare capture sends its text as description only, for the kernel to file in the empty body', async () => {
1036
875
  kernelCallWithSessionMock.mockImplementation((fn) => {
1037
- if (fn === 'chain.capture')
1038
- return Promise.resolve(null);
1039
- if (fn === 'chain.getCollectionFields')
1040
- return Promise.resolve(bodyContract);
1041
876
  if (fn === 'chain.createEntry')
1042
877
  return Promise.resolve({ docId: 'd', entryId: 'INS-3', status: 'draft' });
1043
878
  return Promise.resolve({});
@@ -1045,29 +880,11 @@ describe('runCapture sends only caller fields (TASK-584)', () => {
1045
880
  const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1046
881
  await runCapture({ text: 'an insight', collection: 'insights' });
1047
882
  writeSpy.mockRestore();
1048
- expect(createData()).toEqual({ description: 'an insight' });
1049
- });
1050
- it('sends description as before when the contract fetch fails', async () => {
1051
- kernelCallWithSessionMock.mockImplementation((fn) => {
1052
- if (fn === 'chain.capture')
1053
- return Promise.resolve(null);
1054
- if (fn === 'chain.getCollectionFields')
1055
- return Promise.reject(new Error('network'));
1056
- if (fn === 'chain.createEntry')
1057
- return Promise.resolve({ docId: 'd', entryId: 'INS-4', status: 'draft' });
1058
- return Promise.resolve({});
1059
- });
1060
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1061
- await runCapture({ text: 'an insight', collection: 'insights', field: ['body=b'] });
1062
- writeSpy.mockRestore();
1063
- expect(createData()).toEqual({ description: 'an insight', body: 'b' });
883
+ expect(createData()).toEqual({});
884
+ expect(kernelCallWithSessionMock.mock.calls[0][1]).toMatchObject({ description: 'an insight' });
1064
885
  });
1065
886
  it('a landed create with a dropped field resolves (exit 0) and names only that field as dropped', async () => {
1066
887
  kernelCallWithSessionMock.mockImplementation((fn) => {
1067
- if (fn === 'chain.capture')
1068
- return Promise.resolve(null);
1069
- if (fn === 'chain.getCollectionFields')
1070
- return Promise.resolve(bodyContract);
1071
888
  if (fn === 'chain.createEntry') {
1072
889
  return Promise.resolve({
1073
890
  docId: 'd', entryId: 'INS-5', status: 'draft', warnings: ['Unknown fields dropped: nosuchfield. Expected: body (text, required).'],
@@ -1103,10 +920,6 @@ describe('runCapture prints the kernel pool sentence (TASK-800)', () => {
1103
920
  kernelCallWithSessionMock.mockReset();
1104
921
  mockSession = { sessionId: 'sess-test' };
1105
922
  kernelCallWithSessionMock.mockImplementation((fn) => {
1106
- if (fn === 'chain.capture')
1107
- return Promise.resolve(null);
1108
- if (fn === 'chain.getCollectionFields')
1109
- return Promise.resolve({ governed: false, whyField: null, minChars: 0, descriptionFieldKey: 'description' });
1110
923
  if (fn === 'chain.createEntry')
1111
924
  return Promise.resolve({ docId: 'd', entryId: 'STR-2', status: 'draft', warnings: [POOL] });
1112
925
  return Promise.resolve({});
@@ -1124,174 +937,22 @@ describe('runCapture prints the kernel pool sentence (TASK-800)', () => {
1124
937
  expect(out).toContain(`Warning: ${POOL}`);
1125
938
  });
1126
939
  });
1127
- describe('runCapture WHY pre-flight', () => {
940
+ // TASK-894 (DEC-2113): no client pre-check answers before the kernel; every refusal is the server's own 400.
941
+ describe('runCapture leaves every refusal to the kernel', () => {
1128
942
  beforeEach(() => {
1129
943
  kernelCallWithSessionMock.mockReset();
1130
944
  mockSession = { sessionId: 'sess-test' };
1131
945
  });
1132
- it('blocks with guidance when governed and whyField is missing', async () => {
1133
- kernelCallWithSessionMock.mockImplementation((fn) => {
1134
- if (fn === 'chain.capture')
1135
- return Promise.resolve(null);
1136
- if (fn === 'chain.getCollectionFields') {
1137
- return Promise.resolve({ governed: true, whyField: 'rationale', minChars: 12, fields: [] });
1138
- }
1139
- if (fn === 'chain.createEntry')
1140
- return Promise.resolve({ docId: 'doc-8', entryId: 'STD-2' });
1141
- return Promise.resolve({});
1142
- });
1143
- await expect(runCapture({ text: 'x', collection: 'standards' })).rejects.toThrow(/rationale/);
1144
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.createEntry', expect.anything());
1145
- });
1146
- it('blocks with guidance for a why-required, non-governed collection (WP-770 W2a, DEC-1787 ruling A)', async () => {
1147
- // purpose/vision/strategy/objective are why-required but NOT `governed` (steering-shared
1148
- // subject) — the gate must fire on `whyField` alone, since getCollectionFields now widens
1149
- // `whyField` for these four independently of `governed`.
1150
- kernelCallWithSessionMock.mockImplementation((fn) => {
1151
- if (fn === 'chain.capture')
1152
- return Promise.resolve(null);
1153
- if (fn === 'chain.getCollectionFields') {
1154
- return Promise.resolve({ governed: false, whyField: 'why', minChars: 12, fields: [] });
1155
- }
1156
- if (fn === 'chain.createEntry')
1157
- return Promise.resolve({ docId: 'doc-10', entryId: 'STRAT-2' });
1158
- return Promise.resolve({});
1159
- });
1160
- await expect(runCapture({ text: 'x', collection: 'strategy' })).rejects.toThrow(/why/);
1161
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.createEntry', expect.anything());
1162
- });
1163
- it('does not block when whyField is description', async () => {
1164
- kernelCallWithSessionMock.mockImplementation((fn) => {
1165
- if (fn === 'chain.capture')
1166
- return Promise.resolve(null);
1167
- if (fn === 'chain.getCollectionFields') {
1168
- return Promise.resolve({ governed: true, whyField: 'description', minChars: 12, fields: [] });
1169
- }
1170
- if (fn === 'chain.createEntry')
1171
- return Promise.resolve({ docId: 'doc-9', entryId: 'ARCH-1' });
1172
- return Promise.resolve({});
1173
- });
1174
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1175
- await runCapture({ text: 'an architecture note body', collection: 'architecture' });
1176
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.anything());
1177
- writeSpy.mockRestore();
1178
- });
1179
- it('does not block when description remaps to the whyField (descriptionFieldKey === whyField)', async () => {
1180
- // TEN-1258 regression: `decisions` sets descriptionFieldKey='rationale' and the server
1181
- // remaps data.description → rationale before the WHY gate. A bare `pb capture "DEC: …"`
1182
- // (description set, no --field rationale) must NOT be false-blocked client-side.
1183
- kernelCallWithSessionMock.mockImplementation((fn) => {
1184
- if (fn === 'chain.capture')
1185
- return Promise.resolve(null);
1186
- if (fn === 'chain.getCollectionFields') {
1187
- return Promise.resolve({
1188
- governed: true,
1189
- whyField: 'rationale',
1190
- minChars: 12,
1191
- descriptionFieldKey: 'rationale',
1192
- fields: [],
1193
- });
1194
- }
1195
- if (fn === 'chain.createEntry')
1196
- return Promise.resolve({ docId: 'doc-12', entryId: 'DEC-5' });
1197
- return Promise.resolve({});
1198
- });
1199
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1200
- await runCapture({ text: 'chose X because it is simpler', collection: 'decisions' });
1201
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.anything());
1202
- writeSpy.mockRestore();
1203
- });
1204
- it('still blocks a governed collection whose descriptionFieldKey does NOT map to the whyField', async () => {
1205
- // proof_governed has whyField='rationale' but no descriptionFieldKey remap, so a bare
1206
- // capture (no --field rationale) is correctly blocked — the description will not satisfy it.
1207
- kernelCallWithSessionMock.mockImplementation((fn) => {
1208
- if (fn === 'chain.capture')
1209
- return Promise.resolve(null);
1210
- if (fn === 'chain.getCollectionFields') {
1211
- return Promise.resolve({
1212
- governed: true,
1213
- whyField: 'rationale',
1214
- minChars: 12,
1215
- descriptionFieldKey: null,
1216
- fields: [],
1217
- });
1218
- }
1219
- if (fn === 'chain.createEntry')
1220
- return Promise.resolve({ docId: 'doc-13', entryId: 'PGV-9' });
1221
- return Promise.resolve({});
1222
- });
1223
- await expect(runCapture({ text: 'x', collection: 'proof_governed' })).rejects.toThrow(/rationale/);
1224
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.createEntry', expect.anything());
1225
- });
1226
- it('fails open and proceeds when the contract fetch fails', async () => {
1227
- kernelCallWithSessionMock.mockImplementation((fn) => {
1228
- if (fn === 'chain.capture')
1229
- return Promise.resolve(null);
1230
- if (fn === 'chain.getCollectionFields')
1231
- return Promise.reject(new Error('network'));
1232
- if (fn === 'chain.createEntry')
1233
- return Promise.resolve({ docId: 'doc-10', entryId: 'STD-3' });
1234
- return Promise.resolve({});
1235
- });
1236
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1237
- await runCapture({
1238
- text: 'x',
1239
- collection: 'standards',
1240
- field: ['rationale=enough characters here ok'],
1241
- });
1242
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.anything());
1243
- writeSpy.mockRestore();
1244
- });
1245
- it('fails open when the contract fetch rejects with a CLIError (server 4xx/5xx surfaces as McpError, an instanceof CLIError)', async () => {
1246
- kernelCallWithSessionMock.mockImplementation((fn) => {
1247
- if (fn === 'chain.capture')
1248
- return Promise.resolve(null);
1249
- if (fn === 'chain.getCollectionFields') {
1250
- return Promise.reject(new CLIError('Could not reach test.convex.site.', {
1251
- code: ErrorCode.NETWORK_UNREACHABLE,
1252
- category: 'network',
1253
- }));
1254
- }
1255
- if (fn === 'chain.createEntry')
1256
- return Promise.resolve({ docId: 'doc-11', entryId: 'STD-4' });
1257
- return Promise.resolve({});
1258
- });
1259
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1260
- await runCapture({
1261
- text: 'x',
1262
- collection: 'standards',
1263
- field: ['rationale=enough characters here ok'],
1264
- });
1265
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.anything());
1266
- writeSpy.mockRestore();
1267
- });
1268
- it('surfaces the server WHY-gate reason on rejection', async () => {
1269
- const serverReason = 'Governance entries (standard) require an explicit WHY before they can be created. Set "rationale" to at least 12 characters.';
1270
- kernelCallWithSessionMock.mockImplementation((fn) => {
1271
- if (fn === 'chain.capture')
1272
- return Promise.resolve(null);
1273
- if (fn === 'chain.getCollectionFields') {
1274
- return Promise.resolve({ governed: true, whyField: 'rationale', minChars: 12, fields: [] });
1275
- }
1276
- if (fn === 'chain.createEntry')
1277
- return Promise.reject(new Error(serverReason));
1278
- return Promise.resolve({});
1279
- });
1280
- await expect(runCapture({
1281
- text: 'x',
1282
- collection: 'standards',
1283
- field: ['rationale=short'],
1284
- })).rejects.toThrow(/at least 12 characters/);
1285
- });
1286
- });
1287
- describe('runCapture classification guard', () => {
1288
- beforeEach(() => {
1289
- kernelCallWithSessionMock.mockReset();
1290
- mockSession = { sessionId: 'sess-test' };
1291
- });
1292
- it('throws CLIError when classification requires review', async () => {
1293
- kernelCallWithSessionMock.mockResolvedValueOnce(null);
1294
- await expect(runCapture({ text: 'Ambiguous capture' })).rejects.toThrow('Classification needs review before capture can continue.');
946
+ it.each([
947
+ ['the WHY gate', 'Governance entries (standard) require an explicit WHY before they can be created. Set "rationale" to at least 12 characters.'],
948
+ ['an abstaining classifier', 'No collection fits this capture. Choose one: decisions, insights, tensions.'],
949
+ ['the exact-name guard', 'Duplicate entry: "x" already exists in tensions.'],
950
+ ])('%s: the one request is refused and the server sentence reaches the caller unchanged', async (_gate, reason) => {
951
+ kernelCallWithSessionMock.mockImplementation((fn) => (fn === 'chain.createEntry' ? Promise.reject(new CLIError(reason, { code: ErrorCode.VALIDATION_FAILED, category: 'validation' })) : Promise.resolve({})));
952
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
953
+ await expect(runCapture({ text: 'x', collection: 'standards', field: ['rationale=short'] })).rejects.toThrow(reason);
954
+ expect(kernelCallWithSessionMock.mock.calls.map(([fn]) => fn)).toEqual(['chain.createEntry']);
955
+ stderrSpy.mockRestore();
1295
956
  });
1296
957
  });
1297
958
  describe('runCapture writes no date (TEN-4244, DEC-1787)', () => {
@@ -1305,273 +966,76 @@ describe('runCapture writes no date (TEN-4244, DEC-1787)', () => {
1305
966
  ['tasks', 'TASK-11', 'Task without any date'],
1306
967
  ])('a %s capture carries no data.date, even when the text names a date', async (collection, entryId, text) => {
1307
968
  kernelCallWithSessionMock.mockImplementation((fn) => {
1308
- if (fn === 'chain.capture')
1309
- return Promise.resolve(null);
1310
- if (fn === 'chain.resolveCollection')
1311
- return Promise.resolve({ collection, confidence: 0.9, tier: 'high' });
1312
969
  if (fn === 'chain.createEntry')
1313
- return Promise.resolve({ docId: 'doc-3', entryId });
970
+ return Promise.resolve({ docId: 'doc-3', entryId, collection });
1314
971
  return Promise.resolve({});
1315
972
  });
1316
973
  const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1317
974
  await runCapture({ text });
1318
975
  const createCall = kernelCallWithSessionMock.mock.calls.find(([fn]) => fn === 'chain.createEntry');
1319
- expect(createCall?.[1]).toMatchObject({ collectionSlug: collection });
976
+ expect(createCall?.[1]).not.toHaveProperty('collectionSlug');
1320
977
  expect((createCall?.[1]).data).not.toHaveProperty('date');
1321
978
  writeSpy.mockRestore();
1322
979
  });
1323
980
  });
1324
- // ---------------------------------------------------------------------------
1325
- // WP-319 S3 / WP-755 C1a: Pre-write grounding tests (the one capture door)
1326
- // ---------------------------------------------------------------------------
1327
- describe('runCapture WP-319/WP-755 grounding', () => {
1328
- const emptyVerdict = { kind: 'create', candidates: [], scanTruncated: false };
1329
- const askVerdict = {
1330
- kind: 'ask',
1331
- candidates: [{ entryId: 'TEN-5', name: 'Similar tension', collectionSlug: 'tensions', score: 0.6 }],
1332
- scanTruncated: false,
1333
- };
981
+ // TASK-894 (DEC-2113): the kernel judges inside chain.createEntry; the CLI renders its verdict.
982
+ describe('runCapture renders the kernel verdict', () => {
983
+ const ask = { kind: 'ask', matches: [{ entryId: 'TEN-5', name: 'Similar tension', collectionSlug: 'tensions', score: 0.6 }] };
1334
984
  beforeEach(() => {
1335
985
  kernelCallWithSessionMock.mockReset();
1336
986
  mockSession = { sessionId: 'sess-test' };
1337
- delete process.env.PB_GROUNDING_MODE;
1338
- // Pre-existing gap this fix round's new --strict tests surfaced: runner.ts's outputMode is
1339
- // module-level `let` state, set permanently by any earlier test in this file that passed
1340
- // `json: true` (setOutputMode is never test-scoped). Reset it here so a later test's
1341
- // `!json` branches (the --strict prompt gate) are never silently skipped by an earlier
1342
- // test's leaked JSON mode.
1343
- setOutputMode('auto');
1344
- });
1345
- it('fires chain.capture in parallel and still calls createEntry', async () => {
1346
- // Prefix-based route skips classification; grounding fires for TEN prefix
1347
- kernelCallWithSessionMock
1348
- .mockImplementation((fn) => {
1349
- if (fn === 'chain.capture')
1350
- return Promise.resolve(emptyVerdict);
1351
- if (fn === 'chain.createEntry')
1352
- return Promise.resolve({ docId: 'doc-1', entryId: 'TEN-720' });
1353
- return Promise.resolve({});
1354
- });
1355
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1356
- const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
1357
- await runCapture({ text: 'TEN: grounding test tension' });
1358
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.capture', expect.objectContaining({
1359
- name: 'grounding test tension',
1360
- }));
1361
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.anything());
1362
- writeSpy.mockRestore();
1363
- stderrSpy.mockRestore();
1364
- });
1365
- it('does NOT block capture when grounding call fails', async () => {
1366
- kernelCallWithSessionMock
1367
- .mockImplementation((fn) => {
1368
- if (fn === 'chain.capture')
1369
- return Promise.reject(new Error('network error'));
1370
- if (fn === 'chain.createEntry')
1371
- return Promise.resolve({ docId: 'doc-2', entryId: 'TEN-721' });
1372
- return Promise.resolve({});
1373
- });
1374
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1375
- const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
1376
- // Must not throw — grounding failure is fail-open
1377
- await expect(runCapture({ text: 'TEN: grounding failure test' })).resolves.toBeUndefined();
1378
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.anything());
1379
- writeSpy.mockRestore();
1380
- stderrSpy.mockRestore();
1381
- });
1382
- it('a create verdict prints no Related line and no how-it-connects list (TASK-608, DEC-1915)', async () => {
1383
- kernelCallWithSessionMock
1384
- .mockImplementation((fn) => {
1385
- if (fn === 'chain.capture')
1386
- return Promise.resolve(emptyVerdict);
1387
- if (fn === 'chain.createEntry')
1388
- return Promise.resolve({ docId: 'doc-3', entryId: 'TEN-722' });
1389
- return Promise.resolve({});
1390
- });
1391
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1392
- const stderrLines = [];
1393
- const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
1394
- stderrLines.push(String(chunk));
1395
- return true;
1396
- });
1397
- await runCapture({ text: 'TEN: no matches tension' });
1398
- // No "Related"/"Possible match" banner in stderr, and no first-capture "how it connects" list.
1399
- const groundingInStderr = stderrLines.some((l) => l.includes('Related') || l.includes('Possible match'));
1400
- expect(groundingInStderr).toBe(false);
1401
- const stdoutText = writeSpy.mock.calls.map(([chunk]) => String(chunk)).join('');
1402
- expect(stdoutText).not.toContain('how it connects');
1403
- writeSpy.mockRestore();
1404
- stderrSpy.mockRestore();
1405
- });
1406
- it('PB_GROUNDING_MODE=off skips the door entirely', async () => {
1407
- process.env.PB_GROUNDING_MODE = 'off';
1408
- kernelCallWithSessionMock
1409
- .mockImplementation((fn) => {
1410
- if (fn === 'chain.createEntry')
1411
- return Promise.resolve({ docId: 'doc-4', entryId: 'TEN-723' });
1412
- return Promise.resolve({});
1413
- });
1414
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1415
- const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
1416
- await runCapture({ text: 'TEN: no grounding env off' });
1417
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.capture', expect.anything());
1418
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.anything());
1419
- delete process.env.PB_GROUNDING_MODE;
1420
- writeSpy.mockRestore();
1421
- stderrSpy.mockRestore();
987
+ setOutputMode('auto'); // runner.ts keeps output mode at module level; an earlier json test must not leak it
1422
988
  });
1423
- it('--json output stdout is clean (no grounding on stdout)', async () => {
1424
- kernelCallWithSessionMock
1425
- .mockImplementation((fn) => {
1426
- if (fn === 'chain.capture')
1427
- return Promise.resolve(askVerdict);
1428
- if (fn === 'chain.createEntry')
1429
- return Promise.resolve({ docId: 'doc-5', entryId: 'TEN-724' });
1430
- return Promise.resolve({});
1431
- });
1432
- let stdoutOutput = '';
1433
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
1434
- stdoutOutput += String(chunk);
1435
- return true;
1436
- });
1437
- const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
1438
- await runCapture({ text: 'TEN: json clean stdout', json: true });
1439
- // Stdout must be exactly one valid JSON line (jq .id must work)
1440
- const jsonLines = stdoutOutput.split('\n').filter((l) => { try {
1441
- JSON.parse(l);
1442
- return true;
989
+ async function capture(reply, options = {}) {
990
+ kernelCallWithSessionMock.mockImplementation((fn) => Promise.resolve(fn === 'chain.createEntry' ? { docId: 'doc-1', collection: 'tensions', ...reply } : {}));
991
+ let out = '';
992
+ let err = '';
993
+ const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { out += String(chunk); return true; });
994
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { err += String(chunk); return true; });
995
+ try {
996
+ await runCapture({ text: 'TEN: a capture', ...options });
1443
997
  }
1444
- catch {
1445
- return false;
1446
- } });
1447
- expect(jsonLines).toHaveLength(1);
1448
- const parsed = JSON.parse(jsonLines[0]);
1449
- expect(parsed.id).toBe('TEN-724');
1450
- // No grounding content in stdout
1451
- expect(stdoutOutput).not.toContain('Grounding');
1452
- expect(stdoutOutput).not.toContain('Possible match');
1453
- writeSpy.mockRestore();
1454
- stderrSpy.mockRestore();
1455
- });
1456
- // TASK-619: an automated --json caller must learn the close match (id, name, collection, score)
1457
- // to update it deliberately — still one JSON line on stdout.
1458
- it('--json on an ask verdict carries possibleMatches in the single JSON line', async () => {
1459
- kernelCallWithSessionMock
1460
- .mockImplementation((fn) => {
1461
- if (fn === 'chain.capture')
1462
- return Promise.resolve(askVerdict);
1463
- if (fn === 'chain.createEntry')
1464
- return Promise.resolve({ docId: 'doc-6', entryId: 'TEN-725' });
1465
- return Promise.resolve({});
1466
- });
1467
- let stdoutOutput = '';
1468
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdoutOutput += String(chunk); return true; });
1469
- const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
1470
- await runCapture({ text: 'TEN: json ask shape', json: true });
1471
- const lines = stdoutOutput.split('\n').filter((l) => l.trim());
1472
- expect(lines).toHaveLength(1);
1473
- const parsed = JSON.parse(lines[0]);
1474
- expect(parsed.id).toBe('TEN-725');
1475
- expect(parsed.possibleMatches).toEqual([{ entryId: 'TEN-5', name: 'Similar tension', collection: 'tensions', score: 0.6 }]);
998
+ finally {
999
+ writeSpy.mockRestore();
1000
+ stderrSpy.mockRestore();
1001
+ }
1002
+ return { out, err };
1003
+ }
1004
+ it('one request: no chain.capture, listCollections, resolveCollection, getCollectionFields or recordGroundingOutcome', async () => {
1005
+ await capture({ entryId: 'TEN-720', verdict: { kind: 'create' }, judged: true });
1006
+ expect(kernelCallWithSessionMock.mock.calls.map(([fn]) => fn)).toEqual(['chain.createEntry']);
1007
+ expect(kernelCallWithSessionMock.mock.calls[0][1]).toMatchObject({ name: 'TEN: a capture', description: 'TEN: a capture' });
1008
+ });
1009
+ it('a create verdict prints no Possible match line (TASK-608, DEC-1915)', async () => {
1010
+ const { err } = await capture({ entryId: 'TEN-722', verdict: { kind: 'create' }, judged: true });
1011
+ expect(err).not.toContain('Possible match');
1012
+ });
1013
+ // TASK-619 (TEN-4420): a close match is named and never written to.
1014
+ it('an ask names the match on stderr and never updates it', async () => {
1015
+ const { err } = await capture({ entryId: 'TEN-729', verdict: ask, judged: true });
1016
+ expect(err).toContain('Possible match: TEN-5 "Similar tension". Nothing was written to it');
1476
1017
  expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.updateEntry', expect.anything());
1477
- writeSpy.mockRestore();
1478
- stderrSpy.mockRestore();
1479
1018
  });
1480
- // TASK-899: the kernel's own verdict and whether its judge answered in budget ride the --json line.
1481
- it('--json carries the kernel verdict and judged when chain.createEntry returns them', async () => {
1482
- const verdict = { kind: 'ask', matches: [{ entryId: 'TEN-5', name: 'Similar tension', collectionSlug: 'tensions', score: 0.9 }] };
1483
- kernelCallWithSessionMock.mockImplementation((fn) => {
1484
- if (fn === 'chain.createEntry')
1485
- return Promise.resolve({ docId: 'doc-8', entryId: 'TEN-727', verdict, judged: true, collection: 'tensions' });
1486
- return Promise.resolve(fn === 'chain.capture' ? emptyVerdict : {});
1487
- });
1488
- let stdoutOutput = '';
1489
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdoutOutput += String(chunk); return true; });
1490
- const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
1491
- await runCapture({ text: 'TEN: json kernel verdict', json: true });
1492
- const parsed = JSON.parse(stdoutOutput.split('\n').filter((l) => l.trim())[0]);
1493
- expect(parsed).toMatchObject({ id: 'TEN-727', verdict, judged: true });
1494
- writeSpy.mockRestore();
1495
- stderrSpy.mockRestore();
1496
- });
1497
- it('--json on a create verdict carries no possibleMatches field', async () => {
1498
- kernelCallWithSessionMock
1499
- .mockImplementation((fn) => {
1500
- if (fn === 'chain.capture')
1501
- return Promise.resolve(emptyVerdict);
1502
- if (fn === 'chain.createEntry')
1503
- return Promise.resolve({ docId: 'doc-7', entryId: 'TEN-726' });
1504
- return Promise.resolve({});
1505
- });
1506
- let stdoutOutput = '';
1507
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdoutOutput += String(chunk); return true; });
1508
- const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
1509
- await runCapture({ text: 'TEN: json create shape', json: true });
1510
- const parsed = JSON.parse(stdoutOutput.split('\n').filter((l) => l.trim())[0]);
1511
- expect(parsed).not.toHaveProperty('possibleMatches');
1512
- writeSpy.mockRestore();
1513
- stderrSpy.mockRestore();
1019
+ it('a judge that missed its budget says the check is still running', async () => {
1020
+ const { err } = await capture({ entryId: 'TEN-730', judged: false });
1021
+ expect(err).toContain('The duplicate check is still running; pb get TEN-730');
1514
1022
  });
1515
- const originalStdinIsTTY = process.stdin.isTTY;
1516
- function mockInteractiveStdin(answer) {
1517
- Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
1518
- vi.spyOn(process.stdin, 'once').mockImplementation((_event, cb) => {
1519
- cb(Buffer.from(answer));
1520
- return process.stdin;
1521
- });
1522
- }
1523
- afterEach(() => {
1524
- // vitest's own hook, not a manual end-of-test call — runs even when an assertion above threw,
1525
- // so one failing case in this block can never leak its stdin mock into the next test.
1526
- Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinIsTTY, configurable: true });
1527
- vi.restoreAllMocks();
1023
+ it('a capped judge scan says more matches may exist', async () => {
1024
+ const { err } = await capture({ entryId: 'TEN-731', verdict: { kind: 'create', scanTruncated: true }, judged: true });
1025
+ expect(err).toContain('read a capped sample; more matching entries may exist');
1528
1026
  });
1529
- it('--strict prompts on an "ask" verdict too (unchanged from before this fix round)', async () => {
1530
- kernelCallWithSessionMock
1531
- .mockImplementation((fn) => {
1532
- if (fn === 'chain.capture')
1533
- return Promise.resolve(askVerdict);
1534
- if (fn === 'chain.createEntry')
1535
- return Promise.resolve({ docId: 'doc-12', entryId: 'TEN-728' });
1536
- return Promise.resolve({});
1537
- });
1538
- mockInteractiveStdin('y');
1539
- vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1540
- const stderrLines = [];
1541
- vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
1542
- stderrLines.push(String(chunk));
1543
- return true;
1027
+ it('--json is one clean line carrying possibleMatches, verdict and judged', async () => {
1028
+ const { out } = await capture({ entryId: 'TEN-725', verdict: ask, judged: true }, { json: true });
1029
+ const lines = out.split('\n').filter((l) => l.trim());
1030
+ expect(lines).toHaveLength(1);
1031
+ expect(JSON.parse(lines[0])).toMatchObject({
1032
+ id: 'TEN-725', collection: 'tensions', verdict: ask, judged: true,
1033
+ possibleMatches: [{ entryId: 'TEN-5', name: 'Similar tension', collection: 'tensions', score: 0.6 }],
1544
1034
  });
1545
- await runCapture({ text: 'TEN: ask strict prompt', strict: true });
1546
- expect(stderrLines.some((l) => l.includes('Proceed with capture?'))).toBe(true);
1547
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.anything());
1548
1035
  });
1549
- // TASK-619 (TEN-4420): a close match never overwrites the matched entry. The CLI names the
1550
- // match and writes only its own new draft; chain.updateEntry is never called from capture.
1551
- it('a close same-collection match names the match and never updates it', async () => {
1552
- const closeMatch = {
1553
- kind: 'ask',
1554
- candidates: [{ entryId: 'TEN-9', name: 'Existing near-duplicate tension', collectionSlug: 'tensions', score: 0.95 }],
1555
- scanTruncated: false,
1556
- };
1557
- kernelCallWithSessionMock
1558
- .mockImplementation((fn) => {
1559
- if (fn === 'chain.capture')
1560
- return Promise.resolve(closeMatch);
1561
- if (fn === 'chain.createEntry')
1562
- return Promise.resolve({ docId: 'doc-13', entryId: 'TEN-729' });
1563
- return Promise.resolve({});
1564
- });
1565
- vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
1566
- const stderrLines = [];
1567
- vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
1568
- stderrLines.push(String(chunk));
1569
- return true;
1570
- });
1571
- await runCapture({ text: 'TEN: existing near-duplicate tension' });
1572
- expect(stderrLines.some((l) => l.includes('Possible match') && l.includes('TEN-9'))).toBe(true);
1573
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.updateEntry', expect.anything());
1574
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.anything());
1036
+ it('--json on a create verdict carries no possibleMatches field', async () => {
1037
+ const { out } = await capture({ entryId: 'TEN-726', verdict: { kind: 'create' }, judged: true }, { json: true });
1038
+ expect(JSON.parse(out.trim())).not.toHaveProperty('possibleMatches');
1575
1039
  });
1576
1040
  });
1577
1041
  // TASK-898 (DEC-2113): a re-sent capture the kernel replayed. The first send may have timed out before its link,
@@ -1583,8 +1047,6 @@ describe('runCapture on a replayed reply (TASK-898)', () => {
1583
1047
  kernelCallWithSessionMock.mockReset();
1584
1048
  mockSession = { sessionId: 'sess-test' };
1585
1049
  kernelCallWithSessionMock.mockImplementation((fn) => {
1586
- if (fn === 'chain.capture')
1587
- return Promise.resolve(null);
1588
1050
  if (fn === 'chain.createEntry')
1589
1051
  return Promise.resolve(REPLAY);
1590
1052
  if (fn === 'chain.createEntryRelation')