klypix-mcp 1.42.0 → 1.43.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/bin/klypix-worker.mjs +19 -1
- package/package.json +1 -1
- package/src/codex-brain-hook.mjs +20 -0
- package/src/global-brain-hook.mjs +92 -5
- package/src/klypix-core.mjs +23 -4
- package/src/klypix-format.mjs +534 -32
package/bin/klypix-worker.mjs
CHANGED
|
@@ -329,6 +329,24 @@ server.registerTool('brain_sync', {
|
|
|
329
329
|
}, async ({ project, intent, files, phase, include_context }) => {
|
|
330
330
|
const totalStartedAt = Date.now();
|
|
331
331
|
const report = mcpPresence.sync({ project, intent, files, phase });
|
|
332
|
+
// Class-C ship observation, host-neutral: brain_sync is the ONE surface every
|
|
333
|
+
// MCP host calls at task start, so an MCP-only project (no Claude/Codex hook)
|
|
334
|
+
// still notices a release nobody narrated. Queued here, drained at the next
|
|
335
|
+
// brain write. Zero network, ~2 cheap git/fs reads, never throws.
|
|
336
|
+
let shipNotice = '';
|
|
337
|
+
if (phase === 'start') {
|
|
338
|
+
try {
|
|
339
|
+
const dir = project ? path.resolve(project) : mcpPresence.vault;
|
|
340
|
+
if (typeof brainFormat.observeShipDrift === 'function' && dir) {
|
|
341
|
+
const { execFileSync } = await import('child_process');
|
|
342
|
+
shipNotice = brainFormat.observeShipDrift(dir, {
|
|
343
|
+
gitRun: (args) => execFileSync('git', String(args).split(/\s+/).filter(Boolean), {
|
|
344
|
+
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000,
|
|
345
|
+
}),
|
|
346
|
+
}).notice || '';
|
|
347
|
+
}
|
|
348
|
+
} catch { /* observation is best-effort — never fail a sync */ }
|
|
349
|
+
}
|
|
332
350
|
let taskContext = null;
|
|
333
351
|
if (phase !== 'complete' && include_context !== false && (intent || files?.length)) {
|
|
334
352
|
taskContext = await opBrainTaskContext({
|
|
@@ -368,7 +386,7 @@ server.registerTool('brain_sync', {
|
|
|
368
386
|
return {
|
|
369
387
|
content: [{
|
|
370
388
|
type: 'text',
|
|
371
|
-
text: [report.text, contextText, timingText].filter(Boolean).join('\n\n'),
|
|
389
|
+
text: [report.text, shipNotice, contextText, timingText].filter(Boolean).join('\n\n'),
|
|
372
390
|
}],
|
|
373
391
|
structuredContent,
|
|
374
392
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.0",
|
|
4
4
|
"description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Codex, Cursor, Cline, any model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/src/codex-brain-hook.mjs
CHANGED
|
@@ -31,6 +31,21 @@ function readInput() {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Thin binding of the engine's host-neutral ship observer to a Codex project
|
|
35
|
+
// dir. Guarded on the export so an older bundled klypix-format degrades to no
|
|
36
|
+
// observation rather than killing the hook.
|
|
37
|
+
function observeShipDrift(projectDir) {
|
|
38
|
+
try {
|
|
39
|
+
if (!projectDir || typeof brainFormat.observeShipDrift !== 'function') return '';
|
|
40
|
+
const gitRun = (args) => execFileSync('git', String(args).split(/\s+/).filter(Boolean), {
|
|
41
|
+
cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000,
|
|
42
|
+
});
|
|
43
|
+
return brainFormat.observeShipDrift(projectDir, { gitRun }).notice || '';
|
|
44
|
+
} catch {
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
function gitBranch(cwd) {
|
|
35
50
|
try {
|
|
36
51
|
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
@@ -262,6 +277,11 @@ async function main() {
|
|
|
262
277
|
if (event === 'SessionStart') {
|
|
263
278
|
emitSystemMessage([
|
|
264
279
|
formatPresenceMessage(sessions, sessionId, { includeSolo: true }),
|
|
280
|
+
// Class-C ship observation — the engine's, not a Claude-hook-local copy.
|
|
281
|
+
// Codex-driven projects had NO out-of-session ship detection at all, which
|
|
282
|
+
// is exactly where the incident class bites hardest (2026-07-29 review).
|
|
283
|
+
// The queue drains at the next brain write from any host.
|
|
284
|
+
observeShipDrift(projectDir),
|
|
265
285
|
stampReceivedMessages(messages),
|
|
266
286
|
]);
|
|
267
287
|
return;
|
|
@@ -709,7 +709,20 @@ function filesInEntry(entry) {
|
|
|
709
709
|
// tool_use blocks, no model marker required. Only commands that SUCCEEDED (their
|
|
710
710
|
// tool_result wasn't an error) count, so a failed `gh pr merge` never fabricates a
|
|
711
711
|
// card; dedup rides the same persistent seen-set as markers.
|
|
712
|
-
|
|
712
|
+
// One membership test for "is this tool a shell?" across every host the brain
|
|
713
|
+
// rides in: Claude Code (Bash, PowerShell), Codex CLI (shell, local_shell),
|
|
714
|
+
// Gemini CLI (run_shell_command), Cursor (run_terminal_cmd), Cline/Roo
|
|
715
|
+
// (execute_command), VS Code Copilot (run_in_terminal), plus generic bridge
|
|
716
|
+
// names. Compared case-insensitively — hosts disagree on casing before they
|
|
717
|
+
// disagree on names. 2026-07-29 field hole: this Stop-side set lacked
|
|
718
|
+
// 'PowerShell' while the live detector included it, so on Windows a
|
|
719
|
+
// `gh release create` run through the PowerShell tool produced NO ship card —
|
|
720
|
+
// the v1.3.69 ship triggered no claim reconciliation at all.
|
|
721
|
+
const SHELL_TOOL_NAMES = new Set([
|
|
722
|
+
'bash', 'powershell', 'shell', 'local_shell', 'run_shell', 'run_shell_command',
|
|
723
|
+
'run_terminal_cmd', 'execute_command', 'run_in_terminal', 'run_command', 'terminal', 'exec',
|
|
724
|
+
]);
|
|
725
|
+
const isShellTool = (name) => SHELL_TOOL_NAMES.has(String(name || '').toLowerCase());
|
|
713
726
|
const SHIP_PATTERNS = [
|
|
714
727
|
// PR number is read ONLY from the `pr merge <n>` argument — not "any number in the
|
|
715
728
|
// command" (which grabbed a stray digit from `-R owner/repo4`, mislabeling it #4).
|
|
@@ -726,7 +739,7 @@ function scanToolBlocks(entry, shellCmds, errorIds) {
|
|
|
726
739
|
const c = (entry?.message ?? entry)?.content;
|
|
727
740
|
if (!Array.isArray(c)) return;
|
|
728
741
|
for (const b of c) {
|
|
729
|
-
if (b?.type === 'tool_use' &&
|
|
742
|
+
if (b?.type === 'tool_use' && isShellTool(b.name) && typeof b.input?.command === 'string') shellCmds.push({ id: b.id, cmd: b.input.command });
|
|
730
743
|
else if (b?.type === 'tool_result' && b.is_error && b.tool_use_id) errorIds.add(b.tool_use_id);
|
|
731
744
|
}
|
|
732
745
|
}
|
|
@@ -760,6 +773,35 @@ const COMMIT_STATE = path.resolve(CWD, '.claude', 'brain-last-commit');
|
|
|
760
773
|
const readLastCommit = () => { try { return fs.readFileSync(COMMIT_STATE, 'utf8').trim() || null; } catch { return null; } };
|
|
761
774
|
const writeLastCommit = (s) => { try { fs.mkdirSync(path.dirname(COMMIT_STATE), { recursive: true }); fs.writeFileSync(COMMIT_STATE, String(s || '')); } catch { /* best-effort */ } };
|
|
762
775
|
const git = (args) => execSync(`git ${args}`, { cwd: CWD, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }).trim();
|
|
776
|
+
|
|
777
|
+
// --- Out-of-session ship observation (class-C decay leg, 2026-07-29) --------
|
|
778
|
+
// A ship that happens with NO hooked session watching — another host, a human
|
|
779
|
+
// terminal, CI — used to produce no 🏁 card and trigger no claim
|
|
780
|
+
// reconciliation: the brain only learned what a session narrated (the v1.3.69
|
|
781
|
+
// ship arrived exactly this way and its resolved ❓ stayed open for a day).
|
|
782
|
+
// SessionStart now OBSERVES the two deterministic, zero-network ship signals —
|
|
783
|
+
// the newest local git tag and package.json's version — against a per-project
|
|
784
|
+
// sidecar, QUEUES a pending ship record, and lets the next Stop capture drain
|
|
785
|
+
// it through the existing lock + persistent dedup + fulfillment cross-check.
|
|
786
|
+
// SessionStart itself stays a no-brain-write path (the 1.41.0 print-path
|
|
787
|
+
// contract); first run BASELINES silently, mirroring the commit channel.
|
|
788
|
+
// Ship observation lives in the ENGINE (klypix-format observeShipDrift) so every
|
|
789
|
+
// host shares one implementation — the incident class is worst exactly where no
|
|
790
|
+
// Claude hook runs. These thin wrappers bind it to this hook's CWD + git runner
|
|
791
|
+
// and stay version-skew-safe behind typeof guards.
|
|
792
|
+
function observeOutOfSessionShips(lib) {
|
|
793
|
+
try {
|
|
794
|
+
if (typeof lib?.observeShipDrift !== 'function') return '';
|
|
795
|
+
const { notice } = lib.observeShipDrift(CWD, { gitRun: git, now: nowIso });
|
|
796
|
+
return notice ? `\n${notice}\n` : '';
|
|
797
|
+
} catch { return ''; }
|
|
798
|
+
}
|
|
799
|
+
const advanceShipBaseline = (lib) => {
|
|
800
|
+
try {
|
|
801
|
+
if (typeof lib?.writeShipObsState !== 'function' || typeof lib?.readShipSignals !== 'function') return;
|
|
802
|
+
lib.writeShipObsState(CWD, lib.readShipSignals(CWD, git));
|
|
803
|
+
} catch { /* best-effort */ }
|
|
804
|
+
};
|
|
763
805
|
const CC_RE = /^(feat|fix|perf)(?:\(([^)]+)\))?!?:\s*(.+)$/i;
|
|
764
806
|
function parseCommitLog(raw) {
|
|
765
807
|
return String(raw).split('\x1e').map(s => s.trim()).filter(Boolean).map(rec => {
|
|
@@ -958,7 +1000,7 @@ function detectLiveEvent(input) {
|
|
|
958
1000
|
const tool = input.tool_name || '';
|
|
959
1001
|
const ti = input.tool_input || {};
|
|
960
1002
|
// SHIP — a release/merge/publish/tag from a shell tool that wasn't interrupted/errored.
|
|
961
|
-
if ((tool
|
|
1003
|
+
if (isShellTool(tool) && typeof ti.command === 'string' && ti.command) {
|
|
962
1004
|
const tr = input.tool_response;
|
|
963
1005
|
const interrupted = tr && typeof tr === 'object' && tr.interrupted === true;
|
|
964
1006
|
if (!interrupted) {
|
|
@@ -1480,6 +1522,39 @@ async function capture(lib) {
|
|
|
1480
1522
|
break; // one pattern per command
|
|
1481
1523
|
}
|
|
1482
1524
|
}
|
|
1525
|
+
// Drain out-of-session ship observations queued by SessionStart (class-C
|
|
1526
|
+
// decay leg) — same card shape and the same persistent dedup keys as
|
|
1527
|
+
// in-session ship events, so a ship both narrated AND observed lands
|
|
1528
|
+
// exactly once. Skipped under --dry-run (an inspection must not consume
|
|
1529
|
+
// the queue). Queue is cleared BEFORE processing — a throwing entry must
|
|
1530
|
+
// not wedge the drain forever; entries lost to a crash re-observe at the
|
|
1531
|
+
// next SessionStart because the sidecar only advances on write.
|
|
1532
|
+
// The queue is NOT deleted here — it is deleted only after the brain write
|
|
1533
|
+
// is durable (next to writeState/writeLastCommit), mirroring the commit
|
|
1534
|
+
// channel's "advance the baseline only after a successful write". Deleting
|
|
1535
|
+
// up front meant an EBUSY/rename failure — routine on Windows when the
|
|
1536
|
+
// desktop app holds brain.klypix — silently destroyed the observation with
|
|
1537
|
+
// no card and no retry (2026-07-29 review, CONFIRMED). Per-row JSON.parse
|
|
1538
|
+
// already tolerates a poison row, so nothing needs the early unlink.
|
|
1539
|
+
let drainedShips = false;
|
|
1540
|
+
if (!DRY && typeof lib.readPendingShips === 'function') {
|
|
1541
|
+
try {
|
|
1542
|
+
const rows = lib.readPendingShips(CWD);
|
|
1543
|
+
drainedShips = rows.length > 0;
|
|
1544
|
+
// A version already narrated by a session (this batch or an earlier
|
|
1545
|
+
// one, via the persistent seen-set) is not news — see pendingShipCards.
|
|
1546
|
+
const priorNarrated = ['cut release v', 'tagged v', 'tagged ', 'published to npm ']
|
|
1547
|
+
.flatMap(p => rows.filter(r => r.version).map(r => p + r.version))
|
|
1548
|
+
.filter(s => seen.has(sha(('ship|release|' + s).toLowerCase())))
|
|
1549
|
+
.map(s => s.replace(/^[a-z ]+/, ''));
|
|
1550
|
+
for (const c of lib.pendingShipCards(rows, { isSeen: (k) => seen.has(k), narrated: [...shipSummaries, ...priorNarrated], sha })) {
|
|
1551
|
+
if (c.key) seen.add(c.key);
|
|
1552
|
+
cards.push({ text: `${c.area}: 🏁 ${c.summary}\n#${slugify(c.area)} #auto`, area: c.area, borderColor: 'rgba(59,130,246,0.8)', createdVia: 'ship-observed' });
|
|
1553
|
+
shipSummaries.push(c.summary);
|
|
1554
|
+
ledger.push({ action: 'ship-observed', area: c.area, preview: c.summary.slice(0, 90) });
|
|
1555
|
+
}
|
|
1556
|
+
} catch { /* pending-ship drain is best-effort */ }
|
|
1557
|
+
}
|
|
1483
1558
|
// Refresh this session's lane with what it ACTUALLY did — files it EDITED this
|
|
1484
1559
|
// session (per-session, from the transcript; NOT the shared working-tree diff,
|
|
1485
1560
|
// which is identical for two sessions in one repo) + recent ship-events, so a
|
|
@@ -1527,6 +1602,10 @@ async function capture(lib) {
|
|
|
1527
1602
|
// Record the commit baseline / advance even with nothing to capture, so
|
|
1528
1603
|
// the next run doesn't re-scan the same commits.
|
|
1529
1604
|
if (newLastCommit && newLastCommit !== prevCommit) writeLastCommit(newLastCommit);
|
|
1605
|
+
// This session WAS watching, so whatever ship state it ends with is by
|
|
1606
|
+
// definition already observed — baseline it, or the next SessionStart
|
|
1607
|
+
// reports our own narrated release as a new signal.
|
|
1608
|
+
if (!DRY) advanceShipBaseline(lib);
|
|
1530
1609
|
// Nothing new — but if markers were SEEN-and-skipped or example-rejected,
|
|
1531
1610
|
// record that so "the brief looks stale" has a paper trail.
|
|
1532
1611
|
if (ledger.length) appendJsonl(LEDGER, { ts: nowIso(), mode: 'capture', stats: { added: 0 }, decisions: ledger }, 1000);
|
|
@@ -1552,6 +1631,10 @@ async function capture(lib) {
|
|
|
1552
1631
|
await lib.atomicWrite(BRAIN, out);
|
|
1553
1632
|
writeState(merged);
|
|
1554
1633
|
writeLastCommit(newLastCommit); // advance the commit baseline only after a successful write
|
|
1634
|
+
// Same discipline for the two ship channels: the queue is consumed and
|
|
1635
|
+
// the observation baseline advances ONLY now that the cards are durable.
|
|
1636
|
+
if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
|
|
1637
|
+
advanceShipBaseline(lib);
|
|
1555
1638
|
try { await refreshAgentsBrief(lib, out); } catch { /* AGENTS.md refresh is best-effort */ }
|
|
1556
1639
|
} finally {
|
|
1557
1640
|
if (gotLock) releaseLock(LOCK);
|
|
@@ -2248,6 +2331,10 @@ async function read(lib) {
|
|
|
2248
2331
|
return peerCount ? `\n👥 ${peerCount} other live session(s) in this project right now (snapshot at session start — the per-prompt peer footer stays current; \`npx klypix-mcp doctor\` lists all).` : '';
|
|
2249
2332
|
} catch { return ''; }
|
|
2250
2333
|
})();
|
|
2334
|
+
// Class-C decay leg: notice ships that happened while no hooked session was
|
|
2335
|
+
// watching (tag/version drift vs the per-project sidecar) and queue them
|
|
2336
|
+
// for the Stop capture. The line rides BOTH emit tiers.
|
|
2337
|
+
const shipObsLine = observeOutOfSessionShips(lib);
|
|
2251
2338
|
const { struct } = await lib.parseKlypix(fs.readFileSync(BRAIN));
|
|
2252
2339
|
const { freshness, drifted } = computeFreshness(struct);
|
|
2253
2340
|
// The FULL brief: tiered brief + every self-heal/health footer. Messages are
|
|
@@ -2258,7 +2345,7 @@ async function read(lib) {
|
|
|
2258
2345
|
+ ruleDraftsFooter(input.session_id, struct, { markShown: false })
|
|
2259
2346
|
+ selfCheckFooter() + doctorFooter() + versionCurrencyFooter() + legendFooter() + memoryFooter();
|
|
2260
2347
|
const emitFull = () => {
|
|
2261
|
-
process.stdout.write(full + presenceLine + messageFooter(input.session_id || '', input.transcript_path, lib));
|
|
2348
|
+
process.stdout.write(full + presenceLine + shipObsLine + messageFooter(input.session_id || '', input.transcript_path, lib));
|
|
2262
2349
|
appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(full), cards: struct?.counts?.cards ?? null }, 500);
|
|
2263
2350
|
};
|
|
2264
2351
|
// --full = everything to stdout (manual runs); also the fallback when the
|
|
@@ -2299,7 +2386,7 @@ async function read(lib) {
|
|
|
2299
2386
|
// go right after the ultra brief, at the top of the visible window, never
|
|
2300
2387
|
// after a stack of footers that could push them past a preview cut.
|
|
2301
2388
|
const messages = messageFooter(input.session_id || '', input.transcript_path, lib);
|
|
2302
|
-
const out = ultra + messages + presenceLine + healLine + draftLine
|
|
2389
|
+
const out = ultra + messages + presenceLine + shipObsLine + healLine + draftLine
|
|
2303
2390
|
+ inflightFooter(input.session_id, struct)
|
|
2304
2391
|
+ selfCheckFooter() + doctorFooter() + versionCurrencyFooter();
|
|
2305
2392
|
process.stdout.write(out);
|
package/src/klypix-core.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
statusContextToMarkdown, findFulfillmentCandidates,
|
|
33
33
|
splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor,
|
|
34
34
|
isFastDecayCard, DECAY_STALE_MS, formatDecayAge,
|
|
35
|
+
readPendingShips, clearPendingShips, pendingShipCards,
|
|
35
36
|
} from './klypix-format.mjs';
|
|
36
37
|
import { capMessages, findProjectBrain } from './agent-presence.mjs';
|
|
37
38
|
|
|
@@ -550,15 +551,15 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
|
|
|
550
551
|
// Semantic blend (best-effort, time-bounded): embed the question + the brain's
|
|
551
552
|
// cards on-device, hand rankForQuestion a Map<cardId, cosine>. A missing/warming
|
|
552
553
|
// model degrades cleanly to pure lexical.
|
|
553
|
-
let semantic = null, mode = 'lexical';
|
|
554
|
+
let semantic = null, mode = 'lexical', cardVecs = null;
|
|
554
555
|
try {
|
|
555
556
|
const pipe = await Promise.race([getEmbedder(log), new Promise(r => setTimeout(() => r(null), 20_000))]);
|
|
556
557
|
if (pipe) {
|
|
557
558
|
const [qv] = await embedTexts(pipe, [q]);
|
|
558
559
|
const vecs = await vectorsForBrain(pipe, t.file, struct.cards);
|
|
559
|
-
if (qv && vecs && vecs.size) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); mode = 'semantic+lexical (on-device)'; }
|
|
560
|
+
if (qv && vecs && vecs.size) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); cardVecs = vecs; mode = 'semantic+lexical (on-device)'; }
|
|
560
561
|
}
|
|
561
|
-
} catch { semantic = null; mode = 'lexical (semantic warming — retry for semantic ranking)'; }
|
|
562
|
+
} catch { semantic = null; cardVecs = null; mode = 'lexical (semantic warming — retry for semantic ranking)'; }
|
|
562
563
|
const kk = Math.max(1, Math.min(20, k || 10));
|
|
563
564
|
const timeTravel = asOfTs != null;
|
|
564
565
|
// Cross-encoder rerank (eval-proven: recall@5 15%→40% on human paraphrase
|
|
@@ -569,7 +570,11 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
|
|
|
569
570
|
// today's ranking (same sort, longer slice). Overlays are safe: rankForQuestion
|
|
570
571
|
// attaches corrections to ALL candidates before we reorder. KLYPIX_RERANK=0 kills.
|
|
571
572
|
const wantRerank = !timeTravel && process.env.KLYPIX_RERANK !== '0';
|
|
572
|
-
|
|
573
|
+
// Card↔card cosine for the serve-time ❓↔🏁 pairing pass — the same on-device
|
|
574
|
+
// vectors that rank the question, reused; null degrades pairing to its
|
|
575
|
+
// lexical anchor/coverage fallback (works on every host, embeddings or not).
|
|
576
|
+
const pairSim = cardVecs ? (a, b) => { const va = cardVecs.get(a), vb = cardVecs.get(b); return va && vb ? dot(va, vb) : null; } : null;
|
|
577
|
+
const result = rankForQuestion(struct, q, { semantic, k: wantRerank ? Math.max(kk, 50) : kk, as_of: timeTravel ? as_of : null, pairSim });
|
|
573
578
|
if (wantRerank && result.hits.length > 1) {
|
|
574
579
|
try {
|
|
575
580
|
const rr = await Promise.race([getReranker(log), new Promise(r => setTimeout(() => r(null), 8_000))]);
|
|
@@ -997,10 +1002,24 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
|
|
|
997
1002
|
if (!noteText || !String(noteText).trim()) return err('brain_note needs a non-empty text.');
|
|
998
1003
|
if (!['', '?', '!', '✓', '~', '+'].includes(marker)) return err(`Invalid marker "${marker}" — use: (none)=decision · ?=open question · !=milestone · +=skill (reusable how-to) · ✓=resolve a matching card · ~=update a matching card.`);
|
|
999
1004
|
const input = noteToCaptureInput({ text: noteText, area, marker, closes: closes || '', createdVia: via || 'mcp' });
|
|
1005
|
+
// Deliver any queued out-of-session ship observations on THIS write. The
|
|
1006
|
+
// Claude Stop hook is not the only writer — an MCP-only or Codex-driven
|
|
1007
|
+
// project would otherwise queue observations that never drain (2026-07-29
|
|
1008
|
+
// review, CONFIRMED). Cards ride the same capture batch, so they also pass
|
|
1009
|
+
// through the fulfillment cross-check. Queue is cleared only after the write.
|
|
1010
|
+
const projectDir = path.dirname(file);
|
|
1011
|
+
let pendingShips = [];
|
|
1012
|
+
try { pendingShips = readPendingShips(projectDir); } catch { pendingShips = []; }
|
|
1013
|
+
if (pendingShips.length) {
|
|
1014
|
+
for (const c of pendingShipCards(pendingShips, {})) {
|
|
1015
|
+
input.cards.push({ text: `${c.area}: 🏁 ${c.summary}\n#${String(c.area).toLowerCase()} #auto`, area: c.area, createdVia: 'ship-observed', borderColor: 'rgba(59,130,246,0.8)' });
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1000
1018
|
try {
|
|
1001
1019
|
const res = await captureIntoBrain(fs.readFileSync(file), input);
|
|
1002
1020
|
let out = res.buffer; try { out = (await tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
|
|
1003
1021
|
await atomicWrite(file, out);
|
|
1022
|
+
if (pendingShips.length) clearPendingShips(projectDir); // durable now — safe to consume
|
|
1004
1023
|
const s = res.stats || {};
|
|
1005
1024
|
const bits = [`${s.added || 0} added`];
|
|
1006
1025
|
for (const k of ['resolved', 'updated', 'merged', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
|
package/src/klypix-format.mjs
CHANGED
|
@@ -161,7 +161,16 @@ export async function parseKlypix(buffer) {
|
|
|
161
161
|
// it between two cards a reconcile pass flagged as a FALSE contradiction, and
|
|
162
162
|
// detectContradictions then treats the pair as settled forever (the persisted
|
|
163
163
|
// dismiss path the correction-cue false-positive previously lacked).
|
|
164
|
-
|
|
164
|
+
// 'not_fulfilled' is its twin for the claim engine — and its ABSENCE here was a
|
|
165
|
+
// silent critical: every fulfillment surface has told users to dismiss a wrong
|
|
166
|
+
// ⏳ hint with brain_connect relationship:"not_fulfilled" since 1.31.0, but this
|
|
167
|
+
// allowlist coerced it to 'relates_to', so the stored edge never matched any
|
|
168
|
+
// settled-set check. The tool reported "✓ Drew 1 connection(s)" and the
|
|
169
|
+
// dismissed hint came back on the very next render, forever. Every claim-engine
|
|
170
|
+
// test passed because the fixtures fabricate the connection object directly and
|
|
171
|
+
// never round-trip it through this writer (2026-07-29 review, CONFIRMED).
|
|
172
|
+
export const DISMISSAL_RELS = new Set(['not_contradiction', 'not_fulfilled']);
|
|
173
|
+
const REL = new Set(['leads_to', 'depends_on', 'relates_to', 'conflicts_with', 'supports', 'questions', 'costs', 'blocks', ...DISMISSAL_RELS]);
|
|
165
174
|
|
|
166
175
|
/**
|
|
167
176
|
* Build a real .klypix v4 file (nodebuffer) from a simple spec:
|
|
@@ -1605,7 +1614,7 @@ export const deathDateOfCard = (card) => {
|
|
|
1605
1614
|
const m = /(?:↩︎ superseded|↩ superseded|✅|⤵ consolidated) (\d{4}-\d{2}-\d{2})/.exec(String(c.text || ''));
|
|
1606
1615
|
return m ? Date.parse(m[1]) : null;
|
|
1607
1616
|
};
|
|
1608
|
-
export function rankForQuestion(struct, question, { semantic = null, k = 10, as_of = null, now = Date.now(), semFloor = 0.30, recentDays = 30 } = {}) {
|
|
1617
|
+
export function rankForQuestion(struct, question, { semantic = null, k = 10, as_of = null, now = Date.now(), semFloor = 0.30, recentDays = 30, pairSim = null } = {}) {
|
|
1609
1618
|
// Status-shaped questions score by their CONTENT tokens only — "remaining"
|
|
1610
1619
|
// must never lexically select the stale cards that say "remaining:".
|
|
1611
1620
|
const { content: tokens, statusShaped, strong: statusStrong } = splitQueryTokens(question);
|
|
@@ -1668,6 +1677,54 @@ export function rankForQuestion(struct, question, { semantic = null, k = 10, as_
|
|
|
1668
1677
|
let fulfills = new Map();
|
|
1669
1678
|
if (!timeTravel) { try { fulfills = fulfillmentOverlaysFor(struct, top.map(h => h.card)); } catch { /* best-effort */ } }
|
|
1670
1679
|
const hits = top.map(h => ({ ...h, correction: overlays.get(h.card.id) || null, fulfillment: fulfills.get(h.card.id) || null }));
|
|
1680
|
+
// SERVE-TIME ❓↔🏁 PAIRING (2026-07-29 incident): an answer's own hit set
|
|
1681
|
+
// can contain an open card AND the newer milestone that likely fulfilled
|
|
1682
|
+
// it, side by side, unlinked — the incident answer carried both at sims
|
|
1683
|
+
// 0.44/0.46 and the agent quoted the ❓ as a live blocker. Fulfillment used
|
|
1684
|
+
// to be a persisted-edge lookup only; one missed capture-time check
|
|
1685
|
+
// silenced every ⏳ surface forever. This pass inspects relationships
|
|
1686
|
+
// WITHIN the candidate set only (≤k², no brain scan), embedding-first
|
|
1687
|
+
// (pairSim = card↔card cosine from the caller's vectors) with a lexical
|
|
1688
|
+
// anchor/coverage fallback, respects human dismissals, and renders through
|
|
1689
|
+
// the ⏳ overlay as an UNCONFIRMED hint — suggestion-only, hedged wording.
|
|
1690
|
+
if (!timeTravel) {
|
|
1691
|
+
try {
|
|
1692
|
+
const openHits = hits.filter(h => !h.correction && !h.fulfillment && !h.archived && isUnresolvedOpenCard(h.card));
|
|
1693
|
+
const mileHits = hits.filter(h => isMilestoneCard(h.card) && !/^archive$/i.test(h.card.area || ''));
|
|
1694
|
+
if (openHits.length && mileHits.length) {
|
|
1695
|
+
const settled = new Set();
|
|
1696
|
+
for (const cn of struct.connections || []) {
|
|
1697
|
+
if (cn.label === 'likely closed by' || cn.relationship === 'not_fulfilled') settled.add(`${cn.fromId}|${cn.toId}`);
|
|
1698
|
+
}
|
|
1699
|
+
let df = null;
|
|
1700
|
+
const dfMap = () => (df ??= buildStemDf(struct));
|
|
1701
|
+
for (const oh of openHits) {
|
|
1702
|
+
let best = null, bestScore = 0;
|
|
1703
|
+
for (const mh of mileHits) {
|
|
1704
|
+
const o = oh.card, m = mh.card;
|
|
1705
|
+
if (m.id === o.id || (m.createdAt || 0) <= (o.createdAt || 0)) continue;
|
|
1706
|
+
if (settled.has(`${o.id}|${m.id}`)) continue;
|
|
1707
|
+
const sim = typeof pairSim === 'function' ? pairSim(o.id, m.id) : null;
|
|
1708
|
+
const lex = likelyFulfillsLexical(o, m, dfMap());
|
|
1709
|
+
const sameArea = (o.area || '') === (m.area || '');
|
|
1710
|
+
// Accept: strong embedding agreement backed by ANY lexical
|
|
1711
|
+
// signal, or lexical evidence alone at the anchor/coverage
|
|
1712
|
+
// bars. Pure-semantic pairs (zero shared vocabulary) stay
|
|
1713
|
+
// out — precision-first for a rendered hint.
|
|
1714
|
+
const accept = (sim != null && sim >= 0.55 && (lex.size || 0) >= SERVE_MIN_STEMS && (lex.anchors.length >= 1 || lex.cov >= 0.2))
|
|
1715
|
+
|| serveTimeAccepts(lex, sameArea);
|
|
1716
|
+
if (!accept) continue;
|
|
1717
|
+
const score = (sim ?? 0) + lex.cov + lex.anchors.length * 0.2;
|
|
1718
|
+
if (score > bestScore) { bestScore = score; best = m; }
|
|
1719
|
+
}
|
|
1720
|
+
if (best) {
|
|
1721
|
+
const head = String(best.text || '').replace(/\s+/g, ' ').trim().slice(0, 100);
|
|
1722
|
+
oh.fulfillment = { by: head, byId: best.id, unconfirmed: true };
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
} catch { /* pairing is a best-effort overlay — never fail the answer */ }
|
|
1727
|
+
}
|
|
1671
1728
|
return { hits, total: scored.length, tokens, statusShaped, statusStrong };
|
|
1672
1729
|
}
|
|
1673
1730
|
|
|
@@ -1698,7 +1755,12 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
|
|
|
1698
1755
|
block += `\n\n ⚠️ CORRECTED — this card is STALE; the current truth is:\n ${flat(h.correction.by.text).slice(0, 600)}`;
|
|
1699
1756
|
} else if (h.fulfillment) {
|
|
1700
1757
|
// Precedence: a correction outranks a fulfills-hint (never stack both).
|
|
1701
|
-
|
|
1758
|
+
// Serve-time pairs (detected inside THIS answer's hit set, no
|
|
1759
|
+
// persisted edge yet) hedge harder than edge-confirmed hints — the
|
|
1760
|
+
// reader must verify, not inherit a machine guess as settled truth.
|
|
1761
|
+
block += h.fulfillment.unconfirmed
|
|
1762
|
+
? `\n\n ⏳ POSSIBLY FULFILLED (serve-time hint — detected within this answer, no confirmed link): a newer milestone in this same answer may cover this open item: “${flat(h.fulfillment.by).slice(0, 200)}”. VERIFY before treating it as still-to-do; confirm with a ✓ marker if done, or dismiss via brain_connect relationship:"not_fulfilled".`
|
|
1763
|
+
: `\n\n ⏳ LIKELY FULFILLED — a newer milestone appears to cover this open item: “${flat(h.fulfillment.by).slice(0, 200)}”. Verify before treating it as still-to-do; confirm with a ✓ marker if done.`;
|
|
1702
1764
|
}
|
|
1703
1765
|
if (used + block.length + 2 > budgetChars && shown > 0) { out.push(`\n_…and ${hits.length - shown} more matched card(s) omitted for length — narrow the question or use search for the rest._`); break; }
|
|
1704
1766
|
out.push(block, '');
|
|
@@ -1746,8 +1808,35 @@ const DECAY_VERB_RE = /\b(?:uploaded|published|released|deployed|installed|shipp
|
|
|
1746
1808
|
const DECAY_PENDING_RE = /\b(?:not\s+yet|hasn'?t(?:\s+been)?|haven'?t(?:\s+been)?|nothing(?:\s+has)?(?:\s+been)?|never)\s+(?:been\s+)?(?:uploaded|published|released|deployed|installed|shipped|triggered|tagged|submitted|gone\s+live)\b|\bno\s+(?:\w+[\s/-]+){0,2}?(?:upload|publish|deploy|release|install|build|tag|rollout)[\w/-]*(?:\s+\S+){0,3}?\s+(?:yet|so\s+far|triggered|started|fired|happened)\b|\b(?:awaiting|waiting\s+(?:for|on)|pending)\s+(?:apple|app\s?store|testflight|review|approval|upload|release|rollout)\b/i;
|
|
1747
1809
|
const DECAY_NOUN_RE = /\btest\s?flight\b|\bapp\s?store\b|\bnpm\b|\bci\b|\brelease(?:s)?\b|\brollout(?:s)?\b|\bbuild\s*#?\s*\d+\b|\bv\d+(?:\.\d+)+\b|\b\d+\.\d+\.\d+\b/i;
|
|
1748
1810
|
const DECAY_EVRUN_RE = /\bev:\s*[^\n]{0,60}?\b(?:run|workflow|release|build|rollout|deploy)[a-z-]*\s*[#:/]?\s*\d{3,}/i;
|
|
1749
|
-
|
|
1750
|
-
|
|
1811
|
+
// Cards are STORED hard-wrapped (~37 chars/line via wrapText), so a mid-sentence
|
|
1812
|
+
// '\n' is layout, not punctuation. Any extractor that treats lines or [^\n] runs
|
|
1813
|
+
// as semantic units must rebuild sentences first, or a wrap point silently
|
|
1814
|
+
// gates it (2026-07-29 incident: the wrap fell between "in live" and "v1.3.68",
|
|
1815
|
+
// so classifyDecay saw a verb-only segment and a noun-only segment and the one
|
|
1816
|
+
// card that most needed a ⏱️ LAST KNOWN stamp rendered plain). Rules:
|
|
1817
|
+
// · pure tag lines ("#release #file-x #auto") are dropped, not joined — a
|
|
1818
|
+
// "#file-release-notes" tag would otherwise donate a release NOUN to the
|
|
1819
|
+
// sentence above it (probe-confirmed false-positive class);
|
|
1820
|
+
// · a single '\n' joins to a space; a blank line stays a paragraph boundary.
|
|
1821
|
+
export function normalizeWrappedProse(text) {
|
|
1822
|
+
return String(text || '')
|
|
1823
|
+
.split('\n')
|
|
1824
|
+
.filter(l => !/^\s*(?:#[\p{L}\p{N}_-]+\s*)+$/u.test(l))
|
|
1825
|
+
.join('\n')
|
|
1826
|
+
.replace(/([^\n])\n(?!\n)/g, '$1 ');
|
|
1827
|
+
}
|
|
1828
|
+
// Unwrapping is OPT-IN, and only the card store may opt in. An inter-session
|
|
1829
|
+
// message's newlines are AUTHORED separators: joining them merged a verb line
|
|
1830
|
+
// with an unrelated release-noun line and invented a ⏱️ stamp on every host
|
|
1831
|
+
// lane (2026-07-29 review, CONFIRMED — "shipped the auth fix to the beta
|
|
1832
|
+
// testers\nremaining: npm audit + release notes" is two claims, neither
|
|
1833
|
+
// fast-decay). Line-length geometry cannot tell the two apart either: that
|
|
1834
|
+
// message's authored lines are 40 and 36 chars, well inside wrap range. So the
|
|
1835
|
+
// caller who KNOWS its text came from wrapText declares it, and every other
|
|
1836
|
+
// caller — message stampers on all hosts — keeps raw '\n' segmentation.
|
|
1837
|
+
export function classifyDecay(text, { wrapped = false } = {}) {
|
|
1838
|
+
const raw = String(text || '');
|
|
1839
|
+
const t = wrapped ? normalizeWrappedProse(raw) : raw;
|
|
1751
1840
|
if (!t.trim()) return false;
|
|
1752
1841
|
if (DECAY_EVRUN_RE.test(t)) return true;
|
|
1753
1842
|
// A '.' only ends a segment when followed by whitespace/end — a bare-dot
|
|
@@ -1762,7 +1851,9 @@ export function classifyDecay(text) {
|
|
|
1762
1851
|
// A struct card decays if its TEXT classifies, or its machine evidence carries
|
|
1763
1852
|
// a run/release-shaped receipt (the ev: suffix is stripped from hook-captured
|
|
1764
1853
|
// prose, so the text alone can't see it).
|
|
1765
|
-
|
|
1854
|
+
// Card text IS wrapped by construction (wrapText at capture), so the card path
|
|
1855
|
+
// unwraps explicitly rather than relying on geometry detection.
|
|
1856
|
+
export const isFastDecayCard = (c) => classifyDecay(c?.text, { wrapped: true })
|
|
1766
1857
|
|| (Array.isArray(c?.evidence) && c.evidence.some(e => DECAY_EVRUN_RE.test('ev: ' + String(e?.ref || ''))));
|
|
1767
1858
|
// Compact age for stamps ("20h", "3d") — both message renderers show minutes
|
|
1768
1859
|
// only, which reads as noise at 12h+ ("720m ago").
|
|
@@ -1857,6 +1948,33 @@ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChar
|
|
|
1857
1948
|
// question (review scale finding).
|
|
1858
1949
|
const top = sorted.slice(0, maxOpen);
|
|
1859
1950
|
const fulfills = fulfillmentOverlaysFor(struct, top);
|
|
1951
|
+
// Serve-time augmentation (2026-07-29): edge-lookup alone renders a
|
|
1952
|
+
// fulfilled ❓ plain when the one capture-time check missed the pair.
|
|
1953
|
+
// Re-detect lexically against the newest milestones for the opens THIS
|
|
1954
|
+
// render will show — bounded (top × ≤40 miles), suggestion-only, and
|
|
1955
|
+
// flagged '?' so an unconfirmed hint never reads as a settled one.
|
|
1956
|
+
try {
|
|
1957
|
+
const newestMiles = live.filter(isMilestoneCard)
|
|
1958
|
+
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)).slice(0, 40);
|
|
1959
|
+
if (newestMiles.length && top.length && top.length <= 80) {
|
|
1960
|
+
const settled = new Set();
|
|
1961
|
+
for (const cn of struct.connections || []) {
|
|
1962
|
+
if (cn.relationship === 'not_fulfilled') settled.add(`${cn.fromId}|${cn.toId}`);
|
|
1963
|
+
}
|
|
1964
|
+
let df = null;
|
|
1965
|
+
const dfMap = () => (df ??= buildStemDf(struct));
|
|
1966
|
+
for (const o of top) {
|
|
1967
|
+
if (fulfills.has(o.id)) continue;
|
|
1968
|
+
for (const m of newestMiles) {
|
|
1969
|
+
if (m.id === o.id || (m.createdAt || 0) <= (o.createdAt || 0) || settled.has(`${o.id}|${m.id}`)) continue;
|
|
1970
|
+
const lex = likelyFulfillsLexical(o, m, dfMap());
|
|
1971
|
+
if (!serveTimeAccepts(lex, (o.area || '') === (m.area || ''))) continue;
|
|
1972
|
+
fulfills.set(o.id, { by: String(m.text || '').replace(/\s+/g, ' ').trim().slice(0, 100), byId: m.id, unconfirmed: true });
|
|
1973
|
+
break;
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
} catch { /* augmentation is best-effort — the base render stands */ }
|
|
1860
1978
|
let overlays = new Map();
|
|
1861
1979
|
try { overlays = correctionOverlaysFor(struct, top); } catch { /* best-effort */ }
|
|
1862
1980
|
pushAlways('');
|
|
@@ -1873,7 +1991,7 @@ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChar
|
|
|
1873
1991
|
let shown = 0;
|
|
1874
1992
|
for (const c of top) {
|
|
1875
1993
|
if (used > budgetChars) break;
|
|
1876
|
-
const flags = `${overdueById.has(c.id) ? ' ⏰OVERDUE' : ''}${overlays.has(c.id) ? ' ⚠️CORRECTED' : ''}${fulfills.has(c.id) ? ' ⏳likely-fulfilled' : ''}`;
|
|
1994
|
+
const flags = `${overdueById.has(c.id) ? ' ⏰OVERDUE' : ''}${overlays.has(c.id) ? ' ⚠️CORRECTED' : ''}${fulfills.has(c.id) ? (fulfills.get(c.id)?.unconfirmed ? ' ⏳likely-fulfilled?' : ' ⏳likely-fulfilled') : ''}`;
|
|
1877
1995
|
const d = decayStamp(c);
|
|
1878
1996
|
if (d) pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ⏱️ LAST KNOWN (${d.age}): ${cut(flat(c.text), perCard)} — VERIFY: ${d.probe}`);
|
|
1879
1997
|
else pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ${cut(flat(c.text), perCard)}`);
|
|
@@ -1895,6 +2013,113 @@ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChar
|
|
|
1895
2013
|
return out.join('\n') + '\n';
|
|
1896
2014
|
}
|
|
1897
2015
|
|
|
2016
|
+
// ── Out-of-session ship observation (class-C decay, host-neutral) ────────────
|
|
2017
|
+
// A ship that happens with NO hooked session watching — another host, a human
|
|
2018
|
+
// terminal, CI — produced no 🏁 card and triggered no claim reconciliation: the
|
|
2019
|
+
// brain only learned what a session narrated. The v1.3.69 desktop release
|
|
2020
|
+
// arrived exactly that way and the ❓ it resolved stayed open for a day.
|
|
2021
|
+
//
|
|
2022
|
+
// This lives in the ENGINE, not in one host's hook, because the incident class
|
|
2023
|
+
// is worst precisely where no Claude hook runs (Codex CLI, Cursor, a
|
|
2024
|
+
// MCP-only project). Every host calls observeShipDrift at task start and
|
|
2025
|
+
// drainPendingShips at write time; both take an explicit projectDir so nothing
|
|
2026
|
+
// depends on a hook's CWD. Two deterministic, zero-network signals only:
|
|
2027
|
+
// the newest local git tag and package.json's version.
|
|
2028
|
+
export const shipObsPaths = (projectDir) => ({
|
|
2029
|
+
state: path.join(projectDir || '.', '.claude', 'brain-ship-obs.json'),
|
|
2030
|
+
queue: path.join(projectDir || '.', '.claude', 'brain-pending-ships.jsonl'),
|
|
2031
|
+
});
|
|
2032
|
+
// Tag names may carry '@' (changesets / lerna: "klypix-mcp@1.43.0"); shell
|
|
2033
|
+
// metacharacters stay out because the tag is interpolated into a git command.
|
|
2034
|
+
const SAFE_TAG_RE = /^[\w.@/-]+$/;
|
|
2035
|
+
const cmpSemver3 = (a, b) => {
|
|
2036
|
+
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0), pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
|
|
2037
|
+
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
|
|
2038
|
+
return 0;
|
|
2039
|
+
};
|
|
2040
|
+
export function readShipSignals(projectDir, gitRun) {
|
|
2041
|
+
let tag = '';
|
|
2042
|
+
try { tag = String(gitRun('for-each-ref refs/tags --sort=-creatordate --count=1 --format=%(refname:short)') || '').trim(); } catch { /* no git / no tags */ }
|
|
2043
|
+
let version = '';
|
|
2044
|
+
try { version = String(JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8')).version || ''); } catch { /* not an npm project */ }
|
|
2045
|
+
return { tag, version };
|
|
2046
|
+
}
|
|
2047
|
+
export const writeShipObsState = (projectDir, sig) => {
|
|
2048
|
+
const { state } = shipObsPaths(projectDir);
|
|
2049
|
+
try { fs.mkdirSync(path.dirname(state), { recursive: true }); fs.writeFileSync(state, JSON.stringify(sig)); return true; } catch { return false; }
|
|
2050
|
+
};
|
|
2051
|
+
/**
|
|
2052
|
+
* Compare the live ship signals against this project's last recorded
|
|
2053
|
+
* observation. Returns { events, notice } — events are QUEUED for the next brain
|
|
2054
|
+
* write (so reconciliation runs under the capture lock) and the baseline
|
|
2055
|
+
* advances only after they are durably queued. First call BASELINES silently.
|
|
2056
|
+
* Never throws; a non-git / non-npm project simply yields nothing.
|
|
2057
|
+
*/
|
|
2058
|
+
export function observeShipDrift(projectDir, { gitRun, now = () => new Date().toISOString() } = {}) {
|
|
2059
|
+
const empty = { events: [], notice: '' };
|
|
2060
|
+
if (!projectDir || typeof gitRun !== 'function') return empty;
|
|
2061
|
+
try {
|
|
2062
|
+
const { state, queue } = shipObsPaths(projectDir);
|
|
2063
|
+
let prev = null; try { prev = JSON.parse(fs.readFileSync(state, 'utf8')); } catch { /* first run */ }
|
|
2064
|
+
const sig = readShipSignals(projectDir, gitRun);
|
|
2065
|
+
if (!prev || typeof prev !== 'object') { writeShipObsState(projectDir, sig); return empty; }
|
|
2066
|
+
const events = [];
|
|
2067
|
+
if (sig.tag && prev.tag && sig.tag !== prev.tag && SAFE_TAG_RE.test(sig.tag)) {
|
|
2068
|
+
let subj = ''; try { subj = String(gitRun(`log -1 --format=%s ${sig.tag}`) || '').trim(); } catch { /* optional */ }
|
|
2069
|
+
events.push({ area: 'Release', key: `tagged ${sig.tag}`, tag: sig.tag, summary: `tagged ${sig.tag}${subj ? ' — ' + subj.slice(0, 90) : ''} (observed at task start — new since this project's last recorded observation)` });
|
|
2070
|
+
}
|
|
2071
|
+
// A version CHANGE, not an increase — a revert is real news, but must not
|
|
2072
|
+
// be called a ship. Provenance is deliberately NOT asserted: this
|
|
2073
|
+
// observer cannot know whether some session narrated it, and claiming
|
|
2074
|
+
// "outside any hooked session" was false on every routine release
|
|
2075
|
+
// (2026-07-29 review, CONFIRMED).
|
|
2076
|
+
if (sig.version && prev.version && sig.version !== prev.version) {
|
|
2077
|
+
const dir = cmpSemver3(sig.version, prev.version) < 0 ? 'reverted to' : '→';
|
|
2078
|
+
events.push({ area: 'Release', key: `version-observed ${sig.version}`, version: sig.version, summary: `version ${dir} ${sig.version} observed at task start (was ${prev.version})` });
|
|
2079
|
+
}
|
|
2080
|
+
if (!events.length) { writeShipObsState(projectDir, sig); return empty; }
|
|
2081
|
+
try {
|
|
2082
|
+
fs.mkdirSync(path.dirname(queue), { recursive: true });
|
|
2083
|
+
for (const e of events) fs.appendFileSync(queue, JSON.stringify({ ts: now(), ...e }) + '\n');
|
|
2084
|
+
} catch { return empty; } // couldn't queue → don't advance the baseline
|
|
2085
|
+
writeShipObsState(projectDir, sig);
|
|
2086
|
+
return {
|
|
2087
|
+
events,
|
|
2088
|
+
notice: `⏱️ Ship signal observed since this project's last observation: ${events.map(e => e.key).join(' · ')} — queued for capture + claim reconciliation at the next brain write. Open ❓ cards about this release may already be fulfilled; verify live before reporting them as blockers.`,
|
|
2089
|
+
};
|
|
2090
|
+
} catch { return empty; }
|
|
2091
|
+
}
|
|
2092
|
+
export function readPendingShips(projectDir) {
|
|
2093
|
+
try {
|
|
2094
|
+
const { queue } = shipObsPaths(projectDir);
|
|
2095
|
+
if (!fs.existsSync(queue)) return [];
|
|
2096
|
+
return fs.readFileSync(queue, 'utf8').split('\n').filter(Boolean)
|
|
2097
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
2098
|
+
} catch { return []; }
|
|
2099
|
+
}
|
|
2100
|
+
export const clearPendingShips = (projectDir) => { try { fs.unlinkSync(shipObsPaths(projectDir).queue); } catch { /* already gone */ } };
|
|
2101
|
+
/**
|
|
2102
|
+
* Turn queued observations into capture-ready ship cards. `isSeen(key)` consults
|
|
2103
|
+
* the caller's persistent dedup state; `narrated` are ship summaries captured in
|
|
2104
|
+
* THIS batch. A ship already narrated by some session is not news — an observed
|
|
2105
|
+
* version event keys differently from every narrated summary ("published to
|
|
2106
|
+
* npm", "cut release vX"), which is why the routine release flow used to produce
|
|
2107
|
+
* a duplicate card (2026-07-29 review, CONFIRMED).
|
|
2108
|
+
*/
|
|
2109
|
+
export function pendingShipCards(rows, { isSeen = () => false, narrated = [], sha } = {}) {
|
|
2110
|
+
const out = [];
|
|
2111
|
+
for (const r of rows || []) {
|
|
2112
|
+
if (!r || !r.summary) continue;
|
|
2113
|
+
const area = r.area || 'Release';
|
|
2114
|
+
const key = typeof sha === 'function' ? sha(('ship|' + area + '|' + String(r.key || r.summary)).toLowerCase()) : null;
|
|
2115
|
+
if (key && isSeen(key)) continue;
|
|
2116
|
+
const ver = r.version || (r.tag ? String(r.tag).replace(/^.*?v?(\d+\.\d+\.\d+)$/, '$1') : '');
|
|
2117
|
+
if (ver && narrated.some(s => String(s).includes(ver))) continue;
|
|
2118
|
+
out.push({ key, area, summary: String(r.summary) });
|
|
2119
|
+
}
|
|
2120
|
+
return out;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
1898
2123
|
// ── External-state reconcile — migration omission tripwire ───────────────────
|
|
1899
2124
|
// The brain is a NARRATION-capture system: a fact exists only if someone wrote a
|
|
1900
2125
|
// 🧠 marker or a rationale-bearing commit body. Applying a DB migration to prod
|
|
@@ -2246,7 +2471,23 @@ export async function addBrainConnections(buffer, edges) {
|
|
|
2246
2471
|
for (const e of edges || []) {
|
|
2247
2472
|
if (!e.fromId || !e.toId || e.fromId === e.toId) continue;
|
|
2248
2473
|
if (!canvas.positions[e.fromId] || !canvas.positions[e.toId]) continue; // both must exist
|
|
2249
|
-
|
|
2474
|
+
// A DISMISSAL must never be pair-deduped away by the very hint it
|
|
2475
|
+
// dismisses. The pair-level guard is right for topical edges (it stops
|
|
2476
|
+
// duplicate arrows) but it made the documented escape hatch a no-op:
|
|
2477
|
+
// an auto-written 'likely closed by' hint occupied the pair, so the
|
|
2478
|
+
// human's not_fulfilled edge was dropped ("Drew 0 connection(s)") and
|
|
2479
|
+
// the wrong hint became permanent (2026-07-29 review, CONFIRMED).
|
|
2480
|
+
// Dismissals therefore RETIRE the machine hint in place and are always
|
|
2481
|
+
// recorded; a second identical dismissal is still deduped.
|
|
2482
|
+
if (DISMISSAL_RELS.has(e.relationship)) {
|
|
2483
|
+
const already = canvas.connections.some(c => c.relationship === e.relationship
|
|
2484
|
+
&& ((c.fromId === e.fromId && c.toId === e.toId) || (c.fromId === e.toId && c.toId === e.fromId)));
|
|
2485
|
+
if (already) continue;
|
|
2486
|
+
for (const c of canvas.connections) {
|
|
2487
|
+
const samePair = (c.fromId === e.fromId && c.toId === e.toId) || (c.fromId === e.toId && c.toId === e.fromId);
|
|
2488
|
+
if (samePair && c.label === 'likely closed by') { c.label = '↩ dismissed hint'; c.style = 'dashed'; c.color = 'rgba(148,163,184,0.5)'; }
|
|
2489
|
+
}
|
|
2490
|
+
} else if (linked(e.fromId, e.toId)) continue;
|
|
2250
2491
|
canvas.connections.push({
|
|
2251
2492
|
id: `con_${rand()}`, fromId: e.fromId, toId: e.toId,
|
|
2252
2493
|
relationship: REL.has(e.relationship) ? e.relationship : 'relates_to',
|
|
@@ -3147,7 +3388,10 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
3147
3388
|
const cands = findFulfillmentCandidates(struct, [{ text: card.text, createdAt: now }], { maxPerMilestone: 2 })
|
|
3148
3389
|
.filter(c => !closed.has(c.open.id));
|
|
3149
3390
|
if (!cands.length) continue;
|
|
3150
|
-
|
|
3391
|
+
// Only COVERAGE-grade candidates earn a persisted edge; anchor-grade
|
|
3392
|
+
// ones ride the printed receipt (hedged) and are re-derived at serve
|
|
3393
|
+
// time. See the persist pass below for why.
|
|
3394
|
+
card.__fulfills = cands.filter(c => c.via !== 'anchor').map(c => c.open.id);
|
|
3151
3395
|
for (const c of cands) {
|
|
3152
3396
|
stats.fulfillCandidates.push({
|
|
3153
3397
|
open: String(c.open.text || '').replace(/\s+/g, ' ').slice(0, 90),
|
|
@@ -3155,6 +3399,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
3155
3399
|
item: c.item,
|
|
3156
3400
|
uncovered: c.uncovered,
|
|
3157
3401
|
cov: c.cov,
|
|
3402
|
+
...(c.via ? { via: c.via } : {}),
|
|
3158
3403
|
// A ✓ is suggested ONLY when the claim is FULLY covered and
|
|
3159
3404
|
// its tokens can actually clear the resolve matcher's ≥4
|
|
3160
3405
|
// floor — a partial ✓ used to archive the whole card
|
|
@@ -3227,6 +3472,49 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
3227
3472
|
}
|
|
3228
3473
|
} catch { /* auto-linking is opportunistic */ }
|
|
3229
3474
|
}
|
|
3475
|
+
// PERSIST DETECTION AS EDGES (2026-07-29): staleOpenFooter printed its
|
|
3476
|
+
// findings as prose and wrote nothing, so a detection that fired at
|
|
3477
|
+
// SessionStart was invisible to brain_ask an hour later — miss the one
|
|
3478
|
+
// capture-time check and every ⏳ surface stayed blind forever. Every
|
|
3479
|
+
// capture now leaves dashed 'likely closed by' edges for the pairs the
|
|
3480
|
+
// detector can currently see. Suggestion-only contract unchanged (the
|
|
3481
|
+
// human ✓ retires cards; addConn dedups; a not_fulfilled dismissal
|
|
3482
|
+
// suppresses the pair inside findStaleOpenCards itself). Bounded: the
|
|
3483
|
+
// detector caps at 8 pairs and runs once per capture batch.
|
|
3484
|
+
try {
|
|
3485
|
+
const { gaps } = findStaleOpenCards(struct, { max: 8 });
|
|
3486
|
+
let hintEdges = 0;
|
|
3487
|
+
for (const g of gaps) {
|
|
3488
|
+
// ANCHOR-GRADE EVIDENCE IS NEVER PERSISTED. Its whole contract is
|
|
3489
|
+
// "suggestion, verify by hand" — persisting it made the next
|
|
3490
|
+
// render treat it as an established link, so a hint the serve
|
|
3491
|
+
// path hedged as "POSSIBLY FULFILLED" came back one capture
|
|
3492
|
+
// later at full strength and entered every settled set, i.e.
|
|
3493
|
+
// permanent unless dismissed (2026-07-29 review, CONFIRMED).
|
|
3494
|
+
// Anchor pairs stay serve-time-only, re-derived and re-hedged
|
|
3495
|
+
// on each render, so they cost nothing to be wrong about.
|
|
3496
|
+
if (g.via === 'anchor') continue;
|
|
3497
|
+
const before = stats.linked;
|
|
3498
|
+
addConn(g.open.id, g.by.id, 'likely closed by', undefined, { style: 'dashed', width: 1.5, color: 'rgba(16,185,129,0.55)' });
|
|
3499
|
+
if (stats.linked > before) { hintEdges++; continue; }
|
|
3500
|
+
// hasConn is pair-level and label-blind, and the structural
|
|
3501
|
+
// auto-linker runs FIRST in this same capture — so a co-tagged
|
|
3502
|
+
// pair already wearing an 'auto' relates_to edge could never
|
|
3503
|
+
// receive its hint, on this or any future capture (2026-07-29
|
|
3504
|
+
// review, CONFIRMED). Upgrade the weaker edge in place instead
|
|
3505
|
+
// of dropping the finding on the floor. Dismissals and existing
|
|
3506
|
+
// hints are left exactly as they are.
|
|
3507
|
+
for (const cn of canvas.connections) {
|
|
3508
|
+
const samePair = (cn.fromId === g.open.id && cn.toId === g.by.id) || (cn.fromId === g.by.id && cn.toId === g.open.id);
|
|
3509
|
+
if (!samePair || cn.label === 'likely closed by' || cn.label === '↩ dismissed hint' || DISMISSAL_RELS.has(cn.relationship)) continue;
|
|
3510
|
+
cn.fromId = g.open.id; cn.toId = g.by.id;
|
|
3511
|
+
cn.label = 'likely closed by'; cn.style = 'dashed'; cn.width = 1.5; cn.color = 'rgba(16,185,129,0.55)';
|
|
3512
|
+
hintEdges++;
|
|
3513
|
+
break;
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
if (hintEdges) stats.hintEdges = hintEdges;
|
|
3517
|
+
} catch { /* hint persistence is opportunistic — never fail a capture */ }
|
|
3230
3518
|
stampBrainKind(manifest);
|
|
3231
3519
|
work = await finalizeBrainZip(zip, canvas, manifest, now);
|
|
3232
3520
|
}
|
|
@@ -3451,19 +3739,167 @@ export async function applyGarden(buffer, { syntheses = [] } = {}) {
|
|
|
3451
3739
|
// are stopword- and URL-free: raw tokenSet keeps any ≥4-char word, so
|
|
3452
3740
|
// from/this/with/https/github cleared the 0.6 coverage bar against unrelated
|
|
3453
3741
|
// milestones (four probe-confirmed FP classes, incl. the live dogfood noise).
|
|
3454
|
-
|
|
3742
|
+
// Cue vocabulary is single-sourced: the clause matcher and the line-boundary
|
|
3743
|
+
// preserver below must never drift apart (a cue the matcher knows but the
|
|
3744
|
+
// preserver doesn't gets its line silently merged into the clause above it).
|
|
3745
|
+
const CLAIM_CUE_WORDS = String.raw`remaining|pending|still\s+to\s+do|to-?do|outstanding|open\s+items?`;
|
|
3746
|
+
const NEXT_CUE_WORDS = String.raw`next|awaits?`;
|
|
3747
|
+
const CLAIM_CUE_RE = new RegExp(String.raw`\b(?:${CLAIM_CUE_WORDS})\b[^:\n]{0,40}:\s*([^\n]+)|\b(?:${NEXT_CUE_WORDS})\s*:\s*([^\n]+)`, 'gi');
|
|
3748
|
+
const CUE_LINE_RE = new RegExp(String.raw`^\s*(?:${CLAIM_CUE_WORDS}|${NEXT_CUE_WORDS})\b[^:\n]{0,40}:`, 'i');
|
|
3455
3749
|
const claimTokens = (s) => new Set([...tokenSet(String(s || '').replace(/https?:\/\/\S+/g, ' '))].filter(t => !STOPWORDS.has(t)));
|
|
3750
|
+
// ── Morphology + rarity (2026-07-29 incident) ────────────────────────────────
|
|
3751
|
+
// The incident pair shared the brain's rarest tokens (binariesgithubrelease,
|
|
3752
|
+
// node-llama-cpp) yet measured 0.21 flat coverage: the ❓ was a 42-token
|
|
3753
|
+
// forensic narrative, the 🏁 a terse fix, and tokenSet is morphology-blind
|
|
3754
|
+
// (excludes/excluding, packaged/packaging all count as misses). Two additions,
|
|
3755
|
+
// both feeding the SAME suggestion-only pipeline — the generic 0.6 bar stays:
|
|
3756
|
+
// · stemLight — a deliberately tiny suffix stripper (ing/ed/es/s + trailing
|
|
3757
|
+
// double consonant + trailing e) applied at COMPARISON time only, never to
|
|
3758
|
+
// stored tokens, so no other consumer's token sets change;
|
|
3759
|
+
// · anchor tokens — a token whose stemmed form appears in ≤ANCHOR_DF_MAX live
|
|
3760
|
+
// cards is near-unique in the brain; an open↔milestone pair sharing ≥2 such
|
|
3761
|
+
// anchors (same area; ≥3 cross-area) is hint-worthy regardless of flat
|
|
3762
|
+
// coverage. Rare-by-construction, so the genericity guard stays meaningful.
|
|
3763
|
+
// Suffix stripping is deliberately conservative and, crucially, only ever
|
|
3764
|
+
// consulted through matchesStem below — a bare stem comparison collides distinct
|
|
3765
|
+
// vocabulary (state↔stats, notes↔noting, cares↔caring all collapse), and one
|
|
3766
|
+
// collision is enough to be the tipping anchor on an unrelated pair
|
|
3767
|
+
// (2026-07-29 review, CONFIRMED: 'rotation state' vs 'cost stats').
|
|
3768
|
+
export const stemLight = (w) => {
|
|
3769
|
+
let s = String(w);
|
|
3770
|
+
if (s.length >= 6 && /(?:ing|ed)$/.test(s)) s = s.replace(/(?:ing|ed)$/, '');
|
|
3771
|
+
else if (s.length >= 5 && /[^s]s$/.test(s)) s = s.replace(/es$|s$/, '');
|
|
3772
|
+
if (s.length >= 5 && /(.)\1$/.test(s)) s = s.slice(0, -1);
|
|
3773
|
+
if (s.length >= 5 && s.endsWith('e')) s = s.slice(0, -1);
|
|
3774
|
+
return s;
|
|
3775
|
+
};
|
|
3776
|
+
// Two RAW tokens are the same word only if one is at most one suffix-strip away
|
|
3777
|
+
// from the other. excludes↔excluding passes (each strips to the same form AND
|
|
3778
|
+
// one strips onto the other's stem); state↔stats does not (neither raw form is
|
|
3779
|
+
// the other's stem). This is the guard that makes stemming safe to use as
|
|
3780
|
+
// evidence rather than merely as a recall boost.
|
|
3781
|
+
export const matchesStem = (a, b) => a === b || stemLight(a) === b || a === stemLight(b);
|
|
3782
|
+
// Stem→raw-tokens index: comparison keys on the stem (cheap set intersection),
|
|
3783
|
+
// but every accepted match is re-validated on the RAW words behind it.
|
|
3784
|
+
export const stemIndex = (tokens) => {
|
|
3785
|
+
const m = new Map();
|
|
3786
|
+
for (const t of tokens) { const s = stemLight(t); if (!m.has(s)) m.set(s, new Set()); m.get(s).add(t); }
|
|
3787
|
+
return m;
|
|
3788
|
+
};
|
|
3789
|
+
export const stemSet = (tokens) => new Set(stemIndex(tokens).keys());
|
|
3790
|
+
const rawMatch = (aRaws, bRaws) => { for (const x of aRaws || []) for (const y of bRaws || []) if (matchesStem(x, y)) return true; return false; };
|
|
3791
|
+
// Rarity is RELATIVE to corpus size: an absolute df≤4 meant that in a young
|
|
3792
|
+
// brain (or any area with ≤4 cards) EVERY token was "rare" — including the
|
|
3793
|
+
// area-name prefix every stored card carries, which handed same-area pairs a
|
|
3794
|
+
// free anchor (2026-07-29 review, CONFIRMED: an SSO ship "fulfilling" a
|
|
3795
|
+
// changelog question on [releas, enterpris]).
|
|
3796
|
+
const anchorDfMax = (liveCount) => Math.max(2, Math.ceil(0.02 * Math.max(0, liveCount)));
|
|
3797
|
+
// Document frequency of STEMMED tokens across live (non-archived, non-container)
|
|
3798
|
+
// cards — the rarity oracle for anchor matching. O(cards × tokens), built once
|
|
3799
|
+
// per detection pass and only when a pass actually needs it (lazy).
|
|
3800
|
+
export function buildStemDf(struct) {
|
|
3801
|
+
const df = new Map();
|
|
3802
|
+
let live = 0;
|
|
3803
|
+
for (const c of struct?.cards || []) {
|
|
3804
|
+
if (c.type === 'container' || !(c.text || '').trim() || /^archive$/i.test(c.area || '')) continue;
|
|
3805
|
+
live++;
|
|
3806
|
+
for (const t of stemSet(tokenSet(c.text))) df.set(t, (df.get(t) || 0) + 1);
|
|
3807
|
+
}
|
|
3808
|
+
df.__live = live;
|
|
3809
|
+
df.__max = anchorDfMax(live);
|
|
3810
|
+
return df;
|
|
3811
|
+
}
|
|
3812
|
+
// Tokens a pair shares BY CONSTRUCTION are never evidence: the area name (every
|
|
3813
|
+
// stored card is written "<Area>: …" and containers carry it too) and its #tag
|
|
3814
|
+
// slug. Cheap per-call set — areas are short.
|
|
3815
|
+
const structuralStems = (...areas) => {
|
|
3816
|
+
const out = new Set();
|
|
3817
|
+
for (const a of areas) for (const t of stemSet(tokenSet(String(a || '').replace(/[^\p{L}\p{N}]+/gu, ' ')))) out.add(t);
|
|
3818
|
+
return out;
|
|
3819
|
+
};
|
|
3820
|
+
export function sharedAnchors(aIdx, bIdx, df, { exclude = null } = {}) {
|
|
3821
|
+
const out = [];
|
|
3822
|
+
const max = (df && df.__max) || 2;
|
|
3823
|
+
const aIsMap = aIdx instanceof Map, bIsMap = bIdx instanceof Map;
|
|
3824
|
+
for (const t of (aIsMap ? aIdx.keys() : aIdx)) {
|
|
3825
|
+
if (exclude && exclude.has(t)) continue; // structural (area/tag) token — shared by construction
|
|
3826
|
+
if (!(bIsMap ? bIdx.has(t) : bIdx.has(t))) continue;
|
|
3827
|
+
if ((df?.get(t) || 0) > max) continue; // not rare enough for this corpus size
|
|
3828
|
+
if (aIsMap && bIsMap && !rawMatch(aIdx.get(t), bIdx.get(t))) continue; // stem collision, not the same word
|
|
3829
|
+
out.push(t);
|
|
3830
|
+
}
|
|
3831
|
+
return out;
|
|
3832
|
+
}
|
|
3833
|
+
// Anchors must CORROBORATE topical overlap, never substitute for it entirely:
|
|
3834
|
+
// a small coverage floor keeps two rare words from pairing cards that share
|
|
3835
|
+
// nothing else.
|
|
3836
|
+
const ANCHOR_COV_FLOOR = 0.15;
|
|
3837
|
+
// One pair-scorer for every serve-/heal-time surface: stemmed coverage of the
|
|
3838
|
+
// open card's claim by the milestone + shared rare anchors. Suggestion-grade
|
|
3839
|
+
// evidence, never a close verdict.
|
|
3840
|
+
export function likelyFulfillsLexical(openCard, milestone, df) {
|
|
3841
|
+
const oIdx = stemIndex(claimTokens(normalizeWrappedProse(openCard?.text)));
|
|
3842
|
+
const mIdx = stemIndex(tokenSet(milestone?.text));
|
|
3843
|
+
if (!oIdx.size || !mIdx.size) return { cov: 0, anchors: [], size: oIdx.size };
|
|
3844
|
+
const cov = coverageOf(new Set(oIdx.keys()), new Set(mIdx.keys()));
|
|
3845
|
+
const anchors = df ? sharedAnchors(oIdx, mIdx, df, { exclude: structuralStems(openCard?.area, milestone?.area) }) : [];
|
|
3846
|
+
return { cov: Math.round(cov * 100) / 100, anchors, size: oIdx.size };
|
|
3847
|
+
}
|
|
3848
|
+
const anchorsSufficient = (anchors, sameArea, cov = 1) =>
|
|
3849
|
+
anchors.length >= (sameArea ? 2 : 3) && cov >= ANCHOR_COV_FLOOR;
|
|
3850
|
+
// Serve-time acceptance for a whole-card claim, mirroring the capture paths'
|
|
3851
|
+
// size floors (it.tokens.size≥2 / oTok.size≥3 / tk.size≥4). Without one, a
|
|
3852
|
+
// 2-token open card ("Release: ❓ npm publish?") is trivially covered by any
|
|
3853
|
+
// milestone reusing its words, cross-area, at cov 1.0 (2026-07-29 review,
|
|
3854
|
+
// CONFIRMED) — and a ⏳ flag instructs the reader not to treat it as open.
|
|
3855
|
+
// Small claims must therefore earn a hint through rare anchors, never coverage.
|
|
3856
|
+
const SERVE_COV_BAR = 0.35;
|
|
3857
|
+
const SERVE_MIN_STEMS = 3;
|
|
3858
|
+
export const serveTimeAccepts = (lex, sameArea) =>
|
|
3859
|
+
anchorsSufficient(lex.anchors, sameArea, lex.cov)
|
|
3860
|
+
|| ((lex.size || 0) >= SERVE_MIN_STEMS && lex.cov >= SERVE_COV_BAR);
|
|
3861
|
+
// Imperative-ask cue (2026-07-29): a narrative ❓ card often carries no
|
|
3862
|
+
// colon-anchored "remaining:" clause — its ask is an imperative sentence
|
|
3863
|
+
// ("Narrow the prune … and verify a packaged answer E2E"). For OPEN-shaped
|
|
3864
|
+
// cards only (❓/🎯 in text — a milestone's prose stays exempt), such sentences
|
|
3865
|
+
// become claim clauses, so the ASK is the coverage denominator instead of the
|
|
3866
|
+
// whole diagnosis. Curated base-form verbs, sentence-initial, ≥8 chars of
|
|
3867
|
+
// payload — commit-style "fix:" prefixes never reach here (no ❓/🎯).
|
|
3868
|
+
const IMPERATIVE_ASK_RE = /(?:^|[.!?]\s+)((?:narrow|fix|verify|ship|wire|apply|investigate|add|restore|re-?run|prove|close|migrate|land|publish|deploy|implement|rotate|upgrade|remove|rename|extend|measure|benchmark|audit|rehydrate|persist|surface)\b[^\n.!?]{8,160})/gi;
|
|
3456
3869
|
export function extractOpenClauses(text) {
|
|
3870
|
+
// Sentence-shaped cues ([^:\n], [^\n]+, sentence-initial verbs) read the
|
|
3871
|
+
// UNWRAPPED prose — stored hard-wraps otherwise truncate every clause at
|
|
3872
|
+
// the first wrap point (~37 chars), which silently crippled clause capture
|
|
3873
|
+
// for all STORED cards while tests fed unwrapped text and stayed green.
|
|
3874
|
+
// A line that OPENS with its own cue ("remaining: A" under "pending: B")
|
|
3875
|
+
// is an authored list entry, not a soft wrap — promote it to a paragraph
|
|
3876
|
+
// boundary before joining, or the join would fold sibling claims into one.
|
|
3877
|
+
const t = normalizeWrappedProse(String(text || '').split('\n').map(l => CUE_LINE_RE.test(l) ? '\n' + l : l).join('\n'));
|
|
3457
3878
|
const out = [];
|
|
3458
|
-
for (const m of
|
|
3459
|
-
|
|
3879
|
+
for (const m of t.matchAll(CLAIM_CUE_RE)) {
|
|
3880
|
+
// Stop the clause at the first SENTENCE boundary, not at paragraph end.
|
|
3881
|
+
// Unwrapping made a stored card one long paragraph, so "remaining: X."
|
|
3882
|
+
// followed by "Y shipped already." absorbed the done-sentence into the
|
|
3883
|
+
// claim — coverage then cleared the bar against the milestone that
|
|
3884
|
+
// shipped Y and the footer suggested a ✓ for a card whose real
|
|
3885
|
+
// remaining item was untouched (2026-07-29 review, CONFIRMED).
|
|
3886
|
+
// Same version-literal-safe split as classifyDecay: '1.3.28' survives.
|
|
3887
|
+
const clause = (m[1] || m[2] || '').split(/[!?]\s|\.(?=\s|$)/)[0].trim();
|
|
3460
3888
|
if (!clause) continue;
|
|
3461
3889
|
const items = clause.split(/\s*(?:\+|·|;)\s*/)
|
|
3462
|
-
.map(
|
|
3463
|
-
.map(
|
|
3890
|
+
.map(x => x.trim()).filter(x => x.length >= 4)
|
|
3891
|
+
.map(x => ({ text: x, tokens: claimTokens(x) }))
|
|
3464
3892
|
.filter(it => it.tokens.size >= 1);
|
|
3465
3893
|
if (items.length) out.push({ clause, items });
|
|
3466
3894
|
}
|
|
3895
|
+
if (/❓|🎯/.test(t)) {
|
|
3896
|
+
for (const m of t.matchAll(IMPERATIVE_ASK_RE)) {
|
|
3897
|
+
const clause = (m[1] || '').trim();
|
|
3898
|
+
if (!clause) continue;
|
|
3899
|
+
const tokens = claimTokens(clause);
|
|
3900
|
+
if (tokens.size >= 3) out.push({ clause, items: [{ text: clause, tokens }] });
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3467
3903
|
return out;
|
|
3468
3904
|
}
|
|
3469
3905
|
|
|
@@ -3492,8 +3928,14 @@ export function findFulfillmentCandidates(struct, milestones, { coverAt = 0.6, r
|
|
|
3492
3928
|
}
|
|
3493
3929
|
// Milestone token sets ONCE — this used to sit in the innermost item loop
|
|
3494
3930
|
// (claims × items × milestones tokenizations; output caps bound results,
|
|
3495
|
-
// not work — the 9000-card constraint is about work).
|
|
3496
|
-
|
|
3931
|
+
// not work — the 9000-card constraint is about work). Comparison happens on
|
|
3932
|
+
// STEMMED sets (stemLight) so excludes/excluding, packaged/packaging count
|
|
3933
|
+
// as the hits they are — stored token sets stay untouched.
|
|
3934
|
+
const mPre = milestones.map(m => ({ m, idx: stemIndex(tokenSet(m.text)), tok: stemSet(tokenSet(m.text)) }));
|
|
3935
|
+
// Rarity oracle for the anchor OR-path — built lazily: most captures carry
|
|
3936
|
+
// no open-shaped claims at all and must not pay the corpus scan.
|
|
3937
|
+
let df = null;
|
|
3938
|
+
const dfMap = () => (df ??= buildStemDf(struct));
|
|
3497
3939
|
const out = [];
|
|
3498
3940
|
for (const o of live) {
|
|
3499
3941
|
const clauses = extractOpenClauses(o.text);
|
|
@@ -3506,14 +3948,28 @@ export function findFulfillmentCandidates(struct, milestones, { coverAt = 0.6, r
|
|
|
3506
3948
|
for (const cl of clauses) {
|
|
3507
3949
|
for (const it of cl.items) {
|
|
3508
3950
|
if (it.tokens.size < 2) continue; // one-token items match everything
|
|
3509
|
-
|
|
3951
|
+
const itIdx = stemIndex(it.tokens);
|
|
3952
|
+
const itStems = new Set(itIdx.keys());
|
|
3953
|
+
for (const { m, idx: mIdx, tok: mTok } of mPre) {
|
|
3510
3954
|
if (m.id && m.id === o.id) continue; // a milestone never fulfils its own clause
|
|
3511
3955
|
if (m.id && settled.has(`${o.id}|${m.id}`)) continue;
|
|
3512
3956
|
if (requireNewer && Number.isFinite(m.createdAt) && m.createdAt <= (o.createdAt || 0)) continue;
|
|
3513
|
-
const cov = coverageOf(
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3957
|
+
const cov = coverageOf(itStems, mTok);
|
|
3958
|
+
// ANCHOR OR-PATH (2026-07-29 incident): a verbose diagnostic
|
|
3959
|
+
// ❓ vs a terse differently-worded 🏁 can NEVER reach the flat
|
|
3960
|
+
// bar (42-token denominator, 0.21 measured) — but the pair
|
|
3961
|
+
// shares near-unique tokens. ≥2 rare anchors in the same
|
|
3962
|
+
// area (≥3 cross-area) is hint-grade evidence on its own.
|
|
3963
|
+
// Suggestion-only by construction: via:'anchor' candidates
|
|
3964
|
+
// are never ✓-resolvable and get their own per-milestone cap.
|
|
3965
|
+
let viaAnchor = false;
|
|
3966
|
+
if (cov < coverAt) {
|
|
3967
|
+
const anchors = sharedAnchors(itIdx, mIdx, dfMap(), { exclude: structuralStems(o.area, m.area) });
|
|
3968
|
+
if (!anchorsSufficient(anchors, (o.area || '') === (m.area || ''), cov)) continue;
|
|
3969
|
+
viaAnchor = true;
|
|
3970
|
+
}
|
|
3971
|
+
const uncovered = cl.items.filter(x => x !== it && coverageOf(stemSet(x.tokens), mTok) < coverAt).map(x => x.text);
|
|
3972
|
+
out.push({ open: o, clause: cl.clause, item: it.text, uncovered, milestone: m, cov: Math.round(cov * 100) / 100, resolvable: !viaAnchor && it.tokens.size >= 4, ...(viaAnchor ? { via: 'anchor' } : {}) });
|
|
3517
3973
|
}
|
|
3518
3974
|
}
|
|
3519
3975
|
}
|
|
@@ -3538,14 +3994,21 @@ export function findFulfillmentCandidates(struct, milestones, { coverAt = 0.6, r
|
|
|
3538
3994
|
byItem.get(k).push(c);
|
|
3539
3995
|
}
|
|
3540
3996
|
const perMile = new Map();
|
|
3997
|
+
const perMileAnchor = new Map();
|
|
3541
3998
|
const kept = [];
|
|
3542
3999
|
for (const group of byItem.values()) {
|
|
3543
4000
|
if (group.length > 3) continue; // too generic — no hint
|
|
3544
4001
|
for (const c of group.sort((a, b) => b.cov - a.cov).slice(0, 2)) {
|
|
3545
4002
|
const mk = c.milestone.id || milestones.indexOf(c.milestone);
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
4003
|
+
// Anchor-path candidates spend a SEPARATE (smaller) per-milestone
|
|
4004
|
+
// budget: the coverage-path cap must not crowd them out — the exact
|
|
4005
|
+
// starvation the 2026-07-29 critic probe found at low bars — and
|
|
4006
|
+
// they must not inflate the coverage path's spray allowance either.
|
|
4007
|
+
const budget = c.via === 'anchor' ? perMileAnchor : perMile;
|
|
4008
|
+
const cap = c.via === 'anchor' ? 2 : maxPerMilestone;
|
|
4009
|
+
const n = budget.get(mk) || 0;
|
|
4010
|
+
if (n >= cap) continue;
|
|
4011
|
+
budget.set(mk, n + 1);
|
|
3549
4012
|
kept.push(c);
|
|
3550
4013
|
}
|
|
3551
4014
|
}
|
|
@@ -3562,12 +4025,24 @@ export function fulfillmentOverlaysFor(struct, cards) {
|
|
|
3562
4025
|
if (!struct || !Array.isArray(struct.connections)) return map;
|
|
3563
4026
|
const byId = new Map(struct.cards.map(c => [c.id, c]));
|
|
3564
4027
|
const want = new Set((cards || []).map(c => c.id));
|
|
4028
|
+
// A dismissal WINS even if both edges coexist on the pair — the relabel in
|
|
4029
|
+
// addBrainConnections handles new dismissals, and this covers any brain
|
|
4030
|
+
// where the two edges got written independently (or by an older build).
|
|
4031
|
+
const dismissed = new Set();
|
|
4032
|
+
for (const cn of struct.connections) {
|
|
4033
|
+
if (DISMISSAL_RELS.has(cn.relationship)) { dismissed.add(`${cn.fromId}|${cn.toId}`); dismissed.add(`${cn.toId}|${cn.fromId}`); }
|
|
4034
|
+
}
|
|
3565
4035
|
for (const cn of struct.connections) {
|
|
3566
4036
|
if (cn.label !== 'likely closed by' || !want.has(cn.fromId)) continue;
|
|
4037
|
+
if (dismissed.has(`${cn.fromId}|${cn.toId}`)) continue;
|
|
3567
4038
|
const m = byId.get(cn.toId);
|
|
3568
4039
|
if (!m || /^archive$/i.test(m.area || '')) continue; // a since-archived milestone no longer vouches
|
|
3569
4040
|
const head = String(m.text || '').replace(/\s+/g, ' ').trim().slice(0, 100);
|
|
3570
|
-
|
|
4041
|
+
// Machine-written hints (capture-time detection, no human ✓) stay
|
|
4042
|
+
// HEDGED after persistence. Before this, one capture silently promoted
|
|
4043
|
+
// a suggestion to the confirmed "⏳ LIKELY FULFILLED" tier — the diff's
|
|
4044
|
+
// own hedging defeated one session later (2026-07-29 review, CONFIRMED).
|
|
4045
|
+
if (!map.has(cn.fromId)) map.set(cn.fromId, { by: head, byId: m.id, unconfirmed: cn.hintVia !== 'human' });
|
|
3571
4046
|
}
|
|
3572
4047
|
return map;
|
|
3573
4048
|
}
|
|
@@ -3619,9 +4094,25 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
|
|
|
3619
4094
|
const opens = live.filter(isOpenCard);
|
|
3620
4095
|
const miles = live.filter(isMilestoneCard);
|
|
3621
4096
|
if (!opens.length || !miles.length) return empty;
|
|
4097
|
+
// Human dismissals + existing hint edges suppress a pair here exactly as in
|
|
4098
|
+
// findFulfillmentCandidates — a rejected hint must never resurface in the
|
|
4099
|
+
// self-heal footer either (parity fix, 2026-07-29).
|
|
4100
|
+
const settled = new Set();
|
|
4101
|
+
for (const cn of struct.connections || []) {
|
|
4102
|
+
if (cn.label === 'likely closed by' || cn.relationship === 'not_fulfilled') settled.add(`${cn.fromId}|${cn.toId}`);
|
|
4103
|
+
}
|
|
4104
|
+
// Milestone stem sets ONCE — this sat inside the opens loop (opens × miles
|
|
4105
|
+
// tokenizations per SessionStart; pure waste at 1,500 cards).
|
|
4106
|
+
const mPre = miles.map(m => ({ m, idx: stemIndex(tokenSet(m.text)), tok: stemSet(tokenSet(m.text)) }));
|
|
4107
|
+
let df = null;
|
|
4108
|
+
const dfMap = () => (df ??= buildStemDf(struct));
|
|
3622
4109
|
const out = [];
|
|
3623
4110
|
for (const o of opens) {
|
|
3624
|
-
|
|
4111
|
+
// claimTokens (stopword-stripped, URL-free) — parity with
|
|
4112
|
+
// findFulfillmentCandidates. The raw tokenSet left from/this/with in the
|
|
4113
|
+
// denominator, which both inflated coverage and offered junk anchors.
|
|
4114
|
+
const oIdx = stemIndex(claimTokens(normalizeWrappedProse(o.text)));
|
|
4115
|
+
const oTok = new Set(oIdx.keys());
|
|
3625
4116
|
if (oTok.size < 3) continue; // too vague to match safely → leave it
|
|
3626
4117
|
// CLAUSE-KEYED coverage (2026-07-23): the old whole-card denominator
|
|
3627
4118
|
// meant a 6-token "remaining: X" clause inside a 40-token card could
|
|
@@ -3630,16 +4121,27 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
|
|
|
3630
4121
|
// best of (whole card, any clause item ≥2 tokens).
|
|
3631
4122
|
// ≥3 tokens per clause item: 2-token items ('docs update') hit cov 1.0
|
|
3632
4123
|
// against unrelated milestones (probe-confirmed false stale flags).
|
|
3633
|
-
const clauseToks = extractOpenClauses(o.text).flatMap(cl => cl.items.map(it => it.tokens)).filter(t => t.size >= 3);
|
|
3634
|
-
let best = null, bestCov = 0;
|
|
3635
|
-
for (const m of
|
|
4124
|
+
const clauseToks = extractOpenClauses(o.text).flatMap(cl => cl.items.map(it => stemSet(it.tokens))).filter(t => t.size >= 3);
|
|
4125
|
+
let best = null, bestCov = 0, bestVia = null;
|
|
4126
|
+
for (const { m, idx: mIdx, tok: mTok } of mPre) {
|
|
3636
4127
|
if ((m.createdAt || 0) <= (o.createdAt || 0)) continue; // only a milestone shipped AFTER the goal
|
|
3637
|
-
|
|
4128
|
+
if (m.id && settled.has(`${o.id}|${m.id}`)) continue;
|
|
3638
4129
|
let cov = coverageOf(oTok, mTok); // how much of the goal the milestone covers
|
|
3639
4130
|
for (const ct of clauseToks) cov = Math.max(cov, coverageOf(ct, mTok));
|
|
3640
|
-
if (cov
|
|
4131
|
+
if (cov >= coverAt) {
|
|
4132
|
+
if (cov > bestCov || bestVia === 'anchor') { bestCov = cov; best = m; bestVia = 'coverage'; }
|
|
4133
|
+
continue;
|
|
4134
|
+
}
|
|
4135
|
+
// Anchor OR-path — the verbose-❓/terse-🏁 geometry (see
|
|
4136
|
+
// findFulfillmentCandidates). Coverage wins over anchors when both fire.
|
|
4137
|
+
if (bestVia !== 'coverage') {
|
|
4138
|
+
const anchors = sharedAnchors(oIdx, mIdx, dfMap(), { exclude: structuralStems(o.area, m.area) });
|
|
4139
|
+
if (anchorsSufficient(anchors, (o.area || '') === (m.area || ''), cov) && cov > bestCov) {
|
|
4140
|
+
bestCov = cov; best = m; bestVia = 'anchor';
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
3641
4143
|
}
|
|
3642
|
-
if (best
|
|
4144
|
+
if (best) out.push({ open: o, by: best, cov: Math.round(bestCov * 100) / 100, ...(bestVia === 'anchor' ? { via: 'anchor' } : {}) });
|
|
3643
4145
|
}
|
|
3644
4146
|
out.sort((a, b) => b.cov - a.cov);
|
|
3645
4147
|
return { gaps: out.slice(0, max), total: out.length };
|