create-claude-cabinet 0.51.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -28
- package/lib/modules.js +5 -4
- package/package.json +1 -1
- package/templates/CLAUDE.md +32 -11
- package/templates/EXTENSIONS.md +7 -0
- package/templates/README.md +3 -3
- package/templates/briefing/_briefing-template.md +1 -1
- package/templates/briefing/_briefing-work-tracking-template.md +3 -3
- package/templates/cabinet/_cabinet-member-template.md +10 -7
- package/templates/cabinet/memory-lifecycle-contract.md +16 -0
- package/templates/cabinet/pib-db-access.md +1 -1
- package/templates/cabinet/pib-db-triggers.md +20 -16
- package/templates/cabinet/watchtower-contracts.md +119 -0
- package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +2 -2
- package/templates/memory/patterns/_pattern-template.md +1 -1
- package/templates/rules/enforcement-pipeline.md +2 -2
- package/templates/rules/memory-capture.md +2 -2
- package/templates/scripts/__tests__/absorption-no-prune.test.mjs +89 -0
- package/templates/scripts/__tests__/assessment-recalibration.test.mjs +0 -2
- package/templates/scripts/__tests__/batch-disposition.test.mjs +7 -9
- package/templates/scripts/__tests__/draft-surfacing.test.mjs +46 -162
- package/templates/scripts/__tests__/extraction-classification.test.mjs +219 -0
- package/templates/scripts/__tests__/fixtures/read-pass-2026-07-14.json +325 -0
- package/templates/scripts/__tests__/fold-similarity.test.mjs +290 -0
- package/templates/scripts/__tests__/hold-verb.test.mjs +149 -0
- package/templates/scripts/__tests__/reach1-inbox-relations.test.mjs +274 -0
- package/templates/scripts/__tests__/reach2-significance.test.mjs +300 -0
- package/templates/scripts/__tests__/read-pass-fixture.test.mjs +71 -0
- package/templates/scripts/__tests__/ring3-outage-backfill.test.mjs +156 -0
- package/templates/scripts/memory-consolidation-scan.mjs +149 -0
- package/templates/scripts/pib-db-mcp-server.mjs +2 -2
- package/templates/scripts/watchtower-build-context.mjs +33 -1
- package/templates/scripts/watchtower-lib.mjs +167 -1
- package/templates/scripts/watchtower-queue.mjs +192 -53
- package/templates/scripts/watchtower-ring1.mjs +120 -2
- package/templates/scripts/watchtower-ring2.mjs +49 -112
- package/templates/scripts/watchtower-ring3-close.mjs +728 -38
- package/templates/skills/audit/SKILL.md +5 -5
- package/templates/skills/cabinet-system-advocate/SKILL.md +2 -2
- package/templates/skills/cabinet-workflow-cop/SKILL.md +5 -3
- package/templates/skills/cc-feedback/SKILL.md +4 -4
- package/templates/skills/cc-remember/SKILL.md +4 -4
- package/templates/skills/close/SKILL.md +63 -9
- package/templates/skills/debrief/SKILL.md +14 -8
- package/templates/skills/debrief/phases/qa-handoff-sweep.md +12 -9
- package/templates/skills/dx-feedback/SKILL.md +1 -1
- package/templates/skills/inbox/SKILL.md +85 -16
- package/templates/skills/memory/SKILL.md +1 -1
- package/templates/skills/onboard/SKILL.md +42 -33
- package/templates/skills/onboard/phases/detect-state.md +3 -2
- package/templates/skills/onboard/phases/generate-briefing.md +3 -2
- package/templates/skills/onboard/phases/generate-session-loop.md +70 -37
- package/templates/skills/onboard/phases/interview.md +17 -12
- package/templates/skills/onboard/phases/modularity-menu.md +33 -19
- package/templates/skills/onboard/phases/options.md +3 -1
- package/templates/skills/onboard/phases/post-onboard-audit.md +17 -11
- package/templates/skills/onboard/phases/summary.md +15 -12
- package/templates/skills/onboard/phases/work-tracking.md +14 -8
- package/templates/skills/orient/SKILL.md +7 -5
- package/templates/skills/pulse/SKILL.md +23 -18
- package/templates/skills/seed/phases/build-member.md +5 -3
- package/templates/skills/session-handoff/SKILL.md +72 -45
- package/templates/skills/spring-clean/SKILL.md +15 -13
- package/templates/skills/triage-audit/SKILL.md +3 -3
|
@@ -17,12 +17,12 @@ write it to memory without asking whether to save it.
|
|
|
17
17
|
`/cc-remember` ensures:
|
|
18
18
|
- The new memory lands at `~/.claude/projects/<slug>/memory/<slug>.md`
|
|
19
19
|
- `MEMORY.md` gets a new entry in the "Curated entries (hand-authored)"
|
|
20
|
-
section, so next session
|
|
20
|
+
section, so the next session can find the memory
|
|
21
21
|
- Filename collisions are handled (suffix with date if needed)
|
|
22
22
|
- The write is atomic (temp + rename — safe under concurrent sessions)
|
|
23
23
|
|
|
24
24
|
Direct flat-`.md` writes work but skip the indexing step. The memory
|
|
25
|
-
becomes invisible to
|
|
25
|
+
becomes invisible to the next session until you manually update `MEMORY.md`.
|
|
26
26
|
The `memory-index-guard` PostToolUse hook will flag this when it
|
|
27
27
|
happens, but it's better to avoid the issue: **use `/cc-remember`**.
|
|
28
28
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// absorbThreadSessions (act:6f93a1f1, N4 of grp:retro-remeaning) — the
|
|
2
|
+
// age-only session-record pruning gate (ABSORPTION_SESSION_AGE_DAYS = 90)
|
|
3
|
+
// is RETIRED. Structural contract under test:
|
|
4
|
+
// A1: an active thread's session record survives a slow-tier run, even when
|
|
5
|
+
// the session is far older than the old 90-day cutoff (the deleted
|
|
6
|
+
// constant no longer exists to prune it).
|
|
7
|
+
// A2: dormant-marking is UNCHANGED — a thread untouched for 30+ days still
|
|
8
|
+
// flips to 'dormant'.
|
|
9
|
+
// A3: the retired constant is gone from the module's exports/source.
|
|
10
|
+
//
|
|
11
|
+
// Hermetic per qa-handoff-gate.test.mjs: WATCHTOWER_DIR points at a temp dir,
|
|
12
|
+
// set BEFORE the dynamic import. No Anthropic calls — absorbThreadSessions
|
|
13
|
+
// touches only the filesystem.
|
|
14
|
+
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), 'absorption-no-prune-'));
|
|
22
|
+
process.env.WATCHTOWER_DIR = root;
|
|
23
|
+
|
|
24
|
+
const ring2 = await import('../watchtower-ring2.mjs');
|
|
25
|
+
const { absorbThreadSessions, DORMANT_THREAD_AGE_DAYS } = ring2;
|
|
26
|
+
|
|
27
|
+
test.after(() => rmSync(root, { recursive: true, force: true }));
|
|
28
|
+
|
|
29
|
+
function isoDaysAgo(days) {
|
|
30
|
+
return new Date(Date.now() - days * 86400000).toISOString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('A1: an active thread carrying a >90-day-old session record survives a slow-tier run', async () => {
|
|
34
|
+
const threadsDir = join(root, 'state', 'threads');
|
|
35
|
+
mkdirSync(threadsDir, { recursive: true });
|
|
36
|
+
|
|
37
|
+
const thread = {
|
|
38
|
+
schema_version: 2,
|
|
39
|
+
thread: 'fixture-thread',
|
|
40
|
+
display_name: 'Fixture thread',
|
|
41
|
+
cursor_history: [{ date: isoDaysAgo(2), session_id: 'sess-recent', cursor: { what: 'x', where_left_off: 'y', open_questions: [] } }],
|
|
42
|
+
// Fixture must pin active + recent last_updated, or this hits the
|
|
43
|
+
// dormant branch and the survival assertion is vacuous.
|
|
44
|
+
sessions: [{ id: 'sess-old', project: 'fixture-proj', date: isoDaysAgo(200) }],
|
|
45
|
+
related_fids: [],
|
|
46
|
+
last_updated: isoDaysAgo(2),
|
|
47
|
+
status: 'active',
|
|
48
|
+
};
|
|
49
|
+
writeFileSync(join(threadsDir, 'fixture-thread.json'), JSON.stringify(thread, null, 2));
|
|
50
|
+
|
|
51
|
+
const sessionsDir = join(root, 'state', 'projects', 'fixture-proj', 'sessions');
|
|
52
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
53
|
+
const sessionFilePath = join(sessionsDir, `${isoDaysAgo(200).slice(0, 10)}-sess-old.md`);
|
|
54
|
+
writeFileSync(sessionFilePath, '# Session sess-old\n\nDate: ' + isoDaysAgo(200) + '\n\n- did a thing\n');
|
|
55
|
+
|
|
56
|
+
await absorbThreadSessions();
|
|
57
|
+
|
|
58
|
+
assert.ok(existsSync(sessionFilePath), 'the 200-day-old session record must survive — age-only pruning is retired');
|
|
59
|
+
const stored = JSON.parse(readFileSync(join(threadsDir, 'fixture-thread.json'), 'utf8'));
|
|
60
|
+
assert.equal(stored.status, 'active', 'a recently-updated thread must stay active, not be pruned into dormant');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('A2: dormant-marking is unchanged — a thread untouched 30+ days flips to dormant', async () => {
|
|
64
|
+
const threadsDir = join(root, 'state', 'threads');
|
|
65
|
+
mkdirSync(threadsDir, { recursive: true });
|
|
66
|
+
|
|
67
|
+
const thread = {
|
|
68
|
+
schema_version: 2,
|
|
69
|
+
thread: 'stale-thread',
|
|
70
|
+
display_name: 'Stale thread',
|
|
71
|
+
cursor_history: [],
|
|
72
|
+
sessions: [],
|
|
73
|
+
related_fids: [],
|
|
74
|
+
last_updated: isoDaysAgo(DORMANT_THREAD_AGE_DAYS + 5),
|
|
75
|
+
status: 'active',
|
|
76
|
+
};
|
|
77
|
+
writeFileSync(join(threadsDir, 'stale-thread.json'), JSON.stringify(thread, null, 2));
|
|
78
|
+
|
|
79
|
+
await absorbThreadSessions();
|
|
80
|
+
|
|
81
|
+
const stored = JSON.parse(readFileSync(join(threadsDir, 'stale-thread.json'), 'utf8'));
|
|
82
|
+
assert.equal(stored.status, 'dormant');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('A3: the retired ABSORPTION_SESSION_AGE_DAYS constant is gone', () => {
|
|
86
|
+
assert.equal(ring2.ABSORPTION_SESSION_AGE_DAYS, undefined);
|
|
87
|
+
const src = readFileSync(new URL('../watchtower-ring2.mjs', import.meta.url), 'utf8');
|
|
88
|
+
assert.ok(!src.includes('ABSORPTION_SESSION_AGE_DAYS'));
|
|
89
|
+
});
|
|
@@ -373,9 +373,7 @@ test('R8: the draft-sweep sidecar has a reader — assessRingsAlive surfaces its
|
|
|
373
373
|
last_run: daysAgo(2),
|
|
374
374
|
projects_scanned: 3,
|
|
375
375
|
items_scanned: 41,
|
|
376
|
-
freshness_annotated: 2,
|
|
377
376
|
fold_annotated: 4,
|
|
378
|
-
skipped_projects: [],
|
|
379
377
|
unscanned_items: 0,
|
|
380
378
|
errors: 0,
|
|
381
379
|
}));
|
|
@@ -213,22 +213,20 @@ test('S8: partitionForBatchSignoff tolerates non-array input', () => {
|
|
|
213
213
|
});
|
|
214
214
|
|
|
215
215
|
test('S8b: surfacing annotations demote to individual — by semantic value, never field presence', () => {
|
|
216
|
-
// An overtaken draft (cites since-closed work) is not a clean sign-off.
|
|
217
|
-
assert.equal(q.isHighConfidenceSignoff(signoffItem({
|
|
218
|
-
evidence: { freshness: { overtaken: true, cited: [{ fid: 'act:12345678', closed_at: '2026-07-10' }] } },
|
|
219
|
-
})), false);
|
|
220
216
|
// A fold candidate wants the human to review the pair together.
|
|
221
217
|
assert.equal(q.isHighConfidenceSignoff(signoffItem({
|
|
222
218
|
evidence: { possible_duplicate_of: ['dec-aaaa1111'] },
|
|
223
219
|
})), false);
|
|
224
|
-
// Presence alone must NOT demote:
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
evidence: { freshness: { overtaken: false, cited: [] } },
|
|
228
|
-
})), true);
|
|
220
|
+
// Presence alone must NOT demote: an empty fold list keeps the item
|
|
221
|
+
// batch-eligible. (The sibling freshness/overtaken demotion was RETIRED —
|
|
222
|
+
// act:471dd701, N2 of grp:retro-remeaning — see draft-surfacing.test.mjs.)
|
|
229
223
|
assert.equal(q.isHighConfidenceSignoff(signoffItem({
|
|
230
224
|
evidence: { possible_duplicate_of: [] },
|
|
231
225
|
})), true);
|
|
226
|
+
// A held item (holdItem) is deliberate retention, not a batch nod.
|
|
227
|
+
assert.equal(q.isHighConfidenceSignoff(signoffItem({
|
|
228
|
+
evidence: { held: { reason: 'specimen', held_at: '2026-07-20T00:00:00.000Z' } },
|
|
229
|
+
})), false);
|
|
232
230
|
});
|
|
233
231
|
|
|
234
232
|
test('S9: the batch set feeds applyBatch cleanly — real queued sign-off items resolve in one call', () => {
|
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
// Draft surfacing intelligence —
|
|
2
|
-
//
|
|
1
|
+
// Draft surfacing intelligence — fold proposals (act:00051dca, Lane C of
|
|
2
|
+
// grp:wt-noise-immunity).
|
|
3
3
|
//
|
|
4
4
|
// Contract under test:
|
|
5
|
-
// F1: the fold recipe (
|
|
6
|
-
//
|
|
7
|
-
// the
|
|
5
|
+
// F1: the fold recipe (overlap coefficient >= FOLD_SIMILARITY_THRESHOLD,
|
|
6
|
+
// dual-scored over title-alone AND title+draft, stopworded
|
|
7
|
+
// alphanumeric-run tokens) surfaces EXACTLY the live fold candidates
|
|
8
|
+
// and nothing else.
|
|
8
9
|
// F2: the Ring 2 sweep annotates every surfaced pair RECIPROCALLY (the
|
|
9
10
|
// self-validating criterion) and never auto-dismisses anything.
|
|
10
|
-
// F3: freshness — a pending draft citing an action that is CLOSED in the
|
|
11
|
-
// item's project pib-db gets evidence.freshness {overtaken, cited} and
|
|
12
|
-
// is excluded from the high-confidence sign-off batch. The done
|
|
13
|
-
// predicate covers both close paths (completed=1 and the ungated
|
|
14
|
-
// status='done'). Tri-state discipline: fid-not-found, open action,
|
|
15
|
-
// soft-deleted action, and missing/unreadable db all mean UNKNOWN —
|
|
16
|
-
// no annotation, the draft stays batch-eligible.
|
|
17
11
|
// F4: annotateItemEvidence write discipline — pending fence checked at
|
|
18
12
|
// WRITE time (a sweep can never resurrect a resolved item), deep-equal
|
|
19
13
|
// values skip the write (idempotent re-sweeps), schema_version rides
|
|
@@ -22,16 +16,34 @@
|
|
|
22
16
|
// F5: the sweep writes its positive-confirmation sidecar — "ran with zero
|
|
23
17
|
// annotations" is distinguishable from "never ran".
|
|
24
18
|
//
|
|
19
|
+
// The sibling F3 freshness suite (a draft citing a since-closed act: fid,
|
|
20
|
+
// demoted as "possibly overtaken") was RETIRED alongside the feature
|
|
21
|
+
// (act:471dd701, N2 of grp:retro-remeaning) — measured against the
|
|
22
|
+
// 2026-07-14 read-pass answer key, it fired on 62-of-66 TRUE drafts and
|
|
23
|
+
// 70-of-84 flags were false by construction. extractCitedActFids (its sole
|
|
24
|
+
// caller) was deleted with it.
|
|
25
|
+
//
|
|
25
26
|
// FIXTURE PROVENANCE: the five draft texts below are sanitized copies of the
|
|
26
27
|
// live resolved items the 2026-07-12 full inbox read surfaced as real fold
|
|
27
28
|
// candidates — the Boolean#cast trio (dec-5f850429 / dec-b2dc5070 /
|
|
28
29
|
// dec-f3446e84) and the testimonials pair (dec-eb6dcdb4 / dec-dd944a5b).
|
|
29
30
|
// Sanitization is a 1:1 token rename (the client name → "Acme"), which
|
|
30
|
-
// preserves
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
31
|
+
// preserves similarity values EXACTLY under any token-overlap metric.
|
|
32
|
+
//
|
|
33
|
+
// unigram-Jaccard values (act:00051dca calibration): 5f8↔b2d 0.246,
|
|
34
|
+
// 5f8↔f34 0.311, b2d↔f34 0.218 (below the 0.22 threshold — the trio
|
|
35
|
+
// connected only through its hub), eb6↔dd9 0.600, all cross-topic pairs
|
|
36
|
+
// ≤0.02.
|
|
37
|
+
//
|
|
38
|
+
// overlap-coefficient values, dual-scored (act:421a8ab2, N5 of
|
|
39
|
+
// grp:retro-remeaning — computed at authoring time over these exact fixture
|
|
40
|
+
// strings): 5f8↔b2d title 0.333 / combined 0.483, 5f8↔f34 title 0.636 /
|
|
41
|
+
// combined 0.500, b2d↔f34 title 0.455 / combined 0.414 (BOTH now above
|
|
42
|
+
// threshold — the trio is a fully connected triangle, not hub-only; this is
|
|
43
|
+
// the intended effect of the fix, not a regression), eb6↔dd9 title 0.600 /
|
|
44
|
+
// combined 0.857, all cross-topic pairs ≤0.048 (the noise ceiling holds
|
|
45
|
+
// under the new metric too). Verbatim drafts are deliberately NOT
|
|
46
|
+
// committed: templates/scripts/__tests__/ ships in the public npm tarball.
|
|
35
47
|
//
|
|
36
48
|
// Hermetic: WATCHTOWER_DIR points at a temp dir set BEFORE the dynamic
|
|
37
49
|
// imports (the queue lib reads it into a module const at load).
|
|
@@ -43,9 +55,6 @@ import {
|
|
|
43
55
|
} from 'node:fs';
|
|
44
56
|
import { join } from 'node:path';
|
|
45
57
|
import { tmpdir } from 'node:os';
|
|
46
|
-
import { createRequire } from 'node:module';
|
|
47
|
-
|
|
48
|
-
const require = createRequire(import.meta.url);
|
|
49
58
|
|
|
50
59
|
const root = mkdtempSync(join(tmpdir(), 'draft-surfacing-'));
|
|
51
60
|
process.env.WATCHTOWER_DIR = root;
|
|
@@ -127,20 +136,25 @@ function readStored(id) {
|
|
|
127
136
|
|
|
128
137
|
// --- F1: the fold recipe surfaces exactly the live pairs ---------------------
|
|
129
138
|
|
|
130
|
-
test('F1: proposeFolds surfaces
|
|
139
|
+
test('F1: proposeFolds surfaces the FULL trio triangle (not just the hub) plus the testimonials pair', () => {
|
|
131
140
|
const items = FIXTURES.map((f) => ({ id: f.ref, title: f.title, draft_artifact: f.draft }));
|
|
132
141
|
const pairs = q.proposeFolds(items);
|
|
133
142
|
const keys = pairs.map((p) => [p.a, p.b].sort().join('|')).sort();
|
|
143
|
+
// Under Jaccard (act:00051dca), b2d↔f34 sat just under threshold (0.218)
|
|
144
|
+
// and the trio connected only through its hub. The overlap-coefficient +
|
|
145
|
+
// dual-scoring fix (act:421a8ab2) surfaces it too — a fully connected
|
|
146
|
+
// triangle, the fix's intended effect, not a regression.
|
|
134
147
|
assert.deepEqual(keys, [
|
|
135
148
|
'dec-5f850429|dec-b2dc5070',
|
|
136
149
|
'dec-5f850429|dec-f3446e84',
|
|
150
|
+
'dec-b2dc5070|dec-f3446e84',
|
|
137
151
|
'dec-dd944a5b|dec-eb6dcdb4',
|
|
138
152
|
]);
|
|
139
153
|
for (const p of pairs) assert.ok(p.similarity >= q.FOLD_SIMILARITY_THRESHOLD);
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
assert.ok(!keys.includes('dec-b2dc5070|dec-f3446e84'));
|
|
154
|
+
// Cross-topic pairs stay below threshold under the new metric too (noise
|
|
155
|
+
// ceiling ≤0.048 — see the fixture-provenance header for the full table).
|
|
143
156
|
assert.ok(!keys.some((k) => k.includes('dec-eb6dcdb4') && k.includes('dec-5f850429')));
|
|
157
|
+
assert.ok(!keys.some((k) => k.includes('dec-dd944a5b') && k.includes('dec-b2dc5070')));
|
|
144
158
|
});
|
|
145
159
|
|
|
146
160
|
test('F1b: short-text floor and empty inputs never propose folds (no NaN, no boilerplate match)', () => {
|
|
@@ -169,11 +183,12 @@ test('F2: sweep annotates every surfaced pair reciprocally; nothing is dismissed
|
|
|
169
183
|
{ watchtowerDir: root },
|
|
170
184
|
);
|
|
171
185
|
|
|
172
|
-
// All five ride the fold graph: the trio
|
|
186
|
+
// All five ride the fold graph: the trio is now a fully connected triangle
|
|
187
|
+
// (act:421a8ab2), the pair directly.
|
|
173
188
|
const expectPartners = {
|
|
174
189
|
[ids[TRIO_A.ref]]: [ids[TRIO_B.ref], ids[TRIO_C.ref]].sort(),
|
|
175
|
-
[ids[TRIO_B.ref]]: [ids[TRIO_A.ref]],
|
|
176
|
-
[ids[TRIO_C.ref]]: [ids[TRIO_A.ref]],
|
|
190
|
+
[ids[TRIO_B.ref]]: [ids[TRIO_A.ref], ids[TRIO_C.ref]].sort(),
|
|
191
|
+
[ids[TRIO_C.ref]]: [ids[TRIO_A.ref], ids[TRIO_B.ref]].sort(),
|
|
177
192
|
[ids[PAIR_A.ref]]: [ids[PAIR_B.ref]],
|
|
178
193
|
[ids[PAIR_B.ref]]: [ids[PAIR_A.ref]],
|
|
179
194
|
};
|
|
@@ -189,9 +204,6 @@ test('F2: sweep annotates every surfaced pair reciprocally; nothing is dismissed
|
|
|
189
204
|
}
|
|
190
205
|
}
|
|
191
206
|
assert.equal(summary.fold_annotated, 5);
|
|
192
|
-
// No pib-db for this project — freshness skipped LOUDLY, not silently.
|
|
193
|
-
assert.deepEqual(summary.skipped_projects, [project]);
|
|
194
|
-
assert.equal(summary.freshness_annotated, 0);
|
|
195
207
|
|
|
196
208
|
// Fold-annotated items are excluded from the sign-off batch.
|
|
197
209
|
const { signoff, individual } = q.partitionForBatchSignoff(
|
|
@@ -216,138 +228,10 @@ test('F2b: a second sweep over already-annotated items performs zero writes (ide
|
|
|
216
228
|
}
|
|
217
229
|
});
|
|
218
230
|
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const Database = require('better-sqlite3');
|
|
224
|
-
const db = new Database(join(dir, 'pib.db'));
|
|
225
|
-
db.exec(`CREATE TABLE actions (
|
|
226
|
-
fid TEXT PRIMARY KEY, text TEXT, status TEXT DEFAULT 'open',
|
|
227
|
-
completed INTEGER DEFAULT 0, completed_at TEXT${withDeletedAt ? ', deleted_at TEXT' : ''}
|
|
228
|
-
)`);
|
|
229
|
-
return db;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const openFixtureDb = (projectPath) => {
|
|
233
|
-
const Database = require('better-sqlite3');
|
|
234
|
-
const p = join(projectPath, 'pib.db');
|
|
235
|
-
if (!existsSync(p)) return null;
|
|
236
|
-
return new Database(p, { readonly: true });
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
test('F3: a draft citing a since-closed action is annotated and leaves the sign-off batch', () => {
|
|
240
|
-
const project = 'fresh-proj';
|
|
241
|
-
const projDir = join(root, 'projects', project);
|
|
242
|
-
const db = makeProjectDb(projDir);
|
|
243
|
-
db.prepare("INSERT INTO actions (fid, status, completed, completed_at) VALUES ('act:11112222', 'done', 1, '2026-07-10')").run();
|
|
244
|
-
db.prepare("INSERT INTO actions (fid, status, completed) VALUES ('act:33334444', 'open', 0)").run();
|
|
245
|
-
db.close();
|
|
246
|
-
|
|
247
|
-
const overtaken = queueDraft(project, {
|
|
248
|
-
title: 'lesson: the cross-ring reader still exits nonzero on an empty portfolio',
|
|
249
|
-
draft: 'The reader exits 1 when config has no projects — tracked as act:11112222; workaround is a guard in the caller until that action lands.',
|
|
250
|
-
}, { project_path: projDir });
|
|
251
|
-
const freshOpen = queueDraft(project, {
|
|
252
|
-
title: 'lesson: keep the parity test binding when adding join keys',
|
|
253
|
-
draft: 'Join keys must stay parity-tested against the resolver — see act:33334444 for the open follow-up work.',
|
|
254
|
-
}, { project_path: projDir });
|
|
255
|
-
const noCitations = queueDraft(project, {
|
|
256
|
-
title: 'lesson: plain drafts with no citations ride the batch untouched',
|
|
257
|
-
draft: 'A lesson body citing no work items at all.',
|
|
258
|
-
}, { project_path: projDir });
|
|
259
|
-
const unknownFid = queueDraft(project, {
|
|
260
|
-
title: 'lesson: a citation the db has never heard of stays unknown',
|
|
261
|
-
draft: 'References act:99990000 which exists in no actions table anywhere.',
|
|
262
|
-
}, { project_path: projDir });
|
|
263
|
-
|
|
264
|
-
const summary = r2.runDraftAnnotationSweep(
|
|
265
|
-
{ projects: { [project]: { path: projDir } } },
|
|
266
|
-
{ watchtowerDir: root, openDb: openFixtureDb },
|
|
267
|
-
);
|
|
268
|
-
assert.equal(summary.freshness_annotated, 1);
|
|
269
|
-
assert.deepEqual(summary.skipped_projects, []);
|
|
270
|
-
|
|
271
|
-
const stored = readStored(overtaken);
|
|
272
|
-
assert.equal(stored.status, 'pending', 'never auto-dismissed');
|
|
273
|
-
assert.deepEqual(stored.evidence.freshness, {
|
|
274
|
-
overtaken: true,
|
|
275
|
-
cited: [{ fid: 'act:11112222', closed_at: '2026-07-10' }],
|
|
276
|
-
});
|
|
277
|
-
// Tri-state: open action, no citations, unknown fid ⇒ NO annotation.
|
|
278
|
-
for (const id of [freshOpen, noCitations, unknownFid]) {
|
|
279
|
-
assert.equal(readStored(id).evidence.freshness, undefined, `no annotation on ${id}`);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const { signoff, individual } = q.partitionForBatchSignoff(q.listPending({ project }));
|
|
283
|
-
assert.ok(individual.some((i) => i.id === overtaken), 'overtaken draft demoted to individual');
|
|
284
|
-
assert.deepEqual(signoff.map((i) => i.id).sort(), [freshOpen, noCitations, unknownFid].sort(),
|
|
285
|
-
'unannotated drafts stay batch-eligible');
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
test('F3b: the ungated close path (status=done, completed=0) still reads as closed', () => {
|
|
289
|
-
const project = 'ungated-proj';
|
|
290
|
-
const projDir = join(root, 'projects', project);
|
|
291
|
-
const db = makeProjectDb(projDir);
|
|
292
|
-
db.prepare("INSERT INTO actions (fid, status, completed) VALUES ('act:55556666', 'done', 0)").run();
|
|
293
|
-
db.close();
|
|
294
|
-
const id = queueDraft(project, {
|
|
295
|
-
title: 'lesson: cites work closed through the ungated status flip',
|
|
296
|
-
draft: 'That behavior changed under act:55556666 and this draft may be overtaken by it.',
|
|
297
|
-
}, { project_path: projDir });
|
|
298
|
-
r2.runDraftAnnotationSweep(
|
|
299
|
-
{ projects: { [project]: { path: projDir } } },
|
|
300
|
-
{ watchtowerDir: root, openDb: openFixtureDb },
|
|
301
|
-
);
|
|
302
|
-
const fresh = readStored(id).evidence.freshness;
|
|
303
|
-
assert.equal(fresh.overtaken, true);
|
|
304
|
-
assert.deepEqual(fresh.cited, [{ fid: 'act:55556666', closed_at: null }]);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
test('F3c: a soft-deleted action is unknown, not closed; an old schema without deleted_at still works', () => {
|
|
308
|
-
// deleted_at set ⇒ no annotation.
|
|
309
|
-
const pA = 'deleted-proj';
|
|
310
|
-
const dirA = join(root, 'projects', pA);
|
|
311
|
-
const dbA = makeProjectDb(dirA);
|
|
312
|
-
dbA.prepare("INSERT INTO actions (fid, status, completed, completed_at, deleted_at) VALUES ('act:77778888', 'done', 1, '2026-07-01', '2026-07-02')").run();
|
|
313
|
-
dbA.close();
|
|
314
|
-
const idA = queueDraft(pA, {
|
|
315
|
-
title: 'lesson: cites a soft-deleted action',
|
|
316
|
-
draft: 'Cites act:77778888 which was closed and then deleted.',
|
|
317
|
-
}, { project_path: dirA });
|
|
318
|
-
|
|
319
|
-
// Older consumer schema without the deleted_at column ⇒ fallback query runs.
|
|
320
|
-
const pB = 'oldschema-proj';
|
|
321
|
-
const dirB = join(root, 'projects', pB);
|
|
322
|
-
const dbB = makeProjectDb(dirB, { withDeletedAt: false });
|
|
323
|
-
dbB.prepare("INSERT INTO actions (fid, status, completed, completed_at) VALUES ('act:aaaa1111', 'done', 1, '2026-06-30')").run();
|
|
324
|
-
dbB.close();
|
|
325
|
-
const idB = queueDraft(pB, {
|
|
326
|
-
title: 'lesson: cites closed work in an old-schema db',
|
|
327
|
-
draft: 'Cites act:aaaa1111 living in a db predating soft deletion.',
|
|
328
|
-
}, { project_path: dirB });
|
|
329
|
-
|
|
330
|
-
const summary = r2.runDraftAnnotationSweep(
|
|
331
|
-
{ projects: { [pA]: { path: dirA }, [pB]: { path: dirB } } },
|
|
332
|
-
{ watchtowerDir: root, openDb: openFixtureDb },
|
|
333
|
-
);
|
|
334
|
-
assert.equal(readStored(idA).evidence.freshness, undefined, 'soft-deleted ⇒ unknown');
|
|
335
|
-
assert.equal(readStored(idB).evidence.freshness.overtaken, true, 'old schema ⇒ fallback query');
|
|
336
|
-
assert.equal(summary.freshness_annotated, 1);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
test('F3d: extractCitedActFids — unique, ordered, capped, shape-validated', () => {
|
|
340
|
-
assert.deepEqual(q.extractCitedActFids('see act:12345678 and act:12345678 then act:90abcdef'),
|
|
341
|
-
['act:12345678', 'act:90abcdef']);
|
|
342
|
-
// Malformed fids never match: non-hex, too short, and too long (\b after
|
|
343
|
-
// {8} rejects a 9th hex char) all yield nothing — the regex IS the shape
|
|
344
|
-
// validation for everything that later reaches a SQL parameter.
|
|
345
|
-
assert.deepEqual(q.extractCitedActFids('act:XYZ act:1234 act:123456789 react:12345678'), []);
|
|
346
|
-
assert.deepEqual(q.extractCitedActFids(''), []);
|
|
347
|
-
assert.deepEqual(q.extractCitedActFids(null), []);
|
|
348
|
-
const many = Array.from({ length: 80 }, (_, i) => `act:${String(10000000 + i)}`).join(' ');
|
|
349
|
-
assert.equal(q.extractCitedActFids(many).length, 50, 'capped at 50 per draft');
|
|
350
|
-
});
|
|
231
|
+
// F3 (freshness against a fixture pib-db) and F3d (extractCitedActFids) were
|
|
232
|
+
// RETIRED alongside the feature — act:471dd701, N2 of grp:retro-remeaning.
|
|
233
|
+
// See templates/scripts/__tests__/extraction-classification.test.mjs for
|
|
234
|
+
// their replacement (derivable/subject/unclassifiable).
|
|
351
235
|
|
|
352
236
|
// --- F4: annotate write discipline -------------------------------------------
|
|
353
237
|
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Extraction classification (act:471dd701, N2 of grp:retro-remeaning) — break
|
|
2
|
+
// the type⇒durability weld, add derivable/subject, delete the broken
|
|
3
|
+
// freshness flag, unify the four-axis vocabulary.
|
|
4
|
+
//
|
|
5
|
+
// Contract under test:
|
|
6
|
+
// C1: the extraction prompt carries no "lessons don't decay" axiom.
|
|
7
|
+
// C2: the prompt's JSON schema literal matches the exported
|
|
8
|
+
// KNOWLEDGE_EXTRACTION_TYPES / KNOWLEDGE_EXTRACTION_HOMES arrays —
|
|
9
|
+
// one source of truth, not a re-spelled doc.
|
|
10
|
+
// C3: a derivable item (the "prod is at commit X" / "the census run
|
|
11
|
+
// already executed" shape) is routed to home:'derivation', never
|
|
12
|
+
// written to memory, and is ALWAYS filed — never dropped.
|
|
13
|
+
// C4: a durable lesson (no derivable claim) is routed to home:'memory'
|
|
14
|
+
// with a draft_artifact, unaffected by the derivable path.
|
|
15
|
+
// C5: an unclassifiable item is filed with its required reason — model
|
|
16
|
+
// uncertainty is visible, never a forced type/home guess, and it is
|
|
17
|
+
// never silently dropped either.
|
|
18
|
+
// C6: createItem's boundary check rejects an illegal type/home token, an
|
|
19
|
+
// unclassifiable item missing its reason, and a derivable:true item
|
|
20
|
+
// missing its derivation — mirroring the merge_state precedent.
|
|
21
|
+
//
|
|
22
|
+
// Hermetic: WATCHTOWER_DIR points at a temp dir, set BEFORE the dynamic
|
|
23
|
+
// imports. No live Anthropic calls — callFn is injected throughout.
|
|
24
|
+
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { tmpdir } from 'node:os';
|
|
30
|
+
|
|
31
|
+
const root = mkdtempSync(join(tmpdir(), 'extraction-classification-'));
|
|
32
|
+
process.env.WATCHTOWER_DIR = root;
|
|
33
|
+
mkdirSync(join(root, 'queue', 'items'), { recursive: true });
|
|
34
|
+
mkdirSync(join(root, 'state', 'threads'), { recursive: true });
|
|
35
|
+
|
|
36
|
+
const q = await import('../watchtower-queue.mjs');
|
|
37
|
+
const lib = await import('../watchtower-lib.mjs');
|
|
38
|
+
const r3 = await import('../watchtower-ring3-close.mjs');
|
|
39
|
+
|
|
40
|
+
test.after(() => rmSync(root, { recursive: true, force: true }));
|
|
41
|
+
|
|
42
|
+
const itemFiles = () => readdirSync(join(root, 'queue', 'items')).filter((f) => f.endsWith('.json'));
|
|
43
|
+
const readItems = () => itemFiles().map((f) => JSON.parse(readFileSync(join(root, 'queue', 'items', f), 'utf8')));
|
|
44
|
+
const clearQueue = () => { for (const f of itemFiles()) rmSync(join(root, 'queue', 'items', f)); };
|
|
45
|
+
const project = () => ({ name: 'classify-proj', path: join(tmpdir(), 'no-such-proj'), slug: 'classify-proj' });
|
|
46
|
+
|
|
47
|
+
function capturingCall(response) {
|
|
48
|
+
let capturedSystemPrompt = null;
|
|
49
|
+
const callFn = async (systemPrompt) => {
|
|
50
|
+
capturedSystemPrompt = systemPrompt;
|
|
51
|
+
return JSON.stringify(response);
|
|
52
|
+
};
|
|
53
|
+
return { callFn, getPrompt: () => capturedSystemPrompt };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- C1/C2: the prompt itself ---
|
|
57
|
+
|
|
58
|
+
test('C1: the extraction prompt carries no "lessons don\'t decay" axiom', async () => {
|
|
59
|
+
clearQueue();
|
|
60
|
+
const { callFn, getPrompt } = capturingCall([]);
|
|
61
|
+
await r3.decisionExtraction('transcript', project(), 'sess-prompt', '/t/x.jsonl', [], { callFn });
|
|
62
|
+
const prompt = getPrompt();
|
|
63
|
+
assert.ok(prompt, 'systemPrompt was captured');
|
|
64
|
+
assert.ok(!/does not decay/i.test(prompt), 'the retired axiom must be gone');
|
|
65
|
+
assert.ok(!/durable knowledge — their value/i.test(prompt));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('C2: the prompt\'s JSON schema literal matches the exported four-axis vocabulary', async () => {
|
|
69
|
+
clearQueue();
|
|
70
|
+
const { callFn, getPrompt } = capturingCall([]);
|
|
71
|
+
await r3.decisionExtraction('transcript', project(), 'sess-schema', '/t/x.jsonl', [], { callFn });
|
|
72
|
+
const prompt = getPrompt();
|
|
73
|
+
const typesLiteral = lib.KNOWLEDGE_EXTRACTION_TYPES.join('|');
|
|
74
|
+
const homesLiteral = lib.KNOWLEDGE_EXTRACTION_HOMES.join('|');
|
|
75
|
+
assert.ok(prompt.includes(`"type":"${typesLiteral}"`),
|
|
76
|
+
`prompt type schema must read exactly "${typesLiteral}"`);
|
|
77
|
+
assert.ok(prompt.includes(`"home":"${homesLiteral}"`),
|
|
78
|
+
`prompt home schema must read exactly "${homesLiteral}"`);
|
|
79
|
+
// The taxonomy is present in prose too — not just the trailing JSON literal.
|
|
80
|
+
assert.ok(/DERIVABLE/.test(prompt));
|
|
81
|
+
assert.ok(/SUBJECT/.test(prompt));
|
|
82
|
+
assert.ok(/UNCLASSIFIABLE/.test(prompt));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// --- C3: derivable facts — the two deploy-shaped drafts ---
|
|
86
|
+
|
|
87
|
+
test('C3: two derivable ("deploy state") drafts are both routed to derivation, never memory, never dropped', async () => {
|
|
88
|
+
clearQueue();
|
|
89
|
+
const callFn = async () => JSON.stringify([
|
|
90
|
+
{
|
|
91
|
+
type: 'lesson', home: 'derivation', urgency: 'normal',
|
|
92
|
+
title: 'prod is at commit 35a5d15', content: 'Prod was observed at 35a5d15 during this session.',
|
|
93
|
+
derivable: true, derivation: 'git log --oneline -1 on the deploy remote, or check the deploy log',
|
|
94
|
+
subject: 'classify-proj', covered_by: 'none',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
type: 'lesson', home: 'derivation', urgency: 'normal',
|
|
98
|
+
title: 'the census run already executed', content: 'The arb-forum census run was confirmed complete this session.',
|
|
99
|
+
derivable: true, derivation: "check the run's pib-db action status or the run log",
|
|
100
|
+
subject: 'classify-proj', covered_by: 'none',
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
const r = await r3.decisionExtraction('transcript', project(), 'sess-derivable', '/t/x.jsonl', [], { callFn });
|
|
104
|
+
assert.equal(r.queued, 2, 'both derivable items are ALWAYS filed — never dropped');
|
|
105
|
+
|
|
106
|
+
const items = readItems();
|
|
107
|
+
assert.equal(items.length, 2);
|
|
108
|
+
for (const item of items) {
|
|
109
|
+
assert.equal(item.evidence.home, 'derivation', 'routed to derivation, not memory');
|
|
110
|
+
assert.equal(item.evidence.derivable, true);
|
|
111
|
+
assert.ok(typeof item.evidence.derivation === 'string' && item.evidence.derivation.trim().length > 0);
|
|
112
|
+
assert.equal(item.draft_artifact, null, 'a derivable fact is never written as a memory draft');
|
|
113
|
+
assert.ok(item.summary.includes('Derivation:'), 'the recomputation instruction is visible to the human');
|
|
114
|
+
assert.deepEqual(item.options.map((o) => o.key).sort(), ['acknowledge', 'dismiss']);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// --- C4: a durable lesson is unaffected ---
|
|
119
|
+
|
|
120
|
+
test('C4: a durable lesson (no derivable claim) still routes to memory with a draft', async () => {
|
|
121
|
+
clearQueue();
|
|
122
|
+
const callFn = async () => JSON.stringify([{
|
|
123
|
+
type: 'lesson', home: 'memory', urgency: 'normal',
|
|
124
|
+
title: 'a partial JSONB update silently destroys omitted keys',
|
|
125
|
+
content: 'Updating a JSONB column with a partial payload replaces the whole value — keys omitted from the payload are destroyed, not merged. Always read-merge-write.',
|
|
126
|
+
subject: 'classify-proj', covered_by: 'none',
|
|
127
|
+
}]);
|
|
128
|
+
const r = await r3.decisionExtraction('transcript', project(), 'sess-durable', '/t/x.jsonl', [], { callFn });
|
|
129
|
+
assert.equal(r.queued, 1);
|
|
130
|
+
const [item] = readItems();
|
|
131
|
+
assert.equal(item.evidence.home, 'memory');
|
|
132
|
+
assert.equal(item.evidence.derivable, undefined);
|
|
133
|
+
assert.ok(item.draft_artifact && item.draft_artifact.includes('partial JSONB update'));
|
|
134
|
+
assert.deepEqual(item.options.map((o) => o.key), ['write', 'edit', 'dismiss']);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// --- C5: unclassifiable ---
|
|
138
|
+
|
|
139
|
+
test('C5: an unclassifiable item is filed with its required reason — never a forced guess, never dropped', async () => {
|
|
140
|
+
clearQueue();
|
|
141
|
+
const callFn = async () => JSON.stringify([{
|
|
142
|
+
type: 'unclassifiable',
|
|
143
|
+
title: 'something worth flagging that fits no existing bucket',
|
|
144
|
+
content: 'A genuinely ambiguous observation the model cannot confidently type or home.',
|
|
145
|
+
unclassifiable_reason: 'not clearly a lesson, decision, or preference — and no home fits either',
|
|
146
|
+
covered_by: 'none',
|
|
147
|
+
}]);
|
|
148
|
+
const r = await r3.decisionExtraction('transcript', project(), 'sess-unclassifiable', '/t/x.jsonl', [], { callFn });
|
|
149
|
+
assert.equal(r.queued, 1, 'unclassifiable is filed, not dropped');
|
|
150
|
+
const [item] = readItems();
|
|
151
|
+
assert.equal(item.evidence.type, 'unclassifiable');
|
|
152
|
+
assert.equal(item.evidence.unclassifiable_reason, 'not clearly a lesson, decision, or preference — and no home fits either');
|
|
153
|
+
assert.equal(item.evidence.home, undefined, 'home is not forced for an unclassifiable item');
|
|
154
|
+
assert.deepEqual(item.options.map((o) => o.key).sort(), ['dismiss', 'triage']);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// --- C6: createItem boundary check ---
|
|
158
|
+
|
|
159
|
+
test('C6: createItem rejects an illegal knowledge-extraction type or home', () => {
|
|
160
|
+
assert.throws(
|
|
161
|
+
() => q.createItem({
|
|
162
|
+
project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
|
|
163
|
+
title: 'x', summary: 'x', context_anchor: 'x', evidence: { type: 'nonsense-type' },
|
|
164
|
+
}),
|
|
165
|
+
/illegal knowledge-extraction type/,
|
|
166
|
+
);
|
|
167
|
+
assert.throws(
|
|
168
|
+
() => q.createItem({
|
|
169
|
+
project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
|
|
170
|
+
title: 'x', summary: 'x', context_anchor: 'x', evidence: { type: 'lesson', home: 'nonsense-home' },
|
|
171
|
+
}),
|
|
172
|
+
/illegal knowledge-extraction home/,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('C6b: createItem rejects an unclassifiable item missing its reason', () => {
|
|
177
|
+
assert.throws(
|
|
178
|
+
() => q.createItem({
|
|
179
|
+
project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
|
|
180
|
+
title: 'x', summary: 'x', context_anchor: 'x', evidence: { type: 'unclassifiable' },
|
|
181
|
+
}),
|
|
182
|
+
/unclassifiable_reason/,
|
|
183
|
+
);
|
|
184
|
+
assert.throws(
|
|
185
|
+
() => q.createItem({
|
|
186
|
+
project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
|
|
187
|
+
title: 'x', summary: 'x', context_anchor: 'x', evidence: { type: 'unclassifiable', unclassifiable_reason: ' ' },
|
|
188
|
+
}),
|
|
189
|
+
/unclassifiable_reason/,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('C6c: createItem rejects derivable:true without a derivation', () => {
|
|
194
|
+
assert.throws(
|
|
195
|
+
() => q.createItem({
|
|
196
|
+
project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
|
|
197
|
+
title: 'x', summary: 'x', context_anchor: 'x',
|
|
198
|
+
evidence: { type: 'lesson', home: 'derivation', derivable: true },
|
|
199
|
+
}),
|
|
200
|
+
/derivable:true requires evidence\.derivation/,
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('C6d: home is NOT validated for an unclassifiable item — the model is not asked to guess one', () => {
|
|
205
|
+
// Present-but-off-vocabulary home alongside unclassifiable must not throw:
|
|
206
|
+
// the boundary check skips home validation entirely in this branch.
|
|
207
|
+
assert.doesNotThrow(() => q.createItem({
|
|
208
|
+
project: 'boundary-proj', project_path: '/tmp/boundary-proj', category: 'knowledge-extraction',
|
|
209
|
+
title: 'x', summary: 'x', context_anchor: 'x',
|
|
210
|
+
evidence: { type: 'unclassifiable', unclassifiable_reason: 'genuinely unclear', home: 'whatever' },
|
|
211
|
+
}));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('C6e: KNOWLEDGE_EXTRACTION_TYPES/HOMES are exported consistently from both queue and lib', () => {
|
|
215
|
+
assert.deepEqual(q.KNOWLEDGE_EXTRACTION_TYPES, lib.KNOWLEDGE_EXTRACTION_TYPES);
|
|
216
|
+
assert.deepEqual(q.KNOWLEDGE_EXTRACTION_HOMES, lib.KNOWLEDGE_EXTRACTION_HOMES);
|
|
217
|
+
assert.ok(q.KNOWLEDGE_EXTRACTION_TYPES.includes('unclassifiable'));
|
|
218
|
+
assert.ok(q.KNOWLEDGE_EXTRACTION_HOMES.includes('derivation'));
|
|
219
|
+
});
|