@unpolarize/knowledge-planning 0.1.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 ADDED
@@ -0,0 +1,92 @@
1
+ # @unpolarize/knowledge-planning
2
+
3
+ A durable, git-backed, **life-wide** planning system — software, career, finances, family,
4
+ research — over a markdown SSOT. Combines Nimbalyst (plans/tracker/session-kanban) and Vibe
5
+ Kanban (card → agent execution) patterns, wired to the `~/docs` knowledge base (`blocked_by`
6
+ edges) and the portable `~/.sessions` corpus (`planning_refs`).
7
+
8
+ Implements the design in `~/docs/knowledge/tech/agents/knowledge-planning-design.md`.
9
+
10
+ ## Why
11
+
12
+ Planning was scattered across a Google Doc, `notes.md`, and `tasks.md`, with no durable,
13
+ queryable, session-linked home for ideas/plans/daily work. This makes the *work that agent
14
+ sessions advance* first-class, in git, addressable by stable id.
15
+
16
+ ## Data / code split
17
+
18
+ This repo is **tool source only**. The planning **data** (the SSOT) lives in the knowledge base
19
+ at **`~/docs/planning`**, and execution **sessions** live in **`~/.sessions`** (separate git repos).
20
+ The CLI defaults its store root to `~/docs/planning` (override with `--root` / `$KP_ROOT`).
21
+
22
+ ```
23
+ ~/projects/unpolarize/knowledge-planning ← THIS repo (code)
24
+ src/
25
+ schema/ # Zod entities + frontmatter parse/serialize
26
+ store/ # markdown read/write, edges
27
+ index/ # node:sqlite projection (objects, edges, fts, blocked-by, daily board)
28
+ graph/ # GraphData for the CSV webview
29
+ kb-bridge/ # resolve KnowledgeRef → ~/docs; decision detection
30
+ migrate/ # Google Doc / notes.md bullets → seed objects
31
+ cli/ # the `kp` command
32
+ scripts/seed.ts # reproducible dogfood seed (writes to ~/docs/planning)
33
+
34
+ ~/docs/planning ← the DATA store (SSOT, in the KB)
35
+ config.json # domains, kb_root (~/docs), sessions_root (~/.sessions)
36
+ domains/ catalog/ projects/ ideas/ plans/ tasks/ daily/ reflections/ insights/
37
+ edges.jsonl # explicit edges (append-only; rebuildable from frontmatter)
38
+ .index/planning.db # node:sqlite projection (gitignored, rebuilt by `kp reindex`)
39
+ ```
40
+
41
+ ## Entities
42
+
43
+ `Domain` · `Project` (wraps projects-catalog + KB folder) · `CatalogEntry` (non-repo pursuit) ·
44
+ `Idea` (capture→refine→accepted→…) · `Plan` (speckit dev / goal+steps life) · `Task` (kanban
45
+ lanes) · `DailyPlan` · `Reflection` (human) · `Insight` (AI). Edges via frontmatter
46
+ (`blocked_by`, `depends_on`, `cites`, `related`, `promoted_to`, `linked_sessions`) unioned with
47
+ `edges.jsonl`.
48
+
49
+ **Knowledge blocks work:** a `blocked_by` edge points at a `~/docs` article and is only
50
+ *resolvable* once that article records a decision (`decision:` frontmatter or a `## Decision`
51
+ section) — so the blocker tracks a real artifact instead of rotting.
52
+
53
+ ## CLI
54
+
55
+ No build step — Node ≥22 runs the TypeScript directly; `node:sqlite` needs no native module.
56
+
57
+ ```bash
58
+ node src/cli/index.ts <command> # or: npm run kp -- <command>
59
+
60
+ reindex # rebuild .index/planning.db
61
+ list [--type T] [--status S] # list objects
62
+ daily [--date today] [--carry-over]
63
+ blocked # objects blocked_by unresolved knowledge
64
+ graph [--json] # nodes/edges
65
+ capture "<text>" [--domain D] # quick-capture an idea
66
+ promote <ideaId> [--to planId] # idea → plan (+lineage)
67
+ link-session <objectId> <uuid> # bidirectional: linked_sessions ↔ session planning_refs
68
+ insight <file> --source S # ingest digest bullets → Insight
69
+ set-status <id> <status> # board lane / accept
70
+ export [--date today] # one-shot JSON snapshot (consumed by the CSV planning mode)
71
+ doctor # config, counts, fts, blocked, warnings
72
+ ```
73
+
74
+ `KP_ROOT` (or `--root`) overrides the store root (defaults to this package root).
75
+
76
+ ## Integrations
77
+
78
+ - **`~/docs` agent skills:** `/planning-daily`, `/planning-capture`, `/planning-refine`,
79
+ `/planning-promote`, `/planning-link-session`, `/planning-blocked-by`, `/planning-insight`,
80
+ `/planning-reflect` (in `~/docs/.claude/commands/`).
81
+ - **Sessions:** `code-sessions` `SessionSchema` carries `planning_refs[]` + `phase`; `kp
82
+ link-session` writes both sides.
83
+ - **Code Sessions VS Code:** a **Planning** activity-bar container (Today / Inbox / Projects
84
+ trees, kanban board + graph webviews, status bar) reads this store via `kp export`.
85
+ - **Daily workflow:** the Google Doc stays the mobile capture surface; planning *ingests* from
86
+ `raw/google-docs/personal-todo-notes.md` + `notes.md` rather than replacing it.
87
+
88
+ ## Tests
89
+
90
+ ```bash
91
+ npm test # vitest — schema, store, kb-bridge, migrate, index, graph, cli (29 tests)
92
+ ```
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@unpolarize/knowledge-planning",
3
+ "version": "0.1.0",
4
+ "description": "Durable, git-backed, life-wide planning system: typed entities (Domain/Project/CatalogEntry/Idea/Plan/Task/DailyPlan/Reflection/Insight/KnowledgeRef/Edge) over a markdown SSOT, a node:sqlite projection, a graph/board exporter, and a CLI. Wires ~/docs knowledge (blocked_by edges) and the portable sessions.git corpus (planning_refs).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/unpolarize/knowledge-planning.git"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "bin": {
19
+ "knowledge-planning": "./src/cli/index.ts",
20
+ "kp": "./src/cli/index.ts"
21
+ },
22
+ "exports": {
23
+ ".": "./src/index.ts"
24
+ },
25
+ "scripts": {
26
+ "kp": "node ./src/cli/index.ts",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "yaml": "^2.9.0",
33
+ "zod": "^3.25.0"
34
+ },
35
+ "devDependencies": {
36
+ "vitest": "^2.1.8"
37
+ },
38
+ "engines": {
39
+ "node": ">=22"
40
+ }
41
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { Store } from '../store/store.ts';
6
+ import { PlanningIndex } from '../index/db.ts';
7
+ import * as cmd from './commands.ts';
8
+
9
+ let dir: string;
10
+ let kb: string;
11
+ let sessions: string;
12
+
13
+ beforeEach(() => {
14
+ dir = mkdtempSync(join(tmpdir(), 'kp-cmd-'));
15
+ kb = mkdtempSync(join(tmpdir(), 'kp-cmdkb-'));
16
+ sessions = mkdtempSync(join(tmpdir(), 'kp-cmdsess-'));
17
+ });
18
+ afterEach(() => {
19
+ for (const d of [dir, kb, sessions]) rmSync(d, { recursive: true, force: true });
20
+ });
21
+
22
+ describe('cli commands', () => {
23
+ it('captures an idea', () => {
24
+ const s = new Store(dir);
25
+ const res = cmd.cmdCapture(s, 'Memory maps in CB', 'tech');
26
+ expect(res.id).toBe('ideas/memory-maps-in-cb');
27
+ expect(new Store(dir).get(res.id)?.object.status).toBe('capture');
28
+ });
29
+
30
+ it('promotes an idea to a plan and marks lineage', () => {
31
+ const s = new Store(dir);
32
+ cmd.cmdCapture(s, 'Knowledge planning MVP', 'tech');
33
+ const res = cmd.cmdPromote(s, 'ideas/knowledge-planning-mvp');
34
+ const fresh = new Store(dir);
35
+ expect(fresh.get(res.planId)?.type).toBe('plan');
36
+ expect(fresh.get('ideas/knowledge-planning-mvp')?.data.status).toBe('done');
37
+ expect(fresh.get('ideas/knowledge-planning-mvp')?.data.promoted_to).toBe(res.planId);
38
+ });
39
+
40
+ it('links a session both ways (linked_sessions + planning_refs)', () => {
41
+ const s = new Store(dir);
42
+ s.write({ id: 'ideas/mm', type: 'idea', title: 'MM' });
43
+ const uuid = '4992a4a4-5632-4048-a55e-ab2be5857f2e';
44
+ const envDir = join(sessions, 'hosts/h1/2026-06', uuid);
45
+ mkdirSync(envDir, { recursive: true });
46
+ writeFileSync(
47
+ join(envDir, 'session.json'),
48
+ JSON.stringify({ schema: 'session-store/session@1', session_id: uuid, host: 'h1', agent: 'claude-code' }),
49
+ );
50
+
51
+ const res = cmd.cmdLinkSession(s, sessions, 'ideas/mm', uuid);
52
+ expect(res.envelopeUpdated).toBe(true);
53
+
54
+ const env = JSON.parse(readFileSync(join(envDir, 'session.json'), 'utf8'));
55
+ expect(env.planning_refs).toContain('ideas/mm');
56
+ expect(new Store(dir).get('ideas/mm')?.object.linked_sessions).toContain(uuid);
57
+ });
58
+
59
+ it('baselines on first run, then ingests only new substantial bullets', () => {
60
+ const s = new Store(dir);
61
+ const base = '- brainstorm a thing\n- [ ] call the contractor\n';
62
+ // first run: no prior snapshot → baseline, ingest nothing (never flood a brain-dump)
63
+ const b = cmd.cmdIngestGdoc(s, base, { prevText: null });
64
+ expect(b.baselined).toBe(true);
65
+ expect(b.created.length).toBe(0);
66
+ // next run: one new substantial bullet added → only it ingests ('tiny' filtered by minLen)
67
+ const r = cmd.cmdIngestGdoc(s, base + '- a brand new idea here\n- tiny\n', { prevText: base, domain: 'tech' });
68
+ expect(r.baselined).toBe(false);
69
+ expect(r.created).toEqual(['ideas/a-brand-new-idea-here']);
70
+ expect(new Store(dir).get('ideas/a-brand-new-idea-here')?.object.status).toBe('capture');
71
+ });
72
+
73
+ it('caps new captures per run', () => {
74
+ const s = new Store(dir);
75
+ const lines = Array.from({ length: 10 }, (_, i) => `- new substantial bullet number ${i}`).join('\n');
76
+ const r = cmd.cmdIngestGdoc(s, lines, { prevText: '', max: 3 });
77
+ expect(r.created.length).toBe(3);
78
+ expect(r.capped).toBe(7);
79
+ });
80
+
81
+ it('ingests insight bullets into an Insight object', () => {
82
+ const s = new Store(dir);
83
+ const res = cmd.cmdInsightIngest(s, '- ship the daemon\n- [ ] email outreach\n', 'daily-digest', 'raw/x.md');
84
+ expect(res.suggested).toBe(2);
85
+ expect(new Store(dir).get(res.id)?.type).toBe('insight');
86
+ });
87
+
88
+ it('renders list / daily / blocked / graph as strings', () => {
89
+ const s = new Store(dir);
90
+ s.write({ id: 'ideas/a', type: 'idea', title: 'A', status: 'capture' });
91
+ s.write({ id: 'daily/2026-06-21', type: 'daily_plan', date: '2026-06-21' }, '## Top 3\n- [ ] ship');
92
+ s.write({ id: 'tasks/t', type: 'task', title: 'Ship', status: 'today', daily: 'daily/2026-06-21' });
93
+ s.write({
94
+ id: 'plans/r',
95
+ type: 'plan',
96
+ title: 'R',
97
+ blocked_by: [{ path: 'knowledge/x.md', reason: 'decide' }],
98
+ });
99
+ const idx = new PlanningIndex(s);
100
+ expect(cmd.cmdList(s, { status: 'capture' })).toContain('ideas/a');
101
+ expect(cmd.cmdDaily(s, idx, '2026-06-21', false)).toContain('Ship');
102
+ expect(cmd.cmdBlocked(idx, kb)).toContain('plans/r');
103
+ expect(cmd.cmdGraph(s, kb, true)).toContain('"nodes"');
104
+ });
105
+ });
@@ -0,0 +1,341 @@
1
+ import { join } from 'node:path';
2
+ import { Store } from '../store/store.ts';
3
+ import { PlanningIndex, persistIndex } from '../index/db.ts';
4
+ import { buildGraph } from '../graph/graph.ts';
5
+ import { extractBullets, toSeeds, slugify } from '../migrate/gdoc.ts';
6
+ import { findSessionEnvelope, addPlanningRef } from './sessions.ts';
7
+ import { inspectKnowledgeRef, resolveKnowledgeRef, readTitle } from '../kb-bridge/kb-bridge.ts';
8
+ import { existsSync } from 'node:fs';
9
+ import type { Config } from '../types.ts';
10
+
11
+ export const today = (): string => new Date().toISOString().slice(0, 10);
12
+
13
+ export interface ListOpts {
14
+ status?: string;
15
+ type?: string;
16
+ }
17
+
18
+ export function cmdList(store: Store, opts: ListOpts): string {
19
+ let rows = store.all();
20
+ if (opts.type) rows = rows.filter((d) => d.type === opts.type);
21
+ if (opts.status) rows = rows.filter((d) => (d.object as { status?: string }).status === opts.status);
22
+ if (rows.length === 0) return '(no matching objects)';
23
+ return rows
24
+ .sort((a, b) => a.type.localeCompare(b.type) || a.id.localeCompare(b.id))
25
+ .map((d) => {
26
+ const o = d.object as { title?: string; status?: string };
27
+ return `${pad(d.type, 12)} ${pad(o.status ?? '-', 10)} ${d.id}${o.title ? ` — ${o.title}` : ''}`;
28
+ })
29
+ .join('\n');
30
+ }
31
+
32
+ export function cmdDaily(store: Store, idx: PlanningIndex, date: string, carryOver: boolean): string {
33
+ const board = idx.dailyBoard(date);
34
+ const lines: string[] = [`# Daily — ${date}`];
35
+ if (!board.daily_id) lines.push(`(no daily plan file at daily/${date}; run /planning-daily or 'kp capture')`);
36
+ if (board.body.trim()) lines.push('', board.body.trim());
37
+ lines.push('', '## Tasks by lane');
38
+ for (const [lane, rows] of Object.entries(board.lanes)) {
39
+ if (rows.length === 0) continue;
40
+ lines.push(` ${lane}:`);
41
+ for (const r of rows) lines.push(` - [${r.status === 'done' ? 'x' : ' '}] ${r.title ?? r.id} (${r.id})`);
42
+ }
43
+ if (carryOver) {
44
+ const prev = previousDay(date);
45
+ const prevBoard = idx.dailyBoard(prev);
46
+ const unfinished = [...prevBoard.lanes.today, ...prevBoard.lanes.in_progress, ...prevBoard.lanes.inbox];
47
+ if (unfinished.length) {
48
+ lines.push('', `## Carry-over from ${prev}`);
49
+ for (const r of unfinished) lines.push(` - ${r.title ?? r.id} (${r.id})`);
50
+ }
51
+ }
52
+ return lines.join('\n');
53
+ }
54
+
55
+ export function cmdBlocked(idx: PlanningIndex, kbRoot: string): string {
56
+ const blocked = idx.blockedByKnowledge(kbRoot).filter((b) => b.status !== 'resolved');
57
+ if (blocked.length === 0) return '(nothing blocked by knowledge)';
58
+ return blocked
59
+ .map((b) => {
60
+ const flag = !b.exists ? 'MISSING' : b.resolvable ? 'RESOLVABLE (decision recorded)' : 'open';
61
+ return `${flag.padEnd(30)} ${b.id}\n → ${b.path}${b.reason ? `\n reason: ${b.reason}` : ''}`;
62
+ })
63
+ .join('\n');
64
+ }
65
+
66
+ export function cmdGraph(store: Store, kbRoot: string, json: boolean): string {
67
+ const g = buildGraph(store, kbRoot);
68
+ if (json) return JSON.stringify(g, null, 2);
69
+ return `graph: ${g.nodes.length} nodes, ${g.edges.length} edges\n by type: ${countBy(g.nodes.map((n) => n.type))}`;
70
+ }
71
+
72
+ export function cmdReindex(store: Store, root: string): string {
73
+ const res = persistIndex(store, join(root, '.index/planning.db'));
74
+ const warnings = store.getWarnings();
75
+ return `reindexed → ${join(root, '.index/planning.db')}\n ${res.objects} objects, ${res.edges} edges${
76
+ warnings.length ? `\n ${warnings.length} warning(s):\n ${warnings.join('\n ')}` : ''
77
+ }`;
78
+ }
79
+
80
+ export interface CaptureResult {
81
+ id: string;
82
+ }
83
+
84
+ export function cmdCapture(store: Store, text: string, domain?: string): CaptureResult {
85
+ const id = uniqueId(store, `ideas/${slugify(text)}`);
86
+ store.write({ id, type: 'idea', title: text, status: 'capture', domain, created: today() });
87
+ return { id };
88
+ }
89
+
90
+ export interface PromoteResult {
91
+ ideaId: string;
92
+ planId: string;
93
+ }
94
+
95
+ export function cmdPromote(store: Store, ideaId: string, toId?: string): PromoteResult {
96
+ const idea = store.get(ideaId);
97
+ if (!idea || idea.type !== 'idea') throw new Error(`not an idea: ${ideaId}`);
98
+ const o = idea.object as {
99
+ title?: string;
100
+ domain?: string;
101
+ project?: string;
102
+ blocked_by?: unknown[];
103
+ cites?: string[];
104
+ };
105
+ const planId = toId ?? uniqueId(store, `plans/${ideaId.split('/').pop()}`);
106
+ const isLife = o.domain && o.domain !== 'tech';
107
+ store.write(
108
+ {
109
+ id: planId,
110
+ type: 'plan',
111
+ title: o.title ?? planId,
112
+ status: 'plan',
113
+ kind: isLife ? 'life' : 'dev',
114
+ domain: o.domain,
115
+ project: o.project,
116
+ promoted_from: ideaId,
117
+ blocked_by: o.blocked_by ?? [],
118
+ cites: o.cites ?? [],
119
+ created: today(),
120
+ },
121
+ `# ${o.title ?? planId}\n\n## Goal\n\n_(promoted from [[${ideaId}]])_\n\n## Steps\n\n- [ ] …\n`,
122
+ );
123
+ store.patch(ideaId, (d) => {
124
+ d.status = 'done';
125
+ d.promoted_to = planId;
126
+ });
127
+ store.appendEdge({ kind: 'promoted_to', from: ideaId, to: planId, created_at: new Date().toISOString() });
128
+ return { ideaId, planId };
129
+ }
130
+
131
+ export interface LinkResult {
132
+ objectId: string;
133
+ uuid: string;
134
+ envelopeUpdated: boolean;
135
+ envelopePath?: string;
136
+ }
137
+
138
+ export function cmdLinkSession(store: Store, sessionsRoot: string, objectId: string, uuid: string): LinkResult {
139
+ const obj = store.get(objectId);
140
+ if (!obj) throw new Error(`object not found: ${objectId}`);
141
+ store.patch(objectId, (d) => {
142
+ const refs = new Set((d.linked_sessions as string[]) ?? []);
143
+ refs.add(uuid);
144
+ d.linked_sessions = [...refs];
145
+ });
146
+ store.appendEdge({ kind: 'evidence', from: `session:${uuid}`, to: objectId, created_at: new Date().toISOString() });
147
+ const envelopePath = findSessionEnvelope(sessionsRoot, uuid);
148
+ let envelopeUpdated = false;
149
+ if (envelopePath) envelopeUpdated = addPlanningRef(envelopePath, objectId);
150
+ return { objectId, uuid, envelopeUpdated, envelopePath };
151
+ }
152
+
153
+ export interface InsightResult {
154
+ id: string;
155
+ suggested: number;
156
+ }
157
+
158
+ /** Ingest digest/brief bullets (a markdown file) into an Insight with suggested tasks. */
159
+ export function cmdInsightIngest(store: Store, text: string, source: string, sourceRef?: string): InsightResult {
160
+ const bullets = extractBullets(text).filter((b) => b.text.length > 3);
161
+ const date = today();
162
+ const id = uniqueId(store, `insights/${date}-${slugify(source)}`);
163
+ const suggested = bullets.map((b) => b.text);
164
+ store.write(
165
+ {
166
+ id,
167
+ type: 'insight',
168
+ title: `${source} — ${date}`,
169
+ source,
170
+ source_ref: sourceRef,
171
+ generated_at: new Date().toISOString(),
172
+ status: 'new',
173
+ },
174
+ `# ${source} — ${date}\n\n## Suggested\n\n${suggested.map((s) => `- [ ] ${s}`).join('\n')}\n`,
175
+ );
176
+ return { id, suggested: suggested.length };
177
+ }
178
+
179
+ /** Rich detail for one object: body + resolved references + parent/children + lineage. */
180
+ export function cmdShow(store: Store, kbRoot: string, id: string): unknown {
181
+ const doc = store.get(id);
182
+ if (!doc) throw new Error(`object not found: ${id}`);
183
+ const o = doc.object as Record<string, unknown>;
184
+ const all = store.all();
185
+ const ref = (oid: string) => {
186
+ const d = store.get(oid);
187
+ return d ? { id: d.id, type: d.type, title: (d.object as { title?: string }).title ?? d.id, status: (d.object as { status?: string }).status } : { id: oid, missing: true };
188
+ };
189
+ const cites = (o.cites as string[] | undefined ?? []).map((p) => {
190
+ const abs = resolveKnowledgeRef(p, kbRoot);
191
+ return { path: p, exists: existsSync(abs), title: existsSync(abs) ? readTitle(abs) : undefined };
192
+ });
193
+ const blocked_by = (doc.object.blocked_by ?? []).map((r) => inspectKnowledgeRef(id, r, kbRoot));
194
+ // children: objects that point at this one as their project or plan parent
195
+ const children = all
196
+ .filter((d) => {
197
+ const c = d.object as Record<string, unknown>;
198
+ return d.id !== id && (c.project === id || c.plan === id || c.daily === id);
199
+ })
200
+ .map((d) => ref(d.id));
201
+ const parentId = (o.plan as string) || (o.project as string) || undefined;
202
+ return {
203
+ id: doc.id,
204
+ type: doc.type,
205
+ relpath: doc.relpath,
206
+ path: doc.path,
207
+ frontmatter: o,
208
+ title: o.title ?? doc.id,
209
+ status: o.status,
210
+ domain: o.domain,
211
+ body: doc.body,
212
+ cites,
213
+ blocked_by,
214
+ depends_on: (o.depends_on as string[] | undefined ?? []).map(ref),
215
+ related: (o.related as string[] | undefined ?? []).map(ref),
216
+ linked_sessions: (o.linked_sessions as string[] | undefined ?? []),
217
+ parent: parentId ? ref(parentId) : null,
218
+ children,
219
+ promoted_to: o.promoted_to ? ref(o.promoted_to as string) : null,
220
+ promoted_from: o.promoted_from ? ref(o.promoted_from as string) : null,
221
+ };
222
+ }
223
+
224
+ /** One-shot JSON snapshot for external consumers (the CSV planning mode). */
225
+ export function cmdExport(store: Store, idx: PlanningIndex, config: Config, date: string): unknown {
226
+ return {
227
+ generated_at: new Date().toISOString(),
228
+ root: store.root,
229
+ kb_root: config.kb_root,
230
+ sessions_root: config.sessions_root,
231
+ counts: idx.counts(),
232
+ objects: idx.all(),
233
+ inbox: idx.listByStatus('capture'),
234
+ board: idx.dailyBoard(date),
235
+ blocked: idx.blockedByKnowledge(config.kb_root).filter((b) => b.status !== 'resolved'),
236
+ graph: buildGraph(store, config.kb_root),
237
+ };
238
+ }
239
+
240
+ /** Set an object's status (board lane moves, idea accept/park). */
241
+ export function cmdSetStatus(store: Store, id: string, status: string): void {
242
+ if (!store.get(id)) throw new Error(`object not found: ${id}`);
243
+ store.patch(id, (d) => {
244
+ d.status = status;
245
+ d.updated = today();
246
+ });
247
+ }
248
+
249
+ export interface IngestResult {
250
+ created: string[];
251
+ skipped: number;
252
+ newBullets: number;
253
+ baselined: boolean;
254
+ capped: number;
255
+ }
256
+
257
+ /**
258
+ * Ingest the synced Google-Doc todo mirror into the planning inbox: checkbox items →
259
+ * inbox tasks, plain bullets → capture ideas.
260
+ *
261
+ * Safe by construction for a large brain-dump outline (the real doc is thousands of
262
+ * nested bullets), per the design's "diff triggers append of NEW bullets, human approves":
263
+ * - **Diff-based:** ingests only bullets absent from `prevText` (the prior snapshot).
264
+ * - **Baseline first run:** when `prevText` is null (no snapshot yet), ingest nothing —
265
+ * just establish the baseline so a years-long doc never floods the store.
266
+ * - **Filtered:** only substantial (`minLen`) top-level bullets (`depth <= 2`).
267
+ * - **Capped:** at most `max` new captures per run (the rest wait for the next run).
268
+ * - **Deduped:** an id that already exists is skipped.
269
+ * Captures land as `capture`/`inbox` (triage state); the human promotes/parks them.
270
+ */
271
+ export function cmdIngestGdoc(
272
+ store: Store,
273
+ text: string,
274
+ opts: { domain?: string; source?: string; prevText?: string | null; max?: number; minLen?: number } = {},
275
+ ): IngestResult {
276
+ const minLen = opts.minLen ?? 12;
277
+ const max = opts.max ?? 25;
278
+ const source = opts.source ?? 'gdoc';
279
+ if (opts.prevText == null) {
280
+ return { created: [], skipped: 0, newBullets: 0, baselined: true, capped: 0 };
281
+ }
282
+ const prevSet = new Set(extractBullets(opts.prevText).map((b) => b.text));
283
+ const fresh = extractBullets(text).filter((b) => b.text.length >= minLen && b.depth <= 2 && !prevSet.has(b.text));
284
+ const seeds = toSeeds(fresh.slice(0, max), { domain: opts.domain, source });
285
+ const created: string[] = [];
286
+ let skipped = 0;
287
+ for (const seed of seeds) {
288
+ if (store.get(seed.id)) {
289
+ skipped++;
290
+ continue;
291
+ }
292
+ store.write(
293
+ { id: seed.id, type: seed.type, title: seed.title, status: seed.status, domain: seed.domain, source: seed.source, created: today() },
294
+ seed.title,
295
+ );
296
+ created.push(seed.id);
297
+ }
298
+ return { created, skipped, newBullets: fresh.length, baselined: false, capped: Math.max(0, fresh.length - seeds.length) };
299
+ }
300
+
301
+ export function cmdDoctor(store: Store, idx: PlanningIndex, config: Config): string {
302
+ const counts = idx.counts();
303
+ const blocked = idx.blockedByKnowledge(config.kb_root).filter((b) => b.status !== 'resolved');
304
+ const warnings = store.getWarnings();
305
+ return [
306
+ `store root: ${store.root}`,
307
+ `kb_root: ${config.kb_root}`,
308
+ `sessions: ${config.sessions_root}`,
309
+ `objects: ${Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(' ') || '(none)'}`,
310
+ `fts5: ${idx.fts ? 'yes' : 'no (LIKE fallback)'}`,
311
+ `blocked: ${blocked.length} (${blocked.filter((b) => b.resolvable).length} resolvable, ${blocked.filter((b) => !b.exists).length} missing)`,
312
+ `warnings: ${warnings.length}${warnings.length ? '\n ' + warnings.join('\n ') : ''}`,
313
+ ].join('\n');
314
+ }
315
+
316
+ // --- helpers ---------------------------------------------------------------
317
+
318
+ function pad(s: string, n: number): string {
319
+ return (s ?? '').padEnd(n).slice(0, n);
320
+ }
321
+
322
+ function countBy(items: string[]): string {
323
+ const m: Record<string, number> = {};
324
+ for (const i of items) m[i] = (m[i] ?? 0) + 1;
325
+ return Object.entries(m)
326
+ .map(([k, v]) => `${k}=${v}`)
327
+ .join(' ');
328
+ }
329
+
330
+ function uniqueId(store: Store, base: string): string {
331
+ if (!store.get(base)) return base;
332
+ let n = 2;
333
+ while (store.get(`${base}-${n}`)) n++;
334
+ return `${base}-${n}`;
335
+ }
336
+
337
+ function previousDay(date: string): string {
338
+ const d = new Date(date + 'T00:00:00Z');
339
+ d.setUTCDate(d.getUTCDate() - 1);
340
+ return d.toISOString().slice(0, 10);
341
+ }