cans-spec 0.3.0 → 0.5.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
@@ -212,7 +212,7 @@ Done. You have a `cans/` directory. Start writing bullets.
212
212
 
213
213
  1. **Scaffold** — `cans init` creates `cans/` with 7 spec files, `_adr/`, `_tasks/`, `_collab/`. Use `cans init --flat` for a flat layout, `--bare` for the minimal skeleton, `--tool claude` to also emit `CLAUDE.md`.
214
214
  2. **Write your spec as dense bullets** — hierarchy is indentation, cross-links are `see:` references. The outline is the spec; there is nothing else to learn.
215
- 3. **Check health** — `cans check` lints structure, broken refs, deep hops, redundancy, style, and overflow. `cans check --fix` repairs back-pointers. Exit code 0 means clean.
215
+ 3. **Check health** — `cans check` lints structure, broken refs, deep hops, redundancy, style, and overflow. `cans check --fix` repairs back-pointers. Exit codes: `0` clean · `1` warnings · `2` errors — agents read `$?`, skip parsing.
216
216
  4. **Track work** — `cans new task add-dark-mode` creates a task, `cans status` shows the board, `cans done add-dark-mode` archives it (it blocks on `← @human` gates until a human signs off).
217
217
  5. **Mind the budget** — `cans budget read <concept>` gives your agent a token-budgeted reading plan; `cans budget write <concept>` says what it may edit.
218
218
  6. **Interoperate** — `cans export logseq --from cans` and `cans import logseq <path>` round-trip OPML / Dynalist, Logseq, and Obsidian. State lives in git; the transport layer is a plain text format your tools already read.
@@ -279,13 +279,22 @@ That's the entire agent instruction surface. No 12 skills. No 30 adapters. No sl
279
279
  Configure in `cans/_rules.yaml`. Delete a key to disable that check. No migration.
280
280
 
281
281
  ```bash
282
- cans check # human-readable
283
- cans check --json # machine-readable
284
- cans check --fix # rebuild back-pointer comments (nothing else)
285
- cans check --strict # warnings become errors
286
- cans check 04-api.md # single file
282
+ cans check # human-readable: one line per pattern, count prefixes,
283
+ # root-cause grouping, timing on the summary line
284
+ cans check --json # machine-readable: sections → {file, line, rule, detail}
285
+ cans check --show redundancy # expand a folded section (structure|style|refs|
286
+ # redundancy|overflow|all — comma-separated list works)
287
+ cans check --fix # rebuild back-pointer comments (nothing else)
288
+ cans check --strict # warnings flip `ok` to false
289
+ cans check 04-api.md # single file
287
290
  ```
288
291
 
292
+ The default report groups repeated findings: `61× <min children (2/3)` instead
293
+ of 61 identical lines, `91× missing file` with per-target counts instead of 91
294
+ lines + 91 fix hints. Fix hints appear once per pattern; every `file:line`
295
+ stays present in compact comma lists. Exit codes: `0` clean · `1` warnings ·
296
+ `2` errors.
297
+
289
298
  ---
290
299
 
291
300
  ## Workspace layout
