@unpolarize/knowledge-planning 0.1.0 → 0.2.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.
@@ -1,63 +0,0 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { isAbsolute, join } from 'node:path';
3
- import { parseMarkdown } from '../schema/frontmatter.ts';
4
- import type { KnowledgeRef } from '../schema/entities.ts';
5
-
6
- /** Resolve a KnowledgeRef path (relative to ~/docs) to an absolute path. */
7
- export function resolveKnowledgeRef(refPath: string, kbRoot: string): string {
8
- return isAbsolute(refPath) ? refPath : join(kbRoot, refPath);
9
- }
10
-
11
- /** Best-effort article title: frontmatter `title`, first alias, or first `# ` heading. */
12
- export function readTitle(absPath: string): string | undefined {
13
- if (!existsSync(absPath)) return undefined;
14
- const { data, body } = parseMarkdown(readFileSync(absPath, 'utf8'));
15
- if (typeof data.title === 'string') return data.title;
16
- if (Array.isArray(data.aliases) && typeof data.aliases[0] === 'string') return data.aliases[0];
17
- const m = body.match(/^#\s+(.+)$/m);
18
- return m ? m[1].trim() : undefined;
19
- }
20
-
21
- /**
22
- * Operational definition of "this knowledge is decided", so a `blocked_by` edge is
23
- * resolved against an artifact rather than by hand: the referenced KB article carries
24
- * a non-empty `decision:` frontmatter field OR a `## Decision` section in its body.
25
- */
26
- export function hasDecision(absPath: string): boolean {
27
- if (!existsSync(absPath)) return false;
28
- const { data, body } = parseMarkdown(readFileSync(absPath, 'utf8'));
29
- if (data.decision != null && String(data.decision).trim() !== '') return true;
30
- return /^#{1,6}\s+decision\b/im.test(body);
31
- }
32
-
33
- export interface BlockedInspection {
34
- id: string; // owning object id
35
- path: string; // KB-relative ref path
36
- abs: string;
37
- exists: boolean;
38
- decided: boolean;
39
- status: 'open' | 'resolved';
40
- reason?: string;
41
- title?: string;
42
- /** the article records a decision but the edge is still open → safe to flip */
43
- resolvable: boolean;
44
- }
45
-
46
- /** Inspect one blocked_by KnowledgeRef against the KB. */
47
- export function inspectKnowledgeRef(ownerId: string, ref: KnowledgeRef, kbRoot: string): BlockedInspection {
48
- const abs = resolveKnowledgeRef(ref.path, kbRoot);
49
- const exists = existsSync(abs);
50
- const decided = exists && hasDecision(abs);
51
- const status = ref.status ?? 'open';
52
- return {
53
- id: ownerId,
54
- path: ref.path,
55
- abs,
56
- exists,
57
- decided,
58
- status,
59
- reason: ref.reason,
60
- title: exists ? readTitle(abs) : undefined,
61
- resolvable: decided && status !== 'resolved',
62
- };
63
- }
@@ -1,28 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { extractBullets, toSeeds, slugify } from './gdoc.ts';
3
-
4
- describe('migrate/gdoc', () => {
5
- it('extracts plain bullets and checkbox items', () => {
6
- const bullets = extractBullets('- idea one\n - nested idea\n- [ ] do thing\n- [x] done thing\nnot a bullet\n');
7
- expect(bullets.length).toBe(4);
8
- expect(bullets[1]?.depth).toBe(1);
9
- expect(bullets[2]?.checked).toBe(false);
10
- expect(bullets[3]?.checked).toBe(true);
11
- });
12
-
13
- it('maps checkbox items → tasks and plain bullets → capture ideas', () => {
14
- const seeds = toSeeds(extractBullets('- brainstorm memory maps\n- [ ] call contractor\n- [x] shipped MVP\n'));
15
- expect(seeds.find((s) => s.type === 'idea' && s.status === 'capture')).toBeTruthy();
16
- expect(seeds.find((s) => s.type === 'task' && s.status === 'inbox')).toBeTruthy();
17
- expect(seeds.find((s) => s.type === 'task' && s.status === 'done')).toBeTruthy();
18
- });
19
-
20
- it('de-duplicates generated ids', () => {
21
- const seeds = toSeeds(extractBullets('- same thing\n- same thing\n'));
22
- expect(new Set(seeds.map((s) => s.id)).size).toBe(2);
23
- });
24
-
25
- it('slugifies titles', () => {
26
- expect(slugify('Memory Maps in CB!')).toBe('memory-maps-in-cb');
27
- });
28
- });
@@ -1,68 +0,0 @@
1
- /**
2
- * Migrate scattered capture (the synced Google Doc → raw/google-docs/personal-todo-notes.md,
3
- * notes.md bullets) into seed planning objects. Checkbox items become Tasks; plain
4
- * bullets become capture Ideas. Returns seed frontmatter — the caller decides whether
5
- * to write them (so migration is reviewable, not destructive).
6
- */
7
-
8
- export interface Bullet {
9
- text: string;
10
- checked?: boolean;
11
- depth: number;
12
- }
13
-
14
- export function extractBullets(text: string): Bullet[] {
15
- const out: Bullet[] = [];
16
- for (const line of text.split('\n')) {
17
- const m = line.match(/^(\s*)[-*+]\s+(\[([ xX])\]\s+)?(.+?)\s*$/);
18
- if (!m) continue;
19
- const indent = m[1].replace(/\t/g, ' ').length;
20
- const checked = m[3] !== undefined ? /[xX]/.test(m[3]) : undefined;
21
- out.push({ text: m[4].trim(), checked, depth: Math.floor(indent / 2) });
22
- }
23
- return out;
24
- }
25
-
26
- export function slugify(s: string): string {
27
- return (
28
- s
29
- .toLowerCase()
30
- .replace(/[`*_~]/g, '')
31
- .replace(/[^a-z0-9]+/g, '-')
32
- .replace(/^-+|-+$/g, '')
33
- .slice(0, 48) || 'item'
34
- );
35
- }
36
-
37
- export interface SeedObject {
38
- id: string;
39
- type: 'idea' | 'task';
40
- title: string;
41
- status: string;
42
- domain?: string;
43
- source?: string;
44
- }
45
-
46
- /** Map bullets to seed objects, de-duplicating ids. */
47
- export function toSeeds(bullets: Bullet[], opts: { domain?: string; source?: string } = {}): SeedObject[] {
48
- const seeds: SeedObject[] = [];
49
- const seen = new Set<string>();
50
- for (const b of bullets) {
51
- if (!b.text || b.text.length < 3) continue;
52
- const isTask = b.checked !== undefined;
53
- const base = `${isTask ? 'tasks' : 'ideas'}/${slugify(b.text)}`;
54
- let id = base;
55
- let n = 2;
56
- while (seen.has(id)) id = `${base}-${n++}`;
57
- seen.add(id);
58
- seeds.push({
59
- id,
60
- type: isTask ? 'task' : 'idea',
61
- title: b.text,
62
- status: isTask ? (b.checked ? 'done' : 'inbox') : 'capture',
63
- domain: opts.domain,
64
- source: opts.source,
65
- });
66
- }
67
- return seeds;
68
- }
@@ -1,60 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { parseObject, safeParseObject } from './entities.ts';
3
- import { parseMarkdown, serializeMarkdown } from './frontmatter.ts';
4
-
5
- describe('frontmatter', () => {
6
- it('round-trips frontmatter + body and strips empty arrays', () => {
7
- const md = serializeMarkdown(
8
- { id: 'ideas/x', type: 'idea', title: 'X', tags: [], cites: [] },
9
- 'Body text.\n',
10
- );
11
- const { data, body } = parseMarkdown(md);
12
- expect(data.id).toBe('ideas/x');
13
- expect(data.type).toBe('idea');
14
- expect(body.trim()).toBe('Body text.');
15
- expect(md).not.toContain('tags:'); // empty arrays stripped
16
- });
17
-
18
- it('returns empty data when there is no frontmatter', () => {
19
- const { data, body } = parseMarkdown('# just a heading\n');
20
- expect(data).toEqual({});
21
- expect(body).toContain('heading');
22
- });
23
-
24
- it('preserves nested blocked_by structure through serialize→parse', () => {
25
- const md = serializeMarkdown(
26
- {
27
- id: 'plans/reloc',
28
- type: 'plan',
29
- blocked_by: [{ path: 'knowledge/life/relocation/x.md', reason: 'tax compare', status: 'open' }],
30
- },
31
- '',
32
- );
33
- const { data } = parseMarkdown(md);
34
- const obj = parseObject(data);
35
- expect(obj.blocked_by[0]?.path).toContain('relocation');
36
- expect(obj.blocked_by[0]?.status).toBe('open');
37
- });
38
- });
39
-
40
- describe('entities', () => {
41
- it('applies idea defaults', () => {
42
- const obj = parseObject({ id: 'ideas/memory-maps', type: 'idea' });
43
- expect(obj.type).toBe('idea');
44
- if (obj.type === 'idea') expect(obj.status).toBe('capture');
45
- });
46
-
47
- it('defaults blocked_by KnowledgeRef status to open', () => {
48
- const obj = parseObject({
49
- id: 'plans/reloc',
50
- type: 'plan',
51
- blocked_by: [{ path: 'knowledge/life/relocation/x.md', reason: 'need decision' }],
52
- });
53
- expect(obj.blocked_by[0]?.status).toBe('open');
54
- });
55
-
56
- it('discriminates by type and rejects unknown types', () => {
57
- expect(safeParseObject({ id: 'x', type: 'nonsense' }).success).toBe(false);
58
- expect(safeParseObject({ id: 'p', type: 'project' }).success).toBe(true);
59
- });
60
- });
@@ -1,233 +0,0 @@
1
- import { z } from 'zod';
2
-
3
- /**
4
- * Knowledge-Planning entity model.
5
- *
6
- * Every object is a markdown file whose YAML frontmatter validates against one of
7
- * these schemas, discriminated by `type`. Objects are addressable by a stable `id`
8
- * (used in wikilinks, session `planning_refs`, and CLI args). Schemas are versioned
9
- * `knowledge-planning/<type>@N` so consumers can migrate the way the session store's
10
- * `session-store/*@1` records do.
11
- *
12
- * Schemas are `.passthrough()` on purpose: this is a personal, evolving corpus, so
13
- * unknown frontmatter keys survive a parse→serialize round-trip rather than being
14
- * dropped. Relationship fields default to `[]` for uniform indexing; the serializer
15
- * (frontmatter.ts) strips empties so files stay clean.
16
- */
17
-
18
- export const SCHEMA_VERSION = 1;
19
- export const ns = (t: string): string => `knowledge-planning/${t}@${SCHEMA_VERSION}`;
20
-
21
- export const OBJECT_TYPES = [
22
- 'domain',
23
- 'project',
24
- 'catalog_entry',
25
- 'idea',
26
- 'plan',
27
- 'task',
28
- 'daily_plan',
29
- 'reflection',
30
- 'insight',
31
- ] as const;
32
- export type ObjectType = (typeof OBJECT_TYPES)[number];
33
-
34
- // Lifecycle vocabularies (kanban lanes).
35
- export const IDEA_STATUS = ['capture', 'refine', 'accepted', 'plan', 'parked', 'done'] as const;
36
- export const PLAN_STATUS = ['plan', 'prototype', 'implement', 'validate', 'done', 'parked'] as const;
37
- export const TASK_STATUS = ['inbox', 'today', 'in_progress', 'done', 'deferred'] as const;
38
- export const SESSION_PHASE = ['backlog', 'planning', 'implementing', 'validating', 'complete'] as const;
39
- export const INSIGHT_STATUS = ['new', 'accepted', 'parked'] as const;
40
-
41
- /** A pointer into ~/docs knowledge — the target of `blocked_by` / `cites` edges. */
42
- export const KnowledgeRefSchema = z
43
- .object({
44
- path: z.string().min(1),
45
- reason: z.string().optional(),
46
- status: z.enum(['open', 'resolved']).default('open'),
47
- title: z.string().optional(),
48
- })
49
- .passthrough();
50
- export type KnowledgeRef = z.infer<typeof KnowledgeRefSchema>;
51
-
52
- // Relationship frontmatter fields shared by all objects (typed edges live here +
53
- // optionally in edges.jsonl; the index unions both).
54
- const baseShape = {
55
- schema: z.string().optional(),
56
- id: z.string().min(1),
57
- title: z.string().optional(),
58
- status: z.string().optional(),
59
- created: z.string().optional(),
60
- updated: z.string().optional(),
61
- domain: z.string().optional(),
62
- project: z.string().optional(),
63
- priority: z.string().optional(),
64
- tags: z.array(z.string()).default([]),
65
- cites: z.array(z.string()).default([]),
66
- blocked_by: z.array(KnowledgeRefSchema).default([]),
67
- depends_on: z.array(z.string()).default([]),
68
- related: z.array(z.string()).default([]),
69
- linked_sessions: z.array(z.string()).default([]),
70
- };
71
-
72
- export const DomainSchema = z
73
- .object({ ...baseShape, type: z.literal('domain'), name: z.string().optional() })
74
- .passthrough();
75
-
76
- export const ProjectSchema = z
77
- .object({
78
- ...baseShape,
79
- type: z.literal('project'),
80
- catalog_slug: z.string().optional(),
81
- kb_paths: z.array(z.string()).default([]),
82
- repos: z.array(z.string()).default([]),
83
- status: z.string().default('active'),
84
- })
85
- .passthrough();
86
-
87
- export const CatalogEntrySchema = z
88
- .object({
89
- ...baseShape,
90
- type: z.literal('catalog_entry'),
91
- catalog_slug: z.string().optional(),
92
- kb_paths: z.array(z.string()).default([]),
93
- status: z.string().default('active'),
94
- })
95
- .passthrough();
96
-
97
- export const IdeaSchema = z
98
- .object({
99
- ...baseShape,
100
- type: z.literal('idea'),
101
- status: z.enum(IDEA_STATUS).default('capture'),
102
- promoted_to: z.string().optional(),
103
- })
104
- .passthrough();
105
-
106
- export const PlanSchema = z
107
- .object({
108
- ...baseShape,
109
- type: z.literal('plan'),
110
- status: z.enum(PLAN_STATUS).default('plan'),
111
- kind: z.enum(['dev', 'life']).default('dev'),
112
- phase: z.enum(SESSION_PHASE).optional(),
113
- promoted_from: z.string().optional(),
114
- })
115
- .passthrough();
116
-
117
- export const TaskSchema = z
118
- .object({
119
- ...baseShape,
120
- type: z.literal('task'),
121
- status: z.enum(TASK_STATUS).default('inbox'),
122
- plan: z.string().optional(),
123
- daily: z.string().optional(),
124
- scheduled_for: z.string().optional(),
125
- dispatch_task_id: z.string().optional(),
126
- })
127
- .passthrough();
128
-
129
- export const DailyPlanSchema = z
130
- .object({
131
- ...baseShape,
132
- type: z.literal('daily_plan'),
133
- date: z.string().min(1),
134
- domain_focus: z.array(z.string()).default([]),
135
- carry_over_from: z.string().optional(),
136
- })
137
- .passthrough();
138
-
139
- export const ReflectionSchema = z
140
- .object({
141
- ...baseShape,
142
- type: z.literal('reflection'),
143
- date: z.string().min(1),
144
- daily_plan: z.string().optional(),
145
- domains: z.array(z.string()).default([]),
146
- })
147
- .passthrough();
148
-
149
- export const InsightSchema = z
150
- .object({
151
- ...baseShape,
152
- type: z.literal('insight'),
153
- source: z.string().min(1),
154
- source_ref: z.string().optional(),
155
- generated_at: z.string().optional(),
156
- confidence: z.string().optional(),
157
- suggested_tasks: z.array(z.string()).default([]),
158
- status: z.enum(INSIGHT_STATUS).default('new'),
159
- })
160
- .passthrough();
161
-
162
- export const SCHEMA_BY_TYPE = {
163
- domain: DomainSchema,
164
- project: ProjectSchema,
165
- catalog_entry: CatalogEntrySchema,
166
- idea: IdeaSchema,
167
- plan: PlanSchema,
168
- task: TaskSchema,
169
- daily_plan: DailyPlanSchema,
170
- reflection: ReflectionSchema,
171
- insight: InsightSchema,
172
- } as const;
173
-
174
- export const ObjectSchema = z.discriminatedUnion('type', [
175
- DomainSchema,
176
- ProjectSchema,
177
- CatalogEntrySchema,
178
- IdeaSchema,
179
- PlanSchema,
180
- TaskSchema,
181
- DailyPlanSchema,
182
- ReflectionSchema,
183
- InsightSchema,
184
- ]);
185
-
186
- export type Domain = z.infer<typeof DomainSchema>;
187
- export type Project = z.infer<typeof ProjectSchema>;
188
- export type CatalogEntry = z.infer<typeof CatalogEntrySchema>;
189
- export type Idea = z.infer<typeof IdeaSchema>;
190
- export type Plan = z.infer<typeof PlanSchema>;
191
- export type Task = z.infer<typeof TaskSchema>;
192
- export type DailyPlan = z.infer<typeof DailyPlanSchema>;
193
- export type Reflection = z.infer<typeof ReflectionSchema>;
194
- export type Insight = z.infer<typeof InsightSchema>;
195
- export type PlanningObject = z.infer<typeof ObjectSchema>;
196
-
197
- // Explicit edges (edges.jsonl, append-only + rebuildable from frontmatter).
198
- export const EDGE_KINDS = [
199
- 'depends_on',
200
- 'blocks',
201
- 'blocked_by',
202
- 'implements',
203
- 'cites',
204
- 'translates_to',
205
- 'promoted_to',
206
- 'parent_of',
207
- 'related',
208
- 'evidence',
209
- 'surfaced_on',
210
- 'scheduled_for',
211
- ] as const;
212
- export type EdgeKind = (typeof EDGE_KINDS)[number];
213
-
214
- export const EdgeSchema = z
215
- .object({
216
- kind: z.enum(EDGE_KINDS),
217
- from: z.string().min(1),
218
- to: z.string().min(1),
219
- reason: z.string().optional(),
220
- status: z.enum(['open', 'resolved']).optional(),
221
- created_at: z.string().optional(),
222
- })
223
- .passthrough();
224
- export type Edge = z.infer<typeof EdgeSchema>;
225
-
226
- /** Validate a parsed frontmatter object against the schema for its `type`. */
227
- export function parseObject(data: unknown): PlanningObject {
228
- return ObjectSchema.parse(data);
229
- }
230
-
231
- export function safeParseObject(data: unknown) {
232
- return ObjectSchema.safeParse(data);
233
- }
@@ -1,44 +0,0 @@
1
- import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
2
-
3
- /** A markdown document split into YAML frontmatter (`data`) and `body`. */
4
- export interface MarkdownDoc {
5
- data: Record<string, unknown>;
6
- body: string;
7
- }
8
-
9
- const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
10
-
11
- /** Split `---\n…\n---\n` frontmatter from the body. No frontmatter → `data: {}`. */
12
- export function parseMarkdown(content: string): MarkdownDoc {
13
- const m = content.match(FRONTMATTER_RE);
14
- if (!m) return { data: {}, body: content };
15
- const parsed = parseYaml(m[1]) ?? {};
16
- const data = typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
17
- return { data, body: content.slice(m[0].length) };
18
- }
19
-
20
- /** Drop `undefined`/`null` and empty arrays so serialized frontmatter stays clean. */
21
- function clean(value: unknown): unknown {
22
- if (Array.isArray(value)) return value.map(clean).filter((v) => v !== undefined && v !== null);
23
- if (value && typeof value === 'object') {
24
- const out: Record<string, unknown> = {};
25
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
26
- const cv = clean(v);
27
- if (cv === undefined || cv === null) continue;
28
- if (Array.isArray(cv) && cv.length === 0) continue;
29
- out[k] = cv;
30
- }
31
- return out;
32
- }
33
- return value;
34
- }
35
-
36
- /** Serialize frontmatter + body back to a markdown string. */
37
- export function serializeMarkdown(data: Record<string, unknown>, body = ''): string {
38
- const cleaned = clean(data) as Record<string, unknown>;
39
- const fm = stringifyYaml(cleaned, { lineWidth: 0 }).trimEnd();
40
- const block = `---\n${fm}\n---\n`;
41
- const trimmed = body.replace(/^\n+/, '');
42
- if (!trimmed.trim()) return block;
43
- return `${block}\n${trimmed.replace(/\n*$/, '\n')}`;
44
- }
@@ -1,2 +0,0 @@
1
- export * from './entities.ts';
2
- export * from './frontmatter.ts';
@@ -1,64 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { mkdtempSync, rmSync, existsSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { Store } from './store.ts';
6
-
7
- let dir: string;
8
- beforeEach(() => {
9
- dir = mkdtempSync(join(tmpdir(), 'kp-store-'));
10
- });
11
- afterEach(() => {
12
- rmSync(dir, { recursive: true, force: true });
13
- });
14
-
15
- describe('Store', () => {
16
- it('writes and reloads objects keyed by id', () => {
17
- const s = new Store(dir);
18
- s.write({ id: 'projects/code-build', type: 'project', title: 'Code Build', repos: ['gh:cb'] }, 'CB body');
19
- s.write({ id: 'ideas/memory-maps', type: 'idea', title: 'Memory maps', project: 'projects/code-build' }, 'desc');
20
-
21
- // project is a container → folder/index.md
22
- expect(existsSync(join(dir, 'projects/code-build/index.md'))).toBe(true);
23
- expect(existsSync(join(dir, 'ideas/memory-maps.md'))).toBe(true);
24
-
25
- const fresh = new Store(dir);
26
- expect(fresh.get('ideas/memory-maps')?.object.title).toBe('Memory maps');
27
- expect(fresh.byType('project').length).toBe(1);
28
- expect(fresh.get('projects/code-build')?.body.trim()).toBe('CB body');
29
- });
30
-
31
- it('derives frontmatter edges (blocked_by + parent_of)', () => {
32
- const s = new Store(dir);
33
- s.write({ id: 'projects/cb', type: 'project', title: 'CB' });
34
- s.write({ id: 'ideas/mm', type: 'idea', title: 'MM', project: 'projects/cb' });
35
- s.write({
36
- id: 'plans/reloc',
37
- type: 'plan',
38
- title: 'Reloc',
39
- kind: 'life',
40
- blocked_by: [{ path: 'knowledge/life/relocation/x.md', reason: 'tax compare' }],
41
- });
42
- const edges = new Store(dir).frontmatterEdges();
43
- expect(edges.some((e) => e.kind === 'blocked_by' && e.to.includes('relocation'))).toBe(true);
44
- expect(edges.some((e) => e.kind === 'parent_of' && e.to === 'ideas/mm')).toBe(true);
45
- });
46
-
47
- it('appends and reads explicit edges', () => {
48
- const s = new Store(dir);
49
- s.appendEdge({ kind: 'translates_to', from: 'plans/a', to: 'plans/b' });
50
- expect(new Store(dir).fileEdges().length).toBe(1);
51
- expect(new Store(dir).fileEdges()[0]?.kind).toBe('translates_to');
52
- });
53
-
54
- it('patches frontmatter in place', () => {
55
- const s = new Store(dir);
56
- s.write({ id: 'ideas/x', type: 'idea', title: 'X' }, 'body');
57
- s.patch('ideas/x', (d) => {
58
- d.status = 'accepted';
59
- });
60
- const reloaded = new Store(dir).get('ideas/x');
61
- expect(reloaded?.data.status).toBe('accepted');
62
- expect(reloaded?.body.trim()).toBe('body'); // body preserved
63
- });
64
- });