cans-spec 0.1.1 → 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.
package/README.md CHANGED
@@ -169,7 +169,15 @@ This is the most important section.
169
169
 
170
170
  ## Install
171
171
 
172
- Requires [Bun](https://bun.sh) ≥ 1.0 on your PATH (the CLI runs on the Bun runtime). No build step. Source IS distribution.
172
+ No build step. Source IS distribution. Two runtimes are supported — Bun is the
173
+ primary, Node.js is the fallback (issue #12):
174
+
175
+ - **Bun ≥ 1.0 (recommended, primary)** — fastest path, used automatically when
176
+ Bun is on your PATH.
177
+ - **Node.js ≥ 23.2 (fallback)** — for Termux / Android harnesses and any
178
+ environment without Bun. Termux: `pkg install nodejs` (ships Node 26.x ✓).
179
+ Node's builtin TypeScript stripping runs the sources directly; running from
180
+ a source checkout also works on Node ≥ 22.6 via `--experimental-strip-types`.
173
181
 
174
182
  **npm**
175
183
 
@@ -187,6 +195,8 @@ bun install -g cans-spec
187
195
 
188
196
  ```bash
189
197
  npx -p cans-spec cans init
198
+ # or, with Bun installed:
199
+ bunx cans-spec init
190
200
  ```
191
201
 
192
202
  All three give you the same `cans` command:
@@ -315,19 +325,21 @@ cans/
315
325
 
316
326
  ## Requirements
317
327
 
318
- - [Bun](https://bun.sh) ≥ 1.0
328
+ - [Bun](https://bun.sh) ≥ 1.0 (primary) **or** Node.js ≥ 23.2 (fallback, no Bun needed — Termux: `pkg install nodejs`)
319
329
  - Git (for coordination; not required by CANS itself)
320
330
  - An AI agent that can read markdown (all of them)
321
331
 
322
- No Node.js. No Python. No `package-lock.json`. No `node_modules`. The `dependencies` field in `package.json` does not exist.
332
+ No Python. No `package-lock.json`. No build step. The `dependencies` field in `package.json` does not exist — zero runtime dependencies on both runtimes.
323
333
 
324
334
  ---
325
335
 
326
336
  ## Contributing
327
337
 
328
- The entire codebase is ~1080 lines of TypeScript across 20 files. Read it in an afternoon.
338
+ The entire codebase is ~1100 lines of TypeScript across 20 files. Read it in an afternoon.
329
339
 
330
- - `bun test` runs the suite (~95 tests)
340
+ - `bun test` runs the suite on the primary runtime; `npm run test:node` runs the same suite on the Node fallback (node:test via a tiny bun:test compat shim in `test/node-compat.ts`)
341
+ - CI runs both (`test-bun` + `test-node` matrix on Node 22/24)
342
+ - All runtime-sensitive IO goes through one shim, `src/core/runtime.ts` — the only file allowed to touch Bun APIs; a self-audit test keeps it that way
331
343
  - No build step. Edit `src/`, run `bun test`, done.
332
344
  - PRs that add features get closed. PRs that delete code get merged.
333
345
  - If you want a new command, open an issue and justify it. Most get rejected. That's the point.
package/bin/cans.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Portable launcher for the `cans` bin (issue #12).
4
+ *
5
+ * Priority: Bun stays the primary runtime.
6
+ * 1. Already executing under Bun (`bunx cans-spec`, `bun run`) → run the CLI
7
+ * in-process; the runtime shim's Bun fast paths apply.
8
+ * 2. Bun on PATH but this launcher was started by Node (`npm i -g cans-spec`
9
+ * then `cans ...`) → re-exec `bun src/cli.ts` so Bun fast paths apply.
10
+ * 3. No Bun → run the CLI in-process on Node, stripping TypeScript via
11
+ * Node's builtin `stripTypeScriptTypes` through bin/ts-loader.mjs (Node's
12
+ * default stripping refuses files under node_modules — see issue #12 QA).
13
+ *
14
+ * This file is plain JavaScript ON PURPOSE: it must execute on any Node that
15
+ * npm can provide, before any TypeScript capability is available.
16
+ */
17
+ import { spawnSync } from 'node:child_process';
18
+ import { register } from 'node:module';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath, pathToFileURL } from 'node:url';
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const cli = join(here, '..', 'src', 'cli.ts');
24
+ const args = process.argv.slice(2);
25
+
26
+ /** Run cli.ts in the current process; it calls process.exit() itself, but a
27
+ * load-time failure (unreadable package.json, syntax) must still report. */
28
+ async function runInProcess() {
29
+ try {
30
+ await import(pathToFileURL(cli).href);
31
+ process.exit(process.exitCode ?? 0);
32
+ } catch (e) {
33
+ console.error(`✗ Internal error: ${e instanceof Error ? e.message : e}`);
34
+ process.exit(2);
35
+ }
36
+ }
37
+
38
+ // 1. Already under Bun — in-process, Bun fast paths.
39
+ if (typeof globalThis.Bun !== 'undefined') {
40
+ await runInProcess();
41
+ }
42
+
43
+ // 2. Bun on PATH — re-exec so Bun stays the primary runtime.
44
+ const probe = spawnSync('bun', ['--version'], { stdio: 'ignore' });
45
+ if (!probe.error && probe.status === 0) {
46
+ const r = spawnSync('bun', [cli, ...args], { stdio: 'inherit' });
47
+ if (r.error) {
48
+ console.error(`✗ Internal error: failed to launch bun: ${r.error.message}`);
49
+ process.exit(2);
50
+ }
51
+ process.exit(r.status ?? 130);
52
+ }
53
+
54
+ // 3. Node fallback — needs node:module.stripTypeScriptTypes (Node ≥23.2; made
55
+ // stable in 23.6). Termux `pkg install nodejs` ships 26.x, which qualifies.
56
+ // Namespace access (not a named import) so older Nodes link this file fine.
57
+ const { stripTypeScriptTypes } = await import('node:module');
58
+ if (typeof stripTypeScriptTypes !== 'function') {
59
+ console.error(
60
+ `✗ cans requires Bun >= 1.0 (primary) or Node >= 23.2 with builtin TypeScript stripping; found Node ${process.versions.node} without Bun.`,
61
+ );
62
+ console.error(' Install Bun: https://bun.sh — Termux: pkg install nodejs.');
63
+ process.exit(1);
64
+ }
65
+
66
+ // Node prints an ExperimentalWarning for stripTypeScriptTypes on every run;
67
+ // suppress just that one and pass everything else through untouched.
68
+ process.removeAllListeners('warning');
69
+ process.on('warning', (w) => {
70
+ if (w && w.name === 'ExperimentalWarning' && String(w.message).includes('stripTypeScriptTypes')) return;
71
+ console.error(`(node:${process.pid}) [${w?.name ?? 'Warning'}] ${w?.message ?? w}`);
72
+ });
73
+
74
+ // 4. Register the .ts loader hook, then execute the CLI in-process.
75
+ register(new URL('./ts-loader.mjs', import.meta.url));
76
+ await runInProcess();
@@ -0,0 +1,34 @@
1
+ /**
2
+ * ESM loader hook for the Node fallback runtime (issue #12).
3
+ *
4
+ * Node's built-in TypeScript type-stripping refuses to process files under
5
+ * `node_modules`, which breaks `npm i -g cans-spec` on machines without Bun —
6
+ * the exact Termux scenario this package must support. This hook performs the
7
+ * SAME erasable-syntax stripping ourselves via Node's builtin
8
+ * `node:module.stripTypeScriptTypes` (no dependencies, no build step — source
9
+ * stays distribution), bypassing only that path-based veto.
10
+ */
11
+ import { readFile } from 'node:fs/promises';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { stripTypeScriptTypes } from 'node:module';
14
+
15
+ // This hook runs inside Node's loader thread, whose default handler prints the
16
+ // `stripTypeScriptTypes` ExperimentalWarning on every CLI run. Suppress warnings
17
+ // scoped to THIS thread only (CANS_DEBUG_WARNINGS=1 restores them).
18
+ if (process.env.CANS_DEBUG_WARNINGS !== '1') {
19
+ process.removeAllListeners('warning');
20
+ process.on('warning', () => {});
21
+ }
22
+
23
+ export async function load(url, context, nextLoad) {
24
+ if (url.endsWith('.ts')) {
25
+ const file = fileURLToPath(url);
26
+ const code = await readFile(file, 'utf8');
27
+ // Node ≥23.2 returns an object ({ source }); some versions return the
28
+ // stripped string directly — accept both shapes.
29
+ const result = stripTypeScriptTypes(code, { sourceUrl: file });
30
+ const source = typeof result === 'string' ? result : result?.source;
31
+ return { format: 'module', source, shortCircuit: true };
32
+ }
33
+ return nextLoad(url, context);
34
+ }
package/package.json CHANGED
@@ -1,20 +1,23 @@
1
1
  {
2
2
  "name": "cans-spec",
3
- "version": "0.1.1",
4
- "description": "Canonical Agent-Native Spec — the outline is the spec, the state, and the task board",
3
+ "version": "0.2.0",
4
+ "description": "Canonical Agent-Native Spec \u2014 the outline is the spec, the state, and the task board",
5
5
  "bin": {
6
- "cans": "./src/cli.ts"
6
+ "cans": "./bin/cans.js"
7
7
  },
8
8
  "type": "module",
9
9
  "engines": {
10
- "bun": ">=1.0.0"
10
+ "bun": ">=1.0.0",
11
+ "node": ">=23.2.0"
11
12
  },
12
13
  "scripts": {
13
- "typecheck": "bunx tsc --noEmit",
14
+ "typecheck": "tsc --noEmit",
14
15
  "test": "bun test",
15
- "prepublishOnly": "bun run typecheck && bun test"
16
+ "test:node": "node --test --experimental-strip-types \"test/**/*.test.ts\"",
17
+ "prepublishOnly": "npm run typecheck && bun test"
16
18
  },
17
19
  "files": [
20
+ "bin/",
18
21
  "src/",
19
22
  "templates/",
20
23
  "README.md",
package/src/cli.ts CHANGED
@@ -1,23 +1,28 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
 
3
- import type { CommandResult } from './types';
4
- import { emit } from './core/output';
3
+ import type { CommandResult } from './types.ts';
4
+ import { emit } from './core/output.ts';
5
+ import { argv, readText, dirFromUrl } from './core/runtime.ts';
6
+ import { join } from 'node:path';
5
7
 
6
- /** §44: version line. Keep in sync with package.json. */
7
- const VERSION = '0.1.0';
8
+ /** §44: version line — derived from package.json so it can never drift.
9
+ * Read through the runtime shim (issue #12): a bare JSON import has no
10
+ * import attributes and is therefore not portable to the Node fallback. */
11
+ const PKG_FILE = join(dirFromUrl(import.meta.url), '..', 'package.json');
12
+ const VERSION: string = (JSON.parse(await readText(PKG_FILE)) as { version: string }).version;
8
13
 
9
- const [cmd, ...args] = Bun.argv.slice(2);
14
+ const [cmd, ...args] = argv();
10
15
 
11
16
  async function dispatch(): Promise<CommandResult> {
12
17
  switch (cmd) {
13
- case 'init': return (await import('./commands/init')).run(args);
14
- case 'check': return (await import('./commands/check')).run(args);
15
- case 'new': return (await import('./commands/new')).run(args);
16
- case 'done': return (await import('./commands/done')).run(args);
17
- case 'status': return (await import('./commands/status')).run(args);
18
- case 'budget': return (await import('./commands/budget')).run(args);
19
- case 'import': return (await import('./commands/import')).run(args);
20
- case 'export': return (await import('./commands/export')).run(args);
18
+ case 'init': return (await import('./commands/init.ts')).run(args);
19
+ case 'check': return (await import('./commands/check.ts')).run(args);
20
+ case 'new': return (await import('./commands/new.ts')).run(args);
21
+ case 'done': return (await import('./commands/done.ts')).run(args);
22
+ case 'status': return (await import('./commands/status.ts')).run(args);
23
+ case 'budget': return (await import('./commands/budget.ts')).run(args);
24
+ case 'import': return (await import('./commands/import.ts')).run(args);
25
+ case 'export': return (await import('./commands/export.ts')).run(args);
21
26
  case 'help':
22
27
  case '-h': // §20/§44: conventional help shortcuts
23
28
  case '--help':
@@ -1,10 +1,11 @@
1
1
  import { join, basename } from 'path';
2
- import type { BudgetReadResult, BudgetWriteResult, OutlineNode, Rules } from '../types';
3
- import { resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, dirExists } from '../core/fs';
4
- import { parseOutline } from '../core/outline';
5
- import { loadRules } from '../core/rules';
6
- import { buildRefGraph } from '../core/refs';
7
- import { buildReadPlan, buildWritePlan } from '../core/token-budget';
2
+ import type { BudgetReadResult, BudgetWriteResult, OutlineNode, Rules } from '../types.ts';
3
+ import { readText } from '../core/runtime.ts';
4
+ import { resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, dirExists } from '../core/fs.ts';
5
+ import { parseOutline } from '../core/outline.ts';
6
+ import { loadRules } from '../core/rules.ts';
7
+ import { buildRefGraph } from '../core/refs.ts';
8
+ import { buildReadPlan, buildWritePlan } from '../core/token-budget.ts';
8
9
 
9
10
  export interface BudgetArgs {
10
11
  mode: 'read' | 'write';
@@ -164,7 +165,7 @@ export async function run(args: string[]): Promise<BudgetReadResult | BudgetWrit
164
165
  const files = new Map<string, OutlineNode[]>();
165
166
  for (const rel of discoverSpecFiles(workspace)) {
166
167
  try {
167
- files.set(rel, parseOutline(await Bun.file(join(workspace, rel)).text(), rel));
168
+ files.set(rel, parseOutline(await readText(join(workspace, rel)), rel));
168
169
  } catch {
169
170
  // unreadable spec file: excluded from the plan
170
171
  }
@@ -1,23 +1,24 @@
1
1
  import { join } from 'path';
2
- import type { CheckResult, Issue, OutlineNode } from '../types';
2
+ import type { CheckResult, Issue, OutlineNode } from '../types.ts';
3
+ import { readText, writeText } from '../core/runtime.ts';
3
4
  import {
4
5
  discoverSpecFiles, discoverActiveTasks, discoverAdrs, resolveWorkspaceRoot,
5
6
  dirExists, detectFlatFolderConflicts, detectMalformedSpecDirs, discoverOverflowTargets,
6
- } from '../core/fs';
7
+ } from '../core/fs.ts';
7
8
  import {
8
9
  parseOutline, extractBackPointers, flattenNodes, maxDepth as outlineMaxDepth,
9
10
  type ParseWarning,
10
- } from '../core/outline';
11
- import { loadRules } from '../core/rules';
12
- import { checkStructure, checkTbdPolicy } from '../core/structure';
13
- import { checkStyle } from '../core/style';
14
- import { checkOverflow, checkNoChaining } from '../core/overflow';
15
- import { checkRedundancy } from '../core/redundancy';
11
+ } from '../core/outline.ts';
12
+ import { loadRules } from '../core/rules.ts';
13
+ import { checkStructure, checkTbdPolicy } from '../core/structure.ts';
14
+ import { checkStyle } from '../core/style.ts';
15
+ import { checkOverflow, checkNoChaining } from '../core/overflow.ts';
16
+ import { checkRedundancy } from '../core/redundancy.ts';
16
17
  import {
17
18
  buildRefGraph, checkRefs, detectDeepHops, detectOrphans,
18
19
  rebuildBackPointers, targetMatchesKey,
19
- } from '../core/refs';
20
- import { parseArgs, formatArgErrors, type FlagSpec } from '../core/args';
20
+ } from '../core/refs.ts';
21
+ import { parseArgs, formatArgErrors, type FlagSpec } from '../core/args.ts';
21
22
 
22
23
  export interface CheckArgs {
23
24
  fix: boolean;
@@ -181,7 +182,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
181
182
  for (const rel of specRel) {
182
183
  let text = '';
183
184
  try {
184
- text = await Bun.file(join(root, rel)).text();
185
+ text = await readText(join(root, rel));
185
186
  } catch (e) {
186
187
  issues.push({
187
188
  file: rel, line: 0, level: 'error', category: 'structure',
@@ -212,7 +213,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
212
213
  const auxFiles = new Map<string, OutlineNode[]>();
213
214
  for (const rel of [...safeActiveTasks(root), ...safeAdrs(root)]) {
214
215
  try {
215
- const text = await Bun.file(join(root, rel)).text();
216
+ const text = await readText(join(root, rel));
216
217
  auxFiles.set(rel, parseOutline(text, rel));
217
218
  } catch {
218
219
  // unreadable/unparseable aux file: its refs are simply not counted
@@ -308,7 +309,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
308
309
  const targetFiles = new Map<string, OutlineNode[]>();
309
310
  for (const rel of discoverOverflowTargets(root)) {
310
311
  try {
311
- targetFiles.set(rel, parseOutline(await Bun.file(join(root, rel)).text(), rel));
312
+ targetFiles.set(rel, parseOutline(await readText(join(root, rel)), rel));
312
313
  } catch {
313
314
  // unreadable overflow target: skipped
314
315
  }
@@ -326,7 +327,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
326
327
  const body = desired.get(rel) ?? null;
327
328
  const rewritten = rewriteRefBy(source, body);
328
329
  if (rewritten !== source) {
329
- await Bun.write(join(root, rel), rewritten);
330
+ await writeText(join(root, rel), rewritten);
330
331
  specSources.set(rel, rewritten);
331
332
  backPointersUpdated++;
332
333
  }
@@ -1,10 +1,11 @@
1
1
  import { join } from 'path';
2
2
  import { renameSync } from 'fs';
3
- import type { DoneResult, OutlineNode } from '../types';
4
- import { resolveWorkspaceRoot, mkdirp, isFile, dirExists, globFiles } from '../core/fs';
5
- import { parseOutline, flattenNodes } from '../core/outline';
6
- import { checkWorkspace, type CheckArgs } from './check';
7
- import { parseArgs, type FlagSpec } from '../core/args';
3
+ import type { DoneResult, OutlineNode } from '../types.ts';
4
+ import { readText } from '../core/runtime.ts';
5
+ import { resolveWorkspaceRoot, mkdirp, isFile, dirExists, globFiles } from '../core/fs.ts';
6
+ import { parseOutline, flattenNodes } from '../core/outline.ts';
7
+ import { checkWorkspace, type CheckArgs } from './check.ts';
8
+ import { parseArgs, type FlagSpec } from '../core/args.ts';
8
9
 
9
10
  export interface DoneArgs {
10
11
  name: string;
@@ -105,7 +106,7 @@ export async function run(args: string[]): Promise<DoneResult> {
105
106
  let taskNodes: OutlineNode[] = [];
106
107
  let flat: OutlineNode[] = [];
107
108
  try {
108
- taskNodes = parseOutline(await Bun.file(taskFile).text(), `_tasks/${name}.md`);
109
+ taskNodes = parseOutline(await readText(taskFile), `_tasks/${name}.md`);
109
110
  flat = flattenNodes(taskNodes);
110
111
  } catch {
111
112
  return failResult(name, `cannot parse _tasks/${name}.md — check for tab indentation or malformed content`);
@@ -1,13 +1,14 @@
1
1
  import { join, basename, relative } from 'path';
2
- import type { ExportResult, ExportFormat, OutlineNode, ExternalNode } from '../types';
2
+ import type { ExportResult, ExportFormat, OutlineNode, ExternalNode } from '../types.ts';
3
+ import { readText, writeText } from '../core/runtime.ts';
3
4
  import {
4
5
  resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, discoverAdrs,
5
6
  mkdirp, dirExists, exists,
6
- } from '../core/fs';
7
- import { parseOutline } from '../core/outline';
8
- import { serializeOpml } from '../converters/opml';
9
- import { serializeLogseq } from '../converters/logseq';
10
- import { serializeObsidian } from '../converters/obsidian';
7
+ } from '../core/fs.ts';
8
+ import { parseOutline } from '../core/outline.ts';
9
+ import { serializeOpml } from '../converters/opml.ts';
10
+ import { serializeLogseq } from '../converters/logseq.ts';
11
+ import { serializeObsidian } from '../converters/obsidian.ts';
11
12
 
12
13
  export interface ExportArgs {
13
14
  format: ExportFormat;
@@ -185,7 +186,7 @@ export async function run(args: string[]): Promise<ExportResult> {
185
186
  for (const rel of sources) {
186
187
  let text = '';
187
188
  try {
188
- text = await Bun.file(join(workspace, rel)).text();
189
+ text = await readText(join(workspace, rel));
189
190
  } catch {
190
191
  continue;
191
192
  }
@@ -202,7 +203,7 @@ export async function run(args: string[]): Promise<ExportResult> {
202
203
  if (!opts.dryRun) {
203
204
  try {
204
205
  mkdirp(fmtDir);
205
- await Bun.write(join(fmtDir, outputFileName(rel, f)), content);
206
+ await writeText(join(fmtDir, outputFileName(rel, f)), content);
206
207
  } catch (e) {
207
208
  const code = (e as NodeJS.ErrnoException | null)?.code;
208
209
  if (code === 'ENOTDIR' || code === 'EEXIST') return notDirError(fmtDir);
@@ -2,15 +2,16 @@ import { join, basename, dirname } from 'path';
2
2
  import { readdirSync } from 'fs';
3
3
  import type {
4
4
  ImportResult, ImportFormat, ImportConflict, MergeStrategy, ExternalNode,
5
- } from '../types';
6
- import { resolveWorkspaceRoot, discoverSpecFiles, mkdirp, isFile, dirExists } from '../core/fs';
7
- import { convertArrowRefs, parseOpml, parseOpmlTitle } from '../converters/opml';
8
- import { parseLogseq } from '../converters/logseq';
9
- import { parseObsidian, stripFrontmatter } from '../converters/obsidian';
5
+ } from '../types.ts';
6
+ import { readText, writeText } from '../core/runtime.ts';
7
+ import { resolveWorkspaceRoot, discoverSpecFiles, mkdirp, isFile, dirExists } from '../core/fs.ts';
8
+ import { convertArrowRefs, parseOpml, parseOpmlTitle } from '../converters/opml.ts';
9
+ import { parseLogseq } from '../converters/logseq.ts';
10
+ import { parseObsidian, stripFrontmatter } from '../converters/obsidian.ts';
10
11
  import {
11
12
  serializeToCans, parseFromCans, stripMetadata, parseCheckbox,
12
13
  extractOverflowContent, type OverflowExtraction,
13
- } from '../converters/shared';
14
+ } from '../converters/shared.ts';
14
15
 
15
16
  export interface ImportArgs {
16
17
  format: ImportFormat;
@@ -325,7 +326,7 @@ async function findExistingByRootText(targetDir: string, imported: ExternalNode[
325
326
  for (const rel of discoverSpecFiles(targetDir)) {
326
327
  let text = '';
327
328
  try {
328
- text = await Bun.file(join(targetDir, rel)).text();
329
+ text = await readText(join(targetDir, rel));
329
330
  } catch {
330
331
  continue;
331
332
  }
@@ -400,7 +401,7 @@ export async function run(args: string[]): Promise<ImportResult> {
400
401
  for (const src of files) {
401
402
  let text = '';
402
403
  try {
403
- text = await Bun.file(src).text();
404
+ text = await readText(src);
404
405
  } catch {
405
406
  continue;
406
407
  }
@@ -454,7 +455,7 @@ export async function run(args: string[]): Promise<ImportResult> {
454
455
  if (existingRel !== null) {
455
456
  const absTarget = join(workspace, existingRel);
456
457
  const outcome = mergeInto(
457
- await Bun.file(absTarget).text(),
458
+ await readText(absTarget),
458
459
  imported,
459
460
  opts.mergeStrategy,
460
461
  existingRel,
@@ -462,11 +463,11 @@ export async function run(args: string[]): Promise<ImportResult> {
462
463
  conflicts.push(...outcome.conflicts);
463
464
  if (outcome.content !== null) {
464
465
  if (!opts.dryRun) {
465
- await Bun.write(absTarget, canonicalizeRefTargets(outcome.content));
466
+ await writeText(absTarget, canonicalizeRefTargets(outcome.content));
466
467
  for (const ovf of overflow) {
467
468
  const ovfAbs = join(workspace, ovf.overflowFile);
468
469
  mkdirp(dirname(ovfAbs));
469
- await Bun.write(ovfAbs, `${ovf.content}\n`);
470
+ await writeText(ovfAbs, `${ovf.content}\n`);
470
471
  }
471
472
  }
472
473
  merged.push(existingRel);
@@ -485,11 +486,11 @@ export async function run(args: string[]): Promise<ImportResult> {
485
486
  const cansText = canonicalizeRefTargets(serializeToCans(imported));
486
487
  if (!opts.dryRun) {
487
488
  mkdirp(dirname(absTarget));
488
- await Bun.write(absTarget, cansText);
489
+ await writeText(absTarget, cansText);
489
490
  for (const ovf of overflow) {
490
491
  const ovfAbs = join(workspace, ovf.overflowFile);
491
492
  mkdirp(dirname(ovfAbs));
492
- await Bun.write(ovfAbs, `${ovf.content}\n`);
493
+ await writeText(ovfAbs, `${ovf.content}\n`);
493
494
  }
494
495
  }
495
496
  newFiles.push(relName);
@@ -1,12 +1,13 @@
1
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';
2
+ import type { InitResult } from '../types.ts';
3
+ import { readText, writeText, dirFromUrl } from '../core/runtime.ts';
4
+ import { resolveWorkspaceRoot, resolveInitTarget, mkdirp, exists, dirExists } from '../core/fs.ts';
5
+ import { parseArgs, type FlagSpec } from '../core/args.ts';
5
6
 
6
- const TEMPLATES_DIR = join(import.meta.dir, '..', '..', 'templates');
7
+ const TEMPLATES_DIR = join(dirFromUrl(import.meta.url), '..', '..', 'templates');
7
8
 
8
9
  async function readTemplate(name: string): Promise<string> {
9
- return await Bun.file(join(TEMPLATES_DIR, name)).text();
10
+ return await readText(join(TEMPLATES_DIR, name));
10
11
  }
11
12
 
12
13
  export interface InitArgs {
@@ -168,7 +169,7 @@ export async function run(args: string[]): Promise<InitResult> {
168
169
  skipped.push(entry.path);
169
170
  continue;
170
171
  }
171
- await Bun.write(abs, entry.content ?? '');
172
+ await writeText(abs, entry.content ?? '');
172
173
  created.push(entry.path);
173
174
  }
174
175
 
@@ -1,12 +1,13 @@
1
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';
2
+ import type { NewResult } from '../types.ts';
3
+ import { readText, writeText, dirFromUrl } from '../core/runtime.ts';
4
+ import { resolveWorkspaceRoot, resolveWorkspaceOrCreate, mkdirp, discoverAdrs, dirExists, isFile } from '../core/fs.ts';
5
+ import { parseArgs, type FlagSpec } from '../core/args.ts';
5
6
 
6
- const TEMPLATES_DIR = join(import.meta.dir, '..', '..', 'templates');
7
+ const TEMPLATES_DIR = join(dirFromUrl(import.meta.url), '..', '..', 'templates');
7
8
 
8
9
  async function readTemplate(name: string): Promise<string> {
9
- return await Bun.file(join(TEMPLATES_DIR, name)).text();
10
+ return await readText(join(TEMPLATES_DIR, name));
10
11
  }
11
12
 
12
13
  /** lowercase → strip double quotes → non-alphanumeric runs → hyphens → trim hyphens.
@@ -58,7 +59,7 @@ async function existingContentGuard(
58
59
  change: string,
59
60
  ): Promise<NewResult | null> {
60
61
  if (!isFile(abs)) return null;
61
- const existing = await Bun.file(abs).text();
62
+ const existing = await readText(abs);
62
63
  if (existing === content) {
63
64
  return { ok: true, command: 'new', exitCode: 0, change, file };
64
65
  }
@@ -116,7 +117,7 @@ export async function run(args: string[]): Promise<NewResult> {
116
117
  const file = join('_tasks', `${slug}.md`);
117
118
  const guard = await existingContentGuard(join(workspace, file), file, content, slug);
118
119
  if (guard !== null) return guard;
119
- await Bun.write(join(workspace, file), content);
120
+ await writeText(join(workspace, file), content);
120
121
  return { ok: true, command: 'new', exitCode: 0, change: slug, file };
121
122
  }
122
123
 
@@ -133,6 +134,6 @@ export async function run(args: string[]): Promise<NewResult> {
133
134
  const file = join('_adr', `${NNN}-${slug}.md`);
134
135
  const guard = await existingContentGuard(join(workspace, file), file, content, slug);
135
136
  if (guard !== null) return guard;
136
- await Bun.write(join(workspace, file), content);
137
+ await writeText(join(workspace, file), content);
137
138
  return { ok: true, command: 'new', exitCode: 0, change: slug, file };
138
139
  }
@@ -1,12 +1,13 @@
1
1
  import { join, basename } from 'path';
2
2
  import { readFileSync } from 'fs';
3
- import type { StatusResult, OutlineNode } from '../types';
3
+ import type { StatusResult, OutlineNode } from '../types.ts';
4
+ import { readText } from '../core/runtime.ts';
4
5
  import {
5
6
  resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks,
6
7
  discoverArchivedTasks, discoverAdrs, dirExists,
7
- } from '../core/fs';
8
- import { parseOutline, flattenNodes } from '../core/outline';
9
- import { parseArgs, type FlagSpec } from '../core/args';
8
+ } from '../core/fs.ts';
9
+ import { parseOutline, flattenNodes } from '../core/outline.ts';
10
+ import { parseArgs, type FlagSpec } from '../core/args.ts';
10
11
 
11
12
  export interface StatusArgs {
12
13
  unclaimed: boolean;
@@ -100,7 +101,7 @@ export async function run(args: string[]): Promise<StatusResult> {
100
101
  for (const rel of activeTasks) {
101
102
  let flat: OutlineNode[] = [];
102
103
  try {
103
- flat = flattenNodes(parseOutline(await Bun.file(join(workspace, rel)).text(), rel));
104
+ flat = flattenNodes(parseOutline(await readText(join(workspace, rel)), rel));
104
105
  } catch {
105
106
  // unparsable task file: contributes nothing but its existence
106
107
  }
@@ -1,4 +1,4 @@
1
- export * from './shared';
2
- export * from './opml';
3
- export * from './logseq';
4
- export * from './obsidian';
1
+ export * from './shared.ts';
2
+ export * from './opml.ts';
3
+ export * from './logseq.ts';
4
+ export * from './obsidian.ts';
@@ -1,8 +1,8 @@
1
- import type { ExternalNode } from '../types';
1
+ import type { ExternalNode } from '../types.ts';
2
2
  import {
3
3
  convertOwnerMarkers, convertWikiLinks, logseqSlashLinks, parseCheckbox,
4
4
  parseIndent, reverseWikiLinks, stripMetadata,
5
- } from './shared';
5
+ } from './shared.ts';
6
6
 
7
7
  /** Logseq page → flat ExternalNode list (document order; hierarchy via `indent`).
8
8
  * Drops pure `key:: value` property lines (keys may contain spaces — only `::`
@@ -1,8 +1,8 @@
1
- import type { ExternalNode } from '../types';
1
+ import type { ExternalNode } from '../types.ts';
2
2
  import {
3
3
  convertOwnerMarkers, convertWikiLinks, parseCheckbox, parseIndent,
4
4
  reverseWikiLinks, stripMetadata,
5
- } from './shared';
5
+ } from './shared.ts';
6
6
 
7
7
  /** Remove a leading YAML frontmatter block (`---` fences at very top), fences included. */
8
8
  export function stripFrontmatter(source: string): string {
@@ -1,4 +1,4 @@
1
- import type { ExternalNode } from '../types';
1
+ import type { ExternalNode } from '../types.ts';
2
2
 
3
3
  /** Escape the five XML entities. `&` first so output is never double-encoded. */
4
4
  export function encodeXmlEntity(s: string): string {
@@ -1,4 +1,4 @@
1
- import type { ExternalNode } from '../types';
1
+ import type { ExternalNode } from '../types.ts';
2
2
 
3
3
  /** Leading whitespace → indent units. 2 spaces per level; each tab counts as 2 spaces. */
4
4
  export function parseIndent(raw: string): number {
package/src/core/fs.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { statSync, readdirSync, existsSync, mkdirSync, type Stats } from 'fs';
2
2
  import { join, relative, dirname, basename } from 'path';
3
+ import { globFiles as runtimeGlobFiles } from './runtime.ts';
3
4
 
4
5
  const SPEC_FILE_RE = /^\d{2}-.+\.md$/;
5
6
 
@@ -26,10 +27,8 @@ export function mkdirp(p: string): void {
26
27
  }
27
28
 
28
29
  export function globFiles(dir: string, pattern: string): string[] {
29
- if (!dirExists(dir)) return [];
30
- const g = new Bun.Glob(pattern);
31
- const out = [...g.scanSync({ cwd: dir, onlyFiles: true })] as string[];
32
- return out.sort();
30
+ // Delegated to the runtime shim (issue #12): Bun fast path, node:fs fallback.
31
+ return runtimeGlobFiles(dir, pattern);
33
32
  }
34
33
 
35
34
  /** Spec files: root-level *.md (excluding _-prefixed, AGENTS.md and other tool
package/src/core/index.ts CHANGED
@@ -1,10 +1,10 @@
1
- export * from './output';
2
- export * from './fs';
3
- export * from './outline';
4
- export * from './refs';
5
- export * from './structure';
6
- export * from './style';
7
- export * from './redundancy';
8
- export * from './overflow';
9
- export * from './rules';
10
- export * from './token-budget';
1
+ export * from './output.ts';
2
+ export * from './fs.ts';
3
+ export * from './outline.ts';
4
+ export * from './refs.ts';
5
+ export * from './structure.ts';
6
+ export * from './style.ts';
7
+ export * from './redundancy.ts';
8
+ export * from './overflow.ts';
9
+ export * from './rules.ts';
10
+ export * from './token-budget.ts';
@@ -1,4 +1,4 @@
1
- import type { OutlineNode, BackPointer, RefTarget } from '../types';
1
+ import type { OutlineNode, BackPointer, RefTarget } from '../types.ts';
2
2
 
3
3
  const BULLET_RE = /^(\s*)-\s+(.*)$/;
4
4
  const CHECKBOX_RE = /^\[( |x|X)\]\s+/;
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  CommandResult, CheckResult, Issue, InitResult, NewResult, DoneResult, StatusResult,
3
3
  BudgetReadResult, BudgetWriteResult, ImportResult, ExportResult, VersionResult,
4
- } from '../types';
4
+ } from '../types.ts';
5
5
 
6
6
  /** Single emission point. Commands never console.log or process.exit directly.
7
7
  * `refsOnly` (check only, §22/§36): human output is scoped to the References
@@ -1,5 +1,5 @@
1
- import type { OutlineNode, Issue, OverflowRules } from '../types';
2
- import { flattenNodes } from './outline';
1
+ import type { OutlineNode, Issue, OverflowRules } from '../types.ts';
2
+ import { flattenNodes } from './outline.ts';
3
3
 
4
4
  /** Overflow checks: code fences, tables, over-long nodes. All errors.
5
5
  * §18: `force_file_for` lists the content categories forced into files —
@@ -1,5 +1,5 @@
1
- import type { OutlineNode, Issue, RedundancyRules } from '../types';
2
- import { flattenNodes } from './outline';
1
+ import type { OutlineNode, Issue, RedundancyRules } from '../types.ts';
2
+ import { flattenNodes } from './outline.ts';
3
3
 
4
4
  interface NodeRef {
5
5
  text: string;
package/src/core/refs.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
- import type { OutlineNode, RefTarget, BackPointer, Issue } from '../types';
4
- import { flattenNodes, parseOutline } from './outline';
5
- import { resolveSpecFile, toRelative, isFile } from './fs';
3
+ import type { OutlineNode, RefTarget, BackPointer, Issue } from '../types.ts';
4
+ import { flattenNodes, parseOutline } from './outline.ts';
5
+ import { resolveSpecFile, toRelative, isFile } from './fs.ts';
6
6
 
7
7
  export interface RefGraph {
8
8
  forward: Map<string, RefTarget[]>;
package/src/core/rules.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import type { Rules } from '../types';
3
+ import type { Rules } from '../types.ts';
4
4
 
5
5
  interface YLine {
6
6
  lineNo: number;
@@ -0,0 +1,110 @@
1
+ /** Runtime shim — the ONLY module allowed to touch `Bun.*` APIs (issue #12).
2
+ *
3
+ * Bun stays the primary runtime (fast path, behavior byte-identical to the
4
+ * pre-#12 code); Node.js ≥22.6 is the fallback (Termux / no-Bun harnesses).
5
+ * The Node path uses only `node:` builtins — the zero-dependency law holds on
6
+ * both runtimes. Anything outside this module that needs a runtime-sensitive
7
+ * operation MUST go through one of these exports; a repo self-audit test
8
+ * (test/node-fallback.test.ts) enforces `Bun\.` never appears elsewhere.
9
+ */
10
+ import { readdirSync, statSync } from 'node:fs';
11
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
12
+ import { dirname, join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ /** Minimal structural type for the slice of the Bun global we use. Kept local
16
+ * so the shim also type-checks without relying on ambient `@types/bun`. */
17
+ interface BunGlobal {
18
+ file(path: string): { text(): Promise<string> };
19
+ write(path: string, data: string): Promise<number>;
20
+ Glob: new (pattern: string) => {
21
+ scanSync(opts: { cwd: string; onlyFiles: boolean }): Iterable<string>;
22
+ };
23
+ }
24
+
25
+ const BUN: BunGlobal | undefined = (globalThis as { Bun?: BunGlobal }).Bun;
26
+
27
+ /** Which runtime is executing: `'bun'` (primary) or `'node'` (fallback). */
28
+ export const RUNTIME: 'bun' | 'node' = BUN ? 'bun' : 'node';
29
+
30
+ /** CLI arguments, portable across runtimes (`Bun.argv` is an alias of
31
+ * `process.argv`, so the Node form works on both). */
32
+ export function argv(): string[] {
33
+ return process.argv.slice(2);
34
+ }
35
+
36
+ /** Directory of the module file that passed its `import.meta.url` — the
37
+ * portable replacement for Bun's `import.meta.dir` (Node has no equivalent
38
+ * property; both runtimes support `node:url.fileURLToPath`). */
39
+ export function dirFromUrl(moduleUrl: string): string {
40
+ return dirname(fileURLToPath(moduleUrl));
41
+ }
42
+
43
+ /** Read a UTF-8 text file. Bun: `Bun.file(path).text()` (unchanged fast path).
44
+ * Node: `fs/promises.readFile`. Rejects on missing files on both runtimes. */
45
+ export async function readText(path: string): Promise<string> {
46
+ if (BUN) return BUN.file(path).text();
47
+ return readFile(path, 'utf8');
48
+ }
49
+
50
+ /** Write a UTF-8 text file, creating missing parent directories — `Bun.write`
51
+ * auto-creates parents, so the Node path replicates that (plain
52
+ * `writeFile` would not) to keep behavior identical across runtimes. */
53
+ export async function writeText(path: string, data: string): Promise<void> {
54
+ if (BUN) {
55
+ await BUN.write(path, data);
56
+ return;
57
+ }
58
+ await mkdir(dirname(path), { recursive: true });
59
+ await writeFile(path, data, 'utf8');
60
+ }
61
+
62
+ function isDir(p: string): boolean {
63
+ try {
64
+ return statSync(p).isDirectory();
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /** Glob relative file paths under `dir`, sorted lexicographically — portable
71
+ * replacement for `new Bun.Glob(pattern).scanSync({ cwd, onlyFiles: true })`.
72
+ * Supported forms: single-segment (`*.md`) and recursive (a two-star prefix
73
+ * followed by a slash, e.g. "**" + "/" + "*.md") wildcard patterns; `*` never
74
+ * crosses `/` (same as Bun.Glob). Returns paths relative to `dir`, sorted.
75
+ * Empty array when `dir` does not exist (unchanged contract used by
76
+ * `_tasks`/`_adr` discovery). */
77
+ export function globFiles(dir: string, pattern: string): string[] {
78
+ if (!isDir(dir)) return [];
79
+ if (BUN) {
80
+ const g = new BUN.Glob(pattern);
81
+ return [...g.scanSync({ cwd: dir, onlyFiles: true }) as Iterable<string>].sort();
82
+ }
83
+ const recursive = pattern.startsWith('**/');
84
+ const base = recursive ? pattern.slice(3) : pattern;
85
+ const re = new RegExp(
86
+ `^(?:${base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')})$`,
87
+ );
88
+ const out: string[] = [];
89
+ if (recursive) {
90
+ const walk = (d: string, prefix: string): void => {
91
+ let entries;
92
+ try {
93
+ entries = readdirSync(d, { withFileTypes: true });
94
+ } catch {
95
+ return;
96
+ }
97
+ for (const e of entries) {
98
+ const rel = prefix === '' ? e.name : `${prefix}/${e.name}`;
99
+ if (e.isFile() && re.test(e.name)) out.push(rel);
100
+ else if (e.isDirectory()) walk(join(d, e.name), rel);
101
+ }
102
+ };
103
+ walk(dir, '');
104
+ } else {
105
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
106
+ if (e.isFile() && re.test(e.name)) out.push(e.name);
107
+ }
108
+ }
109
+ return out.sort();
110
+ }
@@ -1,5 +1,5 @@
1
- import type { OutlineNode, Issue, StructureRules, ContentRules } from '../types';
2
- import { flattenNodes } from './outline';
1
+ import type { OutlineNode, Issue, StructureRules, ContentRules } from '../types.ts';
2
+ import { flattenNodes } from './outline.ts';
3
3
 
4
4
  /** Structure checks: node length, depth, sibling count, single-child collapse, empty nodes.
5
5
  * §18 delete-key semantics: a check whose rules key is null/false is OFF — the
package/src/core/style.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { OutlineNode, Issue, StyleRules } from '../types';
1
+ import type { OutlineNode, Issue, StyleRules } from '../types.ts';
2
2
 
3
3
  /** Style checks: shared-prefix nesting hint + unnecessary-nesting collapse hint.
4
4
  * SEVERITY NOTE (arbitration, same class as the refs-severity decision): §14/§36
@@ -3,9 +3,9 @@ import { basename, relative } from 'path';
3
3
  import type {
4
4
  OutlineNode, BackPointer, TokenBudgetRules,
5
5
  BudgetReadPlanItem, BudgetReadResult, BudgetWriteResult,
6
- } from '../types';
7
- import { flattenNodes, parseOutline } from './outline';
8
- import { targetMatchesKey } from './refs';
6
+ } from '../types.ts';
7
+ import { flattenNodes, parseOutline } from './outline.ts';
8
+ import { targetMatchesKey } from './refs.ts';
9
9
 
10
10
  export function estimateTokens(text: string, charsPerToken: number): number {
11
11
  return Math.ceil(text.length / charsPerToken);