@productbrain/cli 0.1.0-beta.11174 → 0.1.0-beta.11180

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 (33) hide show
  1. package/dist/__tests__/capture.test.js +104 -632
  2. package/dist/__tests__/capture.test.js.map +1 -1
  3. package/dist/__tests__/reply-commands-registered.test.js +1 -1
  4. package/dist/__tests__/reply-commands-registered.test.js.map +1 -1
  5. package/dist/commands/capture.d.ts +4 -4
  6. package/dist/commands/capture.d.ts.map +1 -1
  7. package/dist/commands/capture.js +13 -33
  8. package/dist/commands/capture.js.map +1 -1
  9. package/dist/formatters/capture.d.ts +4 -2
  10. package/dist/formatters/capture.d.ts.map +1 -1
  11. package/dist/formatters/capture.js +4 -5
  12. package/dist/formatters/capture.js.map +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/lib/captureLink.d.ts +6 -30
  16. package/dist/lib/captureLink.d.ts.map +1 -1
  17. package/dist/lib/captureLink.js +21 -117
  18. package/dist/lib/captureLink.js.map +1 -1
  19. package/dist/registerEntryCommands.js +2 -2
  20. package/dist/registerEntryCommands.js.map +1 -1
  21. package/package.json +1 -1
  22. package/dist/generated/relationTypes.generated.d.ts +0 -12
  23. package/dist/generated/relationTypes.generated.d.ts.map +0 -1
  24. package/dist/generated/relationTypes.generated.js +0 -11
  25. package/dist/generated/relationTypes.generated.js.map +0 -1
  26. package/dist/lib/__tests__/legalRelationTypes.test.d.ts +0 -2
  27. package/dist/lib/__tests__/legalRelationTypes.test.d.ts.map +0 -1
  28. package/dist/lib/__tests__/legalRelationTypes.test.js +0 -61
  29. package/dist/lib/__tests__/legalRelationTypes.test.js.map +0 -1
  30. package/dist/lib/legalRelationTypes.d.ts +0 -19
  31. package/dist/lib/legalRelationTypes.d.ts.map +0 -1
  32. package/dist/lib/legalRelationTypes.js +0 -64
  33. package/dist/lib/legalRelationTypes.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * pb capture extensions — --link, --json flags, WP-319 grounding.
3
- * TEN-341 test contract: capture --link calls createEntryRelation, capture --json outputs JSON.
3
+ * TASK-897 (DEC-2113): capture --link rides in the one chain.createEntry request as links[]; the reply's outcomes print.
4
4
  * TASK-894 (DEC-2113): one chain.createEntry per capture; the kernel's verdict rides the reply.
5
5
  */
6
6
  import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -20,521 +20,96 @@ vi.mock('../lib/session.js', () => ({
20
20
  readSession: () => mockSession,
21
21
  addCapturedEntry: vi.fn(),
22
22
  }));
