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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cans-hq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,339 @@
1
+ <p align="center">
2
+ <strong style="font-size: 2em;">CANS</strong><br>
3
+ <em>Canonical Agent-Native Spec</em>
4
+ </p>
5
+
6
+ <p align="center">
7
+ <b>The outline is the spec. The outline is the state. The outline is the task board.<br>
8
+ There is nothing else.</b>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <code>~1080 lines</code> · <code>0 dependencies</code> · <code>no build step</code> · <code>no schema</code> · <code>no pipeline</code>
13
+ </p>
14
+
15
+ ---
16
+
17
+ ## The pitch
18
+
19
+ Your spec is a markdown file with bullets. Your AI agent reads it and knows everything. Your state lives in checkboxes. Your coordination protocol is indentation and `see:` links. Your transport layer is git.
20
+
21
+ There is no registry. There is no adapter. There is no artifact DAG. There is no Zod schema validating your English sentences. There is no telemetry phoning home. There is no 4-phase pipeline gating your Tuesday afternoon.
22
+
23
+ There is a file. You edit it. `cans check` tells you if it's healthy. That's the whole product.
24
+
25
+ ---
26
+
27
+ ## Why this exists
28
+
29
+ We used [OpenSpec](https://github.com/Fission-AI/OpenSpec). It's a fine project. It also ships 200+ files, 30+ tool adapters, a 4-artifact pipeline, Zod validation, DAG resolution, delta-spec merges, SHALL/MUST keyword enforcement, scenario counting, a telemetry system, a store/registry layer, and burns 50k–100k tokens per invocation.
30
+
31
+ We tried deleting things. Everything kept working.
32
+
33
+ So we kept deleting.
34
+
35
+ | OpenSpec ships | CANS ships |
36
+ |---|---|
37
+ | 4-artifact pipeline (proposal → specs → design → tasks) | Edit the spec. ADR records why. Task tracks work. |
38
+ | Zod schemas + DAG resolver + 30 adapter files | Structural lint on markdown bullets |
39
+ | Delta-spec merge engine at archive time | `see:` references + `git diff` |
40
+ | SHALL / MUST / WHEN / THEN / GIVEN keywords | Natural language. Dense bullets. |
41
+ | ~36,000 tokens of agent instructions across 12 skills | ~800 tokens. One file. Loaded once. |
42
+ | `openspec status --json` to query project state | Agents read the file. The file IS the state. |
43
+ | 30+ tool-specific command formatters | One prompt block. String-replace the tool name. |
44
+ | YAML frontmatter + markdown headers + requirement blocks | Bullets. Just bullets. |
45
+ | Custom merge logic for spec reconciliation | Git merges. Humans resolve conflicts. |
46
+ | Telemetry, stores, registries, profiles | None. |
47
+ | **50,000–100,000 tokens per invocation** | **~800 tokens, loaded once** |
48
+
49
+ OpenSpec is a framework. CANS is a file with a linter.
50
+
51
+ ---
52
+
53
+ ## See it in action
54
+
55
+ ```text
56
+ You: cans init
57
+ ✓ Created cans/ with 7 spec files, _adr/, _tasks/, _collab/
58
+
59
+ You: (write your spec as dense bullets)
60
+ - Authentication
61
+ - Sign up
62
+ - Email: requires verification
63
+ - Google: uses provider verification
64
+ - Sessions
65
+ - Expire after 24 hours
66
+ - Refresh allowed for 30 days
67
+
68
+ You: cans check
69
+ ✓ 7 files, 42 nodes, max depth 3
70
+ ✓ 3 see: refs, 0 broken, 0 deep hops
71
+ ✓ back-pointers: 3/3 current
72
+ 0 errors, 0 warnings.
73
+
74
+ You: cans new task add-dark-mode
75
+ ✓ Created _tasks/add-dark-mode.md
76
+
77
+ You: (agent implements, checks off tasks, edits specs directly)
78
+
79
+ You: cans done add-dark-mode
80
+ ✓ Archived to _tasks/_archive/2025-09-03-add-dark-mode.md
81
+ ```
82
+
83
+ No `/opsx:propose`. No `/opsx:apply`. No `/opsx:archive`. No pipeline. No phases. You edit the file. The agent edits the file. `cans check` keeps it honest.
84
+
85
+ <details>
86
+ <summary><b>What do the specs actually look like?</b></summary>
87
+
88
+ Bullets. Indented. Dense. No special syntax. No keywords. No scenario blocks.
89
+
90
+ ```markdown
91
+ - Authentication
92
+ - Sign up
93
+ - Email
94
+ - Requires verification
95
+ - Creates user account
96
+ - Google
97
+ - Uses provider verification
98
+ - Sessions
99
+ - Expire after 24 hours
100
+ - Refresh allowed for 30 days
101
+ - Passwords
102
+ - Never stored in plaintext
103
+ - Minimum 12 characters
104
+ ```
105
+
106
+ Reference, don't duplicate:
107
+
108
+ ```markdown
109
+ # in 04-api.md
110
+ - Authentication
111
+ - Session rules: see 02-authentication.md#Sessions
112
+ - Requires Bearer token
113
+ ```
114
+
115
+ That's it. That's the whole spec format. Your agent reads this and knows what to build.
116
+
117
+ </details>
118
+
119
+ ---
120
+
121
+ ## Five principles
122
+
123
+ **1. Dense over verbose.** Every bullet earns its place. No filler levels. No manufactured identifiers.
124
+
125
+ ```markdown
126
+ # bad # good
127
+ - Authentication - Authentication
128
+ - Capability - Sign up
129
+ - Behavior
130
+ - Sign up
131
+ ```
132
+
133
+ **2. Canonical over duplicated.** Every concept has one home. Everywhere else uses `see:`.
134
+
135
+ **3. One hop. Never chain.** A→B is fine. A→B→C is forbidden. `cans check` catches it.
136
+
137
+ **4. Unknowns are first-class.** `TBD` is valid. Don't guess. Don't block. Mark it, move on.
138
+
139
+ **5. Structure emerges from subject.** No fixed hierarchy. No required vocabulary. No kebab-casing reality.
140
+
141
+ ---
142
+
143
+ ## What CANS does NOT do
144
+
145
+ This is the most important section.
146
+
147
+ - ❌ No schema.yaml / artifact DAG / topological sort
148
+ - ❌ No Zod validation of requirement text
149
+ - ❌ No SHALL / MUST keyword enforcement
150
+ - ❌ No scenario counting (WHEN / THEN / GIVEN)
151
+ - ❌ No 30 tool adapters / adapter registry
152
+ - ❌ No YAML frontmatter in spec files
153
+ - ❌ No "delta operations" (ADDED / MODIFIED / REMOVED)
154
+ - ❌ No telemetry. None. Ever.
155
+ - ❌ No store / registry system for multi-repo
156
+ - ❌ No requirement-block parsing
157
+ - ❌ No phase gates (proposal → specs → design → tasks)
158
+ - ❌ No LLM-powered anything (CANS is deterministic; LLMs *use* CANS)
159
+ - ❌ No auto-fixing (flags only, human decides)
160
+ - ❌ No plugin system (10 commands, no extension points)
161
+ - ❌ No web UI / dashboard
162
+ - ❌ No database backend (files ARE the state)
163
+ - ❌ No merge pipeline (agent edits specs directly)
164
+ - ❌ No versioned schema migrations (delete a config key = disable that check)
165
+
166
+ **CANS is advisory, not coercive.** The outline is the contract. `cans check` catches drift. Humans resolve it.
167
+
168
+ ---
169
+
170
+ ## Install
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.
173
+
174
+ **npm**
175
+
176
+ ```bash
177
+ npm install -g cans-spec
178
+ ```
179
+
180
+ **Bun**
181
+
182
+ ```bash
183
+ bun install -g cans-spec
184
+ ```
185
+
186
+ **One-shot, no install**
187
+
188
+ ```bash
189
+ npx -p cans-spec cans init
190
+ ```
191
+
192
+ All three give you the same `cans` command:
193
+
194
+ ```bash
195
+ cd your-project
196
+ cans init
197
+ ```
198
+
199
+ Done. You have a `cans/` directory. Start writing bullets.
200
+
201
+ ### How to use it
202
+
203
+ 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`.
204
+ 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.
205
+ 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.
206
+ 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).
207
+ 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.
208
+ 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.
209
+
210
+ Everything else — ADRs, ownership arrows (`← agent-1`, `← @human`), rules in `_rules.yaml` — is documented inline by the scaffolded files.
211
+
212
+ ---
213
+
214
+ ## Commands
215
+
216
+ ```
217
+ cans init scaffold workspace
218
+ cans check validate everything (structure, refs, style, redundancy, overflow)
219
+ cans new adr create a decision record
220
+ cans new task create a task file
221
+ cans done archive completed task (blocks on ← @human gates)
222
+ cans status workspace summary
223
+ cans budget read token-budgeted reading plan for a concept
224
+ cans budget write what you can / can't edit for a concept
225
+ cans import bring in OPML / Logseq / Obsidian
226
+ cans export push out to OPML / Logseq / Obsidian
227
+ ```
228
+
229
+ Every command supports `--json` for agent consumption.
230
+
231
+ ```bash
232
+ cans check --json
233
+ cans budget read "sessions" --limit 2000 --json
234
+ cans status --json
235
+ ```
236
+
237
+ ---
238
+
239
+ ## How agents work with CANS
240
+
241
+ Agents read one file: `cans/AGENTS.md`. It's ~800 tokens. Here's the gist:
242
+
243
+ > **Reading:** Find the concept → read its canonical home → follow `see:` (one hop only) → read surrounding hierarchy for context.
244
+ >
245
+ > **Writing:** One canonical home per concept. Reference, don't duplicate. Preserve real identifiers. Mark unknowns as TBD. Smallest correct change.
246
+ >
247
+ > **Deciding:** Create an ADR. ADRs are permanent. Specs are the truth. ADRs record WHY.
248
+ >
249
+ > **Tracking:** Create a task. Agent edits specs directly. When done: `cans done <name>`. Blocks until `← @human` gates are checked.
250
+ >
251
+ > **Token budget:** Before reading: `cans budget read "<concept>" --json`. Read ONLY the files in the plan. Before writing: `cans budget write "<concept>" --json`. Only edit files in `canEdit`.
252
+
253
+ That's the entire agent instruction surface. No 12 skills. No 30 adapters. No slash commands.
254
+
255
+ ---
256
+
257
+ ## Validation
258
+
259
+ `cans check` runs five deterministic engines. No ML. No LLM. No embeddings.
260
+
261
+ | Engine | Catches |
262
+ |---|---|
263
+ | **Structure** | Node length, depth, sibling count, single-child collapse, empty nodes |
264
+ | **Style** | Sibling vs nested grouping, shared prefix detection |
265
+ | **References** | Broken `see:`, deep hops (A→B→C), self-refs, orphans, stale back-pointers |
266
+ | **Redundancy** | Word frequency, phrase overlap, fuzzy typos, cross-file duplication |
267
+ | **Overflow** | Code fences, tables, oversized nodes that belong in separate files |
268
+
269
+ Configure in `cans/_rules.yaml`. Delete a key to disable that check. No migration.
270
+
271
+ ```bash
272
+ cans check # human-readable
273
+ cans check --json # machine-readable
274
+ cans check --fix # rebuild back-pointer comments (nothing else)
275
+ cans check --strict # warnings become errors
276
+ cans check 04-api.md # single file
277
+ ```
278
+
279
+ ---
280
+
281
+ ## Workspace layout
282
+
283
+ ```
284
+ cans/
285
+ ├── _rules.yaml # validation config
286
+ ├── AGENTS.md # ~800-token agent instructions
287
+ ├── 00-overview.md
288
+ ├── 01-architecture.md
289
+ ├── 02-authentication.md
290
+ ├── 03-data.md
291
+ ├── 04-api.md
292
+ ├── 05-frontend.md
293
+ ├── 06-operations.md
294
+ ├── _adr/ # decisions (permanent)
295
+ │ ├── 001-css-variables-over-tailwind.md
296
+ │ └── 002-postgres-over-mysql.md
297
+ ├── _tasks/ # work tracking (transient)
298
+ │ └── add-dark-mode.md
299
+ └── _collab/ # handoffs, conflicts, decision index
300
+ ```
301
+
302
+ ---
303
+
304
+ ## How we compare
305
+
306
+ **vs. [OpenSpec](https://github.com/Fission-AI/OpenSpec)** — Comprehensive but heavy. 200+ files, 4-artifact pipelines, 30+ adapters, 50k–100k tokens per invocation. CANS is 1080 lines, zero deps, 800 tokens. You don't need a framework. You need a file.
307
+
308
+ **vs. [Spec Kit](https://github.com/github/spec-kit)** (GitHub) — Rigid phase gates, Python setup, lots of Markdown. CANS has no phases. You edit the spec. That's the workflow.
309
+
310
+ **vs. [Kiro](https://kiro.dev)** (AWS) — Locked to their IDE, limited to Claude. CANS is a CLI that reads markdown files. Works with any agent, any editor, any model.
311
+
312
+ **vs. nothing** — AI coding without specs means vague prompts and drift. CANS gives you a contract in 800 tokens of agent instructions instead of 36,000.
313
+
314
+ ---
315
+
316
+ ## Requirements
317
+
318
+ - [Bun](https://bun.sh) ≥ 1.0
319
+ - Git (for coordination; not required by CANS itself)
320
+ - An AI agent that can read markdown (all of them)
321
+
322
+ No Node.js. No Python. No `package-lock.json`. No `node_modules`. The `dependencies` field in `package.json` does not exist.
323
+
324
+ ---
325
+
326
+ ## Contributing
327
+
328
+ The entire codebase is ~1080 lines of TypeScript across 20 files. Read it in an afternoon.
329
+
330
+ - `bun test` runs the suite (~95 tests)
331
+ - No build step. Edit `src/`, run `bun test`, done.
332
+ - PRs that add features get closed. PRs that delete code get merged.
333
+ - If you want a new command, open an issue and justify it. Most get rejected. That's the point.
334
+
335
+ ---
336
+
337
+ ## License
338
+
339
+ MIT
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "cans-spec",
3
+ "version": "0.1.0",
4
+ "description": "Canonical Agent-Native Spec — the outline is the spec, the state, and the task board",
5
+ "bin": { "cans": "./src/cli.ts" },
6
+ "type": "module",
7
+ "engines": { "bun": ">=1.0.0" },
8
+ "scripts": {
9
+ "typecheck": "bunx tsc --noEmit",
10
+ "test": "bun test",
11
+ "prepublishOnly": "bun run typecheck && bun test"
12
+ },
13
+ "files": ["src/", "templates/", "README.md", "LICENSE"],
14
+ "keywords": [
15
+ "cans",
16
+ "spec",
17
+ "spec-driven",
18
+ "agents",
19
+ "agent-native",
20
+ "ai",
21
+ "markdown",
22
+ "outline"
23
+ ],
24
+ "author": "cans-hq",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/cans-hq/cans-spec.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/cans-hq/cans-spec/issues"
31
+ },
32
+ "homepage": "https://github.com/cans-hq/cans-spec#readme",
33
+ "license": "MIT",
34
+ "devDependencies": {
35
+ "typescript": "^5.5.0",
36
+ "@types/bun": "^1.1.0"
37
+ }
38
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import type { CommandResult } from './types';
4
+ import { emit } from './core/output';
5
+
6
+ /** §44: version line. Keep in sync with package.json. */
7
+ const VERSION = '0.1.0';
8
+
9
+ const [cmd, ...args] = Bun.argv.slice(2);
10
+
11
+ async function dispatch(): Promise<CommandResult> {
12
+ 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);
21
+ case 'help':
22
+ case '-h': // §20/§44: conventional help shortcuts
23
+ case '--help':
24
+ return { ok: true, command: 'help', exitCode: 0 };
25
+ case 'version':
26
+ case '--version': // §44: version surface
27
+ return { ok: true, command: 'version', exitCode: 0, version: VERSION } as CommandResult;
28
+ default:
29
+ // §37: unknown / missing command must say what happened and how to fix it.
30
+ return {
31
+ ok: false,
32
+ command: cmd ?? 'unknown',
33
+ exitCode: 1,
34
+ error: cmd === undefined
35
+ ? 'no command given — run `cans help`'
36
+ : `unknown command "${cmd}" — run \`cans help\``,
37
+ } as CommandResult;
38
+ }
39
+ }
40
+
41
+ try {
42
+ const result = await dispatch();
43
+ emit(result, args.includes('--json'), args.includes('--refs-only'));
44
+ process.exit(result.exitCode);
45
+ } catch (e) {
46
+ console.error(`✗ Internal error: ${e instanceof Error ? e.message : e}`);
47
+ process.exit(2);
48
+ }
@@ -0,0 +1,238 @@
1
+ import { join } from 'path';
2
+ import type { BudgetReadResult, BudgetWriteResult, OutlineNode, Rules } from '../types';
3
+ import { resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, dirExists, isFile } 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';
8
+
9
+ export interface BudgetArgs {
10
+ mode: 'read' | 'write';
11
+ /** The subcommand candidate found at the first non-flag position
12
+ * ('' when absent, or the offending token for unknown-subcommand errors). */
13
+ sub: string;
14
+ concept: string;
15
+ limit: number | null;
16
+ change: string | null;
17
+ json: boolean;
18
+ /** §20/§37: malformed flags (--flag=value, unknown, missing value, invalid
19
+ * --limit value) — user errors, never silently ignored. */
20
+ argErrors: string[];
21
+ }
22
+
23
+ /** §20 flag-position flexibility: budget's known flags (`--json` bool,
24
+ * `--limit`/`--change` value flags) are recognized anywhere on the line —
25
+ * including BEFORE the subcommand — so `budget --json read Sessions` ≡
26
+ * `budget read Sessions --json`. Values following value-flags are consumed
27
+ * even in the pre-scan; leftover tokens decide subcommand + concept.
28
+ * Error wording mirrors the shared parser (§20: `--flag value` only). */
29
+ export function parseBudgetArgs(args: string[]): BudgetArgs {
30
+ const rest: string[] = [];
31
+ const flagErrors: string[] = [];
32
+ let json = false;
33
+ let limitRaw: string | null = null;
34
+ let change: string | null = null;
35
+
36
+ for (let i = 0; i < args.length; i++) {
37
+ const a = args[i]!;
38
+
39
+ // Reject --flag=value form
40
+ if (a.startsWith('--') && a.includes('=')) {
41
+ flagErrors.push(`invalid flag form "${a}" — use "--${a.slice(2).split('=')[0]} <value>"`);
42
+ continue;
43
+ }
44
+
45
+ // Reject short flags / combined flags
46
+ if (/^-[a-zA-Z]/.test(a) && !a.startsWith('--')) {
47
+ flagErrors.push(`unknown flag "${a}" — no short flags supported`);
48
+ continue;
49
+ }
50
+
51
+ if (a.startsWith('--')) {
52
+ const name = a.slice(2);
53
+ if (name === 'json') {
54
+ json = true;
55
+ continue;
56
+ }
57
+ if (name === 'limit' || name === 'change') {
58
+ const val = args[i + 1];
59
+ if (val === undefined || val.startsWith('--')) {
60
+ flagErrors.push(`flag "--${name}" requires a value`);
61
+ continue;
62
+ }
63
+ if (name === 'limit') limitRaw = val;
64
+ else change = val;
65
+ i++; // consume value
66
+ continue;
67
+ }
68
+ flagErrors.push(`unknown flag "--${name}"`);
69
+ continue;
70
+ }
71
+
72
+ rest.push(a);
73
+ }
74
+
75
+ // §37: unknown subcommands must error, never silently run as `read`.
76
+ const sub = rest[0] === 'read' || rest[0] === 'write' ? rest[0] : (rest[0] ?? '');
77
+ const concept = rest[1] ?? '';
78
+
79
+ // §37: a malformed --limit value is a user error, not a silent default.
80
+ // 0 stays a valid (degenerate) limit; negative / non-numeric / non-integer
81
+ // values are rejected with the offending value named.
82
+ let limit: number | null = null;
83
+ if (limitRaw !== null) {
84
+ const n = Number(limitRaw);
85
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
86
+ flagErrors.push(`invalid --limit value "${limitRaw}" — pass a positive integer`);
87
+ } else {
88
+ limit = n;
89
+ }
90
+ }
91
+
92
+ return {
93
+ mode: sub === 'write' ? 'write' : 'read',
94
+ sub,
95
+ concept,
96
+ limit,
97
+ change,
98
+ json,
99
+ argErrors: flagErrors,
100
+ };
101
+ }
102
+
103
+ function readFail(concept: string, error: string): BudgetReadResult {
104
+ return {
105
+ ok: false, command: 'budget-read', exitCode: 1, concept,
106
+ plan: [], skipped: [], totalTokens: 0, budgetLimit: 0, usagePercent: 0,
107
+ error,
108
+ };
109
+ }
110
+
111
+ function writeFail(concept: string, error: string): BudgetWriteResult {
112
+ return {
113
+ ok: false, command: 'budget-write', exitCode: 1, concept,
114
+ canEdit: [], mustNotEdit: [], backPointersToUpdate: [],
115
+ error,
116
+ };
117
+ }
118
+
119
+ /** §37: say what happened and what to do when nothing matches the concept. */
120
+ function noMatchError(concept: string): string {
121
+ return `no files match concept "${concept}" — check spelling or run \`cans status\``;
122
+ }
123
+
124
+ const NO_WORKSPACE_ERROR = 'no cans workspace found — run `cans init` first, or cd into a project with a cans/ directory';
125
+
126
+ export async function run(args: string[]): Promise<BudgetReadResult | BudgetWriteResult> {
127
+ const opts = parseBudgetArgs(args);
128
+
129
+ // §37: reject unknown subcommands with a usage error (never success-shaped).
130
+ if (opts.sub !== 'read' && opts.sub !== 'write') {
131
+ const error = opts.sub === ''
132
+ ? 'usage: cans budget <read|write> <concept>'
133
+ : `unknown subcommand "${opts.sub}" — valid: read, write`;
134
+ return readFail(opts.sub, error);
135
+ }
136
+
137
+ // §20: `--flag value` only — malformed flags are user errors, not silently ignored.
138
+ if (opts.argErrors.length > 0) {
139
+ const error = opts.argErrors.join('\n');
140
+ return opts.mode === 'write' ? writeFail(opts.concept, error) : readFail(opts.concept, error);
141
+ }
142
+
143
+ if (opts.concept === '') {
144
+ return opts.mode === 'write'
145
+ ? writeFail('', 'usage: cans budget write <concept>\n Example: cans budget write sessions')
146
+ : readFail('', 'usage: cans budget read <concept>\n Example: cans budget read sessions');
147
+ }
148
+
149
+ const workspace = resolveWorkspaceRoot();
150
+ if (workspace === null) {
151
+ return opts.mode === 'write'
152
+ ? writeFail(opts.concept, NO_WORKSPACE_ERROR)
153
+ : readFail(opts.concept, NO_WORKSPACE_ERROR);
154
+ }
155
+
156
+ let rules: Rules;
157
+ try {
158
+ rules = loadRules(workspace);
159
+ } catch (e) {
160
+ const error = `invalid _rules.yaml: ${e instanceof Error ? e.message : String(e)}`;
161
+ return opts.mode === 'write' ? writeFail(opts.concept, error) : readFail(opts.concept, error);
162
+ }
163
+
164
+ const files = new Map<string, OutlineNode[]>();
165
+ for (const rel of discoverSpecFiles(workspace)) {
166
+ try {
167
+ files.set(rel, parseOutline(await Bun.file(join(workspace, rel)).text(), rel));
168
+ } catch {
169
+ // unreadable spec file: excluded from the plan
170
+ }
171
+ }
172
+ const graph = buildRefGraph(files, workspace);
173
+
174
+ if (opts.mode === 'read') {
175
+ // --change: center the plan on an active task file.
176
+ let taskFile: string | undefined;
177
+ if (opts.change !== null) {
178
+ const p = join(workspace, '_tasks', `${opts.change}.md`);
179
+ if (isFile(p)) taskFile = p;
180
+ }
181
+
182
+ // §26 step 3: active tasks mentioning the concept join the plan (score 80).
183
+ const activeTaskPaths = dirExists(join(workspace, '_tasks'))
184
+ ? discoverActiveTasks(workspace).map(rel => join(workspace, rel))
185
+ : [];
186
+
187
+ const result = buildReadPlan(
188
+ opts.concept,
189
+ files,
190
+ graph.back,
191
+ rules.token_budget,
192
+ opts.limit ?? undefined,
193
+ taskFile,
194
+ activeTaskPaths,
195
+ );
196
+ if (result.plan.length === 0) {
197
+ // §37 truthfulness: distinguish "the concept matches nothing" from
198
+ // "the limit is smaller than the cheapest matching item" — a limit
199
+ // problem must never be reported as a spelling problem (QA-10 M2b).
200
+ if (opts.limit !== null) {
201
+ const unbounded = buildReadPlan(
202
+ opts.concept, files, graph.back, rules.token_budget,
203
+ undefined, taskFile, activeTaskPaths,
204
+ );
205
+ if (unbounded.plan.length > 0) {
206
+ const cheapest = Math.min(...unbounded.plan.map(p => p.estTokens));
207
+ return readFail(
208
+ opts.concept,
209
+ `plan empty: --limit ${opts.limit} is below the cheapest item (${cheapest} tok) — raise the limit`,
210
+ );
211
+ }
212
+ }
213
+ return { ...result, ok: false, exitCode: 1, error: noMatchError(opts.concept) };
214
+ }
215
+ // §18 token_budget.warn_threshold: warn when plan usage reaches the
216
+ // threshold. A deleted key (null) turns the warning off — never compare
217
+ // against null (it would coerce to 0 and warn on every plan).
218
+ const threshold = rules.token_budget.warn_threshold;
219
+ if (threshold !== null && result.usagePercent >= threshold * 100) {
220
+ console.error(
221
+ ` ⚠ warning: plan usage ${result.usagePercent}% of ${result.budgetLimit} tokens ` +
222
+ `exceeds token_budget.warn_threshold (${threshold * 100}%) — trim the plan or raise default_limit in _rules.yaml`,
223
+ );
224
+ }
225
+ return result;
226
+ }
227
+
228
+ // write
229
+ const activeTasks = dirExists(join(workspace, '_tasks'))
230
+ ? discoverActiveTasks(workspace).map(rel => join(workspace, rel))
231
+ : [];
232
+ const result = buildWritePlan(opts.concept, files, graph.back, activeTasks);
233
+ // §19: empty scope (no home, no mentioning task, no back-refs) = user-correctable failure.
234
+ if (result.canEdit.length === 0 && result.mustNotEdit.length === 0) {
235
+ return { ...result, ok: false, exitCode: 1, error: noMatchError(opts.concept) };
236
+ }
237
+ return result;
238
+ }