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 +21 -0
- package/README.md +339 -0
- package/package.json +38 -0
- package/src/cli.ts +48 -0
- package/src/commands/budget.ts +238 -0
- package/src/commands/check.ts +422 -0
- package/src/commands/done.ts +171 -0
- package/src/commands/export.ts +223 -0
- package/src/commands/import.ts +436 -0
- package/src/commands/init.ts +184 -0
- package/src/commands/new.ts +138 -0
- package/src/commands/status.ts +152 -0
- package/src/converters/index.ts +4 -0
- package/src/converters/logseq.ts +42 -0
- package/src/converters/obsidian.ts +95 -0
- package/src/converters/opml.ts +143 -0
- package/src/converters/shared.ts +268 -0
- package/src/core/args.ts +79 -0
- package/src/core/fs.ts +309 -0
- package/src/core/index.ts +10 -0
- package/src/core/outline.ts +237 -0
- package/src/core/output.ts +300 -0
- package/src/core/overflow.ts +75 -0
- package/src/core/redundancy.ts +261 -0
- package/src/core/refs.ts +275 -0
- package/src/core/rules.ts +483 -0
- package/src/core/structure.ts +86 -0
- package/src/core/style.ts +75 -0
- package/src/core/token-budget.ts +284 -0
- package/src/types.ts +284 -0
- package/templates/AGENTS.md +209 -0
- package/templates/_rules.yaml +46 -0
- package/templates/adr-template.md +28 -0
- package/templates/task-template.md +15 -0
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
|
+
}
|