linksee-memory 0.8.0 → 0.11.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 +923 -782
- package/dist/bin/guard-hook.d.ts +2 -0
- package/dist/bin/guard-hook.js +96 -0
- package/dist/bin/import-sessions.js +40 -7
- package/dist/bin/map-import.d.ts +2 -0
- package/dist/bin/map-import.js +493 -0
- package/dist/bin/setup.js +94 -5
- package/dist/db/migrate.js +36 -0
- package/dist/db/schema.sql +96 -2
- package/dist/lib/drift-detection.js +38 -2
- package/dist/lib/guard.d.ts +81 -0
- package/dist/lib/guard.js +321 -0
- package/dist/lib/lexical-match.d.ts +6 -0
- package/dist/lib/lexical-match.js +87 -0
- package/dist/lib/map-import.d.ts +55 -0
- package/dist/lib/map-import.js +150 -0
- package/dist/lib/map-reconcile.d.ts +29 -0
- package/dist/lib/map-reconcile.js +245 -0
- package/dist/lib/map-view.d.ts +103 -0
- package/dist/lib/map-view.js +201 -0
- package/dist/lib/session-extractor.d.ts +1 -0
- package/dist/lib/session-extractor.js +62 -11
- package/dist/mcp/server.js +455 -5
- package/dist/skill/SKILL.md +103 -0
- package/package.json +12 -5
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Lexical matching primitives — path-glob + violation-signal detection with precision guards.
|
|
2
|
+
//
|
|
3
|
+
// DUPLICATED VERBATIM from drift-detection.ts (parseArray / normPath / compileGlob / signalIndex /
|
|
4
|
+
// matchViolation / SCRAPE_ANCHOR). Kept standalone so the re-injection guard (guard.ts) reuses the
|
|
5
|
+
// EXACT same lexical logic as the post-hoc drift detector WITHOUT importing the detector (which carries
|
|
6
|
+
// unrelated state, and is mid-WIP). FOLLOW-UP: when drift-detection.ts's in-flight changes land, unify
|
|
7
|
+
// by having it import from here and deleting its private copies. No embedding layer in this stack:
|
|
8
|
+
// matching is substring + path-glob + word-boundary by construction.
|
|
9
|
+
export function parseArray(s) {
|
|
10
|
+
if (!s)
|
|
11
|
+
return [];
|
|
12
|
+
try {
|
|
13
|
+
const a = JSON.parse(s);
|
|
14
|
+
return Array.isArray(a) ? a.map((x) => String(x)) : [];
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// Normalize a path/glob to canonical form (forward slashes, lowercase) so Windows reality
|
|
21
|
+
// (mixed `C:\...` and `C:/...`, mixed case) matches lowercase forward-slash anchor fragments.
|
|
22
|
+
export function normPath(p) {
|
|
23
|
+
return p.replace(/\\/g, '/').toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
// Compile an affects glob into a matcher over a normalized path. A glob with no wildcard is a plain
|
|
26
|
+
// substring; `*` matches within a path segment, `**` crosses segments; tested unanchored so a fragment
|
|
27
|
+
// matches anywhere in the absolute path.
|
|
28
|
+
export function compileGlob(glob) {
|
|
29
|
+
const g = normPath(glob.trim());
|
|
30
|
+
if (!g)
|
|
31
|
+
return () => false;
|
|
32
|
+
if (!/[*?]/.test(g)) {
|
|
33
|
+
return (path) => path.includes(g);
|
|
34
|
+
}
|
|
35
|
+
let re = '';
|
|
36
|
+
for (let i = 0; i < g.length; i++) {
|
|
37
|
+
const c = g[i];
|
|
38
|
+
if (c === '*') {
|
|
39
|
+
if (g[i + 1] === '*') {
|
|
40
|
+
re += '.*';
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
re += '[^/]*';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (c === '?') {
|
|
48
|
+
re += '[^/]';
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const rx = new RegExp(re);
|
|
55
|
+
return (path) => rx.test(path);
|
|
56
|
+
}
|
|
57
|
+
// ── precision guards (mirror drift-detection.ts §"precision guards") ───────────
|
|
58
|
+
// A raw substring of a forbidden term is too weak a notion of "violation"; reject three FP classes
|
|
59
|
+
// deductively: 1. sub-token ("cp" in "scp") → word-boundary; 2. negated ("do NOT cp") → negation
|
|
60
|
+
// window before; 3. citation (a scrape-host appears only as a stored value) → require a real net call.
|
|
61
|
+
const NEGATION_NEAR = /\b(?:not|never|no\s+longer|don'?t|do\s+not|avoid|without)\b|禁止|しない|させない|不可|避け|してはいけない|ではなく/i;
|
|
62
|
+
export const SCRAPE_ANCHOR = /crawl|scrap|robots|クロール|スクレイピング|スクレイプ|自動収集|自動巡回/i;
|
|
63
|
+
const NET_CALL = /\b(?:fetch|axios|requests?|urllib|httpx|curl|wget|got|puppeteer|playwright|cheerio|beautifulsoup|selenium|crawl|scrape|scraping)\b|クロール|スクレイピング/i;
|
|
64
|
+
// Locate a signal: ASCII signals must hit on a word boundary; CJK (no boundaries) stays substring.
|
|
65
|
+
export function signalIndex(lowerLine, signal) {
|
|
66
|
+
if (/^[\x20-\x7e]+$/.test(signal)) {
|
|
67
|
+
const esc = signal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
68
|
+
const m = new RegExp(`(?<![a-z0-9_])${esc}(?![a-z0-9_])`).exec(lowerLine);
|
|
69
|
+
return m ? m.index : -1;
|
|
70
|
+
}
|
|
71
|
+
return lowerLine.indexOf(signal);
|
|
72
|
+
}
|
|
73
|
+
// Deductive violation match with the three precision guards applied (returns the hit term or null).
|
|
74
|
+
export function matchViolation(rawLine, lowerLine, signals, isScrapeAnchor) {
|
|
75
|
+
for (const s of signals) {
|
|
76
|
+
const idx = signalIndex(lowerLine, s);
|
|
77
|
+
if (idx < 0)
|
|
78
|
+
continue; // 1. word-boundary
|
|
79
|
+
if (NEGATION_NEAR.test(rawLine.slice(Math.max(0, idx - 24), idx)))
|
|
80
|
+
continue; // 2. negated context
|
|
81
|
+
if (isScrapeAnchor && !NET_CALL.test(rawLine))
|
|
82
|
+
continue; // 3. citation, no net call
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=lexical-match.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export declare const EDGE_TYPES: Set<string>;
|
|
3
|
+
export declare const EDGE_STRENGTHS: Set<string>;
|
|
4
|
+
export declare const NODE_LAYERS: Set<string>;
|
|
5
|
+
export declare const NODE_STATUSES: Set<string>;
|
|
6
|
+
export interface MapNode {
|
|
7
|
+
id: string;
|
|
8
|
+
layer: string;
|
|
9
|
+
stage?: string;
|
|
10
|
+
statement: string;
|
|
11
|
+
status?: string;
|
|
12
|
+
facets?: string[];
|
|
13
|
+
role?: string;
|
|
14
|
+
note?: string;
|
|
15
|
+
due?: string;
|
|
16
|
+
paused_reason?: string;
|
|
17
|
+
related_project?: string;
|
|
18
|
+
spinout_candidate?: boolean;
|
|
19
|
+
anchor?: number;
|
|
20
|
+
review_by?: string;
|
|
21
|
+
revival_condition?: string;
|
|
22
|
+
reality?: Record<string, unknown>;
|
|
23
|
+
[k: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
export interface MapEdge {
|
|
26
|
+
from: string;
|
|
27
|
+
to: string;
|
|
28
|
+
type: string;
|
|
29
|
+
strength?: string;
|
|
30
|
+
note?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface ParsedMap {
|
|
33
|
+
project: string;
|
|
34
|
+
template?: string;
|
|
35
|
+
product_status?: string;
|
|
36
|
+
job?: string;
|
|
37
|
+
audience?: Record<string, unknown>;
|
|
38
|
+
related_projects?: unknown[];
|
|
39
|
+
stages: Array<{
|
|
40
|
+
id: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
}>;
|
|
43
|
+
nodes: MapNode[];
|
|
44
|
+
edges: MapEdge[];
|
|
45
|
+
}
|
|
46
|
+
export declare function parseMapFile(path: string): ParsedMap;
|
|
47
|
+
export interface ImportResult {
|
|
48
|
+
project: string;
|
|
49
|
+
nodes: number;
|
|
50
|
+
edges: number;
|
|
51
|
+
linked_anchors: number;
|
|
52
|
+
warnings: string[];
|
|
53
|
+
}
|
|
54
|
+
export declare function importMap(db: Database.Database, map: ParsedMap): ImportResult;
|
|
55
|
+
export declare function importMapFile(db: Database.Database, path: string): ImportResult;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
3
|
+
export const EDGE_TYPES = new Set(['realizes', 'supports', 'must-stay-consistent-with', 'should-align-with', 'mentions', 'reflux']);
|
|
4
|
+
export const EDGE_STRENGTHS = new Set(['hard', 'soft', 'watch']);
|
|
5
|
+
export const NODE_LAYERS = new Set(['surface', 'implementation']);
|
|
6
|
+
// Recognized statuses — anything else imports but is flagged as a warning (so a
|
|
7
|
+
// typo can't silently become a phantom lifecycle the dashboard renders blank).
|
|
8
|
+
export const NODE_STATUSES = new Set([
|
|
9
|
+
'active', 'experiment', 'commitment', 'planned', 'paused', 'suspect', 'future_thesis',
|
|
10
|
+
]);
|
|
11
|
+
const KNOWN_NODE_COLS = new Set([
|
|
12
|
+
'id', 'layer', 'stage', 'statement', 'status', 'facets', 'role', 'note',
|
|
13
|
+
'due', 'paused_reason', 'related_project', 'spinout_candidate', 'anchor', 'reality',
|
|
14
|
+
'review_by', 'revival_condition',
|
|
15
|
+
]);
|
|
16
|
+
export function parseMapFile(path) {
|
|
17
|
+
const raw = parseYaml(readFileSync(path, 'utf8'));
|
|
18
|
+
if (!raw || typeof raw !== 'object')
|
|
19
|
+
throw new Error(`map.yaml at ${path} parsed to nothing`);
|
|
20
|
+
if (!raw.project)
|
|
21
|
+
throw new Error('map.yaml missing required top-level `project`');
|
|
22
|
+
if (!Array.isArray(raw.stages) || raw.stages.length === 0)
|
|
23
|
+
throw new Error('map.yaml needs a non-empty `stages` spine');
|
|
24
|
+
if (!Array.isArray(raw.nodes))
|
|
25
|
+
throw new Error('map.yaml `nodes` must be a list');
|
|
26
|
+
return {
|
|
27
|
+
project: String(raw.project),
|
|
28
|
+
template: raw.template ? String(raw.template) : undefined,
|
|
29
|
+
product_status: raw.product_status ? String(raw.product_status) : undefined,
|
|
30
|
+
job: raw.job ? String(raw.job) : undefined,
|
|
31
|
+
audience: raw.audience ?? undefined,
|
|
32
|
+
related_projects: Array.isArray(raw.related_projects) ? raw.related_projects : undefined,
|
|
33
|
+
stages: raw.stages.map((s) => ({ id: String(s.id), label: s.label ? String(s.label) : undefined })),
|
|
34
|
+
nodes: raw.nodes ?? [],
|
|
35
|
+
edges: Array.isArray(raw.edges) ? raw.edges : [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Full-rebuild import inside one transaction. Validates topology and collects
|
|
39
|
+
// warnings (dangling edge endpoints, unknown stage/status) WITHOUT aborting — a
|
|
40
|
+
// map mid-edit should still load so the dashboard can show what IS there.
|
|
41
|
+
export function importMap(db, map) {
|
|
42
|
+
const warnings = [];
|
|
43
|
+
const stageIds = new Set(map.stages.map((s) => s.id));
|
|
44
|
+
const nodeIds = new Set(map.nodes.map((n) => n.id));
|
|
45
|
+
// validate nodes
|
|
46
|
+
for (const n of map.nodes) {
|
|
47
|
+
if (!n.id) {
|
|
48
|
+
warnings.push('node with no id skipped');
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!NODE_LAYERS.has(n.layer))
|
|
52
|
+
warnings.push(`node ${n.id}: unknown layer "${n.layer}"`);
|
|
53
|
+
if (n.layer === 'surface' && n.stage && !stageIds.has(n.stage))
|
|
54
|
+
warnings.push(`node ${n.id}: stage "${n.stage}" not in spine`);
|
|
55
|
+
if (n.layer === 'surface' && !n.stage)
|
|
56
|
+
warnings.push(`node ${n.id}: surface node has no stage`);
|
|
57
|
+
if (n.status && !NODE_STATUSES.has(n.status))
|
|
58
|
+
warnings.push(`node ${n.id}: unrecognized status "${n.status}"`);
|
|
59
|
+
}
|
|
60
|
+
// validate edges
|
|
61
|
+
for (const e of map.edges) {
|
|
62
|
+
if (!EDGE_TYPES.has(e.type))
|
|
63
|
+
warnings.push(`edge ${e.from}→${e.to}: unknown type "${e.type}"`);
|
|
64
|
+
if (e.strength && !EDGE_STRENGTHS.has(e.strength))
|
|
65
|
+
warnings.push(`edge ${e.from}→${e.to}: unknown strength "${e.strength}"`);
|
|
66
|
+
if (!nodeIds.has(e.from))
|
|
67
|
+
warnings.push(`edge endpoint "${e.from}" is not a node (dangling)`);
|
|
68
|
+
if (!nodeIds.has(e.to))
|
|
69
|
+
warnings.push(`edge endpoint "${e.to}" is not a node (dangling)`);
|
|
70
|
+
}
|
|
71
|
+
const anchorExists = db.prepare('SELECT 1 FROM drift_anchors WHERE id = ?');
|
|
72
|
+
let linked = 0;
|
|
73
|
+
const tx = db.transaction(() => {
|
|
74
|
+
db.prepare(`
|
|
75
|
+
INSERT INTO map_projects (project, job, audience, product_status, template, stages, related_projects, updated_at)
|
|
76
|
+
VALUES (@project, @job, @audience, @product_status, @template, @stages, @related_projects, unixepoch())
|
|
77
|
+
ON CONFLICT(project) DO UPDATE SET
|
|
78
|
+
job=excluded.job, audience=excluded.audience, product_status=excluded.product_status,
|
|
79
|
+
template=excluded.template, stages=excluded.stages, related_projects=excluded.related_projects,
|
|
80
|
+
updated_at=unixepoch()
|
|
81
|
+
`).run({
|
|
82
|
+
project: map.project,
|
|
83
|
+
job: map.job ?? null,
|
|
84
|
+
audience: JSON.stringify(map.audience ?? {}),
|
|
85
|
+
product_status: map.product_status ?? null,
|
|
86
|
+
template: map.template ?? null,
|
|
87
|
+
stages: JSON.stringify(map.stages),
|
|
88
|
+
related_projects: JSON.stringify(map.related_projects ?? []),
|
|
89
|
+
});
|
|
90
|
+
db.prepare('DELETE FROM map_edges WHERE project = ?').run(map.project);
|
|
91
|
+
db.prepare('DELETE FROM map_nodes WHERE project = ?').run(map.project);
|
|
92
|
+
const insNode = db.prepare(`
|
|
93
|
+
INSERT INTO map_nodes
|
|
94
|
+
(id, project, layer, stage, statement, status, facets, role, note, due,
|
|
95
|
+
paused_reason, related_project, spinout_candidate, anchor_id, review_by, revival_condition, reality, extra, updated_at)
|
|
96
|
+
VALUES
|
|
97
|
+
(@id, @project, @layer, @stage, @statement, @status, @facets, @role, @note, @due,
|
|
98
|
+
@paused_reason, @related_project, @spinout_candidate, @anchor_id, @review_by, @revival_condition, @reality, @extra, unixepoch())
|
|
99
|
+
`);
|
|
100
|
+
for (const n of map.nodes) {
|
|
101
|
+
if (!n.id)
|
|
102
|
+
continue;
|
|
103
|
+
let anchorId = null;
|
|
104
|
+
if (n.anchor != null) {
|
|
105
|
+
if (anchorExists.get(n.anchor)) {
|
|
106
|
+
anchorId = Number(n.anchor);
|
|
107
|
+
linked++;
|
|
108
|
+
}
|
|
109
|
+
else
|
|
110
|
+
warnings.push(`node ${n.id}: anchor #${n.anchor} not found — link skipped`);
|
|
111
|
+
}
|
|
112
|
+
const extra = {};
|
|
113
|
+
for (const k of Object.keys(n))
|
|
114
|
+
if (!KNOWN_NODE_COLS.has(k))
|
|
115
|
+
extra[k] = n[k];
|
|
116
|
+
insNode.run({
|
|
117
|
+
id: n.id,
|
|
118
|
+
project: map.project,
|
|
119
|
+
layer: n.layer ?? 'surface',
|
|
120
|
+
stage: n.stage ?? null,
|
|
121
|
+
statement: n.statement ?? '',
|
|
122
|
+
status: n.status ?? 'active',
|
|
123
|
+
facets: JSON.stringify(n.facets ?? []),
|
|
124
|
+
role: n.role ?? null,
|
|
125
|
+
note: n.note ?? null,
|
|
126
|
+
due: n.due ?? null,
|
|
127
|
+
paused_reason: n.paused_reason ?? null,
|
|
128
|
+
related_project: n.related_project ?? null,
|
|
129
|
+
spinout_candidate: n.spinout_candidate ? 1 : 0,
|
|
130
|
+
anchor_id: anchorId,
|
|
131
|
+
review_by: n.review_by ?? null,
|
|
132
|
+
revival_condition: n.revival_condition ?? null,
|
|
133
|
+
reality: JSON.stringify(n.reality ?? {}),
|
|
134
|
+
extra: JSON.stringify(extra),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const insEdge = db.prepare(`
|
|
138
|
+
INSERT OR IGNORE INTO map_edges (project, from_id, to_id, type, strength, note)
|
|
139
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
140
|
+
`);
|
|
141
|
+
for (const e of map.edges)
|
|
142
|
+
insEdge.run(map.project, e.from, e.to, e.type, e.strength ?? null, e.note ?? null);
|
|
143
|
+
});
|
|
144
|
+
tx();
|
|
145
|
+
return { project: map.project, nodes: map.nodes.filter((n) => n.id).length, edges: map.edges.length, linked_anchors: linked, warnings };
|
|
146
|
+
}
|
|
147
|
+
export function importMapFile(db, path) {
|
|
148
|
+
return importMap(db, parseMapFile(path));
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=map-import.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export type Verdict = 'convergence' | 'divergence' | 'absence';
|
|
3
|
+
export interface CheckResult {
|
|
4
|
+
claim: string;
|
|
5
|
+
ok: boolean;
|
|
6
|
+
file: string | null;
|
|
7
|
+
line: number | null;
|
|
8
|
+
detail: string;
|
|
9
|
+
}
|
|
10
|
+
export interface NodeVerdict {
|
|
11
|
+
id: string;
|
|
12
|
+
status: string;
|
|
13
|
+
verdict: Verdict | null;
|
|
14
|
+
reason: string;
|
|
15
|
+
why: string | null;
|
|
16
|
+
fix: string[];
|
|
17
|
+
checks: CheckResult[];
|
|
18
|
+
evidence: Record<string, unknown>;
|
|
19
|
+
flipped: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface ReconcileResult {
|
|
22
|
+
project: string;
|
|
23
|
+
checked: number;
|
|
24
|
+
external: number;
|
|
25
|
+
verdicts: NodeVerdict[];
|
|
26
|
+
refuted: NodeVerdict[];
|
|
27
|
+
confirmed: NodeVerdict[];
|
|
28
|
+
}
|
|
29
|
+
export declare function reconcile(db: Database.Database, project: string, repoRoot: string): ReconcileResult;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, extname } from 'node:path';
|
|
3
|
+
const CODE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.sql', '.py', '.go', '.rs', '.java', '.rb', '.php', '.sh']);
|
|
4
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.next', 'coverage', 'build']);
|
|
5
|
+
const COMMENT_PREFIXES = ['//', '*', '/*', '#', '<!--', '--'];
|
|
6
|
+
const MAX_FILES = 4000;
|
|
7
|
+
function signalHit(line, signals, skipComments) {
|
|
8
|
+
const trimmed = line.trim();
|
|
9
|
+
if (!trimmed)
|
|
10
|
+
return null;
|
|
11
|
+
// For CODE scans, a comment MENTIONS a term but doesn't realize it — skip. For DOC
|
|
12
|
+
// scans (README etc.) the content IS in those lines, so don't skip.
|
|
13
|
+
if (skipComments && COMMENT_PREFIXES.some((p) => trimmed.startsWith(p)))
|
|
14
|
+
return null;
|
|
15
|
+
const lower = trimmed.toLowerCase();
|
|
16
|
+
for (const s of signals) {
|
|
17
|
+
const sl = s.toLowerCase();
|
|
18
|
+
if (/^[\x20-\x7e]+$/.test(sl)) {
|
|
19
|
+
const esc = sl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
|
+
if (new RegExp(`(?<![a-z0-9_])${esc}(?![a-z0-9_])`).test(lower))
|
|
21
|
+
return s;
|
|
22
|
+
}
|
|
23
|
+
else if (lower.includes(sl))
|
|
24
|
+
return s;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
// Scan a single file (any type; e.g. README.md) for the first signal match.
|
|
29
|
+
function scanFileForSignal(file, signals, skipComments) {
|
|
30
|
+
return scanFile(file, (line) => signalHit(line, signals, skipComments));
|
|
31
|
+
}
|
|
32
|
+
// Scan a single file for the first regex match (meaning-aware: "where_am_i\s+.*(tool|MCP)").
|
|
33
|
+
function scanFileForRegex(file, re, skipComments) {
|
|
34
|
+
return scanFile(file, (line) => {
|
|
35
|
+
const trimmed = line.trim();
|
|
36
|
+
if (!trimmed)
|
|
37
|
+
return null;
|
|
38
|
+
if (skipComments && COMMENT_PREFIXES.some((p) => trimmed.startsWith(p)))
|
|
39
|
+
return null;
|
|
40
|
+
const m = re.exec(trimmed);
|
|
41
|
+
return m ? m[0].slice(0, 60) : null;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function scanFile(file, match) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = readFileSync(file, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const lines = text.split('\n');
|
|
53
|
+
for (let i = 0; i < lines.length; i++) {
|
|
54
|
+
const hit = match(lines[i]);
|
|
55
|
+
if (hit)
|
|
56
|
+
return { file, line_no: i + 1, line_text: lines[i].trim().slice(0, 200), hit };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
// Walk a code subtree, applying a per-file matcher (comments skipped). Returns first hit or null.
|
|
61
|
+
function scanDir(root, perFile) {
|
|
62
|
+
let scanned = 0;
|
|
63
|
+
const stack = [root];
|
|
64
|
+
while (stack.length) {
|
|
65
|
+
const dir = stack.pop();
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = readdirSync(dir);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const name of entries) {
|
|
74
|
+
const full = join(dir, name);
|
|
75
|
+
let st;
|
|
76
|
+
try {
|
|
77
|
+
st = statSync(full);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (st.isDirectory()) {
|
|
83
|
+
if (!SKIP_DIRS.has(name))
|
|
84
|
+
stack.push(full);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!CODE_EXT.has(extname(name)))
|
|
88
|
+
continue;
|
|
89
|
+
if (++scanned > MAX_FILES)
|
|
90
|
+
return null;
|
|
91
|
+
const hit = perFile(full);
|
|
92
|
+
if (hit)
|
|
93
|
+
return hit;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const scanForSignal = (root, signals) => scanDir(root, (f) => scanFileForSignal(f, signals, true));
|
|
99
|
+
const scanForRegex = (root, re) => scanDir(root, (f) => scanFileForRegex(f, re, true));
|
|
100
|
+
// Scan ONLY within a named markdown section (header containing `section`, until the next
|
|
101
|
+
// same-or-higher header). Beats a bare substring: "where_am_i" must be IN the Tools section,
|
|
102
|
+
// not merely somewhere in the file (e.g. next to "未対応です").
|
|
103
|
+
function scanSection(file, section, signals) {
|
|
104
|
+
let text;
|
|
105
|
+
try {
|
|
106
|
+
text = readFileSync(file, 'utf8');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const lines = text.split('\n');
|
|
112
|
+
const want = section.toLowerCase();
|
|
113
|
+
let start = -1, level = 0;
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
const m = /^(#{1,6})\s+(.*)$/.exec(lines[i].trim());
|
|
116
|
+
if (m && m[2].toLowerCase().includes(want)) {
|
|
117
|
+
start = i;
|
|
118
|
+
level = m[1].length;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (start < 0)
|
|
123
|
+
return null; // section not found at all
|
|
124
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
125
|
+
const h = /^(#{1,6})\s+/.exec(lines[i].trim());
|
|
126
|
+
if (h && h[1].length <= level)
|
|
127
|
+
break; // reached the end of this section
|
|
128
|
+
const hit = signalHit(lines[i], signals, false);
|
|
129
|
+
if (hit)
|
|
130
|
+
return { file, line_no: i + 1, line_text: lines[i].trim().slice(0, 200), hit };
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
// Run ONE check → ✓/✗ with evidence. path-targeted scans don't skip comments (docs).
|
|
135
|
+
function runCheck(check, repoRoot) {
|
|
136
|
+
if (check.kind === 'file_present' || check.kind === 'file_absent') {
|
|
137
|
+
const present = check.path ? existsSync(join(repoRoot, check.path)) : false;
|
|
138
|
+
const ok = check.kind === 'file_present' ? present : !present;
|
|
139
|
+
return { claim: check.claim, ok, file: check.path ?? null, line: null, detail: present ? 'file present' : 'file absent' };
|
|
140
|
+
}
|
|
141
|
+
// regex check — meaning-aware ("where_am_i\s+.*(tool|MCP)"), beats a bare substring
|
|
142
|
+
if (check.kind === 'regex_present' || check.kind === 'regex_absent') {
|
|
143
|
+
let re;
|
|
144
|
+
try {
|
|
145
|
+
re = new RegExp(check.pattern ?? '', 'i');
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { claim: check.claim, ok: false, file: null, line: null, detail: `bad regex: ${check.pattern}` };
|
|
149
|
+
}
|
|
150
|
+
const hit = check.path ? scanFileForRegex(join(repoRoot, check.path), re, false) : scanForRegex(join(repoRoot, check.dir ?? '.'), re);
|
|
151
|
+
const found = hit != null;
|
|
152
|
+
const ok = check.kind === 'regex_present' ? found : !found;
|
|
153
|
+
return {
|
|
154
|
+
claim: check.claim, ok, file: hit ? hit.file : (check.path ?? check.dir ?? null), line: hit ? hit.line_no : null,
|
|
155
|
+
detail: found ? `matched /${check.pattern}/` : `/${check.pattern}/ not matched`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const signals = (check.signal ?? []).filter(Boolean);
|
|
159
|
+
// section_contains — the signal must appear inside a named markdown section
|
|
160
|
+
if (check.kind === 'section_contains') {
|
|
161
|
+
const hit = check.path ? scanSection(join(repoRoot, check.path), check.section ?? '', signals) : null;
|
|
162
|
+
return {
|
|
163
|
+
claim: check.claim, ok: hit != null, file: hit ? hit.file : (check.path ?? null), line: hit ? hit.line_no : null,
|
|
164
|
+
detail: hit ? `found "${hit.hit}" in section "${check.section}"` : `"${signals.join(', ')}" not in section "${check.section}"`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// signal scan: a single path (doc, no comment-skip) or a code subtree (comment-skip)
|
|
168
|
+
const hit = check.path
|
|
169
|
+
? scanFileForSignal(join(repoRoot, check.path), signals, false)
|
|
170
|
+
: scanForSignal(join(repoRoot, check.dir ?? '.'), signals);
|
|
171
|
+
const found = hit != null;
|
|
172
|
+
const ok = check.kind === 'signal_present' ? found : !found;
|
|
173
|
+
return {
|
|
174
|
+
claim: check.claim, ok,
|
|
175
|
+
file: hit ? hit.file : (check.path ?? check.dir ?? null), line: hit ? hit.line_no : null,
|
|
176
|
+
detail: found ? `found "${hit.hit}"` : `"${signals.join(', ')}" not found`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function evaluate(reality, repoRoot) {
|
|
180
|
+
if (!reality.kind && !reality.checks) {
|
|
181
|
+
return { verdict: null, reason: 'no reality declaration — stays human-declared', why: null, fix: [], checks: [], expected: null, check: null };
|
|
182
|
+
}
|
|
183
|
+
if (reality.kind === 'external') {
|
|
184
|
+
return {
|
|
185
|
+
verdict: null, reason: 'external state — not locally verifiable; human-confirmed',
|
|
186
|
+
why: reality.why ?? null, fix: reality.fix ?? [], checks: [],
|
|
187
|
+
expected: reality.expected ?? null, check: reality.check ?? null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// Multiple named checks, or one check from the single-kind shorthand (back-compat).
|
|
191
|
+
const checks = reality.checks ?? [{
|
|
192
|
+
claim: reality.kind === 'signal_present' ? `code implements: ${(reality.signal ?? []).join(', ')}`
|
|
193
|
+
: reality.kind === 'signal_absent' ? `forbidden absent: ${(reality.signal ?? []).join(', ')}`
|
|
194
|
+
: reality.kind === 'file_present' ? `file present: ${reality.path}`
|
|
195
|
+
: `file absent: ${reality.path}`,
|
|
196
|
+
kind: reality.kind, dir: reality.dir, path: reality.path, signal: reality.signal,
|
|
197
|
+
}];
|
|
198
|
+
const results = checks.map((c) => runCheck(c, repoRoot));
|
|
199
|
+
const failed = results.filter((r) => !r.ok);
|
|
200
|
+
const verdict = failed.length === 0 ? 'convergence' : 'divergence';
|
|
201
|
+
const why = reality.why ?? (failed.length === 0
|
|
202
|
+
? 'all checks pass — reality matches the declaration'
|
|
203
|
+
: `${failed.length} check(s) fail: ${failed.map((f) => f.claim).join('; ')}`);
|
|
204
|
+
const reason = failed.length === 0 ? `verified: ${results.length} check(s) pass` : `${failed.length}/${results.length} checks failed`;
|
|
205
|
+
return { verdict, reason, why, fix: reality.fix ?? [], checks: results, expected: null, check: null };
|
|
206
|
+
}
|
|
207
|
+
export function reconcile(db, project, repoRoot) {
|
|
208
|
+
const rows = db.prepare('SELECT id, status, reality FROM map_nodes WHERE project = ?').all(project);
|
|
209
|
+
const upd = db.prepare('UPDATE map_nodes SET live_verdict = ?, verdict_evidence = ?, reconciled_at = unixepoch() WHERE project = ? AND id = ?');
|
|
210
|
+
const verdicts = [];
|
|
211
|
+
let checked = 0, external = 0;
|
|
212
|
+
const tx = db.transaction(() => {
|
|
213
|
+
for (const r of rows) {
|
|
214
|
+
let reality = {};
|
|
215
|
+
try {
|
|
216
|
+
reality = JSON.parse(r.reality || '{}');
|
|
217
|
+
}
|
|
218
|
+
catch { /* malformed → treat as none */ }
|
|
219
|
+
const { verdict, reason, why, fix, checks, expected, check } = evaluate(reality, repoRoot);
|
|
220
|
+
const firstShown = checks.find((c) => !c.ok) ?? checks[0];
|
|
221
|
+
const evidence = {
|
|
222
|
+
reason, why, fix, checks, expected, check,
|
|
223
|
+
file: firstShown?.file ?? null, line_no: firstShown?.line ?? null, // compact convenience for the dashboard
|
|
224
|
+
};
|
|
225
|
+
if (verdict === null) {
|
|
226
|
+
external++;
|
|
227
|
+
upd.run(null, JSON.stringify(evidence), project, r.id);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
checked++;
|
|
231
|
+
upd.run(verdict, JSON.stringify(evidence), project, r.id);
|
|
232
|
+
}
|
|
233
|
+
// "flipped" = hand-declared suspect that reality contradicts (suspect→convergence),
|
|
234
|
+
// or a non-suspect that reality flags (→divergence).
|
|
235
|
+
const flipped = (r.status === 'suspect' && verdict === 'convergence')
|
|
236
|
+
|| (r.status !== 'suspect' && verdict === 'divergence');
|
|
237
|
+
verdicts.push({ id: r.id, status: r.status, verdict, reason, why, fix, checks, evidence, flipped });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
tx();
|
|
241
|
+
const refuted = verdicts.filter((v) => v.status === 'suspect' && v.verdict === 'convergence');
|
|
242
|
+
const confirmed = verdicts.filter((v) => v.status === 'suspect' && (v.verdict === 'divergence' || v.verdict === 'absence'));
|
|
243
|
+
return { project, checked, external, verdicts, refuted, confirmed };
|
|
244
|
+
}
|
|
245
|
+
//# sourceMappingURL=map-reconcile.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export interface NodeRow {
|
|
3
|
+
id: string;
|
|
4
|
+
project: string;
|
|
5
|
+
layer: string;
|
|
6
|
+
stage: string | null;
|
|
7
|
+
statement: string;
|
|
8
|
+
status: string;
|
|
9
|
+
facets: string;
|
|
10
|
+
role: string | null;
|
|
11
|
+
note: string | null;
|
|
12
|
+
due: string | null;
|
|
13
|
+
paused_reason: string | null;
|
|
14
|
+
related_project: string | null;
|
|
15
|
+
spinout_candidate: number;
|
|
16
|
+
anchor_id: number | null;
|
|
17
|
+
live_verdict?: string | null;
|
|
18
|
+
verdict_evidence?: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface EdgeRow {
|
|
21
|
+
from_id: string;
|
|
22
|
+
to_id: string;
|
|
23
|
+
type: string;
|
|
24
|
+
strength: string | null;
|
|
25
|
+
note: string | null;
|
|
26
|
+
}
|
|
27
|
+
export type EdgeStrength = 'hard' | 'soft' | 'watch';
|
|
28
|
+
export declare const edgeStrength: (e: {
|
|
29
|
+
type: string;
|
|
30
|
+
strength?: string | null;
|
|
31
|
+
}) => EdgeStrength;
|
|
32
|
+
export type StatusColor = 'green' | 'red' | 'gray' | 'amber' | 'blue';
|
|
33
|
+
export declare function statusColor(status: string): StatusColor;
|
|
34
|
+
export declare function verdictColor(verdict: string): StatusColor;
|
|
35
|
+
export declare function nodeColor(n: {
|
|
36
|
+
status: string;
|
|
37
|
+
live_verdict?: string | null;
|
|
38
|
+
}): StatusColor;
|
|
39
|
+
export interface ProjectMeta {
|
|
40
|
+
project: string;
|
|
41
|
+
job: string | null;
|
|
42
|
+
audience: Record<string, unknown>;
|
|
43
|
+
product_status: string | null;
|
|
44
|
+
template: string | null;
|
|
45
|
+
stages: Array<{
|
|
46
|
+
id: string;
|
|
47
|
+
label?: string;
|
|
48
|
+
}>;
|
|
49
|
+
related_projects: unknown[];
|
|
50
|
+
}
|
|
51
|
+
export declare function getProjectMeta(db: Database.Database, project: string): ProjectMeta | undefined;
|
|
52
|
+
export declare function listMapProjects(db: Database.Database): string[];
|
|
53
|
+
export declare function getNode(db: Database.Database, project: string, id: string): NodeRow | undefined;
|
|
54
|
+
export declare function getSuspects(db: Database.Database, project: string): NodeRow[];
|
|
55
|
+
export interface BlastHit {
|
|
56
|
+
id: string;
|
|
57
|
+
statement: string;
|
|
58
|
+
status: string;
|
|
59
|
+
via: string;
|
|
60
|
+
relation: string;
|
|
61
|
+
strength: EdgeStrength;
|
|
62
|
+
}
|
|
63
|
+
export declare function blastRadius(db: Database.Database, project: string, id: string): BlastHit[];
|
|
64
|
+
export interface WhereAmIMatch {
|
|
65
|
+
node: NodeRow;
|
|
66
|
+
stage_label: string | null;
|
|
67
|
+
match_reason: string;
|
|
68
|
+
blast: BlastHit[];
|
|
69
|
+
anchor: {
|
|
70
|
+
id: number;
|
|
71
|
+
statement: string;
|
|
72
|
+
} | null;
|
|
73
|
+
}
|
|
74
|
+
export interface WhereAmIResult {
|
|
75
|
+
project: string;
|
|
76
|
+
job: string | null;
|
|
77
|
+
matched: WhereAmIMatch[];
|
|
78
|
+
}
|
|
79
|
+
export declare function whereAmI(db: Database.Database, opts: {
|
|
80
|
+
project?: string;
|
|
81
|
+
query?: string;
|
|
82
|
+
node_id?: string;
|
|
83
|
+
limit?: number;
|
|
84
|
+
}): WhereAmIResult;
|
|
85
|
+
export interface BlueprintCell {
|
|
86
|
+
stage: string;
|
|
87
|
+
label: string;
|
|
88
|
+
nodes: Array<NodeRow & {
|
|
89
|
+
color: StatusColor;
|
|
90
|
+
}>;
|
|
91
|
+
}
|
|
92
|
+
export interface Blueprint {
|
|
93
|
+
project: string;
|
|
94
|
+
stages: BlueprintCell[];
|
|
95
|
+
implementation: Array<NodeRow & {
|
|
96
|
+
color: StatusColor;
|
|
97
|
+
}>;
|
|
98
|
+
counts: Record<string, number>;
|
|
99
|
+
}
|
|
100
|
+
export declare function getBlueprint(db: Database.Database, project: string, stageOrder?: Array<{
|
|
101
|
+
id: string;
|
|
102
|
+
label?: string;
|
|
103
|
+
}>): Blueprint;
|