23
- describe('runCapture --link', () => {
23
+ // TASK-897 (DEC-2113): a linked capture is ONE request. The --link/--link-type pairs ride in chain.createEntry as
24
+ // links[]; the kernel judges each in the transaction that lands the entry and answers per link, in its own words
25
+ // (`line`, RULE-664). A refused link never fails the capture (exit 0, the entry landed); a pair the kernel could never
26
+ // judge (no type, an empty id) is refused before anything is sent, so nothing is half-done.
27
+ describe('runCapture --link (TASK-897)', () => {
24
28
  beforeEach(() => {
25
29
  kernelCallWithSessionMock.mockReset();
26
30
  mockSession = { sessionId: 'sess-test' };
27
31
  });
28
- // WP-688 S4 (TASK-105, element 11): --link with no --link-type no longer defaults to
29
- // surfaces_tension_in — it refuses instead. The capture itself still succeeds (createEntry
30
- // ran for real); only the relation write is skipped, replaced by a PREVIEW probe used to
31
- // list the source entry's legal verbs in the refusal message.
32
- it('--link without --link-type refuses (no default), preserving the created entry', async () => {
33
- kernelCallWithSessionMock.mockImplementation((fn, args) => {
34
- if (fn === 'chain.createEntry')
35
- return Promise.resolve({ docId: 'doc-1', entryId: 'TEN-710' });
36
- if (fn === 'chain.getEntry') {
37
- const entryId = args?.entryId;
38
- if (entryId === 'TEN-710')
39
- return Promise.resolve({ entryId: 'TEN-710', name: 'x', status: 'draft', canonicalKey: 'tension' });
40
- return Promise.resolve(null);
41
- }
42
- // bdABL: the type row is resolved by scanning the semantic-types collection and
43
- // matching on data.canonical_key — NOT by guessing entryId === `TYPE-<key>`. This
44
- // row deliberately uses a non-conventional entryId (`ST-1`) to prove that.
45
- if (fn === 'chain.listEntries') {
46
- return Promise.resolve([{ entryId: 'ST-1', name: 'tension', status: 'active', data: { canonical_key: 'tension', valid_relation_types: ['related_to', 'depends_on', 'surfaces_tension_in'], description: 'x', cardinality_rule: 'multiple' } }]);
47
- }
48
- return Promise.resolve({});
49
- });
50
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
51
- await expect(runCapture({ text: 'TEN: missing validation', link: 'BET-151' })).rejects.toThrow(CLIError);
52
- // The entry was created for real — only the LINK is refused. TASK-894: the text goes as typed; the kernel routes the prefix.
53
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntry', expect.objectContaining({
54
- name: 'TEN: missing validation',
55
- }));
56
- // Legal verbs are READ by scanning the semantic-types collection (chain.listEntries)
57
- // and matching data.canonical_key — the same resolution loadTypeMap itself uses,
58
- // never a probe, never a guessed entryId.
59
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.getEntry', { entryId: 'TEN-710' });
60
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.listEntries', { collectionSlug: 'semantic-types' });
61
- // Never a createEntryRelation call at all — a real write, preview, or probe.
62
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.createEntryRelation', expect.anything());
63
- writeSpy.mockRestore();
64
- });
65
- it('--link without --link-type: refusal message lists the source entry\'s declared relation types', async () => {
66
- kernelCallWithSessionMock.mockImplementation((fn, args) => {
67
- if (fn === 'chain.createEntry')
68
- return Promise.resolve({ docId: 'doc-1b', entryId: 'TEN-711' });
69
- if (fn === 'chain.getEntry') {
70
- const entryId = args?.entryId;
71
- if (entryId === 'TEN-711')
72
- return Promise.resolve({ entryId: 'TEN-711', name: 'x', status: 'draft', canonicalKey: 'tension' });
73
- // bcRUMR: the target must actually resolve for this test to exercise the
74
- // pb-relate-guidance branch rather than the target-missing branch.
75
- if (entryId === 'BET-151')
76
- return Promise.resolve({ entryId: 'BET-151', name: 'y', status: 'active', canonicalKey: 'work_package' });
77
- return Promise.resolve(null);
78
- }
79
- if (fn === 'chain.listEntries') {
80
- return Promise.resolve([{ entryId: 'TYPE-tension', name: 'tension', status: 'active', data: { canonical_key: 'tension', valid_relation_types: ['related_to', 'depends_on', 'surfaces_tension_in'], description: 'x', cardinality_rule: 'multiple' } }]);
81
- }
82
- return Promise.resolve({});
83
- });
84
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
85
- let caught;
86
- try {
87
- await runCapture({ text: 'TEN: missing validation', link: 'BET-151' });
88
- }
89
- catch (e) {
90
- caught = e;
91
- }
92
- writeSpy.mockRestore();
93
- expect(caught).toBeInstanceOf(CLIError);
94
- expect(caught?.code).toBe(ErrorCode.VALIDATION_FAILED);
95
- expect(caught?.message).toContain('related_to, depends_on, surfaces_tension_in');
96
- expect(caught?.guidance).toContain('TEN-711');
97
- expect(caught?.guidance).toContain('pb relate TEN-711');
98
- });
99
- // WP-688 S4 review round 5 (bcRUML): a whitespace-only --link must not silently no-op —
100
- // the entry still gets created, but the link itself must be explicitly refused.
101
- it('--link " " (whitespace-only) refuses instead of silently dropping the link', async () => {
102
- kernelCallWithSessionMock.mockImplementation((fn) => {
103
- if (fn === 'chain.createEntry')
104
- return Promise.resolve({ docId: 'doc-ws', entryId: 'TEN-720' });
105
- return Promise.resolve({});
106
- });
107
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
108
- let caught;
109
- try {
110
- await runCapture({ text: 'TEN: whitespace link', link: ' ' });
111
- }
112
- catch (e) {
113
- caught = e;
114
- }
115
- writeSpy.mockRestore();
116
- expect(caught).toBeInstanceOf(CLIError);
117
- expect(caught?.message).toContain('--link requires a non-empty entry ID');
118
- // Never a relate/probe call — the target never resolved to anything real.
119
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.createEntryRelation', expect.anything());
120
- });
121
- // WP-688 S4 review round 5 (bcRpJQ): --link "" is falsy at the outer `if (options.link)` —
122
- // must check `!== undefined` instead, or this whole block (and the bcRUML refusal) never runs.
123
- it('--link "" (empty string) refuses instead of silently dropping the link', async () => {
124
- kernelCallWithSessionMock.mockImplementation((fn) => {
125
- if (fn === 'chain.createEntry')
126
- return Promise.resolve({ docId: 'doc-empty', entryId: 'TEN-722' });
127
- return Promise.resolve({});
128
- });
129
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
130
- let caught;
131
- try {
132
- await runCapture({ text: 'TEN: empty link', link: '' });
133
- }
134
- catch (e) {
135
- caught = e;
136
- }
137
- writeSpy.mockRestore();
138
- expect(caught).toBeInstanceOf(CLIError);
139
- expect(caught?.message).toContain('--link requires a non-empty entry ID');
140
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('chain.createEntryRelation', expect.anything());
141
- });
142
- // WP-688 S4 review round 5 (bcRUMR): the target entry doesn't exist — the recovery
143
- // guidance must say so rather than recommending `pb relate <verb> <missing-id>`.
144
- it('--link <missing-id> without --link-type reports the target was not found, not a verb-recovery command', async () => {
145
- kernelCallWithSessionMock.mockImplementation((fn, args) => {
146
- if (fn === 'chain.createEntry')
147
- return Promise.resolve({ docId: 'doc-miss', entryId: 'TEN-721' });
148
- if (fn === 'chain.getEntry') {
149
- const entryId = args?.entryId;
150
- if (entryId === 'TEN-721')
151
- return Promise.resolve({ entryId: 'TEN-721', name: 'x', status: 'draft', canonicalKey: 'tension' });
152
- return Promise.resolve(null); // GHOST-1 does not exist
153
- }
154
- if (fn === 'chain.listEntries') {
155
- return Promise.resolve([{ entryId: 'TYPE-tension', name: 'tension', status: 'active', data: { canonical_key: 'tension', valid_relation_types: ['informs'], description: 'x', cardinality_rule: 'multiple' } }]);
156
- }
157
- return Promise.resolve({});
158
- });
159
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
160
- let caught;
161
- try {
162
- await runCapture({ text: 'TEN: bad target', link: 'GHOST-1' });
163
- }
164
- catch (e) {
165
- caught = e;
166
- }
167
- writeSpy.mockRestore();
168
- expect(caught?.guidance).toContain('not found');
169
- expect(caught?.guidance).toContain('GHOST-1');
170
- expect(caught?.guidance).not.toContain('pb relate');
171
- });
172
- // WP-688 S4 review round 3 (bc4Pj): `replaces`/`do_not_confuse_with` are declared for
173
- // scoring but hard-refused at the generic relate door with a DIFFERENT recovery command
174
- // (setSupersededBy / addDoNotConfuseWith, guards.ts:308-321) — listing them as usable via
175
- // `pb relate` would name a guaranteed-to-fail re-run.
176
- it('excludes replaces and do_not_confuse_with from the refusal\'s legal-verb list (both are door-blocked)', async () => {
177
- kernelCallWithSessionMock.mockImplementation((fn, args) => {
178
- if (fn === 'chain.createEntry')
179
- return Promise.resolve({ docId: 'doc-1c', entryId: 'DEC-1' });
180
- if (fn === 'chain.getEntry') {
181
- const entryId = args?.entryId;
182
- if (entryId === 'DEC-1')
183
- return Promise.resolve({ entryId: 'DEC-1', name: 'x', status: 'draft', canonicalKey: 'decision' });
184
- return Promise.resolve(null);
185
- }
186
- if (fn === 'chain.listEntries') {
187
- return Promise.resolve([{ entryId: 'TYPE-decision', name: 'decision', status: 'active', data: { canonical_key: 'decision', valid_relation_types: ['informs', 'governs', 'replaces', 'conflicts_with'], description: 'x', cardinality_rule: 'multiple' } }]);
188
- }
189
- return Promise.resolve({});
190
- });
191
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
192
- let caught;
193
- try {
194
- await runCapture({ text: 'DEC: no link-type', link: 'STD-1' });
195
- }
196
- catch (e) {
197
- caught = e;
198
- }
199
- writeSpy.mockRestore();
200
- expect(caught?.message).toContain('informs, governs, conflicts_with');
201
- expect(caught?.message).not.toContain('replaces');
202
- });
203
- // WP-688 S4 review round 5 (bdB5K): a type's declared valid_relation_types may name a verb outside
204
- // the kernel ALLOWED_RELATION_TYPES allowlist. guardRelation still refuses it (guards.ts:302-307),
205
- // so it must never be listed as a `pb relate`-usable recovery verb.
206
- it('excludes a declared verb outside the kernel allowlist from the refusal\'s legal-verb list', async () => {
207
- kernelCallWithSessionMock.mockImplementation((fn, args) => {
208
- if (fn === 'chain.createEntry')
209
- return Promise.resolve({ docId: 'doc-1d', entryId: 'GOAL-1' });
210
- if (fn === 'chain.getEntry') {
211
- const entryId = args?.entryId;
212
- if (entryId === 'GOAL-1')
213
- return Promise.resolve({ entryId: 'GOAL-1', name: 'x', status: 'draft', canonicalKey: 'goal' });
214
- return Promise.resolve(null);
215
- }
216
- if (fn === 'chain.listEntries') {
217
- return Promise.resolve([{ entryId: 'TYPE-goal', name: 'goal', status: 'active', data: { canonical_key: 'goal', valid_relation_types: ['informs', 'depends_on', 'custom_hand_added'], description: 'x', cardinality_rule: 'multiple' } }]);
218
- }
219
- return Promise.resolve({});
220
- });
221
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
222
- let caught;
223
- try {
224
- await runCapture({ text: 'GOAL: no link-type', link: 'BET-1' });
225
- }
226
- catch (e) {
227
- caught = e;
228
- }
229
- writeSpy.mockRestore();
230
- expect(caught?.message).toContain('informs, depends_on');
231
- expect(caught?.message).not.toContain('custom_hand_added');
232
- });
233
- // WP-688 S4 review round 5 (cQFZ_): belongs_to/authority_domain toward a domain-tagged
234
- // target require ratification metadata (stagingEntryId + source) that `pb relate` cannot
235
- // supply (guards.ts:349-363) — never recommend them as recovery verbs for such a target.
236
- it('excludes domain-tag verbs from the refusal\'s legal-verb list when the target is a domain', async () => {
237
- kernelCallWithSessionMock.mockImplementation((fn, args) => {
238
- if (fn === 'chain.createEntry')
239
- return Promise.resolve({ docId: 'doc-1e', entryId: 'INS-1' });
240
- if (fn === 'chain.getEntry') {
241
- const entryId = args?.entryId;
242
- if (entryId === 'INS-1')
243
- return Promise.resolve({ entryId: 'INS-1', name: 'x', status: 'draft', canonicalKey: 'insight' });
244
- if (entryId === 'DOM-1')
245
- return Promise.resolve({ entryId: 'DOM-1', name: 'product-design', status: 'active', canonicalKey: 'domain' });
246
- return Promise.resolve(null);
247
- }
248
- if (fn === 'chain.listEntries') {
249
- return Promise.resolve([{ entryId: 'TYPE-insight', name: 'insight', status: 'active', data: { canonical_key: 'insight', valid_relation_types: ['informs', 'belongs_to', 'authority_domain'], description: 'x', cardinality_rule: 'multiple' } }]);
250
- }
251
- return Promise.resolve({});
252
- });
253
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
254
- let caught;
255
- try {
256
- await runCapture({ text: 'INS: no link-type', link: 'DOM-1' });
257
- }
258
- catch (e) {
259
- caught = e;
260
- }
261
- writeSpy.mockRestore();
262
- expect(caught?.message).toContain('Legal relation types for this entry: informs.');
263
- expect(caught?.message).not.toContain('belongs_to');
264
- expect(caught?.message).not.toContain('authority_domain');
265
- });
266
- it('uses the supplied relation type when --link-type provided (no probe, real write)', async () => {
267
- kernelCallWithSessionMock.mockImplementation((fn) => {
268
- if (fn === 'chain.createEntry')
269
- return Promise.resolve({ docId: 'doc-2', entryId: 'INS-50' });
270
- if (fn === 'chain.createEntryRelation')
271
- return Promise.resolve({ relationId: 'rel-1' });
272
- return Promise.resolve({});
273
- });
274
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
275
- await runCapture({ text: 'INS: discovered X', link: 'DEC-264', linkType: 'informed_by' });
276
- // A REAL (non-preview) write — the exact verb supplied, echoed verbatim, no probe call.
277
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('chain.createEntryRelation', {
278
- fromEntryId: 'INS-50',
279
- toEntryId: 'DEC-264',
280
- type: 'informed_by',
281
- });
282
- const previewCalls = kernelCallWithSessionMock.mock.calls.filter(([fn, args]) => fn === 'chain.createEntryRelation' && args?.preview);
283
- expect(previewCalls).toHaveLength(0);
284
- writeSpy.mockRestore();
285
- });
286
- // WP-745 review (Codex P1): a relate-door refusal (e.g. REL-LEGAL-2) at the --link-type
287
- // branch must not escape as an unhandled rejection — the entry above was ALREADY persisted,
288
- // so the capture reports it, naming only the link as refused (same shape as the
289
- // --link-without---link-type branch above).
290
- it('a relate-door refusal during --link is deferred, naming the persisted entry — never an unhandled rejection', async () => {
291
- kernelCallWithSessionMock.mockImplementation((fn) => {
292
- if (fn === 'chain.createEntry')
293
- return Promise.resolve({ docId: 'doc-wp', entryId: 'WP-6' });
294
- if (fn === 'chain.createEntryRelation') {
295
- return Promise.reject(Object.assign(new Error('A work package is never part_of another work package — capture the smaller piece as a task and relate it part_of this work package.'), { name: 'McpError' }));
296
- }
297
- return Promise.resolve({});
298
- });
299
- let caught;
300
- try {
301
- await runCapture({ text: 'WP: child work package', link: 'WP-5', linkType: 'part_of' });
302
- }
303
- catch (e) {
304
- caught = e;
305
- }
306
- expect(caught).toBeInstanceOf(CLIError);
307
- expect(caught?.message).toContain('A work package is never part_of another work package');
308
- expect(caught?.guidance).toContain('WP-6 was captured');
309
- expect(caught?.guidance).toContain('only the link was refused');
310
- });
311
- // WP-745 round 3 (PR #896 review, PRRT_kwDORS4ils6h0CCc): a NON-REL-LEGAL-2 relate-door
312
- // failure (auth denial, rate limit) must keep its OWN code/category — the earlier fix
313
- // unconditionally relabeled every deferred link failure VALIDATION_FAILED, discarding the
314
- // real error's identity.
315
- it('a non-validation relate-door failure during --link preserves its own code/category, not a relabeled VALIDATION_FAILED', async () => {
316
- kernelCallWithSessionMock.mockImplementation((fn) => {
317
- if (fn === 'chain.createEntry')
318
- return Promise.resolve({ docId: 'doc-wp7', entryId: 'WP-7' });
319
- if (fn === 'chain.createEntryRelation') {
320
- return Promise.reject(new CLIError('Rate limit exceeded', { code: ErrorCode.RATE_LIMITED, category: 'network', guidance: 'Wait and retry.' }));
321
- }
322
- if (fn === 'quality.evaluateHeuristicAndSchedule')
323
- return Promise.resolve(undefined);
324
- return Promise.resolve({});
325
- });
326
- let caught;
327
- try {
328
- await runCapture({ text: 'WP: child work package', link: 'WP-5', linkType: 'part_of' });
329
- }
330
- catch (e) {
331
- caught = e;
332
- }
333
- expect(caught).toBeInstanceOf(CLIError);
334
- expect(caught?.code).toBe(ErrorCode.RATE_LIMITED);
335
- expect(caught?.category).toBe('network');
336
- expect(caught?.message).toBe('Rate limit exceeded');
337
- expect(caught?.guidance).toContain('WP-7 was captured');
338
- });
339
- // WP-745 round 3 (PR #896 review, PRRT_kwDORS4ils6h0VPP): a NETWORK_TIMEOUT relate failure is
340
- // outcome-UNKNOWN (the write may have landed server-side) — the guidance must say so, never
341
- // the "was refused, re-run" template, which would prompt a blind duplicate-risking retry.
342
- it('a NETWORK_TIMEOUT relate-door failure during --link says outcome-unknown, never "was refused"', async () => {
343
- kernelCallWithSessionMock.mockImplementation((fn) => {
344
- if (fn === 'chain.createEntry')
345
- return Promise.resolve({ docId: 'doc-wp9', entryId: 'WP-9' });
346
- if (fn === 'chain.createEntryRelation') {
347
- return Promise.reject(new CLIError('"chain.createEntryRelation" exceeded its budget.', { code: ErrorCode.NETWORK_TIMEOUT, category: 'network', guidance: 'Check before retrying.' }));
348
- }
349
- if (fn === 'quality.evaluateHeuristicAndSchedule')
350
- return Promise.resolve(undefined);
351
- return Promise.resolve({});
352
- });
353
- let caught;
354
- try {
355
- await runCapture({ text: 'WP: child work package', link: 'WP-5', linkType: 'part_of' });
356
- }
357
- catch (e) {
358
- caught = e;
359
- }
360
- expect(caught).toBeInstanceOf(CLIError);
361
- expect(caught?.code).toBe(ErrorCode.NETWORK_TIMEOUT);
362
- expect(caught?.guidance).toContain('outcome is UNKNOWN');
363
- expect(caught?.guidance).not.toContain('was refused');
364
- });
365
- // WP-745 round 3 (PR #896 review, PRRT_kwDORS4ils6h0CCc): the quality re-eval must still run
366
- // (un-defers deferLLMSchedule) even when the relate call itself throws — previously the
367
- // re-eval sat INSIDE the same try as the relate call, so a thrown refusal skipped it
368
- // entirely, leaving the entry stuck at llmStatus:'skipped' with no background judgment ever
369
- // scheduled.
370
- it('the quality re-eval still runs after a relate-door refusal — the entry is not left permanently deferred', async () => {
371
- kernelCallWithSessionMock.mockImplementation((fn) => {
372
- if (fn === 'chain.createEntry')
373
- return Promise.resolve({ docId: 'doc-wp8', entryId: 'WP-8' });
374
- if (fn === 'chain.createEntryRelation') {
375
- return Promise.reject(Object.assign(new Error('refused'), { name: 'McpError' }));
376
- }
377
- if (fn === 'quality.evaluateHeuristicAndSchedule')
378
- return Promise.resolve({ verdict: 'pass' });
379
- return Promise.resolve({});
380
- });
381
- try {
382
- await runCapture({ text: 'WP: child work package', link: 'WP-5', linkType: 'part_of' });
383
- }
384
- catch {
385
- // The refusal itself is expected — asserted by the sibling test above; this test only
386
- // cares that the re-eval call happened before/regardless of it.
387
- }
388
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('quality.evaluateHeuristicAndSchedule', {
389
- entryId: 'WP-8',
390
- context: 'capture',
391
- });
392
- });
393
- // WP-688 S4 (element 11): the human receipt prints the REAL verb — no `?? 'surfaces_tension_in'`
394
- // fallback (formatters/capture.ts:105).
395
- it('the human-readable receipt prints the real relation type, never the dead surfaces_tension_in fallback', async () => {
396
- kernelCallWithSessionMock.mockImplementation((fn) => {
397
- if (fn === 'chain.createEntry')
398
- return Promise.resolve({ docId: 'doc-2b', entryId: 'INS-51' });
399
- if (fn === 'chain.createEntryRelation')
400
- return Promise.resolve({ relationId: 'rel-1' });
401
- return Promise.resolve({});
402
- });
403
- let stdoutOutput = '';
404
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
405
- stdoutOutput += String(chunk);
406
- return true;
407
- });
408
- setOutputMode('pretty'); // force the human receipt path (test harness's stdout is non-TTY)
32
+ const LINKS = [
33
+ { toEntryId: 'DEC-29', type: 'related_to', outcome: 'written', line: 'linked related_to DEC-29' },
34
+ { toEntryId: 'WP-29', type: 'part_of', outcome: 'refused', reason: 'A work package is never part_of another work package.', line: 'link refused: A work package is never part_of another work package.' },
35
+ ];
36
+ async function captureWithTwoLinks(json) {
37
+ kernelCallWithSessionMock.mockImplementation((fn) => (fn === 'chain.createEntry' ? Promise.resolve({ docId: 'doc-1', entryId: 'TEN-710', collection: 'tensions', links: LINKS }) : Promise.resolve({})));
38
+ let out = '';
39
+ const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { out += String(chunk); return true; });
40
+ const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
41
+ setOutputMode(json ? 'json' : 'pretty');
409
42
  try {
410
- await runCapture({ text: 'INS: discovered Y', link: 'DEC-265', linkType: 'validates' });
43
+ await runCapture({ text: 'TEN: missing validation', link: ['DEC-29', 'WP-29'], linkType: ['related_to', 'part_of'], json });
411
44
  }
412
45
  finally {
413
46
  setOutputMode('auto');
414
47
  writeSpy.mockRestore();
48
+ errSpy.mockRestore();
415
49
  }
416
- expect(stdoutOutput).toContain('Linked: → DEC-265 (validates)');
417
- expect(stdoutOutput).not.toContain('surfaces_tension_in');
418
- });
419
- // WP-688 S4 review round 2 (bc04z): a diverted relation call (the DEC-1399 auto-link gate
420
- // routing an edge to a pending suggestion instead of writing it — WP-776 S2a, DEC-1771
421
- // deletes the OTHER diversion this once named, the governs-consent proposal) returns
422
- // WITHOUT a relationId — only a real, persisted write may claim `linkedTo`/`relationType`.
423
- it('an auto-link-gate diversion (no relationId) is never reported as a persisted link', async () => {
424
- kernelCallWithSessionMock.mockImplementation((fn) => {
425
- if (fn === 'chain.createEntry')
426
- return Promise.resolve({ docId: 'doc-3', entryId: 'WPKG-1' });
427
- if (fn === 'chain.createEntryRelation') {
428
- return Promise.resolve({ ok: false, status: 'advised', advised: true });
429
- }
430
- return Promise.resolve({});
431
- });
432
- let stdoutOutput = '';
433
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
434
- stdoutOutput += String(chunk);
435
- return true;
436
- });
437
- await runCapture({ text: 'WPKG: governance test', link: 'STD-1', linkType: 'governs', json: true });
438
- writeSpy.mockRestore();
439
- const output = JSON.parse(stdoutOutput.split('\n').find((line) => {
440
- try {
441
- JSON.parse(line);
442
- return true;
443
- }
444
- catch {
445
- return false;
446
- }
447
- }));
448
- expect(output.relationType).toBeUndefined();
449
- // WP-688 S4 review fix (bc7cr): the re-eval call STILL fires — it's the only thing that
450
- // un-defers the LLM schedule deferLLMSchedule set (a relate WAS attempted) — but its
451
- // verdict is discarded: no relation landed, so relation-dependent criteria stay unscored.
452
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('quality.evaluateHeuristicAndSchedule', {
453
- entryId: 'WPKG-1',
454
- context: 'capture',
455
- });
456
- expect(output.coaching).toBeUndefined();
457
- });
458
- // WP-688 S4 review round 2 (bc045): --link without --link-type must not defer the LLM
459
- // schedule — the refusal branch never runs the re-eval that would un-defer it, so a
460
- // deferred-then-never-run schedule would leave the entry permanently stuck.
461
- it('--link without --link-type does NOT defer the LLM schedule (nothing will ever un-defer it)', async () => {
462
- kernelCallWithSessionMock.mockImplementation((fn) => {
463
- if (fn === 'chain.createEntry')
464
- return Promise.resolve({ docId: 'doc-4', entryId: 'TEN-20' });
465
- if (fn === 'chain.createEntryRelation')
466
- return Promise.resolve({ preview: true, advisories: [] });
467
- return Promise.resolve({});
468
- });
50
+ return out;
51
+ }
52
+ it('two --link pairs send ONE request carrying links[], and the receipt prints the kernel line per link; a refused link exits 0', async () => {
53
+ const out = await captureWithTwoLinks(false);
54
+ expect(kernelCallWithSessionMock.mock.calls.map(([fn]) => fn)).toEqual(['chain.createEntry']);
55
+ expect(kernelCallWithSessionMock.mock.calls[0][1]).toMatchObject({
56
+ name: 'TEN: missing validation', links: [{ toEntryId: 'DEC-29', type: 'related_to' }, { toEntryId: 'WP-29', type: 'part_of' }],
57
+ });
58
+ expect(kernelCallWithSessionMock.mock.calls[0][1]).not.toHaveProperty('deferLLMSchedule');
59
+ expect(out).toContain('→ TEN-710 captured');
60
+ expect(out).toContain(' linked related_to DEC-29');
61
+ expect(out).toContain(' link refused: A work package is never part_of another work package.');
62
+ });
63
+ it('--json carries the links as the kernel answered them', async () => {
64
+ const out = await captureWithTwoLinks(true);
65
+ const output = JSON.parse(out.trim().split('\n').pop());
66
+ expect(output).toMatchObject({ id: 'TEN-710', collection: 'tensions', links: LINKS });
67
+ expect(output).not.toHaveProperty('relationType');
68
+ });
69
+ it('no --link: the request carries no links field and no defer flag', async () => {
70
+ kernelCallWithSessionMock.mockImplementation((fn) => (fn === 'chain.createEntry' ? Promise.resolve({ docId: 'doc-2', entryId: 'TEN-711' }) : Promise.resolve({})));
469
71
  const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
470
- await expect(runCapture({ text: 'TEN: refused link, no defer', link: 'BET-1' })).rejects.toThrow(CLIError);
72
+ await runCapture({ text: 'TEN: plain', json: true });
471
73
  writeSpy.mockRestore();
472
- const createCall = kernelCallWithSessionMock.mock.calls.find(([fn]) => fn === 'chain.createEntry');
473
- expect(createCall?.[1]).not.toHaveProperty('deferLLMSchedule');
74
+ const args = kernelCallWithSessionMock.mock.calls[0][1];
75
+ expect(args).not.toHaveProperty('links');
76
+ expect(args).not.toHaveProperty('deferLLMSchedule');
77
+ expect(kernelCallWithSessionMock.mock.calls.map(([fn]) => fn)).toEqual(['chain.createEntry']);
474
78
  });
475
- // WP-688 S4 review round 2 (bc049): a dropped field and a --link-type refusal can both be true
476
- // on the SAME capture — the entry was created either way, so both must reach the operator.
477
- // TASK-584 (WP-802, TEN-4312): the dropped field is a warning on the landed reply, the link
478
- // refusal is the only error.
479
- it('prints the dropped-field warning AND throws the link-type refusal when they co-occur', async () => {
480
- kernelCallWithSessionMock.mockImplementation((fn) => {
481
- if (fn === 'chain.createEntry') {
482
- return Promise.resolve({
483
- docId: 'doc-5', entryId: 'TEN-21',
484
- normalization: { remapped: {}, rejected: ['badField'], accepted: [] },
485
- });
486
- }
487
- if (fn === 'chain.createEntryRelation')
488
- return Promise.resolve({ preview: true, advisories: [] });
489
- return Promise.resolve({});
490
- });
491
- const out = [];
492
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { out.push(String(chunk)); return true; });
79
+ // WP-688 S4 (element 11): no default verb, ever. TASK-897: the refusal comes before the request, so nothing is captured.
80
+ it('--link without --link-type is refused before anything is sent', async () => {
493
81
  let caught;
494
82
  try {
495
- await runCapture({ text: 'TEN: both failures at once', link: 'BET-1', json: true });
83
+ await runCapture({ text: 'TEN: missing validation', link: ['BET-151'] });
496
84
  }
497
85
  catch (e) {
498
86
  caught = e;
499
87
  }
500
- writeSpy.mockRestore();
501
88
  expect(caught).toBeInstanceOf(CLIError);
502
- expect(JSON.parse(out.join(''))).toMatchObject({ id: 'TEN-21', droppedFields: ['badField'] });
503
- expect(caught?.message).not.toContain('Fields rejected');
504
- expect(caught?.message).toContain('--link requires --link-type');
505
- expect(caught?.guidance).toContain('TEN-21');
506
- });
507
- // WP-745 round 3 (PR #896 review, PRRT_kwDORS4ils6h0e82): the link-refused error beside a
508
- // dropped field must preserve the link failure's OWN code/category (e.g. NETWORK_TIMEOUT)
509
- // rather than always relabeling the aggregate VALIDATION_FAILED — automation branching on the
510
- // error code must still see the timeout, not a generic validation failure.
511
- it('the link-refused error beside a dropped field keeps the link failure\'s own code (NETWORK_TIMEOUT), not VALIDATION_FAILED', async () => {
512
- kernelCallWithSessionMock.mockImplementation((fn) => {
513
- if (fn === 'chain.createEntry') {
514
- return Promise.resolve({
515
- docId: 'doc-10', entryId: 'TEN-22',
516
- normalization: { remapped: {}, rejected: ['badField'], accepted: [] },
517
- });
518
- }
519
- if (fn === 'chain.createEntryRelation') {
520
- return Promise.reject(new CLIError('"chain.createEntryRelation" exceeded its budget.', { code: ErrorCode.NETWORK_TIMEOUT, category: 'network', guidance: 'Check before retrying.' }));
521
- }
522
- if (fn === 'quality.evaluateHeuristicAndSchedule')
523
- return Promise.resolve(undefined);
524
- return Promise.resolve({});
525
- });
526
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
527
- let caught;
528
- try {
529
- await runCapture({ text: 'TEN: both failures, timeout link', link: 'BET-1', linkType: 'part_of' });
530
- }
531
- catch (e) {
532
- caught = e;
533
- }
89
+ expect(caught?.code).toBe(ErrorCode.VALIDATION_FAILED);
90
+ expect(caught?.message).toContain('--link BET-151 requires --link-type');
91
+ expect(caught?.guidance).toContain('Nothing was captured');
92
+ expect(kernelCallWithSessionMock).not.toHaveBeenCalled();
93
+ });
94
+ it('a second --link without its own --link-type is refused before anything is sent', async () => {
95
+ await expect(runCapture({ text: 'TEN: two links one type', link: ['DEC-29', 'WP-29'], linkType: ['related_to'] })).rejects.toThrow('--link WP-29 requires --link-type');
96
+ expect(kernelCallWithSessionMock).not.toHaveBeenCalled();
97
+ });
98
+ // WP-688 S4 review round 5 (bcRUML, bcRpJQ): an empty or whitespace-only --link never silently no-ops.
99
+ it.each(['', ' '])('--link %j is refused before anything is sent', async (link) => {
100
+ await expect(runCapture({ text: 'TEN: empty link', link: [link], linkType: ['related_to'] })).rejects.toThrow('--link requires a non-empty entry ID');
101
+ expect(kernelCallWithSessionMock).not.toHaveBeenCalled();
102
+ });
103
+ // TASK-584 (WP-802, TEN-4312): a dropped field is a warning on the landed reply; with a refused link both reach the operator and the command exits 0.
104
+ it('prints the dropped-field warning AND the refused link on one landed capture, exit 0', async () => {
105
+ kernelCallWithSessionMock.mockImplementation((fn) => (fn === 'chain.createEntry'
106
+ ? Promise.resolve({ docId: 'doc-5', entryId: 'TEN-21', normalization: { remapped: {}, rejected: ['badField'], accepted: [] }, links: [LINKS[1]] })
107
+ : Promise.resolve({})));
108
+ let out = '';
109
+ const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { out += String(chunk); return true; });
110
+ await runCapture({ text: 'TEN: both at once', link: ['WP-29'], linkType: ['part_of'], json: true });
534
111
  writeSpy.mockRestore();
535
- expect(caught).toBeInstanceOf(CLIError);
536
- expect(caught?.code).toBe(ErrorCode.NETWORK_TIMEOUT);
537
- expect(caught?.message).not.toContain('Fields rejected');
112
+ expect(JSON.parse(out.trim())).toMatchObject({ id: 'TEN-21', droppedFields: ['badField'], links: [{ outcome: 'refused' }] });
538
113
  });
539
114
  });
