tldr-experts 0.3.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/CHANGELOG.md +913 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/hooks/answer-capture.js +174 -0
- package/dist/hooks/budget-gate.js +173 -0
- package/dist/hooks/chunk-0bt6yb2q.js +88 -0
- package/dist/hooks/chunk-1zwcxd3f.js +66 -0
- package/dist/hooks/chunk-9gb21660.js +66 -0
- package/dist/hooks/chunk-a8p2rc94.js +20 -0
- package/dist/hooks/chunk-ae6bkfs5.js +0 -0
- package/dist/hooks/chunk-azctppjh.js +198 -0
- package/dist/hooks/chunk-g395gk7e.js +503 -0
- package/dist/hooks/chunk-j234zf0t.js +339 -0
- package/dist/hooks/chunk-kw4tffzf.js +139 -0
- package/dist/hooks/chunk-p274ckxv.js +7435 -0
- package/dist/hooks/chunk-sdjnnmzz.js +497 -0
- package/dist/hooks/chunk-t56k6146.js +14 -0
- package/dist/hooks/chunk-t8tdv11p.js +35 -0
- package/dist/hooks/chunk-x98qs959.js +302 -0
- package/dist/hooks/chunk-y0jdr3et.js +627 -0
- package/dist/hooks/claim-sources.js +72 -0
- package/dist/hooks/dod-gate.js +198 -0
- package/dist/hooks/no-reask.js +67 -0
- package/dist/hooks/session-start.js +1348 -0
- package/dist/hooks/statusline.js +116 -0
- package/dist/tldrx.js +34219 -0
- package/env.yml +79 -0
- package/package.json +60 -0
- package/plugin/.claude-plugin/plugin.json +9 -0
- package/plugin/README.md +113 -0
- package/plugin/agents/README.md +22 -0
- package/plugin/hooks/hooks.json +107 -0
- package/plugin/skills/tldrx/SKILL.md +170 -0
- package/stages/build/stage.md +70 -0
- package/stages/build/stage.yml +54 -0
- package/stages/how/stage.md +89 -0
- package/stages/how/stage.yml +53 -0
- package/stages/plan/stage.md +89 -0
- package/stages/plan/stage.yml +48 -0
- package/stages/watch/stage.md +85 -0
- package/stages/watch/stage.yml +61 -0
- package/stages/what/stage.md +89 -0
- package/stages/what/stage.yml +61 -0
- package/templates/budget.yml +17 -0
- package/templates/competencies.yml +14 -0
- package/templates/env.yml +19 -0
- package/templates/epic.md +38 -0
- package/templates/expert.md +51 -0
- package/templates/experts/architect.md +77 -0
- package/templates/experts/delivery.md +76 -0
- package/templates/experts/developer.md +78 -0
- package/templates/experts/operations.md +74 -0
- package/templates/experts/product.md +78 -0
- package/templates/facts.yml +18 -0
- package/templates/handoff.md +63 -0
- package/templates/process.yml +33 -0
- package/templates/questions.md +63 -0
- package/templates/run.yml +24 -0
- package/templates/story.md +55 -0
- package/templates/watcher.md +68 -0
- package/templates/waves.yml +17 -0
- package/templates/workspace.yml +30 -0
- package/workflows/bugfix.yml +26 -0
- package/workflows/docs.yml +23 -0
- package/workflows/feature.yml +22 -0
- package/workflows/hotfix.yml +24 -0
- package/workflows/integration.yml +24 -0
- package/workflows/migration.yml +24 -0
- package/workflows/performance.yml +23 -0
- package/workflows/prototype.yml +24 -0
- package/workflows/refactor.yml +24 -0
- package/workflows/retro.yml +20 -0
- package/workflows/security-patch.yml +23 -0
- package/workflows/spike.yml +23 -0
- package/workflows/upgrade.yml +23 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alan Martinez
|
|
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,174 @@
|
|
|
1
|
+
# tldr-experts
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/tldr-experts) [](https://github.com/ederwii/tldr-experts/actions/workflows/ci.yml) 
|
|
4
|
+
|
|
5
|
+
**A lightweight, file-based AI development workflow.** Open source, tool-agnostic in design, piloted on Claude Code. **Alpha:** every command is implemented and verified by running it; interfaces may change without notice, and `tldrx --help` is the authoritative surface.
|
|
6
|
+
|
|
7
|
+
One loop — *Investigate → Handoff → Interview → Gate* — five phases, **what · how · plan · build ·
|
|
8
|
+
watch**, one stage per command, each stopping at a gate you own; the files ARE the state, the
|
|
9
|
+
dashboard, the resume point and the memory. Every claim a stage writes carries a `[src: …]` token
|
|
10
|
+
that must resolve against a real file, fact or question, or the write is refused; every dollar is
|
|
11
|
+
recorded from what the model reported, never estimated. Nothing prints success for work it did not
|
|
12
|
+
do: a command that cannot do the thing exits non-zero and says which thing.
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
> **Not on npm yet.** Every published version was unpublished on 2026-08-29 (`npm view tldr-experts
|
|
17
|
+
> version` → `E404 Unpublished`) and there is no `v0.3.0` tag, so the `npm i -g` line 404s until
|
|
18
|
+
> `scripts/release.sh 0.3.0` is run. Until then: clone and `bun link`, or `bun <repo>/bin/tldrx.ts <cmd>`.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm i -g tldr-experts # installs `tldrx` (short) and `tldr-experts` (same binary)
|
|
22
|
+
cd your-project
|
|
23
|
+
tldrx doctor # check the environment — it is the authority, not a list in a README
|
|
24
|
+
tldrx init # detect repos, map the code, write .tldrx/, ask only the gaps
|
|
25
|
+
tldrx install --claude # write the skill, hooks and status line into ./.claude/
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then open Claude Code there and type **`/tldrx`**. It runs `tldrx status`, finds what is already
|
|
29
|
+
waiting on you — unanswered setup questions, a proposed split nobody decided, a run waiting on a gate,
|
|
30
|
+
an expert no stage can lean on yet — and walks you through it one item at a time, asking every decision
|
|
31
|
+
that is yours and running only the mechanical steps. Or drive it from a shell, with no Claude Code:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
tldrx run new payments --scope feature --seed docs/payments/ --budget 25
|
|
35
|
+
tldrx run auto # `next`, over and over, until something actually needs you
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Runtime: Node ≥ 20 or Bun** — the published package is a pre-built bundle with zero runtime
|
|
39
|
+
dependencies, so an installed `tldrx` needs only Node; Bun builds it. Full walkthrough:
|
|
40
|
+
[`docs/guide/01-quick-start.md`](docs/guide/01-quick-start.md).
|
|
41
|
+
|
|
42
|
+
## How much human is in the loop
|
|
43
|
+
|
|
44
|
+
Every stage ends at a gate; what you choose is **who closes it**. `human` waits for `tldrx approve`.
|
|
45
|
+
`auto` lets the harness close it, and only when all five conditions hold: the stage's checks pass, its
|
|
46
|
+
phase has no open question, the spend is inside both the stage and phase ceilings, the stage did not
|
|
47
|
+
fail, and the claim-sources validator reports nothing. Any one failing falls back to the human gate
|
|
48
|
+
and says which one and what it measured.
|
|
49
|
+
|
|
50
|
+
| Scope | what | how | plan | build | watch |
|
|
51
|
+
|---|---|---|---|---|---|
|
|
52
|
+
| `feature` `bugfix` `integration` `refactor` | human | auto | human | auto | human |
|
|
53
|
+
| `performance` | human | auto | — | auto | human |
|
|
54
|
+
| `docs` | auto | — | — | human | — |
|
|
55
|
+
| `spike` | auto | human | — | — | — |
|
|
56
|
+
| `prototype` | auto | auto | — | human | — |
|
|
57
|
+
| `upgrade` | auto | — | auto | auto | human |
|
|
58
|
+
| `hotfix` | auto | — | — | human | human |
|
|
59
|
+
| `security-patch` | auto | auto | — | human | human |
|
|
60
|
+
| `migration` | auto | auto | auto | human | human |
|
|
61
|
+
|
|
62
|
+
Those are the shipped defaults, and every scope keeps at least one human gate. Override per run with
|
|
63
|
+
`--gates <stage,stage>` — **the list is the human gates** — or `--gates all|none`. When the machine
|
|
64
|
+
signs something it should not have, `tldrx reject --stage <phase>/<stage> --note "…"` revokes it, moves
|
|
65
|
+
the cursor back and marks the later stages `stale`. What an auto gate cannot do:
|
|
66
|
+
[`docs/guide/03-runs-and-gates.md`](docs/guide/03-runs-and-gates.md).
|
|
67
|
+
|
|
68
|
+
## What you see while it runs
|
|
69
|
+
|
|
70
|
+
A stage can take four minutes. `tldrx next`, `run auto`, `expert train` and
|
|
71
|
+
`seed triage --propose` show a classroom on stderr. Captured at 80x24, mid-stage:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
+----------------------------------------------+ .---.
|
|
75
|
+
| what · 260829-tenancy · attempt 1 | / \
|
|
76
|
+
+----------------------------------------------+ | · |
|
|
77
|
+
| reading api/src/Outbox.cs | \ \ /
|
|
78
|
+
| grep "Outbox" | '---'
|
|
79
|
+
| The tenancy boundary is the row filter, not… | 03:41
|
|
80
|
+
| $ dotnet test tests/Unit → running |
|
|
81
|
+
| → ok (12 s) |
|
|
82
|
+
| writing tldrx-work/260829-tenancy/01-what/h… |
|
|
83
|
+
+----------------------------------------------+
|
|
84
|
+
|
|
85
|
+
,-----. .-------.
|
|
86
|
+
( ##### ) | o o |
|
|
87
|
+
'--|--' | - |
|
|
88
|
+
/ | | '---+---'
|
|
89
|
+
__/ [~] \__ __/ \__
|
|
90
|
+
|_____________| |___________|
|
|
91
|
+
|| || || ||
|
|
92
|
+
|
|
93
|
+
$0.00 of $6.00 · Ctrl-C stops after this turn
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Nothing on that screen costs anything: every line is derived from the `stream-json` events `claude`
|
|
97
|
+
was already sending — no second model call, no summary agent — and the dollar figure is what has
|
|
98
|
+
been **recorded**, never an estimate. `--ui scene|compact|plain|off` (`auto` by default); stdout is
|
|
99
|
+
byte-identical with it on or off.
|
|
100
|
+
|
|
101
|
+
## Cost control
|
|
102
|
+
|
|
103
|
+
Four things bound what a stage costs, and only two act before the money.
|
|
104
|
+
`tldrx next --prepare` prints the context ledger, so what you are about to pay for is visible
|
|
105
|
+
first:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
context 83.7 KB of 160.0 KB (~23.8k tok, 12% of sonnet's ~200.0k window)
|
|
109
|
+
stage 3.7 KB · inputs 77.3 KB · experts 2.7 KB (bodies 2.5 KB, knowledge 250 B)
|
|
110
|
+
input docs/domain-design/DECISIONS-NEEDED.md 15.1 KB
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Over `prompt_max_bytes` the stage is **refused** (exit 2) before anything spawns; `max_reads` stops
|
|
114
|
+
the sub-agent at a read ceiling; `--effort` changes what a turn costs. What `--max-budget-usd` does is
|
|
115
|
+
end a run *after* the turn it is already in — measured, one turn spent **$5.15** against a **$1.50**
|
|
116
|
+
ceiling — so size the prompt for the money you are willing to lose. Afterwards `tldrx cost [--all]` adds up what was actually charged, per attempt, per stage, per
|
|
117
|
+
run, read off `agent.result` events and nothing else. Retries are never merged — a retry is
|
|
118
|
+
exactly the money you are looking for — and work whose cost this process never saw is UNMETERED,
|
|
119
|
+
never $0.00. `tldrx run estimate` is the only command that guesses, and says so in words.
|
|
120
|
+
Details: [`docs/guide/06-budgets-and-cost.md`](docs/guide/06-budgets-and-cost.md).
|
|
121
|
+
|
|
122
|
+
## Several runs
|
|
123
|
+
|
|
124
|
+
With several runs open and no id, every run-targeting command **refuses rather than guessing**,
|
|
125
|
+
exits `2`, and lists them — `tldrx next: 3 runs are open — pass one:`. That means "you left off
|
|
126
|
+
the id", not "it broke". Pass a positional `<run>` on `next`, `run status`, `cost`, `replay` and
|
|
127
|
+
`retro`; `--run <id>` on the rest. `tldrx run status` with several open lists them all, exit `0`.
|
|
128
|
+
|
|
129
|
+
## What to commit
|
|
130
|
+
|
|
131
|
+
**Both `.tldrx/` and `tldrx-work/`.** The files are the state — the map, the facts, the questions and
|
|
132
|
+
their answers, `run.yml`, `budget.yml`, `events.jsonl`, the handoffs, the plan — so a teammate who clones
|
|
133
|
+
the repo gets the run. The block `tldrx init` appends to `.gitignore` excludes five paths and nothing
|
|
134
|
+
else, because those five are machine-local or regenerated: `.tldrx/graphify-out/`, `.tldrx/cache/`,
|
|
135
|
+
`.tldrx/worktrees/`, `tldrx-work/*/.lock`, `tldrx-work/*/.agent/`.
|
|
136
|
+
|
|
137
|
+
## Documentation
|
|
138
|
+
|
|
139
|
+
The guide, in `docs/guide/`: [1 Quick start](docs/guide/01-quick-start.md) ·
|
|
140
|
+
[2 The loop](docs/guide/02-the-loop.md) (the four steps, what a stage file controls, the two execution modes) ·
|
|
141
|
+
[3 Runs and gates](docs/guide/03-runs-and-gates.md) (`run new`→`retro`, gate policy, `run auto`, unlock/cancel, dashboard, tickets) ·
|
|
142
|
+
[4 Experts](docs/guide/04-experts.md) (loading rules, role experts, training, levels) ·
|
|
143
|
+
[5 Seeds and triage](docs/guide/05-seeds-and-triage.md) (`--seed`, `--from`, splitting a big seed) ·
|
|
144
|
+
[6 Budgets and cost](docs/guide/06-budgets-and-cost.md) · [7 Claude Code](docs/guide/07-claude-code.md) (plugin, hooks, `/tldrx`) ·
|
|
145
|
+
[8 CLI reference](docs/guide/08-cli-reference.md) (every command, flag and exit code) ·
|
|
146
|
+
[9 Troubleshooting](docs/guide/09-troubleshooting.md) (every refusal, and the move that clears it).
|
|
147
|
+
Design docs: [`docs/concept.md`](docs/concept.md) (why) · [`docs/spec.md`](docs/spec.md) (the schemas, and §7's
|
|
148
|
+
open decisions) · [`docs/ROADMAP.md`](docs/ROADMAP.md) (next) · [`CHANGELOG.md`](CHANGELOG.md) (shipped) ·
|
|
149
|
+
[`docs/dashboard-model.md`](docs/dashboard-model.md).
|
|
150
|
+
|
|
151
|
+
## Releases and status tags
|
|
152
|
+
|
|
153
|
+
Install name is **`tldr-experts`**; it installs two commands, **`tldrx`** (short) and `tldr-experts` (same binary).
|
|
154
|
+
Unscoped `tldrx` as a *package* name is refused by npm's name-similarity rule (too close to `tsdx`). Versions 0.0.1–0.2.0
|
|
155
|
+
were published then unpublished on 2026-08-29; per npm policy those numbers can never be reused, so the first version
|
|
156
|
+
back on the registry is 0.3.0.
|
|
157
|
+
|
|
158
|
+
| Version | Date | Status | Contains |
|
|
159
|
+
|---|---|---|---|
|
|
160
|
+
| 0.3.0 | 2026-08-30 | `alpha` | expert training with provenance, auto gates with an undo, `tldrx status`, seed triage, the token economy (context ledger, `max_reads`, `cost`, `estimate`), `install --claude`, `interview`, the ticket mirror, `--help` with flags and exit codes |
|
|
161
|
+
| 0.2.0 | 2026-08-29 | `alpha` | Build executor (worktree + branch per story, epic branches, DoD gate, reviewer), Watch cards, live dashboard |
|
|
162
|
+
| 0.1.0 | 2026-08-29 | `alpha` | greenfield `init --stack` + `run new --seed`, story/epic/waves schemas, `tldrx budget show\|raise`, sections must hold list items |
|
|
163
|
+
| 0.0.2 | 2026-08-29 | `alpha` | pilot-driven fixes (source resolution, retry semantics, distill dedupe) |
|
|
164
|
+
| 0.0.1 | 2026-08-29 | `alpha` | v0 loop: init, map, doctor, run lifecycle, `next`, six hooks, views |
|
|
165
|
+
|
|
166
|
+
Status tags: `alpha` = every command real and tested, interfaces may change without notice, one pilot workspace;
|
|
167
|
+
`beta` = file formats frozen (`version: 1` schemas only grow), two or more real workspaces through Build, upgrade
|
|
168
|
+
path documented; `stable` = 1.0, semver from here on. The badge above shows the newest release's tag.
|
|
169
|
+
|
|
170
|
+
## Releasing
|
|
171
|
+
|
|
172
|
+
**One command: `scripts/release.sh X.Y.Z --tag alpha`.** It is the only sanctioned path — a Claude Code hook denies hand-made `git tag` / `npm publish`, and `publish.yml` re-runs the same checks. Checklist and judgement calls: `docs/RELEASING.md`.
|
|
173
|
+
|
|
174
|
+
MIT, © 2026 Alan Martinez — a placeholder made while scaffolding; change it freely before anything ships.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
FactsStore,
|
|
4
|
+
MAX_FACT_CHARS
|
|
5
|
+
} from "./chunk-x98qs959.js";
|
|
6
|
+
import {
|
|
7
|
+
parseHookInput,
|
|
8
|
+
readStdin
|
|
9
|
+
} from "./chunk-a8p2rc94.js";
|
|
10
|
+
import {
|
|
11
|
+
EventLog
|
|
12
|
+
} from "./chunk-azctppjh.js";
|
|
13
|
+
import {
|
|
14
|
+
detectAnswered,
|
|
15
|
+
parseQuestions,
|
|
16
|
+
recordAnswer,
|
|
17
|
+
replaceBlock,
|
|
18
|
+
serializeQuestions
|
|
19
|
+
} from "./chunk-j234zf0t.js";
|
|
20
|
+
import"./chunk-t56k6146.js";
|
|
21
|
+
import"./chunk-g395gk7e.js";
|
|
22
|
+
import"./chunk-0bt6yb2q.js";
|
|
23
|
+
import {
|
|
24
|
+
PROJECT_WORK_DIR,
|
|
25
|
+
factsPath
|
|
26
|
+
} from "./chunk-p274ckxv.js";
|
|
27
|
+
|
|
28
|
+
// src/hooks/answer-capture.ts
|
|
29
|
+
import { existsSync as existsSync2 } from "fs";
|
|
30
|
+
|
|
31
|
+
// src/hooks/lib/decide.ts
|
|
32
|
+
function allow() {
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
function postContext(text) {
|
|
36
|
+
process.stdout.write(`${JSON.stringify({
|
|
37
|
+
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text }
|
|
38
|
+
})}
|
|
39
|
+
`);
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
function failOpen(name, error) {
|
|
43
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
44
|
+
process.stderr.write(`tldrx hook ${name}: internal error, allowing — ${message.split(`
|
|
45
|
+
`)[0] ?? ""}
|
|
46
|
+
`);
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
async function runHook(name, body) {
|
|
50
|
+
try {
|
|
51
|
+
await body();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
failOpen(name, error);
|
|
54
|
+
}
|
|
55
|
+
return allow();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/hooks/lib/payload.ts
|
|
59
|
+
async function readPayload() {
|
|
60
|
+
return parseHookInput(await readStdin()) ?? {};
|
|
61
|
+
}
|
|
62
|
+
function toolInput(payload) {
|
|
63
|
+
const input = payload.tool_input;
|
|
64
|
+
return input !== undefined && typeof input === "object" ? input : {};
|
|
65
|
+
}
|
|
66
|
+
function filePathOf(payload) {
|
|
67
|
+
return toolInput(payload).file_path ?? payload.file_path ?? payload.path ?? "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/hooks/lib/workspace.ts
|
|
71
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
72
|
+
function locateWork(filePath) {
|
|
73
|
+
if (filePath === "")
|
|
74
|
+
return null;
|
|
75
|
+
const abs = isAbsolute(filePath) ? filePath : resolve(filePath);
|
|
76
|
+
const parts = abs.split(sep);
|
|
77
|
+
const at = parts.lastIndexOf(PROJECT_WORK_DIR);
|
|
78
|
+
if (at === -1 || at + 1 >= parts.length)
|
|
79
|
+
return null;
|
|
80
|
+
const run = parts[at + 1] ?? "";
|
|
81
|
+
if (run === "")
|
|
82
|
+
return null;
|
|
83
|
+
return {
|
|
84
|
+
root: parts.slice(0, at).join(sep) || sep,
|
|
85
|
+
runDir: parts.slice(0, at + 2).join(sep),
|
|
86
|
+
run,
|
|
87
|
+
relative: parts.slice(at + 2).join("/")
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/hooks/lib/actor.ts
|
|
92
|
+
function currentActor() {
|
|
93
|
+
const user = process.env.USER ?? process.env.USERNAME ?? "";
|
|
94
|
+
return user.trim() === "" ? "unknown" : user.trim();
|
|
95
|
+
}
|
|
96
|
+
function nowRfc3339() {
|
|
97
|
+
return `${new Date().toISOString().slice(0, 19)}Z`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/core/answers/captureAnswers.ts
|
|
101
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
102
|
+
function factTextFor(title, answer) {
|
|
103
|
+
return `${title} — ${answer}`.slice(0, MAX_FACT_CHARS);
|
|
104
|
+
}
|
|
105
|
+
function captureAnswers(questionsPath, ctx) {
|
|
106
|
+
if (!existsSync(questionsPath))
|
|
107
|
+
return [];
|
|
108
|
+
let doc = parseQuestions(readFileSync(questionsPath, "utf8"));
|
|
109
|
+
const answered = detectAnswered(doc.blocks);
|
|
110
|
+
if (answered.length === 0)
|
|
111
|
+
return [];
|
|
112
|
+
const log = EventLog.forRun(ctx.runDir);
|
|
113
|
+
const captured = [];
|
|
114
|
+
FactsStore.update(factsPath(ctx.root), (store) => {
|
|
115
|
+
for (const block of answered) {
|
|
116
|
+
const area = block.metadata?.area ?? "unscoped";
|
|
117
|
+
const fact = store.append({
|
|
118
|
+
fact: factTextFor(block.title, block.answer),
|
|
119
|
+
area,
|
|
120
|
+
repos: [],
|
|
121
|
+
kind: "answer",
|
|
122
|
+
confidence: "stated",
|
|
123
|
+
source: { who: ctx.actor, when: ctx.at, run: ctx.run, q: block.id }
|
|
124
|
+
});
|
|
125
|
+
doc = replaceBlock(doc, recordAnswer(block, { answered_by: ctx.actor, answered_at: ctx.at, fact: fact.id }));
|
|
126
|
+
log.tryAppend({
|
|
127
|
+
ts: ctx.at,
|
|
128
|
+
run: ctx.run,
|
|
129
|
+
stage: null,
|
|
130
|
+
type: "question.answered",
|
|
131
|
+
actor: ctx.actor,
|
|
132
|
+
cost_usd: 0,
|
|
133
|
+
payload: { q: block.id, answer: block.answer, fact: fact.id }
|
|
134
|
+
});
|
|
135
|
+
log.tryAppend({
|
|
136
|
+
ts: ctx.at,
|
|
137
|
+
run: ctx.run,
|
|
138
|
+
stage: null,
|
|
139
|
+
type: "fact.added",
|
|
140
|
+
actor: ctx.actor,
|
|
141
|
+
cost_usd: 0,
|
|
142
|
+
payload: { fact: fact.id, area: fact.area, kind: fact.kind, q: block.id }
|
|
143
|
+
});
|
|
144
|
+
captured.push({ q: block.id, fact: fact.id, answer: block.answer, area });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
writeFileSync(questionsPath, serializeQuestions(doc), "utf8");
|
|
148
|
+
return captured;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/hooks/answer-capture.ts
|
|
152
|
+
await runHook("answer-capture", async () => {
|
|
153
|
+
const payload = await readPayload();
|
|
154
|
+
const event = payload.hook_event_name ?? "";
|
|
155
|
+
if (event === "PostToolUse" && payload.tool_name !== "Write" && payload.tool_name !== "Edit")
|
|
156
|
+
return;
|
|
157
|
+
const filePath = filePathOf(payload);
|
|
158
|
+
if (!filePath.endsWith("questions.md") || !existsSync2(filePath))
|
|
159
|
+
return;
|
|
160
|
+
const location = locateWork(filePath);
|
|
161
|
+
if (location === null)
|
|
162
|
+
return;
|
|
163
|
+
const captured = captureAnswers(filePath, {
|
|
164
|
+
root: location.root,
|
|
165
|
+
runDir: location.runDir,
|
|
166
|
+
run: location.run,
|
|
167
|
+
actor: currentActor(),
|
|
168
|
+
at: nowRfc3339()
|
|
169
|
+
});
|
|
170
|
+
if (captured.length === 0)
|
|
171
|
+
return;
|
|
172
|
+
postContext(`tldrx: recorded ${captured.map((c) => `${c.q} \u2192 ${c.fact}`).join(", ")}`);
|
|
173
|
+
});
|
|
174
|
+
allow();
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
budgetGateDeny
|
|
4
|
+
} from "./chunk-9gb21660.js";
|
|
5
|
+
import {
|
|
6
|
+
allow,
|
|
7
|
+
deny,
|
|
8
|
+
readPayload,
|
|
9
|
+
runHook,
|
|
10
|
+
toolInput
|
|
11
|
+
} from "./chunk-1zwcxd3f.js";
|
|
12
|
+
import"./chunk-a8p2rc94.js";
|
|
13
|
+
import {
|
|
14
|
+
asRunBudget,
|
|
15
|
+
currentActor,
|
|
16
|
+
cursorStage,
|
|
17
|
+
loadRunView,
|
|
18
|
+
newestActiveRun,
|
|
19
|
+
nowRfc3339,
|
|
20
|
+
raiseCommand,
|
|
21
|
+
shortBy,
|
|
22
|
+
validateRunBudget,
|
|
23
|
+
wouldExceed
|
|
24
|
+
} from "./chunk-sdjnnmzz.js";
|
|
25
|
+
import {
|
|
26
|
+
EventLog
|
|
27
|
+
} from "./chunk-azctppjh.js";
|
|
28
|
+
import {
|
|
29
|
+
noteDeprecations
|
|
30
|
+
} from "./chunk-t56k6146.js";
|
|
31
|
+
import"./chunk-0bt6yb2q.js";
|
|
32
|
+
import {
|
|
33
|
+
PROJECT_WORK_DIR,
|
|
34
|
+
findWorkspaceRoot,
|
|
35
|
+
locateWork,
|
|
36
|
+
parseYaml,
|
|
37
|
+
stageYamlPath
|
|
38
|
+
} from "./chunk-p274ckxv.js";
|
|
39
|
+
|
|
40
|
+
// src/hooks/budget-gate.ts
|
|
41
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "node:fs";
|
|
42
|
+
import { join as join2 } from "node:path";
|
|
43
|
+
|
|
44
|
+
// src/core/budget/loadBudget.ts
|
|
45
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
46
|
+
import { join } from "node:path";
|
|
47
|
+
function loadBudget(path) {
|
|
48
|
+
const doc = parseYaml(readFileSync(path, "utf8"));
|
|
49
|
+
const validation = validateRunBudget(doc);
|
|
50
|
+
noteDeprecations(path, validation);
|
|
51
|
+
if (!validation.ok) {
|
|
52
|
+
const first = validation.issues[0];
|
|
53
|
+
throw new Error(`invalid budget.yml (${path}): ${first?.path ?? ""} ${first?.message ?? "schema error"}`);
|
|
54
|
+
}
|
|
55
|
+
return asRunBudget(doc);
|
|
56
|
+
}
|
|
57
|
+
function loadRunBudget(runDir) {
|
|
58
|
+
const path = join(runDir, "budget.yml");
|
|
59
|
+
return existsSync(path) ? loadBudget(path) : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/hooks/budget-gate.ts
|
|
63
|
+
var SPAWN_RE = /^(claude -p|tldrx next|tldrx run auto|tldrx expert train|tldrx seed triage)\b/;
|
|
64
|
+
var RUN_ARG_RE = /--run[= ]([\w.-]+)/;
|
|
65
|
+
var DEFAULT_TRAIN_USD = 2;
|
|
66
|
+
var DEFAULT_TRIAGE_USD = 1;
|
|
67
|
+
var MAX_USD_RE = /--max-usd[= ]([0-9]+(?:\.[0-9]+)?)/;
|
|
68
|
+
var MAX_BUDGET_RE = /--max-budget-usd[= ]([0-9]+(?:\.[0-9]+)?)/;
|
|
69
|
+
await runHook("budget-gate", async () => {
|
|
70
|
+
const payload = await readPayload();
|
|
71
|
+
if (payload.tool_name !== "Bash")
|
|
72
|
+
return;
|
|
73
|
+
const command = (toolInput(payload).command ?? "").trim();
|
|
74
|
+
if (!SPAWN_RE.test(command))
|
|
75
|
+
return;
|
|
76
|
+
const cwd = payload.cwd ?? process.cwd();
|
|
77
|
+
const root = findWorkspaceRoot(cwd);
|
|
78
|
+
if (root === null)
|
|
79
|
+
return;
|
|
80
|
+
let view;
|
|
81
|
+
try {
|
|
82
|
+
view = resolveRun(root, cwd, command);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
failClosed(command, error instanceof Error ? error.message : String(error));
|
|
85
|
+
}
|
|
86
|
+
if (view === null) {
|
|
87
|
+
if (/^tldrx (expert train|seed triage)\b/.test(command)) {
|
|
88
|
+
process.stderr.write("tldrx hook budget-gate: no run to charge this against — " + `\`${command.slice(0, 60)}\` spends outside any run's budget.yml
|
|
89
|
+
`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
failClosed(command, `no readable run under ${PROJECT_WORK_DIR}/`);
|
|
93
|
+
}
|
|
94
|
+
if (view.cursor === null)
|
|
95
|
+
failClosed(command, `${view.dir}/run.yml has no cursor`);
|
|
96
|
+
let budget;
|
|
97
|
+
try {
|
|
98
|
+
budget = loadRunBudget(view.dir);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
failClosed(command, error instanceof Error ? error.message : String(error));
|
|
101
|
+
}
|
|
102
|
+
if (budget === null)
|
|
103
|
+
failClosed(command, `${view.dir}/budget.yml is missing or unreadable`);
|
|
104
|
+
const stage = cursorStage(view);
|
|
105
|
+
const estimate = estimateFor(command, stage?.budget_usd ?? stageBudgetFromLibrary(root, view.cursor.stage));
|
|
106
|
+
if (estimate <= 0)
|
|
107
|
+
return;
|
|
108
|
+
const decision = wouldExceed(budget, view.cursor.phase, estimate);
|
|
109
|
+
if (!decision.blocked)
|
|
110
|
+
return;
|
|
111
|
+
new EventLog(join2(view.dir, "events.jsonl")).tryAppend({
|
|
112
|
+
ts: nowRfc3339(),
|
|
113
|
+
run: view.run,
|
|
114
|
+
stage: view.cursor.stage,
|
|
115
|
+
type: "budget.blocked",
|
|
116
|
+
actor: `hook:budget-gate`,
|
|
117
|
+
cost_usd: 0,
|
|
118
|
+
payload: {
|
|
119
|
+
phase: view.cursor.phase,
|
|
120
|
+
scope: decision.scope,
|
|
121
|
+
remaining_usd: decision.remaining,
|
|
122
|
+
ceiling_usd: decision.ceiling,
|
|
123
|
+
estimate_usd: decision.estimate,
|
|
124
|
+
blocked_by: currentActor()
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
deny(budgetGateDeny(view.cursor.stage, view.cursor.phase, decision.remaining, decision.ceiling, estimate, raiseCommand(view.run, view.cursor.phase, shortBy(estimate, decision.remaining))));
|
|
128
|
+
});
|
|
129
|
+
function estimateFor(command, stageBudget) {
|
|
130
|
+
const flagged = Number(MAX_USD_RE.exec(command)?.[1] ?? MAX_BUDGET_RE.exec(command)?.[1] ?? NaN);
|
|
131
|
+
if (/^tldrx run auto\b/.test(command)) {
|
|
132
|
+
return Number.isFinite(flagged) ? flagged : stageBudget ?? 0;
|
|
133
|
+
}
|
|
134
|
+
if (/^tldrx expert train\b/.test(command)) {
|
|
135
|
+
return Number.isFinite(flagged) ? flagged : DEFAULT_TRAIN_USD;
|
|
136
|
+
}
|
|
137
|
+
if (/^tldrx seed triage\b/.test(command)) {
|
|
138
|
+
return Number.isFinite(flagged) ? flagged : DEFAULT_TRIAGE_USD;
|
|
139
|
+
}
|
|
140
|
+
return stageBudget ?? 0;
|
|
141
|
+
}
|
|
142
|
+
function failClosed(command, why) {
|
|
143
|
+
deny(`[tldrx] budget-gate: refusing \`${command.slice(0, 80)}\` — this gate could not read the budget ` + `it is supposed to enforce (${why}).
|
|
144
|
+
` + "It fails CLOSED: a spend nothing can check is exactly the one that must not start. Fix the run's " + "budget.yml, or pass `--run <id>` so the gate knows which run to charge.");
|
|
145
|
+
}
|
|
146
|
+
function resolveRun(root, cwd, command) {
|
|
147
|
+
const named = RUN_ARG_RE.exec(command)?.[1];
|
|
148
|
+
if (named !== undefined) {
|
|
149
|
+
const dir = join2(root, PROJECT_WORK_DIR, named);
|
|
150
|
+
if (existsSync2(dir) && statSync(dir).isDirectory())
|
|
151
|
+
return loadRunView(dir);
|
|
152
|
+
}
|
|
153
|
+
const here = locateWork(cwd);
|
|
154
|
+
if (here !== null) {
|
|
155
|
+
const view = loadRunView(here.runDir);
|
|
156
|
+
if (view !== null)
|
|
157
|
+
return view;
|
|
158
|
+
}
|
|
159
|
+
return newestActiveRun(root);
|
|
160
|
+
}
|
|
161
|
+
function stageBudgetFromLibrary(root, stage) {
|
|
162
|
+
const path = stageYamlPath(root, stage);
|
|
163
|
+
if (!existsSync2(path))
|
|
164
|
+
return null;
|
|
165
|
+
try {
|
|
166
|
+
const doc = parseYaml(readFileSync2(path, "utf8"));
|
|
167
|
+
const value = doc?.budget_usd;
|
|
168
|
+
return typeof value === "number" ? value : null;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
allow();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// src/core/schemas/validation.ts
|
|
2
|
+
function result(issues, deprecations = []) {
|
|
3
|
+
return { ok: issues.length === 0, issues, deprecations };
|
|
4
|
+
}
|
|
5
|
+
var VERSION_KEY = "version";
|
|
6
|
+
var LEGACY_VERSION_KEY = "schema_version";
|
|
7
|
+
var SCHEMA_VERSION = 1;
|
|
8
|
+
var LEGACY_VERSION_NOTE = `${LEGACY_VERSION_KEY} is deprecated — say ${VERSION_KEY}: ${String(SCHEMA_VERSION)}`;
|
|
9
|
+
function requireVersion(doc, issues, deprecations) {
|
|
10
|
+
const declared = doc[VERSION_KEY];
|
|
11
|
+
if (declared !== undefined) {
|
|
12
|
+
if (declared !== SCHEMA_VERSION) {
|
|
13
|
+
issues.push({
|
|
14
|
+
path: VERSION_KEY,
|
|
15
|
+
message: `unknown schema version ${describeValue(declared)} (expected ${String(SCHEMA_VERSION)})`
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (!(LEGACY_VERSION_KEY in doc) || doc[LEGACY_VERSION_KEY] === undefined) {
|
|
21
|
+
issues.push({ path: VERSION_KEY, message: `missing required key \`${VERSION_KEY}\`` });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
requireNumber(doc[LEGACY_VERSION_KEY], LEGACY_VERSION_KEY, issues);
|
|
25
|
+
deprecations.push(LEGACY_VERSION_NOTE);
|
|
26
|
+
}
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function joinPath(base, key) {
|
|
31
|
+
return base === "" ? key : `${base}.${key}`;
|
|
32
|
+
}
|
|
33
|
+
function requireKeys(value, keys, base, issues) {
|
|
34
|
+
for (const key of keys) {
|
|
35
|
+
if (!(key in value) || value[key] === undefined) {
|
|
36
|
+
issues.push({ path: joinPath(base, key), message: `missing required key \`${key}\`` });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function requireEnum(value, allowed, path, issues) {
|
|
41
|
+
if (value === undefined)
|
|
42
|
+
return;
|
|
43
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
44
|
+
issues.push({ path, message: `expected one of ${allowed.join(" | ")}, got ${describe(value)}` });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function requireArray(value, path, issues) {
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
return false;
|
|
50
|
+
if (!Array.isArray(value)) {
|
|
51
|
+
issues.push({ path, message: `expected an array, got ${describe(value)}` });
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
function requireNumber(value, path, issues) {
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
return;
|
|
59
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
60
|
+
issues.push({ path, message: `expected a number, got ${describe(value)}` });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function requireString(value, path, issues) {
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
return;
|
|
66
|
+
if (typeof value !== "string") {
|
|
67
|
+
issues.push({ path, message: `expected a string, got ${describe(value)}` });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function asDocument(input, issues) {
|
|
71
|
+
if (!isRecord(input)) {
|
|
72
|
+
issues.push({ path: "", message: `expected a mapping at the document root, got ${describe(input)}` });
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return input;
|
|
76
|
+
}
|
|
77
|
+
function describe(value) {
|
|
78
|
+
if (value === null)
|
|
79
|
+
return "null";
|
|
80
|
+
if (Array.isArray(value))
|
|
81
|
+
return "an array";
|
|
82
|
+
return typeof value;
|
|
83
|
+
}
|
|
84
|
+
function describeValue(value) {
|
|
85
|
+
return typeof value === "number" || typeof value === "boolean" ? String(value) : describe(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { result, requireVersion, isRecord, requireKeys, requireEnum, requireArray, requireNumber, requireString, asDocument };
|