@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.
- package/dist/__tests__/capture.test.js +104 -632
- package/dist/__tests__/capture.test.js.map +1 -1
- package/dist/__tests__/reply-commands-registered.test.js +1 -1
- package/dist/__tests__/reply-commands-registered.test.js.map +1 -1
- package/dist/commands/capture.d.ts +4 -4
- package/dist/commands/capture.d.ts.map +1 -1
- package/dist/commands/capture.js +13 -33
- package/dist/commands/capture.js.map +1 -1
- package/dist/formatters/capture.d.ts +4 -2
- package/dist/formatters/capture.d.ts.map +1 -1
- package/dist/formatters/capture.js +4 -5
- package/dist/formatters/capture.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/captureLink.d.ts +6 -30
- package/dist/lib/captureLink.d.ts.map +1 -1
- package/dist/lib/captureLink.js +21 -117
- package/dist/lib/captureLink.js.map +1 -1
- package/dist/registerEntryCommands.js +2 -2
- package/dist/registerEntryCommands.js.map +1 -1
- package/package.json +1 -1
- package/dist/generated/relationTypes.generated.d.ts +0 -12
- package/dist/generated/relationTypes.generated.d.ts.map +0 -1
- package/dist/generated/relationTypes.generated.js +0 -11
- package/dist/generated/relationTypes.generated.js.map +0 -1
- package/dist/lib/__tests__/legalRelationTypes.test.d.ts +0 -2
- package/dist/lib/__tests__/legalRelationTypes.test.d.ts.map +0 -1
- package/dist/lib/__tests__/legalRelationTypes.test.js +0 -61
- package/dist/lib/__tests__/legalRelationTypes.test.js.map +0 -1
- package/dist/lib/legalRelationTypes.d.ts +0 -19
- package/dist/lib/legalRelationTypes.d.ts.map +0 -1
- package/dist/lib/legalRelationTypes.js +0 -64
- 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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
kernelCallWithSessionMock.mockImplementation((fn,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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: '
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
kernelCallWithSessionMock.
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
72
|
+
await runCapture({ text: 'TEN: plain', json: true });
|
|
471
73
|
writeSpy.mockRestore();
|
|
472
|
-
const
|
|
473
|
-
expect(
|
|
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
|
|
476
|
-
|
|
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:
|
|
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(
|
|
503
|
-
expect(caught?.message).
|
|
504
|
-
expect(caught?.
|
|
505
|
-
expect(
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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(
|
|
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
|
-
//
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
|
790
|
-
const
|
|
791
|
-
expect(
|
|
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.
|
|
1042
|
-
//
|
|
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
|
|
1046
|
-
|
|
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
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
|
1084
|
-
|
|
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('
|
|
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.
|