package/bin/cans.js CHANGED
@@ -1,21 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Portable launcher for the `cans` bin (issue #12).
3
+ * Portable launcher for the `cans` bin (issue #12; warning hygiene per issue #41).
4
4
  *
5
5
  * Priority: Bun stays the primary runtime.
6
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.
7
+ * in-process; the runtime shim's Bun fast paths apply. No hooks needed.
8
8
  * 2. Bun on PATH but this launcher was started by Node (`npm i -g cans-spec`
9
9
  * then `cans ...`) → re-exec `bun src/cli.ts` so Bun fast paths apply.
10
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).
11
+ * Node's builtin `stripTypeScriptTypes`, installed in this order:
12
+ * a. `module.registerHooks()` (Node ≥ 23.5) — in-process SYNCHRONOUS
13
+ * hooks. No loader thread, so Node never prints the loader-thread
14
+ * ExperimentalWarning, and `module.register()` is never called —
15
+ * so Node ≥ 26 can never emit its
16
+ * "`module.register()` is deprecated. Use `module.registerHooks()`"
17
+ * DeprecationWarning (issue #41: that line leaked into users'
18
+ * captured stdout pipelines). The fix is by construction, not by
19
+ * filtering.
20
+ * b. `module.register()` fallback (Node 23.2–23.4, no registerHooks) —
21
+ * spawns bin/ts-loader.mjs in a loader thread; both the
22
+ * stripTypeScriptTypes ExperimentalWarning and any
23
+ * `module.register` DeprecationWarning are suppressed below
24
+ * (CANS_DEBUG_WARNINGS=1 restores them).
25
+ * c. Neither hook API → actionable error, exit 1.
26
+ *
27
+ * Warning policy (issue #41 design rule 6): runtime warnings NEVER reach
28
+ * stdout. The stripTypeScriptTypes ExperimentalWarning is always suppressed;
29
+ * every other warning goes to stderr via console.error — never stdout.
30
+ *
31
+ * Node's builtin stripping refuses files under node_modules — bin/ts-loader.mjs
32
+ * documents why the stripping lives here (issue #12 QA).
13
33
  *
14
34
  * This file is plain JavaScript ON PURPOSE: it must execute on any Node that
15
35
  * npm can provide, before any TypeScript capability is available.
16
36
  */
17
37
  import { spawnSync } from 'node:child_process';
18
- import { register } from 'node:module';
38
+ import { readFileSync } from 'node:fs';
19
39
  import { dirname, join } from 'node:path';
20
40
  import { fileURLToPath, pathToFileURL } from 'node:url';
21
41
 
@@ -35,7 +55,8 @@ async function runInProcess() {
35
55
  }
36
56
  }
37
57
 
38
- // 1. Already under Bun — in-process, Bun fast paths.
58
+ // 1. Already under Bun — in-process, Bun fast paths. Bun prints no runtime
59
+ // warnings for this workload; no hooks or filters are installed here.
39
60
  if (typeof globalThis.Bun !== 'undefined') {
40
61
  await runInProcess();
41
62
  }
@@ -54,7 +75,8 @@ if (!probe.error && probe.status === 0) {
54
75
  // 3. Node fallback — needs node:module.stripTypeScriptTypes (Node ≥23.2; made
55
76
  // stable in 23.6). Termux `pkg install nodejs` ships 26.x, which qualifies.
56
77
  // Namespace access (not a named import) so older Nodes link this file fine.
57
- const { stripTypeScriptTypes } = await import('node:module');
78
+ const nodeModule = await import('node:module');
79
+ const { stripTypeScriptTypes } = nodeModule;
58
80
  if (typeof stripTypeScriptTypes !== 'function') {
59
81
  console.error(
60
82
  `✗ cans requires Bun >= 1.0 (primary) or Node >= 23.2 with builtin TypeScript stripping; found Node ${process.versions.node} without Bun.`,
@@ -63,14 +85,65 @@ if (typeof stripTypeScriptTypes !== 'function') {
63
85
  process.exit(1);
64
86
  }
65
87
 
66
- // Node prints an ExperimentalWarning for stripTypeScriptTypes on every run;
67
- // suppress just that one and pass everything else through untouched.
88
+ // Warning policy (issue #41, design rule 6): keep runtime warnings off stdout.
89
+ // - The stripTypeScriptTypes ExperimentalWarning is always suppressed: it
90
+ // describes Node's own machinery, not the user's spec. It fires once per
91
+ // process on the first strip call (registerHooks path) or inside the loader
92
+ // thread (register path — silenced there too, see bin/ts-loader.mjs).
93
+ // - The `module.register()` DeprecationWarning can only exist when register()
94
+ // is actually called (fallback b, Node 23.2–23.4); suppressing it here as
95
+ // well is by-construction safe — path (a) never calls register(), so the
96
+ // warning cannot exist there.
97
+ // - Anything else still goes to stderr (console.error) — never stdout.
98
+ // - CANS_DEBUG_WARNINGS=1 restores every warning on both paths (escape hatch).
68
99
  process.removeAllListeners('warning');
69
100
  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}`);
101
+ const msg = String(w?.message ?? w ?? '');
102
+ if (process.env.CANS_DEBUG_WARNINGS === '1') {
103
+ console.error(`(node:${process.pid}) [${w?.name ?? 'Warning'}] ${msg}`);
104
+ return;
105
+ }
106
+ if (w?.name === 'ExperimentalWarning' && /striptypescript|type.?stripping/i.test(msg)) return;
107
+ if (w?.name === 'DeprecationWarning' && msg.includes('module.register')) return;
108
+ console.error(`(node:${process.pid}) [${w?.name ?? 'Warning'}] ${msg}`);
72
109
  });
73
110
 
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();
111
+ // 4a. Preferred: in-process synchronous hooks (Node ≥ 23.5). Same
112
+ // load(url, context, nextLoad) contract as the register() loader, but no
113
+ // loader thread → no ExperimentalWarning, and no register() call → no
114
+ // DeprecationWarning on Node ≥ 26. registerHooks hooks must be
115
+ // SYNCHRONOUS (Node validates the returned source synchronously), so this
116
+ // is the sync twin of bin/ts-loader.mjs's load hook — keep the two
117
+ // logic-for-byte equivalent.
118
+ if (typeof nodeModule.registerHooks === 'function') {
119
+ nodeModule.registerHooks({
120
+ load(url, context, nextLoad) {
121
+ if (url.endsWith('.ts')) {
122
+ const file = fileURLToPath(url);
123
+ // Node ≥23.2 returns an object ({ source }); some versions return the
124
+ // stripped string directly — accept both shapes (same as ts-loader).
125
+ const result = stripTypeScriptTypes(readFileSync(file, 'utf8'), { sourceUrl: file });
126
+ const source = typeof result === 'string' ? result : result?.source;
127
+ return { format: 'module', source, shortCircuit: true };
128
+ }
129
+ return nextLoad(url, context);
130
+ },
131
+ });
132
+ await runInProcess();
133
+ }
134
+
135
+ // 4b. Fallback: loader-thread register() (Node 23.2–23.4, no registerHooks).
136
+ // bin/ts-loader.mjs performs the same stripping inside the thread (where
137
+ // the thread's own ExperimentalWarning is silenced, unless
138
+ // CANS_DEBUG_WARNINGS=1).
139
+ if (typeof nodeModule.register === 'function') {
140
+ nodeModule.register(new URL('./ts-loader.mjs', import.meta.url));
141
+ await runInProcess();
142
+ }
143
+
144
+ // 4c. Neither hook API exists — too old to run the CLI without Bun.
145
+ console.error(
146
+ `✗ cans needs module.registerHooks (Node >= 23.5) or module.register (Node >= 23.2) to run without Bun; found Node ${process.versions.node}.`,
147
+ );
148
+ console.error(' Install Bun: https://bun.sh — Termux: pkg install nodejs.');
149
+ process.exit(1);
package/bin/ts-loader.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ESM loader hook for the Node fallback runtime (issue #12).
2
+ * ESM loader hook for the Node fallback runtime (issue #12, register() path).
3
3
  *
4
4
  * Node's built-in TypeScript type-stripping refuses to process files under
5
5
  * `node_modules`, which breaks `npm i -g cans-spec` on machines without Bun —
@@ -7,6 +7,12 @@
7
7
  * SAME erasable-syntax stripping ourselves via Node's builtin
8
8
  * `node:module.stripTypeScriptTypes` (no dependencies, no build step — source
9
9
  * stays distribution), bypassing only that path-based veto.
10
+ *
11
+ * Issue #41: since Node ≥ 23.5 offers `module.registerHooks()` (in-process
12
+ * synchronous hooks), bin/cans.js prefers it and this file is only loaded when
13
+ * register() is the only option (Node 23.2–23.4) — the loader thread that
14
+ * register() spawns. cans.js carries a synchronous twin of the load() hook
15
+ * below for the registerHooks path; keep the two logic-for-byte equivalent.
10
16
  */
11
17
  import { readFile } from 'node:fs/promises';
12
18
  import { fileURLToPath } from 'node:url';
@@ -14,7 +20,8 @@ import { stripTypeScriptTypes } from 'node:module';
14
20
 
15
21
  // This hook runs inside Node's loader thread, whose default handler prints the
16
22
  // `stripTypeScriptTypes` ExperimentalWarning on every CLI run. Suppress warnings
17
- // scoped to THIS thread only (CANS_DEBUG_WARNINGS=1 restores them).
23
+ // scoped to THIS thread only (CANS_DEBUG_WARNINGS=1 restores them). The main
24
+ // thread's warning policy lives in bin/cans.js.
18
25
  if (process.env.CANS_DEBUG_WARNINGS !== '1') {
19
26
  process.removeAllListeners('warning');
20
27
  process.on('warning', () => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cans-spec",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Canonical Agent-Native Spec — the outline is the spec, the state, and the task board",
5
5
  "bin": {
6
6
  "cans": "./bin/cans.js"
package/src/cli.ts CHANGED
@@ -45,7 +45,12 @@ async function dispatch(): Promise<CommandResult> {
45
45
 
46
46
  try {
47
47
  const result = await dispatch();
48
- emit(result, args.includes('--json'), args.includes('--refs-only'));
48
+ // issue #41: check's --show sections are parsed for the EMIT side only —
49
+ // lazily, so non-check commands never load the check module.
50
+ const show = cmd === 'check'
51
+ ? (await import('./commands/check.ts')).showSectionsFromArgs(args)
52
+ : undefined;
53
+ emit(result, args.includes('--json'), args.includes('--refs-only'), show);
49
54
  process.exit(result.exitCode);
50
55
  } catch (e) {
51
56
  console.error(`✗ Internal error: ${e instanceof Error ? e.message : e}`);
@@ -1,5 +1,7 @@
1
1
  import { join, basename } from 'path';
2
- import type { BudgetReadResult, BudgetWriteResult, OutlineNode, Rules } from '../types.ts';
2
+ import type {
3
+ BudgetReadResult, BudgetWriteResult, OutlineNode, Rules, TokenBudgetRules,
4
+ } from '../types.ts';
3
5
  import { readText } from '../core/runtime.ts';
4
6
  import { resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, dirExists } from '../core/fs.ts';
5
7
  import { parseOutline } from '../core/outline.ts';
@@ -101,6 +103,47 @@ export function parseBudgetArgs(args: string[]): BudgetArgs {
101
103
  };
102
104
  }
103
105
 
106
+ /** §18/§26 (QA-17 round 6, F16/F47/F50): the `token_budget` values are user
107
+ * config consumed by BOTH budget commands — validated exactly like the
108
+ * `--limit` flag, so a garbage value or a dead switch can never silently
109
+ * succeed. Returns the §19 user-correctable error message naming the key and
110
+ * the file, or null when the config is valid.
111
+ *
112
+ * - `enabled` must be a boolean. `false` observably disables budget planning:
113
+ * `budget read` / `budget write` refuse (F16 — the switch is real config,
114
+ * not dead). An empty value (null) keeps the documented default (true),
115
+ * the same §18 empty-value convention `prefer:`/`mode:` follow.
116
+ * - `default_limit` gets the SAME validation as `--limit` (flag≡config
117
+ * parity, issue #15): a finite non-negative integer. 0 stays a valid
118
+ * (degenerate) limit that affords nothing — the truthful empty-plan
119
+ * diagnosis names it, exactly like `--limit 0` does.
120
+ * - `estimate_chars_per_token` must be a finite positive number. A value of
121
+ * 0 or less makes every estimate Infinity and no limit could ever fix it
122
+ * (F50) — the error names the real cause instead of blaming the limit. */
123
+ export function validateTokenBudgetRules(tb: TokenBudgetRules): string | null {
124
+ // Values arrive from the YAML merge at runtime; the static types describe
125
+ // the valid shape only, so compare through unknown.
126
+ const enabled: unknown = tb.enabled;
127
+ if (enabled !== null && typeof enabled !== 'boolean') {
128
+ return `invalid token_budget.enabled "${String(enabled)}" in _rules.yaml — pass true or false`;
129
+ }
130
+ if (enabled === false) {
131
+ return 'budget planning disabled: token_budget.enabled is false in _rules.yaml — set it to true (or delete the key) to plan budgets';
132
+ }
133
+ const defaultLimit: unknown = tb.default_limit;
134
+ if (
135
+ typeof defaultLimit !== 'number' || !Number.isFinite(defaultLimit) ||
136
+ !Number.isInteger(defaultLimit) || defaultLimit < 0
137
+ ) {
138
+ return `invalid token_budget.default_limit "${String(defaultLimit)}" in _rules.yaml — pass a positive integer`;
139
+ }
140
+ const cpt: unknown = tb.estimate_chars_per_token;
141
+ if (typeof cpt !== 'number' || !Number.isFinite(cpt) || cpt <= 0) {
142
+ return `invalid token_budget.estimate_chars_per_token "${String(cpt)}" in _rules.yaml — pass a positive number`;
143
+ }
144
+ return null;
145
+ }
146
+
104
147
  function readFail(concept: string, error: string): BudgetReadResult {
105
148
  return {
106
149
  ok: false, command: 'budget-read', exitCode: 1, concept,
@@ -162,6 +205,18 @@ export async function run(args: string[]): Promise<BudgetReadResult | BudgetWrit
162
205
  return opts.mode === 'write' ? writeFail(opts.concept, error) : readFail(opts.concept, error);
163
206
  }
164
207
 
208
+ // §18/§26 (QA-17 round 6): both budget commands validate the token_budget
209
+ // VALUES the moment the rules load — flag≡config parity for limit values
210
+ // (issue #15, F47), a real `enabled` switch (F16), and finite token
211
+ // estimates (F50). A garbage config value is a user-correctable error even
212
+ // when a valid --limit is present: silently ignoring it was the F47 hole.
213
+ const budgetConfigError = validateTokenBudgetRules(rules.token_budget);
214
+ if (budgetConfigError !== null) {
215
+ return opts.mode === 'write'
216
+ ? writeFail(opts.concept, budgetConfigError)
217
+ : readFail(opts.concept, budgetConfigError);
218
+ }
219
+
165
220
  const files = new Map<string, OutlineNode[]>();
166
221
  for (const rel of discoverSpecFiles(workspace)) {
167
222
  try {
@@ -210,21 +265,37 @@ export async function run(args: string[]): Promise<BudgetReadResult | BudgetWrit
210
265
  activeTaskPaths,
211
266
  );
212
267
  if (result.plan.length === 0) {
213
- // §37 truthfulness: distinguish "the concept matches nothing" from
214
- // "the limit is smaller than the cheapest matching item" — a limit
215
- // problem must never be reported as a spelling problem (QA-10 M2b).
216
- if (opts.limit !== null) {
217
- const unbounded = buildReadPlan(
218
- opts.concept, files, graph.back, rules.token_budget,
219
- undefined, taskFile, activeTaskPaths,
220
- );
221
- if (unbounded.plan.length > 0) {
222
- const cheapest = Math.min(...unbounded.plan.map(p => p.estTokens));
223
- return readFail(
224
- opts.concept,
225
- `plan empty: --limit ${opts.limit} is below the cheapest item (${cheapest} tok) — raise the limit`,
226
- );
227
- }
268
+ // §37 truthfulness (issues #15/#16): distinguish "the concept matches
269
+ // nothing" from "the effective limit cannot afford any matching item" —
270
+ // a limit problem must never be reported as a spelling problem
271
+ // (QA-10 M2b), regardless of whether the limit came from an explicit
272
+ // --limit flag or from token_budget.default_limit in _rules.yaml (the
273
+ // two paths to the identical limit value must behave identically). The
274
+ // comparison plan is TRULY unbounded (Infinity — `undefined` would fall
275
+ // back to rules.default_limit and hide cases behind the same tiny
276
+ // limit), so it is non-empty exactly when the concept matches at least
277
+ // one file.
278
+ const unbounded = buildReadPlan(
279
+ opts.concept, files, graph.back, rules.token_budget,
280
+ Infinity, taskFile, activeTaskPaths,
281
+ );
282
+ if (unbounded.plan.length > 0) {
283
+ // Best-effort packing (§26 step 4) already includes every matching
284
+ // item that fits, so an empty plan means the limit is below EVERY
285
+ // item — name the top-priority item that busts it (file + estTokens),
286
+ // the actual source of the limit, and the matching remedy; never a
287
+ // false cause.
288
+ const top = unbounded.plan[0]!;
289
+ const source = opts.limit !== null
290
+ ? `--limit ${opts.limit}`
291
+ : `token_budget.default_limit (${result.budgetLimit}) in _rules.yaml`;
292
+ const remedy = opts.limit !== null
293
+ ? 'raise the limit'
294
+ : 'raise default_limit or pass --limit';
295
+ return {
296
+ ...result, ok: false, exitCode: 1,
297
+ error: `plan empty: ${source} is below the top-priority item ${top.file} (${top.estTokens} tok) — ${remedy}`,
298
+ };
228
299
  }
229
300
  return { ...result, ok: false, exitCode: 1, error: noMatchError(opts.concept) };
230
301
  }