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 +15 -6
- package/bin/cans.js +87 -14
- package/bin/ts-loader.mjs +9 -2
- package/package.json +1 -1
- package/src/cli.ts +6 -1
- package/src/commands/budget.ts +87 -16
- package/src/commands/check.ts +313 -58
- package/src/commands/import.ts +216 -6
- package/src/core/fs.ts +31 -11
- package/src/core/outline.ts +17 -1
- package/src/core/output.ts +202 -57
- package/src/core/overflow.ts +4 -0
- package/src/core/redundancy.ts +5 -0
- package/src/core/refs.ts +197 -25
- package/src/core/report.ts +575 -0
- package/src/core/structure.ts +10 -0
- package/src/core/style.ts +2 -0
- package/src/core/token-budget.ts +34 -3
- package/src/types.ts +17 -0
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
|
|
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
|
|
283
|
-
|
|
284
|
-
cans check --
|
|
285
|
-
cans check --
|
|
286
|
-
|
|
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
|
|
12
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
67
|
-
//
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
//
|
|
75
|
-
register(
|
|
76
|
-
|
|
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
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
|
-
|
|
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}`);
|
package/src/commands/budget.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { join, basename } from 'path';
|
|
2
|
-
import type {
|
|
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
|
|
214
|
-
// "the limit
|
|
215
|
-
// problem must never be reported as a spelling problem
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
}
|