cans-spec 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.
@@ -0,0 +1,184 @@
1
+ import { basename, dirname, join, relative } from 'path';
2
+ import type { InitResult } from '../types';
3
+ import { resolveWorkspaceRoot, resolveInitTarget, mkdirp, exists, dirExists } from '../core/fs';
4
+ import { parseArgs, type FlagSpec } from '../core/args';
5
+
6
+ const TEMPLATES_DIR = join(import.meta.dir, '..', '..', 'templates');
7
+
8
+ async function readTemplate(name: string): Promise<string> {
9
+ return await Bun.file(join(TEMPLATES_DIR, name)).text();
10
+ }
11
+
12
+ export interface InitArgs {
13
+ flat: boolean;
14
+ folders: boolean;
15
+ bare: boolean;
16
+ force: boolean;
17
+ json: boolean;
18
+ tool: string | null;
19
+ errors: string[];
20
+ }
21
+
22
+ const INIT_FLAGS: FlagSpec[] = [
23
+ { name: 'flat', boolean: true },
24
+ { name: 'folders', boolean: true },
25
+ { name: 'bare', boolean: true },
26
+ { name: 'force', boolean: true },
27
+ { name: 'json', boolean: true },
28
+ { name: 'tool', boolean: false },
29
+ ];
30
+
31
+ /** §21: `--tool <name>` emits a tool-specific copy of AGENTS.md. */
32
+ const KNOWN_TOOLS = ['claude', 'cursor'];
33
+
34
+ export function parseInitArgs(args: string[]): InitArgs {
35
+ const parsed = parseArgs(args, INIT_FLAGS);
36
+ const folders = parsed.flags.has('folders');
37
+ const flat = parsed.flags.has('flat');
38
+ const tool = parsed.flags.get('tool');
39
+ return {
40
+ flat: flat || !folders,
41
+ folders,
42
+ bare: parsed.flags.has('bare'),
43
+ force: parsed.flags.has('force'),
44
+ json: parsed.flags.has('json'),
45
+ tool: typeof tool === 'string' ? tool : null,
46
+ errors: parsed.errors,
47
+ };
48
+ }
49
+
50
+ // Dense, clean spec stubs (parents have 2+ children, texts 3..120 chars, depth <= 5).
51
+ const SPEC_STUBS: Array<{ name: string; content: string }> = [
52
+ { name: '00-overview.md', content: '- Overview\n - Product: TBD\n - Users: TBD\n - Status: draft\n' },
53
+ { name: '01-architecture.md', content: '- Architecture\n - Stack: TBD\n - Layers: TBD\n - Boundaries: TBD\n - Principles: TBD\n' },
54
+ { name: '02-authentication.md', content: '- Authentication\n - Sign up: TBD\n - Sessions: TBD\n - Passwords: TBD\n' },
55
+ { name: '03-data.md', content: '- Data\n - Storage: TBD\n - Schema: TBD\n - Backups: TBD\n - Retention: TBD\n' },
56
+ { name: '04-api.md', content: '- API\n - Endpoints: TBD\n - Auth: TBD\n - Errors: TBD\n - Rate limits: TBD\n' },
57
+ { name: '05-frontend.md', content: '- Frontend\n - Framework: TBD\n - Routes: TBD\n - State: TBD\n' },
58
+ { name: '06-operations.md', content: '- Operations\n - Deployment: TBD\n - Environments: TBD\n - Monitoring: TBD\n - Runbooks: TBD\n' },
59
+ ];
60
+
61
+ interface PlanEntry {
62
+ path: string;
63
+ dir: boolean;
64
+ content: string | null;
65
+ }
66
+
67
+ /** §21: "Refuses if already inside a `cans/` directory" — at any depth
68
+ * (`<root>/cans` as well as `<root>/cans/_collab`). */
69
+ function insideCansDirectory(): boolean {
70
+ let dir = process.cwd();
71
+ for (;;) {
72
+ if (basename(dir) === 'cans' && dirExists(dir)) return true;
73
+ const parent = dirname(dir);
74
+ if (parent === dir) return false;
75
+ dir = parent;
76
+ }
77
+ }
78
+
79
+ function initRefusal(error: string): InitResult {
80
+ return { ok: false, command: 'init', exitCode: 1, created: [], skipped: [], root: '', error };
81
+ }
82
+
83
+ export async function run(args: string[]): Promise<InitResult> {
84
+ const opts = parseInitArgs(args);
85
+
86
+ // §20/§37: unknown or malformed flags are never silently ignored.
87
+ if (opts.errors.length > 0) {
88
+ return initRefusal(opts.errors[0]);
89
+ }
90
+
91
+ // §21/§37: an unrecognized --tool value must be surfaced, not dropped.
92
+ if (opts.tool !== null && !KNOWN_TOOLS.includes(opts.tool)) {
93
+ return initRefusal(`unknown tool "${opts.tool}" — supported tools: ${KNOWN_TOOLS.join(', ')}`);
94
+ }
95
+
96
+ // §21: refuse when standing anywhere inside a cans/ directory.
97
+ if (insideCansDirectory()) {
98
+ return initRefusal('already inside a cans/ workspace — cd to the project root first');
99
+ }
100
+
101
+ // Idempotent: an existing workspace (walk-up `cans/` or scratch) is re-used —
102
+ // existing files are skipped unless --force. Architecture §21.
103
+ const existing = resolveWorkspaceRoot();
104
+ const workspace = existing ?? join(resolveInitTarget(), 'cans');
105
+ mkdirp(workspace);
106
+
107
+ const rulesContent = await readTemplate('_rules.yaml');
108
+ const agentsContent = await readTemplate('AGENTS.md');
109
+
110
+ const plan: PlanEntry[] = [
111
+ { path: '_rules.yaml', dir: false, content: rulesContent },
112
+ { path: 'AGENTS.md', dir: false, content: agentsContent },
113
+ ];
114
+
115
+ // Spec stubs: flat files by default; --folders keeps 00-overview.md flat
116
+ // and puts numbered specs into NN-name/index.md folders. §8: "Flat wins over
117
+ // folder" — when the flat file already exists, never manufacture a folder
118
+ // twin beside it (duplicate canonical home).
119
+ for (const stub of SPEC_STUBS) {
120
+ if (opts.bare && stub.name !== '00-overview.md') continue;
121
+ const folderMode = opts.folders && /^(?!00-)\d{2}-/.test(stub.name);
122
+ const path = folderMode && !exists(join(workspace, stub.name))
123
+ ? `${stub.name.slice(0, -3)}/index.md`
124
+ : stub.name;
125
+ plan.push({ path, dir: false, content: stub.content });
126
+ }
127
+
128
+ if (!opts.bare) {
129
+ plan.push({ path: '_adr', dir: true, content: null });
130
+ plan.push({ path: '_tasks', dir: true, content: null });
131
+ plan.push({ path: '_collab/handoffs.md', dir: false, content: '- handoffs\n' });
132
+ plan.push({ path: '_collab/conflicts.md', dir: false, content: '- conflicts\n' });
133
+ plan.push({ path: '_collab/decisions.md', dir: false, content: '- decisions\n' });
134
+ }
135
+ // --bare (§21 "minimal") keeps the workspace lean (_rules.yaml + AGENTS.md +
136
+ // 00-overview.md) but still emits cans/AGENTS.md — §36 help advertises
137
+ // "Agents: cans/AGENTS.md" and the bare skeleton must match it (QA-07
138
+ // QA-01 #10).
139
+
140
+ if (opts.tool === 'claude') {
141
+ plan.push({ path: 'CLAUDE.md', dir: false, content: agentsContent });
142
+ } else if (opts.tool === 'cursor') {
143
+ plan.push({ path: '.cursorrules', dir: false, content: agentsContent });
144
+ }
145
+
146
+ const created: string[] = [];
147
+ const skipped: string[] = [];
148
+ for (const entry of plan) {
149
+ const abs = join(workspace, entry.path);
150
+ if (entry.dir) {
151
+ const label = `${entry.path}/`;
152
+ if (dirExists(abs)) {
153
+ skipped.push(label);
154
+ } else {
155
+ mkdirp(abs);
156
+ created.push(label);
157
+ }
158
+ continue;
159
+ }
160
+ // §29: _collab/*.md carry append-only coordination state (decisions.md is
161
+ // the ADR index). --force regenerates the skeleton spec files but must
162
+ // never clobber existing _collab files — create only when missing (QA-09 E1).
163
+ if (opts.force && entry.path.startsWith('_collab/') && exists(abs)) {
164
+ skipped.push(entry.path);
165
+ continue;
166
+ }
167
+ if (exists(abs) && !opts.force) {
168
+ skipped.push(entry.path);
169
+ continue;
170
+ }
171
+ await Bun.write(abs, entry.content ?? '');
172
+ created.push(entry.path);
173
+ }
174
+
175
+ // §35 init.json fixture: `root` is the relative display form ("./cans").
176
+ // Programmatic (non-JSON) callers keep the absolute path for path joins.
177
+ let root = workspace;
178
+ if (opts.json) {
179
+ const relRoot = relative(process.cwd(), workspace) || '.';
180
+ root = relRoot.startsWith('.') ? relRoot : `./${relRoot}`;
181
+ }
182
+
183
+ return { ok: true, command: 'init', exitCode: 0, created, skipped, root };
184
+ }
@@ -0,0 +1,138 @@
1
+ import { join, basename } from 'path';
2
+ import type { NewResult } from '../types';
3
+ import { resolveWorkspaceRoot, resolveWorkspaceOrCreate, mkdirp, discoverAdrs, dirExists, isFile } from '../core/fs';
4
+ import { parseArgs, type FlagSpec } from '../core/args';
5
+
6
+ const TEMPLATES_DIR = join(import.meta.dir, '..', '..', 'templates');
7
+
8
+ async function readTemplate(name: string): Promise<string> {
9
+ return await Bun.file(join(TEMPLATES_DIR, name)).text();
10
+ }
11
+
12
+ /** lowercase → strip double quotes → non-alphanumeric runs → hyphens → trim hyphens.
13
+ * Apostrophes become hyphens ("What's Next?" → "what-s-next").
14
+ * §23/§37: the slug is capped at 80 chars (truncating cleanly, without
15
+ * trailing dashes) so the derived `_adr/NNN-<slug>.md` / `_tasks/<slug>.md`
16
+ * filename is always filesystem-safe — a 300-char title is user input to
17
+ * normalize, never an ENAMETOOLONG internal error (QA-08 A5). */
18
+ export const MAX_SLUG_LENGTH = 80;
19
+
20
+ export function slugify(input: string): string {
21
+ const full = input
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/["“”]/g, '')
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-+|-+$/g, '');
27
+ if (full.length <= MAX_SLUG_LENGTH) return full;
28
+ return full.slice(0, MAX_SLUG_LENGTH).replace(/-+$/g, '');
29
+ }
30
+
31
+ /** Next ADR number: max existing NNN in _adr + 1 (starts at 1). */
32
+ export function nextAdrNumber(root: string): number {
33
+ let max = 0;
34
+ if (dirExists(join(root, '_adr'))) {
35
+ for (const rel of discoverAdrs(root)) {
36
+ const m = basename(rel).match(/^(\d{3})-/);
37
+ if (m !== null) max = Math.max(max, Number(m[1]));
38
+ }
39
+ }
40
+ return max + 1;
41
+ }
42
+
43
+ const NEW_FLAGS: FlagSpec[] = [
44
+ { name: 'json', boolean: true },
45
+ ];
46
+
47
+ function today(): string {
48
+ return new Date().toISOString().slice(0, 10);
49
+ }
50
+
51
+ /** §21/§37: `new` never silently resets an existing artifact. If the target
52
+ * file already exists, refuse unless the content would be byte-identical
53
+ * (idempotent no-op). */
54
+ async function existingContentGuard(
55
+ abs: string,
56
+ file: string,
57
+ content: string,
58
+ change: string,
59
+ ): Promise<NewResult | null> {
60
+ if (!isFile(abs)) return null;
61
+ const existing = await Bun.file(abs).text();
62
+ if (existing === content) {
63
+ return { ok: true, command: 'new', exitCode: 0, change, file };
64
+ }
65
+ return {
66
+ ok: false, command: 'new', exitCode: 1, change, file,
67
+ error: `refusing to overwrite existing ${file} — it already has content; delete it or use a different name`,
68
+ };
69
+ }
70
+
71
+ export async function run(args: string[]): Promise<NewResult> {
72
+ const parsed = parseArgs(args, NEW_FLAGS);
73
+
74
+ if (parsed.errors.length > 0) {
75
+ return { ok: false, command: 'new', exitCode: 1, change: '', file: '', error: parsed.errors[0] };
76
+ }
77
+
78
+ const kind = parsed.positional[0] ?? '';
79
+ const rawName = parsed.positional.slice(1).join(' ');
80
+ const slug = slugify(rawName);
81
+
82
+ if (kind !== 'task' && kind !== 'adr') {
83
+ return {
84
+ ok: false, command: 'new', exitCode: 1, change: rawName, file: '',
85
+ error: kind === '' ? 'usage: cans new <adr|task> <name>' : `unknown kind "${kind}" — use "adr" or "task"`,
86
+ };
87
+ }
88
+
89
+ if (slug === '') {
90
+ return {
91
+ ok: false, command: 'new', exitCode: 1, change: rawName, file: '',
92
+ error: `empty slug from "${rawName}" — provide a non-empty title`,
93
+ };
94
+ }
95
+
96
+ // §21 (QA-06 6c): only `init` may create cans/. `new` operates on an existing
97
+ // workspace (walk-up `cans/`, CANS_ROOT, or a scratch dir under <cwd>/.tmp)
98
+ // and must REFUSE — never auto-vivify a partial cans/ skeleton — when no
99
+ // workspace directory exists. Every resolver result except the
100
+ // `<cwd>/cans` last-resort points at a directory that already exists on
101
+ // disk, so requiring an existing directory is exactly the no-vivify guard.
102
+ let workspace = resolveWorkspaceRoot();
103
+ if (workspace === null) workspace = resolveWorkspaceOrCreate();
104
+ if (!dirExists(workspace)) {
105
+ return {
106
+ ok: false, command: 'new', exitCode: 1, change: slug, file: '',
107
+ error: 'no cans workspace found — run `cans init` first',
108
+ };
109
+ }
110
+
111
+ if (kind === 'task') {
112
+ const tasksDir = join(workspace, '_tasks');
113
+ mkdirp(tasksDir);
114
+ const template = await readTemplate('task-template.md');
115
+ const content = template.replaceAll('{slug}', slug);
116
+ const file = join('_tasks', `${slug}.md`);
117
+ const guard = await existingContentGuard(join(workspace, file), file, content, slug);
118
+ if (guard !== null) return guard;
119
+ await Bun.write(join(workspace, file), content);
120
+ return { ok: true, command: 'new', exitCode: 0, change: slug, file };
121
+ }
122
+
123
+ // adr
124
+ const adrDir = join(workspace, '_adr');
125
+ mkdirp(adrDir);
126
+ const n = nextAdrNumber(workspace);
127
+ const NNN = String(n).padStart(3, '0');
128
+ const template = await readTemplate('adr-template.md');
129
+ const content = template
130
+ .replaceAll('{NNN}', NNN)
131
+ .replaceAll('{Title}', rawName)
132
+ .replaceAll('{YYYY-MM-DD}', today());
133
+ const file = join('_adr', `${NNN}-${slug}.md`);
134
+ const guard = await existingContentGuard(join(workspace, file), file, content, slug);
135
+ if (guard !== null) return guard;
136
+ await Bun.write(join(workspace, file), content);
137
+ return { ok: true, command: 'new', exitCode: 0, change: slug, file };
138
+ }
@@ -0,0 +1,152 @@
1
+ import { join, basename } from 'path';
2
+ import { readFileSync } from 'fs';
3
+ import type { StatusResult, OutlineNode } from '../types';
4
+ import {
5
+ resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks,
6
+ discoverArchivedTasks, discoverAdrs, dirExists,
7
+ } from '../core/fs';
8
+ import { parseOutline, flattenNodes } from '../core/outline';
9
+ import { parseArgs, type FlagSpec } from '../core/args';
10
+
11
+ export interface StatusArgs {
12
+ unclaimed: boolean;
13
+ blocked: boolean;
14
+ owners: boolean;
15
+ json: boolean;
16
+ errors: string[];
17
+ }
18
+
19
+ const STATUS_FLAGS: FlagSpec[] = [
20
+ { name: 'unclaimed', boolean: true },
21
+ { name: 'blocked', boolean: true },
22
+ { name: 'owners', boolean: true },
23
+ { name: 'json', boolean: true },
24
+ ];
25
+
26
+ export function parseStatusArgs(args: string[]): StatusArgs {
27
+ const parsed = parseArgs(args, STATUS_FLAGS);
28
+ return {
29
+ unclaimed: parsed.flags.has('unclaimed'),
30
+ blocked: parsed.flags.has('blocked'),
31
+ owners: parsed.flags.has('owners'),
32
+ json: parsed.flags.has('json'),
33
+ errors: parsed.errors,
34
+ };
35
+ }
36
+
37
+ // globFiles throws ENOENT on missing dirs — guard the optional ones.
38
+ function safeActiveTasks(root: string): string[] {
39
+ return dirExists(join(root, '_tasks')) ? discoverActiveTasks(root) : [];
40
+ }
41
+
42
+ function safeArchivedTasks(root: string): string[] {
43
+ return dirExists(join(root, '_tasks', '_archive')) ? discoverArchivedTasks(root) : [];
44
+ }
45
+
46
+ function safeAdrs(root: string): string[] {
47
+ return dirExists(join(root, '_adr')) ? discoverAdrs(root) : [];
48
+ }
49
+
50
+ function countConflicts(conflictsPath: string): number {
51
+ try {
52
+ return readFileSync(conflictsPath, 'utf-8')
53
+ .split('\n')
54
+ .filter(line => /status:\s*unresolved/i.test(line)).length;
55
+ } catch {
56
+ return 0;
57
+ }
58
+ }
59
+
60
+ export async function run(args: string[]): Promise<StatusResult> {
61
+ const opts = parseStatusArgs(args);
62
+
63
+ if (opts.errors.length > 0) {
64
+ return {
65
+ ok: false, command: 'status', exitCode: 1,
66
+ specFiles: 0, activeTasks: 0, archivedTasks: 0, adrCount: 0,
67
+ tasks: { total: 0, done: 0, unclaimed: 0, blocked: 0 },
68
+ owners: {},
69
+ taskFiles: [],
70
+ conflicts: 0,
71
+ error: opts.errors[0],
72
+ };
73
+ }
74
+
75
+ const workspace = resolveWorkspaceRoot();
76
+ if (workspace === null) {
77
+ return {
78
+ ok: false, command: 'status', exitCode: 1,
79
+ specFiles: 0, activeTasks: 0, archivedTasks: 0, adrCount: 0,
80
+ tasks: { total: 0, done: 0, unclaimed: 0, blocked: 0 },
81
+ owners: {},
82
+ taskFiles: [],
83
+ conflicts: 0,
84
+ error: 'no cans workspace found — run `cans init` first',
85
+ };
86
+ }
87
+
88
+ const specFiles = discoverSpecFiles(workspace);
89
+ const activeTasks = safeActiveTasks(workspace);
90
+ const archivedTasks = safeArchivedTasks(workspace);
91
+ const adrs = safeAdrs(workspace);
92
+
93
+ let tasksTotal = 0;
94
+ let tasksDone = 0;
95
+ let tasksUnclaimed = 0;
96
+ let blockedFiles = 0;
97
+ const owners: Record<string, { tasks: number; done: number }> = {};
98
+ const taskFiles: StatusResult['taskFiles'] = [];
99
+
100
+ for (const rel of activeTasks) {
101
+ let flat: OutlineNode[] = [];
102
+ try {
103
+ flat = flattenNodes(parseOutline(await Bun.file(join(workspace, rel)).text(), rel));
104
+ } catch {
105
+ // unparsable task file: contributes nothing but its existence
106
+ }
107
+
108
+ const tasks = flat.filter(n => n.isTask && !n.isHumanGate);
109
+ const gates = flat.filter(n => n.isTask && n.isHumanGate);
110
+ const tasksDoneCount = tasks.filter(n => n.isDone).length;
111
+ const gatesDoneCount = gates.filter(n => n.isDone).length;
112
+ const isBlocked = gates.some(n => !n.isDone) || tasks.some(n => !n.isDone);
113
+ const unclaimedCount = tasks.filter(n => n.owner === null).length;
114
+
115
+ taskFiles.push({
116
+ name: basename(rel, '.md'),
117
+ tasksDone: tasksDoneCount,
118
+ tasksTotal: tasks.length,
119
+ gatesDone: gatesDoneCount,
120
+ gatesTotal: gates.length,
121
+ blocked: isBlocked,
122
+ unclaimed: unclaimedCount,
123
+ });
124
+
125
+ if (isBlocked) blockedFiles++;
126
+ tasksTotal += tasks.length;
127
+ tasksDone += tasksDoneCount;
128
+ tasksUnclaimed += unclaimedCount;
129
+
130
+ for (const n of flat) {
131
+ if (!n.isTask) continue;
132
+ if (n.owner === null || n.owner === '@human') continue;
133
+ const entry = owners[n.owner] ?? { tasks: 0, done: 0 };
134
+ entry.tasks++;
135
+ if (n.isDone) entry.done++;
136
+ owners[n.owner] = entry;
137
+ }
138
+ }
139
+
140
+ return {
141
+ ok: true, command: 'status', exitCode: 0,
142
+ specFiles: specFiles.length,
143
+ activeTasks: activeTasks.length,
144
+ archivedTasks: archivedTasks.length,
145
+ adrCount: adrs.length,
146
+ tasks: { total: tasksTotal, done: tasksDone, unclaimed: tasksUnclaimed, blocked: blockedFiles },
147
+ owners,
148
+ taskFiles,
149
+ conflicts: countConflicts(join(workspace, '_collab', 'conflicts.md')),
150
+ filter: opts.unclaimed ? 'unclaimed' : opts.blocked ? 'blocked' : opts.owners ? 'owners' : undefined,
151
+ };
152
+ }
@@ -0,0 +1,4 @@
1
+ export * from './shared';
2
+ export * from './opml';
3
+ export * from './logseq';
4
+ export * from './obsidian';
@@ -0,0 +1,42 @@
1
+ import type { ExternalNode } from '../types';
2
+ import {
3
+ convertOwnerMarkers, convertWikiLinks, logseqSlashLinks, parseCheckbox,
4
+ parseIndent, reverseWikiLinks, stripMetadata,
5
+ } from './shared';
6
+
7
+ /** Logseq page → flat ExternalNode list (document order; hierarchy via `indent`).
8
+ * Drops pure `key:: value` property lines (keys may contain spaces — only `::`
9
+ * marks the property, QA-08 E11), strips `((block-refs))`, `[[wiki]]` → `see:`
10
+ * (with `[[X/Y]]` → `see: X.md#Y` per §28, QA-09 D8), TODO/DONE → isTask/isDone,
11
+ * `⏳ Human` → `← @human` (QA-09 D5). */
12
+ export function parseLogseq(source: string): ExternalNode[] {
13
+ const nodes: ExternalNode[] = [];
14
+ for (const raw of source.split(/\r?\n/)) {
15
+ if (!/^\s*-\s/.test(raw)) continue; // logseq pages are bullets only
16
+ const { isTask, isDone, clean } = parseCheckbox(raw);
17
+ if (/^[\w\s-]+::/.test(clean)) continue; // pure property line → drop
18
+ const text = stripMetadata(
19
+ convertWikiLinks(
20
+ convertOwnerMarkers(logseqSlashLinks(clean.replace(/\(\([\w-]+\)\)/g, '')), 'logseq'),
21
+ ),
22
+ 'logseq',
23
+ );
24
+ if (!text) continue;
25
+ nodes.push({ text, indent: parseIndent(raw), isTask, isDone, children: [], metadata: {} });
26
+ }
27
+ return nodes;
28
+ }
29
+
30
+ /** CANS → Logseq: `- [ ] t` → `- TODO t`, `- [x] t` → `- DONE t`, `see: X#Y` → `[[X#Y]]`. */
31
+ export function serializeLogseq(nodes: ExternalNode[]): string {
32
+ const lines: string[] = [];
33
+ const walk = (list: ExternalNode[]): void => {
34
+ for (const n of list) {
35
+ const marker = n.isTask ? (n.isDone ? 'DONE ' : 'TODO ') : '';
36
+ lines.push(' '.repeat(n.indent) + '- ' + marker + reverseWikiLinks(n.text));
37
+ walk(n.children);
38
+ }
39
+ };
40
+ walk(nodes);
41
+ return lines.length > 0 ? lines.join('\n') + '\n' : '';
42
+ }
@@ -0,0 +1,95 @@
1
+ import type { ExternalNode } from '../types';
2
+ import {
3
+ convertOwnerMarkers, convertWikiLinks, parseCheckbox, parseIndent,
4
+ reverseWikiLinks, stripMetadata,
5
+ } from './shared';
6
+
7
+ /** Remove a leading YAML frontmatter block (`---` fences at very top), fences included. */
8
+ export function stripFrontmatter(source: string): string {
9
+ const m = source.match(/^[ \t]*---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/);
10
+ return m ? source.slice(m[0].length) : source;
11
+ }
12
+
13
+ /** Obsidian callout line classifier (§31 "Handles callout markers").
14
+ * `> [!type] Title` → { kind: 'header', type, title }
15
+ * `> body text` → { kind: 'body', text }
16
+ * null for non-callout lines and empty `>` lines. */
17
+ function parseCalloutLine(
18
+ line: string,
19
+ ): { kind: 'header'; type: string; title: string } | { kind: 'body'; text: string } | null {
20
+ const trimmed = line.trim();
21
+ if (!trimmed.startsWith('>')) return null;
22
+ const content = trimmed.replace(/^>\s*/, '');
23
+ const headerMatch = content.match(/^\[!(\w+)\]\s*(.*)/);
24
+ if (headerMatch) {
25
+ return { kind: 'header', type: headerMatch[1], title: headerMatch[2].trim() };
26
+ }
27
+ if (content.trim() !== '') {
28
+ return { kind: 'body', text: content.trim() };
29
+ }
30
+ return null; // empty `>` line — skip
31
+ }
32
+
33
+ /** Obsidian note → flat ExternalNode list (document order; hierarchy via `indent`).
34
+ * Frontmatter stripped, `[[wiki]]`/`![[embeds]]` → `see:`, `#tags` stripped,
35
+ * callouts (`> [!type]` + body) preserved as nodes (QA-05 F4 — no silent drop),
36
+ * native `- [ ]` checkboxes preserved. */
37
+ export function parseObsidian(source: string): ExternalNode[] {
38
+ const nodes: ExternalNode[] = [];
39
+ let calloutIndent = 0; // callout content attaches at the last bullet's level
40
+
41
+ for (const raw of stripFrontmatter(source).split(/\r?\n/)) {
42
+ // Callout lines (`> …`) become nodes instead of vanishing
43
+ const callout = parseCalloutLine(raw);
44
+ if (callout !== null) {
45
+ if (callout.kind === 'header') {
46
+ // `> [!note] Decision` → node "Decision" (the callout type is metadata, not content)
47
+ const text = callout.title !== '' ? callout.title : callout.type;
48
+ if (text !== '') {
49
+ nodes.push({
50
+ text, indent: calloutIndent, isTask: false, isDone: false,
51
+ children: [], metadata: { callout: callout.type },
52
+ });
53
+ }
54
+ } else {
55
+ // `> body text` → child node under the callout header
56
+ const text = convertWikiLinks(
57
+ stripMetadata(convertOwnerMarkers(callout.text, 'obsidian'), 'obsidian'),
58
+ );
59
+ if (text !== '') {
60
+ nodes.push({
61
+ text, indent: calloutIndent + 1, isTask: false, isDone: false,
62
+ children: [], metadata: {},
63
+ });
64
+ }
65
+ }
66
+ continue;
67
+ }
68
+
69
+ if (!/^\s*-\s/.test(raw)) continue; // bullets only
70
+ const { isTask, isDone, clean } = parseCheckbox(raw);
71
+ // strip #tags before link conversion so anchors (`X#Y`) are never eaten; `![[embed]]` → plain link.
72
+ // §28 inverse: `🤖 agent-1` / `⏳ Human` come back as owner/gate arrows (QA-09 D5).
73
+ const text = convertWikiLinks(
74
+ stripMetadata(convertOwnerMarkers(clean.replace(/!\[\[/g, '[['), 'obsidian'), 'obsidian'),
75
+ );
76
+ if (!text) continue;
77
+ calloutIndent = parseIndent(raw); // track last bullet indent for callout attachment
78
+ nodes.push({ text, indent: parseIndent(raw), isTask, isDone, children: [], metadata: {} });
79
+ }
80
+ return nodes;
81
+ }
82
+
83
+ /** CANS → Obsidian: `see: X#Y` → `[[X#Y]]`, native `- [ ]`/`- [x]` checkboxes kept as-is. */
84
+ export function serializeObsidian(nodes: ExternalNode[]): string {
85
+ const lines: string[] = [];
86
+ const walk = (list: ExternalNode[]): void => {
87
+ for (const n of list) {
88
+ const box = n.isTask ? (n.isDone ? '[x] ' : '[ ] ') : '';
89
+ lines.push(' '.repeat(n.indent) + '- ' + box + reverseWikiLinks(n.text));
90
+ walk(n.children);
91
+ }
92
+ };
93
+ walk(nodes);
94
+ return lines.length > 0 ? lines.join('\n') + '\n' : '';
95
+ }