540
115
  // WP-684 TASK-77 (E1.3): one-step typed create — --entry-type forwards to chain.createEntry's
@@ -692,103 +267,20 @@ describe('runCapture coaching passthrough (WP-475 E1)', () => {
692
267
  expect(jsonLines).toHaveLength(1);
693
268
  expect(JSON.parse(jsonLines[0])).not.toHaveProperty('coaching');
694
269
  });
695
- // ── WP-475 E1 fix: relation-aware receipt on --link ────────────────────────
696
- // The create-response verdict is computed BEFORE the --link relation exists,
697
- // so relation-dependent criteria (wired-in checks) read falsely failed. When
698
- // the capture created a relation, the CLI re-evaluates AFTER it lands and
699
- // renders THAT verdict.
700
- const reEvalCoaching = {
701
- source: 'heuristic',
702
- verdict: {
703
- ...coaching.verdict,
704
- passed: true,
705
- criteria: [{ id: 'specificity', passed: true, weight: 2, hint: 'Be specific.', questionTemplate: 'What exactly?' }],
706
- weakest: null,
707
- },
708
- };
709
- it('--link: re-evaluates after the relation lands and renders the post-relation verdict', async () => {
710
- kernelCallWithSessionMock.mockImplementation((fn) => {
711
- if (fn === 'chain.createEntry')
712
- return Promise.resolve({ docId: 'doc-c3', entryId: 'DEC-79', coaching });
713
- if (fn === 'chain.createEntryRelation')
714
- return Promise.resolve({ relationId: 'rel-1' });
715
- if (fn === 'quality.evaluateHeuristicAndSchedule')
716
- return Promise.resolve(reEvalCoaching);
717
- return Promise.resolve({});
718
- });
270
+ // TASK-897: the one verdict on the reply already saw the links (the kernel writes them before it judges), so a
271
+ // linked capture renders the create-response verdict as a plain one does; nothing is re-evaluated.
272
+ it('--link: renders the create-response verdict and calls no re-evaluation', async () => {
273
+ kernelCallWithSessionMock.mockImplementation((fn) => (fn === 'chain.createEntry'
274
+ ? Promise.resolve({ docId: 'doc-c3', entryId: 'DEC-79', coaching, links: [{ toEntryId: 'TEN-1', type: 'informs', outcome: 'written', line: 'linked informs TEN-1' }] })
275
+ : Promise.resolve({})));
719
276
  let stdoutOutput = '';
720
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
721
- stdoutOutput += String(chunk);
722
- return true;
723
- });
724
- await runCapture({ text: 'DEC: chose with a link', link: 'TEN-1', linkType: 'informs', json: true });
725
- writeSpy.mockRestore();
726
- // Deterministic --link fix: the CREATE call defers the LLM schedule (the
727
- // post-relation re-eval owns the single LLM run)…
728
- const createCall = kernelCallWithSessionMock.mock.calls.find(([fn]) => fn === 'chain.createEntry');
729
- expect(createCall?.[1]).toMatchObject({ deferLLMSchedule: true });
730
- // …and the re-eval fires with the created entry, AFTER the relation write,
731
- // WITHOUT the defer flag (it schedules the LLM run as normal).
732
- expect(kernelCallWithSessionMock).toHaveBeenCalledWith('quality.evaluateHeuristicAndSchedule', {
733
- entryId: 'DEC-79',
734
- context: 'capture',
735
- });
736
- const callOrder = kernelCallWithSessionMock.mock.calls.map(([fn]) => fn);
737
- expect(callOrder.indexOf('chain.createEntryRelation')).toBeLessThan(callOrder.indexOf('quality.evaluateHeuristicAndSchedule'));
738
- // The receipt carries the POST-relation verdict, not the create-response one.
739
- const jsonLines = stdoutOutput.split('\n').filter((line) => {
740
- try {
741
- JSON.parse(line);
742
- return true;
743
- }
744
- catch {
745
- return false;
746
- }
747
- });
748
- expect(jsonLines).toHaveLength(1);
749
- expect(JSON.parse(jsonLines[0]).coaching).toEqual(reEvalCoaching);
750
- });
751
- it('--link: keeps the create-response verdict when the re-eval fails (fail-open, BR-144)', async () => {
752
- kernelCallWithSessionMock.mockImplementation((fn) => {
753
- if (fn === 'chain.createEntry')
754
- return Promise.resolve({ docId: 'doc-c4', entryId: 'DEC-80', coaching });
755
- if (fn === 'chain.createEntryRelation')
756
- return Promise.resolve({ relationId: 'rel-1' });
757
- if (fn === 'quality.evaluateHeuristicAndSchedule')
758
- return Promise.reject(new Error('network'));
759
- return Promise.resolve({});
760
- });
761
- let stdoutOutput = '';
762
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
763
- stdoutOutput += String(chunk);
764
- return true;
765
- });
766
- await runCapture({ text: 'DEC: chose with a broken re-eval', link: 'TEN-2', linkType: 'informs', json: true });
767
- writeSpy.mockRestore();
768
- const jsonLines = stdoutOutput.split('\n').filter((line) => {
769
- try {
770
- JSON.parse(line);
771
- return true;
772
- }
773
- catch {
774
- return false;
775
- }
776
- });
777
- expect(jsonLines).toHaveLength(1);
778
- expect(JSON.parse(jsonLines[0]).coaching).toEqual(coaching);
779
- });
780
- it('no --link: does NOT call the re-evaluation and does NOT defer the LLM schedule (plain capture unchanged)', async () => {
781
- kernelCallWithSessionMock.mockImplementation((fn) => {
782
- if (fn === 'chain.createEntry')
783
- return Promise.resolve({ docId: 'doc-c5', entryId: 'DEC-81', coaching });
784
- return Promise.resolve({});
785
- });
786
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
787
- await runCapture({ text: 'DEC: chose without a link', json: true });
277
+ const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdoutOutput += String(chunk); return true; });
278
+ await runCapture({ text: 'DEC: chose with a link', link: ['TEN-1'], linkType: ['informs'], json: true });
788
279
  writeSpy.mockRestore();
789
- expect(kernelCallWithSessionMock).not.toHaveBeenCalledWith('quality.evaluateHeuristicAndSchedule', expect.anything());
790
- const createCall = kernelCallWithSessionMock.mock.calls.find(([fn]) => fn === 'chain.createEntry');
791
- expect(createCall?.[1]).not.toHaveProperty('deferLLMSchedule');
280
+ expect(kernelCallWithSessionMock.mock.calls.map(([fn]) => fn)).toEqual(['chain.createEntry']);
281
+ const output = JSON.parse(stdoutOutput.trim());
282
+ expect(output.coaching).toEqual(coaching);
283
+ expect(output.links).toEqual([{ toEntryId: 'TEN-1', type: 'informs', outcome: 'written', line: 'linked informs TEN-1' }]);
792
284
  });
793
285
  });
794
286
  // WP-690 S7 (TASK-120): the 'runCapture description field routing' describe block (TEN-1258's
@@ -1038,63 +530,43 @@ describe('runCapture renders the kernel verdict', () => {
1038
530
  expect(JSON.parse(out.trim())).not.toHaveProperty('possibleMatches');
1039
531
  });
1040
532
  });
1041
- // TASK-898 (DEC-2113): a re-sent capture the kernel replayed. The first send may have timed out before its link,
1042
- // so the link is resent if-missing; nothing model-driven (the re-eval) or bookkeeping (the grounding outcome) runs
1043
- // again, and the reply names the collection the first send landed in, not the retry's own resolution.
533
+ // TASK-898 (DEC-2113): a re-sent capture the kernel replayed. TASK-897: the kernel answers the retry's links from the rows
534
+ // (written by the first send, or refused then); the CLI prints them as on a first send and sends nothing more.
1044
535
  describe('runCapture on a replayed reply (TASK-898)', () => {
1045
- const REPLAY = { entryId: 'INS-60', docId: 'doc-60', status: 'draft', collection: 'decisions', replayed: true };
1046
- function mockReplay(relate) {
536
+ const REPLAY_LINKS = [
537
+ { toEntryId: 'DEC-266', type: 'informed_by', outcome: 'written', line: 'linked informed_by DEC-266' },
538
+ { toEntryId: 'WP-29', type: 'part_of', outcome: 'refused', reason: 'Not written when INS-60 first landed; this reply is a replay. Relate it again to read the reason.', line: 'link refused: Not written when INS-60 first landed; this reply is a replay. Relate it again to read the reason.' },
539
+ ];
540
+ const REPLAY = { entryId: 'INS-60', docId: 'doc-60', status: 'draft', collection: 'decisions', replayed: true, links: REPLAY_LINKS };
541
+ async function run(json) {
1047
542
  kernelCallWithSessionMock.mockReset();
1048
543
  mockSession = { sessionId: 'sess-test' };
1049
- kernelCallWithSessionMock.mockImplementation((fn) => {
1050
- if (fn === 'chain.createEntry')
1051
- return Promise.resolve(REPLAY);
1052
- if (fn === 'chain.createEntryRelation')
1053
- return relate();
1054
- return Promise.resolve({});
1055
- });
1056
- }
1057
- async function run() {
544
+ kernelCallWithSessionMock.mockImplementation((fn) => (fn === 'chain.createEntry' ? Promise.resolve(REPLAY) : Promise.resolve({})));
1058
545
  let out = '';
1059
546
  const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { out += String(chunk); return true; });
547
+ const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
548
+ setOutputMode(json ? 'json' : 'pretty');
1060
549
  try {
1061
- await runCapture({ text: 'INS: discovered Z', link: 'DEC-266', linkType: 'informed_by', json: true });
550
+ await runCapture({ text: 'INS: discovered Z', link: ['DEC-266', 'WP-29'], linkType: ['informed_by', 'part_of'], json });
1062
551
  }
1063
552
  finally {
553
+ setOutputMode('auto');
1064
554
  writeSpy.mockRestore();
555
+ errSpy.mockRestore();
1065
556
  }
1066
557
  return out;
1067
558
  }
1068
- const calledFns = () => kernelCallWithSessionMock.mock.calls.map(([fn]) => fn);
1069
- it('an already-existing link is success: exit 0, "existing", no re-eval, no outcome row, the kernel collection', async () => {
1070
- mockReplay(() => Promise.reject(new CLIError('Duplicate relation: a "informed_by" relation from this entry to the target already exists.', { code: ErrorCode.VALIDATION_FAILED, category: 'validation' })));
1071
- const output = JSON.parse((await run()).trim().split('\n').pop());
1072
- expect(output).toMatchObject({ id: 'INS-60', collection: 'decisions', replayed: true, relationType: 'informed_by', linkOutcome: 'existing' });
1073
- expect(calledFns()).not.toContain('quality.evaluateHeuristicAndSchedule');
1074
- expect(calledFns()).not.toContain('chain.recordGroundingOutcome');
1075
- });
1076
- it('a link the first send never reached is written now, still with no re-eval', async () => {
1077
- mockReplay(() => Promise.resolve({ relationId: 'rel-60' }));
1078
- const output = JSON.parse((await run()).trim().split('\n').pop());
1079
- expect(output).toMatchObject({ id: 'INS-60', collection: 'decisions', replayed: true, linkOutcome: 'written' });
1080
- expect(calledFns().filter((fn) => fn === 'chain.createEntryRelation')).toHaveLength(1);
1081
- expect(calledFns()).not.toContain('quality.evaluateHeuristicAndSchedule');
559
+ it('the replay is one request too: exit 0, the kernel collection, the links as the kernel reports them', async () => {
560
+ const output = JSON.parse((await run(true)).trim().split('\n').pop());
561
+ expect(output).toMatchObject({ id: 'INS-60', collection: 'decisions', replayed: true, links: REPLAY_LINKS });
562
+ expect(kernelCallWithSessionMock.mock.calls.map(([fn]) => fn)).toEqual(['chain.createEntry']);
1082
563
  });
1083
- it('the receipt names the first send\'s collection and says the link is already there', async () => {
1084
- mockReplay(() => Promise.reject(new CLIError('Duplicate relation: already exists.', { code: ErrorCode.VALIDATION_FAILED, category: 'validation' })));
1085
- let out = '';
1086
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { out += String(chunk); return true; });
1087
- setOutputMode('pretty');
1088
- try {
1089
- await runCapture({ text: 'INS: discovered Z', link: 'DEC-266', linkType: 'informed_by' });
1090
- }
1091
- finally {
1092
- setOutputMode('auto');
1093
- writeSpy.mockRestore();
1094
- }
564
+ it('the receipt names the first send\'s collection, says nothing was written twice, and prints the link lines', async () => {
565
+ const out = await run(false);
1095
566
  expect(out).toContain('decisions');
1096
567
  expect(out).toContain('Already captured as INS-60');
1097
- expect(out).toContain('Link informed_by DEC-266: already there.');
568
+ expect(out).toContain(' linked informed_by DEC-266');
569
+ expect(out).toContain(' link refused: Not written when INS-60 first landed');
1098
570
  });
1099
571
  });
1100
572
  // TASK-889 (TEN-4610, RULE-664): the capture receipt names the new row in the kernel's word, the one the Rail shows.