tldr-experts 0.10.0 → 0.11.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 +181 -0
- package/README.md +5 -2
- package/dist/hooks/answer-capture.js +297 -23
- package/dist/hooks/budget-gate.js +7 -7
- package/dist/hooks/{chunk-cmx01x0r.js → chunk-1c43349s.js} +56 -38
- package/dist/hooks/{chunk-3rwbyema.js → chunk-6c84kjd7.js} +1 -1
- package/dist/hooks/{chunk-dzm2bzqp.js → chunk-6ws778wq.js} +1 -1
- package/dist/hooks/{chunk-dtpjhddq.js → chunk-9v45j71e.js} +21 -2
- package/dist/hooks/chunk-bk7rkdzw.js +103 -0
- package/dist/hooks/{chunk-br08fp2d.js → chunk-d20x2aew.js} +27 -19
- package/dist/hooks/{chunk-w3jssmwe.js → chunk-ecwes7kd.js} +1 -1
- package/dist/hooks/{chunk-ecxt4p6n.js → chunk-kqvbzyc0.js} +21 -5
- package/dist/hooks/{chunk-f6d6cp4h.js → chunk-r1efatxk.js} +9 -1
- package/dist/hooks/{chunk-7ye9jde9.js → chunk-stw0dxna.js} +1 -1
- package/dist/hooks/{chunk-gvs5fvve.js → chunk-tg7ppsxr.js} +4 -1
- package/dist/hooks/{chunk-14496ta8.js → chunk-wzzywf6c.js} +32 -2
- package/dist/hooks/claim-sources.js +5 -5
- package/dist/hooks/dod-gate.js +5 -5
- package/dist/hooks/no-reask.js +8 -8
- package/dist/hooks/session-start.js +37 -118
- package/dist/hooks/statusline.js +6 -6
- package/dist/tldrx.js +2404 -1044
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/stages/build/stage.yml +14 -0
- package/stages/how/stage.yml +6 -0
- package/stages/plan/stage.yml +6 -0
- package/stages/watch/stage.yml +6 -0
- package/stages/what/stage.yml +6 -0
- package/templates/experts/stack/dotnet.md +4 -3
- package/templates/experts/stack/javascript.md +4 -3
- package/templates/experts/stack/python.md +4 -3
- package/templates/experts/stack/typescript.md +4 -3
- package/templates/workspace.yml +6 -0
- package/workflows/bugfix.yml +7 -0
- package/workflows/docs.yml +7 -0
- package/workflows/feature.yml +7 -0
- package/workflows/hotfix.yml +7 -0
- package/workflows/integration.yml +7 -0
- package/workflows/migration.yml +7 -0
- package/workflows/performance.yml +7 -0
- package/workflows/prototype.yml +7 -0
- package/workflows/refactor.yml +7 -0
- package/workflows/retro.yml +7 -0
- package/workflows/security-patch.yml +7 -0
- package/workflows/spike.yml +7 -0
- package/workflows/upgrade.yml +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,187 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## 0.11.0 — 2026-09-08
|
|
5
|
+
|
|
6
|
+
### Fixed
|
|
7
|
+
|
|
8
|
+
- **An answered decision now says who decided it, and what it binds to.** `tldrx answer`
|
|
9
|
+
wrote a fact with no attribution and no repo scope, so a superseded owner call and an
|
|
10
|
+
unattended driver default read identically in `facts.yml` and in every prompt built from
|
|
11
|
+
it. `--decided-by <owner|driver>` (last one wins, like any ordinary flag) and repeatable
|
|
12
|
+
`--repo <name>` land on the fact `answer` writes — `--decided-by` outside the closed set,
|
|
13
|
+
or `--repo` naming no workspace repo, is a usage refusal (exit 1) with nothing written, and
|
|
14
|
+
`--repo` passed twice scopes once. With neither flag the fact says "not stated," never a
|
|
15
|
+
guessed owner. `Fact.decided_by` is additive on `facts.yml`; `Fact.repos` already existed
|
|
16
|
+
and is required, not additive. (#169)
|
|
17
|
+
- **Two signed facts that disagree produce a question, without an agent choosing to
|
|
18
|
+
notice.** The contradiction check now runs on every `answer`, scores the
|
|
19
|
+
new fact against the live ones in the same `area`, and — on a hit — RAISES an advisory
|
|
20
|
+
question (`asked_by: tldrx`, a new `advisory:` metadata key that opts a block out of
|
|
21
|
+
every reader that COUNTS open questions — the auto gate, `next`'s `awaiting_answer`
|
|
22
|
+
branch, `skip_if` and `tldrx status` — without hiding it from the readers that LIST
|
|
23
|
+
them, `tldrx questions` among them) rather than refusing the answer. The
|
|
24
|
+
new fact keeps `conflicts_with: [<id>]` naming the fact it contradicts (additive,
|
|
25
|
+
empty-omitted, unquoted) — which one is right is left to the question it raises, nothing in
|
|
26
|
+
the mechanism decides a "loser." "Not detected" never reads as "checked and agreed" — the
|
|
27
|
+
check is lexical, and honestly cannot catch what its own transcript produced here: three
|
|
28
|
+
differently-titled answers whose contents actually disagree. The new `fact.conflict_raised`
|
|
29
|
+
event (`{fact, conflicts_with, score, q, raised}`) joins the closed `EVENT_TYPES` enum.
|
|
30
|
+
(#169)
|
|
31
|
+
- **A defect in a file no story declared has a sanctioned remedy and a visible home.**
|
|
32
|
+
Reaching it used to mean a hand edit to a story's `touches:` while the boundary card
|
|
33
|
+
described exactly the verb that edit needed and the CLI forbade it. Separately, and by
|
|
34
|
+
owner decision REPORT ONLY — no new gate condition, no new refusal, no new exit code — a
|
|
35
|
+
Build finding whose file matches no story's declared surface now reaches the Build
|
|
36
|
+
handoff, the PR body and the boundary card (only when a boundary trigger already draws
|
|
37
|
+
one), named as unowned rather than silently absorbed by whichever story happened to run
|
|
38
|
+
last. (#171)
|
|
39
|
+
|
|
40
|
+
- **The developer bundle carries `result_schema` too, so both halves of one handshake make the
|
|
41
|
+
same promise.** Measured on disk in a real run: `.agent/<story>/pending.json` had no
|
|
42
|
+
`result_schema` while the reviewer bundle one directory down had one. The reviewer prompt says
|
|
43
|
+
in as many words to read the envelope shape out of the bundle and never from memory; the
|
|
44
|
+
developer had nothing to read it out of, so a host guessed the shape by copying a sibling
|
|
45
|
+
story's `result.json`, and a reviewer later "corrected" it from the other file. A developer
|
|
46
|
+
bundle — the Build story's, and every single-agent stage's — now carries the
|
|
47
|
+
`{outputs, questions_asked, notes}` envelope the spawned half is handed through
|
|
48
|
+
`claude --json-schema`, plus the `cost_usd` and `session_id` a host may declare and
|
|
49
|
+
`readResult` reads back. It is DERIVED from that schema rather than retyped, so a change to
|
|
50
|
+
the envelope cannot reach the spawn without reaching the bundle. The one thing the two halves
|
|
51
|
+
do not share is how strictly the schema is read, and that is now written down instead of
|
|
52
|
+
implied: a reviewer envelope is refused on its form, a developer envelope is coerced, and
|
|
53
|
+
`--commit --check` is what reports the difference.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- **`tldrx story widen <id> <path>... --note "<why>"`** — the verb the boundary card had
|
|
58
|
+
been pointing at while the CLI forbade the hand edit it described. Records
|
|
59
|
+
`story.touches_widened` (`{story, paths, note, before, after}`, joining the closed event
|
|
60
|
+
enum) and rewrites `touches:` through the same validated write every other command uses.
|
|
61
|
+
Refused (exit 2, nothing written) for a `done` story — naming `reopen --for-fix` as the
|
|
62
|
+
remedy — a `..` anywhere in a path (a substring test, not a segment one), a path already declared, an unknown story, or a plan the
|
|
63
|
+
story doesn't have; an unresolved run is exit 3 (not found), and `--for-fix` is not a flag
|
|
64
|
+
of `widen` itself and passing it is a usage refusal (exit 1). (#171)
|
|
65
|
+
- **`tldrx budget grant <amount> --fact <id>`** — a ceiling that answers to a recorded
|
|
66
|
+
authorization instead of a bare number. Writes `authorized_usd` at run or phase scope, and
|
|
67
|
+
`authorized_by`/`authorized_at` always at the run level, citing the fact behind it (not
|
|
68
|
+
duplicated under a second key) — `--phase` scopes the amount, never the citation. Fires
|
|
69
|
+
`budget.granted` (`{amount_usd, fact, phase, note, ceiling_usd, previous_usd}`, joining the
|
|
70
|
+
closed event enum) — a second grant replaces the amount and the event records what it
|
|
71
|
+
replaced, `null` on the first grant; a non-positive amount is refused (exit 1, nothing
|
|
72
|
+
written), by the VALIDATOR as well as by the verb, so a hand-edited `authorized_usd: 0` —
|
|
73
|
+
which `grantFor` would otherwise read as a real $0 grant that blocks every later raise —
|
|
74
|
+
is a schema error rather than a record that quietly governs nothing. Absence is untouched:
|
|
75
|
+
no key still means no grant recorded, never `$0`. `on_grant_exceed` (default `warn`, distinct from and never confused with
|
|
76
|
+
`on_exceed`) says what a later `budget raise` past the grant does; `budget show` renders
|
|
77
|
+
the grant back and stays silent
|
|
78
|
+
when none is recorded. `--fact`/`--phase`/`--on-exceed` are refused (exit 1) on
|
|
79
|
+
`budget show` and `budget raise`, where recording a policy is not their job. All four keys
|
|
80
|
+
are additive on `budget.yml` — a file written before this change still loads and means no
|
|
81
|
+
grant. `DASHBOARD_MODEL_VERSION` is unchanged (3): `BudgetModel`/`BudgetPhaseModel` widen,
|
|
82
|
+
no existing field's meaning moved. (#170)
|
|
83
|
+
- **`tldrx cost --stories`** — what a story cost against the ceiling its spawn was given,
|
|
84
|
+
one row per story, ceilings keyed off each `agent.spawned`'s `max_budget_usd` and money
|
|
85
|
+
keyed off the envelope's own metered `cost_usd`. A story with an unmetered turn among its
|
|
86
|
+
metered ones is named a lower bound, never handed the unqualified "inside the ceiling"
|
|
87
|
+
verdict; a story with no spawned ceiling is reported as absent, not as a zero; a run with
|
|
88
|
+
no story spawns says so instead of printing an empty table. The same arithmetic now
|
|
89
|
+
appends an over-ceiling clause to the Build handoff's existing cost line when the stories
|
|
90
|
+
behind it ran over. (#170)
|
|
91
|
+
- **A run close, `run cancel`, `approve` and the Build handoff header all say how many of a
|
|
92
|
+
run's decisions name a decider.** One tally (`owner` / `driver` / not-stated), computed
|
|
93
|
+
once and read by all four surfaces; no sentence at all when the run recorded no facts,
|
|
94
|
+
never a confident zero. (#169)
|
|
95
|
+
|
|
96
|
+
- **`workspace.yml` gains an optional `test_fast:` command — the developer iterates on it, and
|
|
97
|
+
the suite runs once at the Definition of Done.** A story's ```dod block must be byte-equal to
|
|
98
|
+
a `workspace.yml` command, and the developer prompt hands the sub-agent that same list with
|
|
99
|
+
"these commands are the only ones you may run". Between them, the only test instrument the
|
|
100
|
+
framework offered was the whole suite. Measured this week on three real workspaces: in one of
|
|
101
|
+
them that suite is 11,929 tests over 855 files, and a developer iterating a story ran it six
|
|
102
|
+
to ten times, on top of the two or three the Definition of Done itself pays for (preflight
|
|
103
|
+
once per run, cached; then once per attempt). The waste was never the gate — it was the
|
|
104
|
+
iteration loop, for which there was no faster instrument. `test_fast` is that instrument, and
|
|
105
|
+
it is deliberately not evidence: it is declared, so the developer may run it, and a ```dod
|
|
106
|
+
line equal to it is REFUSED with a sentence that names the slot rather than the generic "not
|
|
107
|
+
one of workspace.yml's commands", which would be false about a command the file plainly
|
|
108
|
+
declares. `tldrx init` does not guess it — no manifest says which subset of a suite is the
|
|
109
|
+
fast one, and a synthesised answer is exactly the conventional wisdom `command_probes:` exists
|
|
110
|
+
to keep out of this file — so the emitted `workspace.yml` carries the slot commented out with
|
|
111
|
+
a line saying what it is for, and nothing probes a slot nobody declared. When the workspace
|
|
112
|
+
declares one, the developer prompt says both halves: iterate on the fast command, and run the
|
|
113
|
+
declared full command once before you stop, because the gate re-runs it after. When it does
|
|
114
|
+
not, the prompt is byte-identical to the one before this existed. Additive: `version:` stays
|
|
115
|
+
`1`, and every workspace written without the slot loads and behaves exactly as it did.
|
|
116
|
+
- **`tldrx next --commit --check` rehearses the commit and writes nothing, so a bad envelope
|
|
117
|
+
is caught while the turn is still open.** Measured on a real workspace this week: two reviews
|
|
118
|
+
were refused at `tldrx next --commit --review` because a `[src: …]` citation inside a `refuted`
|
|
119
|
+
finding was not the last thing on its line. Both refusals were correct; both arrived after the
|
|
120
|
+
turn had been paid for, and the host's answer was to ban `refuted` from the next thirty briefs
|
|
121
|
+
— a protocol feature switched off to dodge a late validator, which is the most expensive
|
|
122
|
+
possible outcome for a rule the framework got right. The reviewer cannot check itself: its
|
|
123
|
+
tools are `Read`, `Grep`, `Glob` and `Bash(git diff *)`, so there is no door to run a validator
|
|
124
|
+
through, and its prompt already states the end-of-line rule with a refused and an accepted
|
|
125
|
+
example generated from the reader's own patterns. What was missing was a way for the host to
|
|
126
|
+
ask "is this readable?" before saying the turn is done. `--check` validates the prepared
|
|
127
|
+
bundle's `result.json` through the SAME reader `--commit` uses — no second implementation of
|
|
128
|
+
the grammar, the dispositions or the verdict enum — prints every refusal verbatim with the
|
|
129
|
+
offending line, and exits `0` when `--commit` would read the envelope and `1` when it would
|
|
130
|
+
not. It takes no lock, moves no cursor, records no event and spends no attempt; the tests
|
|
131
|
+
compare `run.yml`, the story file and `events.jsonl` as bytes either side of the call. On a
|
|
132
|
+
developer bundle the reader is deliberately tolerant — a missing `outputs` is read as `[]`, a
|
|
133
|
+
non-string `notes` as `""` — so `--check` exits `0` and NAMES what is about to be coerced
|
|
134
|
+
rather than inventing a refusal the framework does not make. That naming goes down to the
|
|
135
|
+
ELEMENT, because the coercion that hides best is the one inside an array: `outputs` and
|
|
136
|
+
`questions_asked` are declared `items: {type: "string"}` and the reader silently FILTERS every
|
|
137
|
+
element that is not one, so `["good", 42, null, "also-good"]` is an array, passes any
|
|
138
|
+
whole-field type test, and still reaches the run as two entries. `--check` names each dropped
|
|
139
|
+
element by index and by the JSON of its value, through the same one-line predicate the reader
|
|
140
|
+
filters on — called, not restated, so the check cannot name a set the reader does not drop.
|
|
141
|
+
Nothing citable is lost in the bargain: a dropped element is by definition not a string, and a
|
|
142
|
+
`[src: …]` citation is a token inside one.
|
|
143
|
+
|
|
144
|
+
### Changed
|
|
145
|
+
|
|
146
|
+
- **The shipped stage and workflow money literals are labelled `[assumption]`**, and
|
|
147
|
+
deliberately NOT recalibrated: `tldrx init` copies no `stages/` into `.tldrx/`, so moving
|
|
148
|
+
one moves the ceiling of every workspace that never wrote an override, and this repo holds
|
|
149
|
+
no corpus to derive a new number from. `triage.budget_basis` (closed set: `model-guess` /
|
|
150
|
+
`owner-grant` / `preset`; additive on `RunFile`'s `triage:`, absent means nothing) records
|
|
151
|
+
which of the three produced a triage's numbers — `tldrx seed apply` always writes
|
|
152
|
+
`model-guess` today. `tldrx cost --stories` is the command that would produce the corpus a
|
|
153
|
+
recalibration needs. (#170)
|
|
154
|
+
|
|
155
|
+
- **The Build stage ships `parallel: 2`, so a wave runs two stories at a time out of the box.**
|
|
156
|
+
`--parallel N` shipped in 0.3.1 and nothing used it: no shipped workflow and no shipped
|
|
157
|
+
stage file set it, so every workspace ran one story at a time by inheriting a code constant
|
|
158
|
+
nobody chose. Measured across three real workspaces over one week and 23 runs: runs of 34-43 h
|
|
159
|
+
wall clock whose per-story medians were 0.6-1.35 h — the wall clock was serialization, not work.
|
|
160
|
+
The stories of one wave are independent by construction (`waves.yml` puts every `depends_on` in
|
|
161
|
+
an EARLIER wave, and `validatePlan` refuses a plan that does not), so this is a schedule change
|
|
162
|
+
and not a correctness one; merges into the epic still happen serially, in the wave's listed
|
|
163
|
+
order, and so do the reviewers, whose merge base moves under them otherwise. Two rather than
|
|
164
|
+
more because one of those workspaces hit OOM kills at a wider fan-out — raise it per run with
|
|
165
|
+
`--parallel N`, or for good with `parallel: N` in your own `.tldrx/stages/build/stage.yml`.
|
|
166
|
+
`DEFAULT_PARALLEL` in code stays 1: the opinion belongs in the stage file an operator can read
|
|
167
|
+
and override, and the last resort stays the value that surprises nobody. The one thing
|
|
168
|
+
`parallel:` does not change is the `--prepare`/`--commit` handshake, which is per story and
|
|
169
|
+
stays sequential — that side cannot know how many sub-agents the host session will run. (#176)
|
|
170
|
+
- **The "can this test fail?" check now names its instrument: one test file, not the whole
|
|
171
|
+
declared suite.** The drive mandate asked for the one check a reviewer cannot do — break the
|
|
172
|
+
line a new test covers and watch it go red — and the four stack packs asked the same question
|
|
173
|
+
under `## Checks`, telling the reader to "re-run the test command declared in
|
|
174
|
+
`.tldrx/workspace.yml`". That command IS the full suite by construction: a story's Definition
|
|
175
|
+
of Done is byte-equal to a `workspace.yml` command. So the words nobody read as "full suite"
|
|
176
|
+
bought exactly that, once per mutation — measured over a week of unattended runs on three real
|
|
177
|
+
workspaces, five mutations in a story meant five extra full suites, six to ten suite runs per
|
|
178
|
+
story, against a suite of 11,929 tests over 855 files in one of those workspaces. The check
|
|
179
|
+
keeps earning its place (it caught a test that only passed because base64 hid a raw id); what
|
|
180
|
+
was expensive was the instrument. The mandate and all four packs now scope the mutation re-run
|
|
181
|
+
to the single test file that covers the broken line, and say where the declared command
|
|
182
|
+
belongs: once, at the Definition of Done. The empty-slot clause is untouched — a workspace
|
|
183
|
+
that declares no test command is still named rather than quietly passed.
|
|
184
|
+
|
|
4
185
|
## 0.10.0 — 2026-09-07
|
|
5
186
|
|
|
6
187
|
### Fixed
|
package/README.md
CHANGED
|
@@ -163,8 +163,10 @@ its own refusal. See [10 Unattended mode](docs/guide/10-unattended-mode.md).
|
|
|
163
163
|
| `security-patch` | auto | auto | — | human | human |
|
|
164
164
|
| `migration` | auto | auto | auto | human | human |
|
|
165
165
|
|
|
166
|
-
`--parallel <n>` on `next` / `run auto` builds that many of a wave's stories at once
|
|
167
|
-
|
|
166
|
+
`--parallel <n>` on `next` / `run auto` builds that many of a wave's stories at once. The
|
|
167
|
+
shipped Build stage declares `parallel: 2`, so a workspace that overrides nothing already runs
|
|
168
|
+
two lanes; the code fallback stays 1, for a stage file that says nothing. Merges still land in
|
|
169
|
+
the wave's listed order whatever the number is.
|
|
168
170
|
|
|
169
171
|
A scope with `—` under `plan` does not run the Plan phase, and Build writes the one story that
|
|
170
172
|
decision implies (`04-build/implicit-plan.yml`) from your What handoff rather than refusing;
|
|
@@ -314,6 +316,7 @@ back on the registry is 0.3.0.
|
|
|
314
316
|
|
|
315
317
|
| Version | Date | Status | Contains |
|
|
316
318
|
|---|---|---|---|
|
|
319
|
+
| 0.11.0 | 2026-09-08 | `beta` | governance that leaves a record: every answer says who decided it and what repo it binds, an advisory contradiction check raises a question that never stops a run and states its own limit, the close and the Build handoff count decided vs not-stated; `tldrx story widen` is the sanctioned way to grow a story's surface (a done story needs `reopen --for-fix` first) and a defect no story owns is named in the handoff, the PR body and the boundary card instead of absorbed; a budget grant is a recorded number in `budget.yml` that survives every writer, `budget raise` warns or refuses against it, presets are labelled as the assumptions they are, `triage.budget_basis` says where a figure came from and `tldrx cost --stories` measures each story against the ceiling its spawn was given, honest about lower bounds |
|
|
317
320
|
| 0.10.0 | 2026-09-07 | `beta` | the mechanical blockers measured in real runs, fixed: a refused DoD command is recorded as refused (never a fabricated exit 126) in the event, the handoff, the review and retro logs, and the readers that used to recover it as green; the dirty-tree refusal prints the exact stash and pop commands and its true reason; the reviewer diffs the epic as it was before the merge (`epic_base` recorded on the story and the bundle) instead of an empty range; `tldrx init` probes each declared command once and records `command_probes` beside a `commands:` allowlist it never guesses; `tldrx ship` opens a PR whose body describes the change and its known defects, with the handoff folded underneath, and a state refusal that honours settled stories' declared touches; reviewer turns carry their token split and the spend basis reads it, so a whole provider no longer reads as absent |
|
|
318
321
|
| 0.9.2 | 2026-09-07 | `beta` | the Build executor decomposed: 4,351 lines became an orchestrator plus eight modules under src/core/build/ (review ledger and phase cost, money caps, the DoD runner with its preflight cache, worktrees and epic-branch claims, the reviewer bundle and review round) — a pure refactor proven byte-for-byte by a golden guard over prompts, ordered events, run.yml rows and exit codes across three scenarios; no behaviour changed, every remaining hardening fix now lands in a file a reviewer can hold |
|
|
319
322
|
| 0.9.1 | 2026-09-06 | `beta` | records and money that do not lie: a turn without a provider USD figure is unmetered, never a metered $0.00; a refusal no longer discards the turn's cost (banked first, deduped only against marked rows); an event over the 4096-byte cap spills its text beside the run and names it instead of losing every row of the invocation; preflight rows carry `command_hash` and their own `checked_at`, so a cached red is re-probed when the command changed, after 30 minutes, or under --prepare; the fix list writes back git's canonical 40-hex sha; `tldrx facts add` exists with owner/driver attribution rendered in prompts; run.yml rows carry the provider's token split only when reported |
|
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
} from "./chunk-
|
|
3
|
+
conflictOf
|
|
4
|
+
} from "./chunk-bk7rkdzw.js";
|
|
5
|
+
import {
|
|
6
|
+
FactsStore,
|
|
7
|
+
formatJaccard
|
|
8
|
+
} from "./chunk-kqvbzyc0.js";
|
|
5
9
|
import {
|
|
6
10
|
parseHookInput,
|
|
7
11
|
readStdin
|
|
8
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-6c84kjd7.js";
|
|
9
13
|
import {
|
|
10
14
|
EventLog,
|
|
11
15
|
PHASE_ID_RE
|
|
12
|
-
} from "./chunk-
|
|
13
|
-
import"./chunk-
|
|
16
|
+
} from "./chunk-9v45j71e.js";
|
|
17
|
+
import"./chunk-tg7ppsxr.js";
|
|
14
18
|
import {
|
|
19
|
+
ADVISORY_KEY,
|
|
15
20
|
MAX_FACT_CHARS,
|
|
16
21
|
detectAnswered,
|
|
17
22
|
parseQuestions,
|
|
18
23
|
recordAnswer,
|
|
24
|
+
renderQuestionBlock,
|
|
19
25
|
replaceBlock,
|
|
20
26
|
serializeQuestions
|
|
21
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-wzzywf6c.js";
|
|
22
28
|
import {
|
|
29
|
+
ITERATION_ONLY_SLOT,
|
|
30
|
+
PROJECT_FRAMEWORK_DIR,
|
|
23
31
|
PROJECT_WORK_DIR,
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
commandProbeIssues,
|
|
33
|
+
factsPath,
|
|
34
|
+
parseYaml
|
|
35
|
+
} from "./chunk-r1efatxk.js";
|
|
26
36
|
|
|
27
37
|
// src/hooks/answer-capture.ts
|
|
28
|
-
import { existsSync as
|
|
38
|
+
import { existsSync as existsSync5 } from "fs";
|
|
29
39
|
|
|
30
40
|
// src/hooks/lib/decide.ts
|
|
31
41
|
function allow() {
|
|
@@ -67,6 +77,7 @@ function filePathOf(payload) {
|
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
// src/hooks/lib/workspace.ts
|
|
80
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
70
81
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
71
82
|
function locateWork(filePath) {
|
|
72
83
|
if (filePath === "")
|
|
@@ -86,6 +97,111 @@ function locateWork(filePath) {
|
|
|
86
97
|
relative: parts.slice(at + 2).join("/")
|
|
87
98
|
};
|
|
88
99
|
}
|
|
100
|
+
var FALLBACK_DEFAULT_BRANCH = "main";
|
|
101
|
+
function commandRolesOf(doc) {
|
|
102
|
+
const out = new Map;
|
|
103
|
+
const list = doc?.repos;
|
|
104
|
+
if (!Array.isArray(list))
|
|
105
|
+
return out;
|
|
106
|
+
for (const entry of list) {
|
|
107
|
+
if (typeof entry?.name !== "string")
|
|
108
|
+
continue;
|
|
109
|
+
const roles = new Map;
|
|
110
|
+
const cmds = entry.commands;
|
|
111
|
+
if (cmds !== null && typeof cmds === "object") {
|
|
112
|
+
for (const [role, value] of Object.entries(cmds)) {
|
|
113
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
114
|
+
continue;
|
|
115
|
+
roles.set(role, value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
out.set(entry.name, roles);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
function commandProbesOf(doc) {
|
|
123
|
+
const out = new Map;
|
|
124
|
+
const list = doc?.repos;
|
|
125
|
+
if (!Array.isArray(list))
|
|
126
|
+
return out;
|
|
127
|
+
for (const entry of list) {
|
|
128
|
+
if (typeof entry?.name !== "string")
|
|
129
|
+
continue;
|
|
130
|
+
const probes = new Map;
|
|
131
|
+
const raw = entry.command_probes;
|
|
132
|
+
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
|
133
|
+
for (const [slot, value] of Object.entries(raw)) {
|
|
134
|
+
if (commandProbeIssues(value, slot).length > 0)
|
|
135
|
+
continue;
|
|
136
|
+
probes.set(slot, value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
out.set(entry.name, probes);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
function loadWorkspace(root) {
|
|
144
|
+
const repos = new Map;
|
|
145
|
+
const commands = new Set;
|
|
146
|
+
const repoCommands = new Map;
|
|
147
|
+
const commandRoles = new Map;
|
|
148
|
+
const commandProbes = new Map;
|
|
149
|
+
const iterationCommands = new Set;
|
|
150
|
+
const defaultBranches = new Map;
|
|
151
|
+
let seedTriageThresholdTokens = null;
|
|
152
|
+
const empty = () => ({
|
|
153
|
+
root,
|
|
154
|
+
repos,
|
|
155
|
+
commands,
|
|
156
|
+
repoCommands,
|
|
157
|
+
commandRoles,
|
|
158
|
+
commandProbes,
|
|
159
|
+
iterationCommands,
|
|
160
|
+
defaultBranches,
|
|
161
|
+
seedTriageThresholdTokens
|
|
162
|
+
});
|
|
163
|
+
const path = join(root, PROJECT_FRAMEWORK_DIR, "workspace.yml");
|
|
164
|
+
if (!existsSync(path))
|
|
165
|
+
return empty();
|
|
166
|
+
let doc;
|
|
167
|
+
try {
|
|
168
|
+
doc = parseYaml(readFileSync(path, "utf8"));
|
|
169
|
+
} catch {
|
|
170
|
+
return empty();
|
|
171
|
+
}
|
|
172
|
+
const triage = doc?.seed_triage;
|
|
173
|
+
if (triage !== null && typeof triage === "object") {
|
|
174
|
+
const tokens = triage.threshold_tokens;
|
|
175
|
+
if (typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) {
|
|
176
|
+
seedTriageThresholdTokens = tokens;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const list = doc?.repos;
|
|
180
|
+
if (!Array.isArray(list))
|
|
181
|
+
return empty();
|
|
182
|
+
const declared = commandRolesOf(doc);
|
|
183
|
+
const measured = commandProbesOf(doc);
|
|
184
|
+
for (const entry of list) {
|
|
185
|
+
if (typeof entry?.name !== "string")
|
|
186
|
+
continue;
|
|
187
|
+
repos.set(entry.name, typeof entry.path === "string" ? entry.path : ".");
|
|
188
|
+
defaultBranches.set(entry.name, typeof entry.default_branch === "string" && entry.default_branch !== "" ? entry.default_branch : FALLBACK_DEFAULT_BRANCH);
|
|
189
|
+
const own = [];
|
|
190
|
+
const roles = declared.get(entry.name) ?? new Map;
|
|
191
|
+
for (const value of roles.values()) {
|
|
192
|
+
commands.add(value);
|
|
193
|
+
if (!own.includes(value))
|
|
194
|
+
own.push(value);
|
|
195
|
+
}
|
|
196
|
+
repoCommands.set(entry.name, own);
|
|
197
|
+
commandRoles.set(entry.name, roles);
|
|
198
|
+
const fast = roles.get(ITERATION_ONLY_SLOT);
|
|
199
|
+
if (fast !== undefined)
|
|
200
|
+
iterationCommands.add(fast);
|
|
201
|
+
commandProbes.set(entry.name, measured.get(entry.name) ?? new Map);
|
|
202
|
+
}
|
|
203
|
+
return empty();
|
|
204
|
+
}
|
|
89
205
|
|
|
90
206
|
// src/hooks/lib/actor.ts
|
|
91
207
|
function currentActor() {
|
|
@@ -97,10 +213,10 @@ function nowRfc3339() {
|
|
|
97
213
|
}
|
|
98
214
|
|
|
99
215
|
// src/core/answers/captureAnswers.ts
|
|
100
|
-
import { existsSync as
|
|
216
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync } from "node:fs";
|
|
101
217
|
|
|
102
218
|
// src/core/answers/stampSuperseded.ts
|
|
103
|
-
import { appendFileSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
219
|
+
import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
|
|
104
220
|
import { isAbsolute as isAbsolute2, join as join2, relative } from "node:path";
|
|
105
221
|
var STAMP_MARKER = "tldrx:superseded";
|
|
106
222
|
var AFFECTS_KEY = "affects";
|
|
@@ -140,7 +256,7 @@ function stampSuperseded(runDir, questionsPath, block, fact, at) {
|
|
|
140
256
|
const questionsRel = runRelative(runDir, questionsPath) ?? "questions.md";
|
|
141
257
|
const stamped = [];
|
|
142
258
|
for (const doc of affectedDocs(runDir, questionsPath, block)) {
|
|
143
|
-
const text =
|
|
259
|
+
const text = readFileSync2(doc.abs, "utf8");
|
|
144
260
|
if (isStamped(text, fact))
|
|
145
261
|
continue;
|
|
146
262
|
const lead = text === "" ? "" : text.endsWith(`
|
|
@@ -165,7 +281,7 @@ function resolveInRun(runDir, raw) {
|
|
|
165
281
|
const rel = runRelative(runDir, candidate);
|
|
166
282
|
if (rel === null)
|
|
167
283
|
continue;
|
|
168
|
-
if (!
|
|
284
|
+
if (!existsSync2(candidate) || !statSync2(candidate).isFile())
|
|
169
285
|
continue;
|
|
170
286
|
return rel;
|
|
171
287
|
}
|
|
@@ -185,7 +301,113 @@ function isEarlier(phase, than) {
|
|
|
185
301
|
return Number(phase.slice(0, 2)) < Number(than.slice(0, 2));
|
|
186
302
|
}
|
|
187
303
|
|
|
304
|
+
// src/core/answers/reposFromAffects.ts
|
|
305
|
+
function uniqueRepos(names) {
|
|
306
|
+
return [...new Set(names)];
|
|
307
|
+
}
|
|
308
|
+
function reposFromAffects(affects, repoNames) {
|
|
309
|
+
const repos = [];
|
|
310
|
+
const unresolved = [];
|
|
311
|
+
for (const raw of affects) {
|
|
312
|
+
const entry = raw.trim();
|
|
313
|
+
if (entry === "")
|
|
314
|
+
continue;
|
|
315
|
+
if (repoNames.has(entry)) {
|
|
316
|
+
repos.push(entry);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const colon = entry.indexOf(":");
|
|
320
|
+
if (colon <= 0)
|
|
321
|
+
continue;
|
|
322
|
+
const prefix = entry.slice(0, colon);
|
|
323
|
+
if (repoNames.has(prefix)) {
|
|
324
|
+
repos.push(prefix);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
unresolved.push(entry);
|
|
328
|
+
}
|
|
329
|
+
return { repos: uniqueRepos(repos), unresolved };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/core/answers/raiseConflict.ts
|
|
333
|
+
import { appendFileSync as appendFileSync2, existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
334
|
+
import { join as join3 } from "node:path";
|
|
335
|
+
|
|
336
|
+
// src/core/run/questionCards.ts
|
|
337
|
+
var QUESTION_PHASES = ["01-what", "02-how", "03-plan", "04-build", "05-watch"];
|
|
338
|
+
|
|
339
|
+
// src/core/answers/raiseConflict.ts
|
|
340
|
+
var RAISED_BY = "tldrx";
|
|
341
|
+
function nextQuestionId(runDir) {
|
|
342
|
+
let highest = 0;
|
|
343
|
+
for (const phase of QUESTION_PHASES) {
|
|
344
|
+
const path = join3(runDir, phase, "questions.md");
|
|
345
|
+
if (!existsSync3(path))
|
|
346
|
+
continue;
|
|
347
|
+
let blocks;
|
|
348
|
+
try {
|
|
349
|
+
blocks = parseQuestions(readFileSync3(path, "utf8")).blocks;
|
|
350
|
+
} catch {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
for (const block of blocks) {
|
|
354
|
+
const n = Number.parseInt(block.id.slice(1), 10);
|
|
355
|
+
if (Number.isFinite(n) && n > highest)
|
|
356
|
+
highest = n;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return `Q${String(highest + 1)}`;
|
|
360
|
+
}
|
|
361
|
+
function raiseConflictQuestion(args) {
|
|
362
|
+
const id = nextQuestionId(args.runDir);
|
|
363
|
+
const block = renderQuestionBlock({
|
|
364
|
+
id,
|
|
365
|
+
title: `Which is right about ${args.area}: ${args.newFactId} or ${args.oldFactId}?`,
|
|
366
|
+
metadata: {
|
|
367
|
+
id,
|
|
368
|
+
status: "open",
|
|
369
|
+
area: args.area,
|
|
370
|
+
asked_by: RAISED_BY,
|
|
371
|
+
asked_at: args.at,
|
|
372
|
+
extra: [[ADVISORY_KEY, "true"]]
|
|
373
|
+
},
|
|
374
|
+
metadataIndex: -1,
|
|
375
|
+
whyAsked: `answering ${args.answeredQ} recorded ${args.newFactId}, which overlaps ${args.oldFactId}'s ` + `text at Jaccard ${formatJaccard(args.score)} on the question's wording, in the same area, ` + `and answers it differently — ${args.oldFactId}: "${oneLine(args.oldFactText)}" ` + `[src: ${args.oldFactId}]`,
|
|
376
|
+
whySrc: null,
|
|
377
|
+
options: [
|
|
378
|
+
{ letter: "A", text: `${args.newFactId} is right — supersede ${args.oldFactId}` },
|
|
379
|
+
{ letter: "B", text: `${args.oldFactId} is right — supersede ${args.newFactId}` },
|
|
380
|
+
{ letter: "C", text: "Both are partly right — write the correction below" }
|
|
381
|
+
],
|
|
382
|
+
answer: "",
|
|
383
|
+
answerIndex: -1,
|
|
384
|
+
footer: null,
|
|
385
|
+
startLine: -1,
|
|
386
|
+
lines: []
|
|
387
|
+
});
|
|
388
|
+
appendFileSync2(args.questionsPath, `
|
|
389
|
+
${block}
|
|
390
|
+
`, "utf8");
|
|
391
|
+
return { q: id, fact: args.newFactId, conflictsWith: args.oldFactId, score: args.score };
|
|
392
|
+
}
|
|
393
|
+
function oneLine(text) {
|
|
394
|
+
return text.replace(/\s+/g, " ").trim();
|
|
395
|
+
}
|
|
396
|
+
|
|
188
397
|
// src/core/answers/captureAnswers.ts
|
|
398
|
+
function answerProvenance(block, overrides, repoNames, fallbackRepos) {
|
|
399
|
+
const override = overrides?.get(block.id);
|
|
400
|
+
const named = reposFromAffects(declaredAffects(block), repoNames ?? new Set);
|
|
401
|
+
const repos = override?.repos ?? (named.repos.length > 0 ? named.repos : fallbackRepos);
|
|
402
|
+
return {
|
|
403
|
+
repos,
|
|
404
|
+
unresolved: named.unresolved,
|
|
405
|
+
source: override?.decidedBy === undefined ? {} : { decided_by: override.decidedBy }
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function unresolvedEntries(captured) {
|
|
409
|
+
return captured.flatMap((c) => c.unresolvedAffects.map((entry) => `${c.q}: affects: ${entry} names no repo in this workspace — it scoped nothing`));
|
|
410
|
+
}
|
|
189
411
|
var TRUNCATION_MARK = " …";
|
|
190
412
|
function factTextFor(title, answer) {
|
|
191
413
|
const whole = `${title} — ${answer}`;
|
|
@@ -197,28 +419,35 @@ function factWasTruncated(title, answer) {
|
|
|
197
419
|
return `${title} — ${answer}`.length > MAX_FACT_CHARS;
|
|
198
420
|
}
|
|
199
421
|
function captureAnswers(questionsPath, ctx) {
|
|
200
|
-
if (!
|
|
422
|
+
if (!existsSync4(questionsPath))
|
|
201
423
|
return [];
|
|
202
|
-
let doc = parseQuestions(
|
|
424
|
+
let doc = parseQuestions(readFileSync4(questionsPath, "utf8"));
|
|
203
425
|
const answered = detectAnswered(doc.blocks);
|
|
204
426
|
if (answered.length === 0)
|
|
205
427
|
return [];
|
|
206
428
|
const log = EventLog.forRun(ctx.runDir);
|
|
207
429
|
const captured = [];
|
|
208
430
|
const recorded = [];
|
|
431
|
+
const clashes = [];
|
|
209
432
|
FactsStore.update(factsPath(ctx.root), (store) => {
|
|
210
433
|
for (const block of answered) {
|
|
211
434
|
const area = block.metadata?.area ?? "unscoped";
|
|
212
435
|
const truncated = factWasTruncated(block.title, block.answer);
|
|
436
|
+
const prov = answerProvenance(block, ctx.overrides, ctx.repoNames, []);
|
|
437
|
+
const text = factTextFor(block.title, block.answer);
|
|
438
|
+
const clash = conflictOf({ match: block.title, area, text }, store.active);
|
|
213
439
|
const fact = store.append({
|
|
214
|
-
fact:
|
|
440
|
+
fact: text,
|
|
215
441
|
...truncated ? { truncated: true } : {},
|
|
442
|
+
...clash === null ? {} : { conflicts_with: [clash.fact.id] },
|
|
216
443
|
area,
|
|
217
|
-
repos:
|
|
444
|
+
repos: prov.repos,
|
|
218
445
|
kind: "answer",
|
|
219
446
|
confidence: "stated",
|
|
220
|
-
source: { who: ctx.actor, when: ctx.at, run: ctx.run, q: block.id }
|
|
447
|
+
source: { who: ctx.actor, when: ctx.at, run: ctx.run, q: block.id, ...prov.source }
|
|
221
448
|
});
|
|
449
|
+
if (clash !== null)
|
|
450
|
+
clashes.push({ block, area, factId: fact.id, hit: clash });
|
|
222
451
|
doc = replaceBlock(doc, recordAnswer(block, { answered_by: ctx.actor, answered_at: ctx.at, fact: fact.id }));
|
|
223
452
|
log.tryAppend({
|
|
224
453
|
ts: ctx.at,
|
|
@@ -238,15 +467,58 @@ function captureAnswers(questionsPath, ctx) {
|
|
|
238
467
|
cost_usd: 0,
|
|
239
468
|
payload: { fact: fact.id, area: fact.area, kind: fact.kind, q: block.id }
|
|
240
469
|
});
|
|
241
|
-
captured.push({
|
|
470
|
+
captured.push({
|
|
471
|
+
q: block.id,
|
|
472
|
+
fact: fact.id,
|
|
473
|
+
answer: block.answer,
|
|
474
|
+
area,
|
|
475
|
+
unresolvedAffects: prov.unresolved
|
|
476
|
+
});
|
|
242
477
|
recorded.push({ block, fact: fact.id });
|
|
243
478
|
}
|
|
244
479
|
});
|
|
245
480
|
writeFileSync(questionsPath, serializeQuestions(doc), "utf8");
|
|
481
|
+
const raised = raiseAll(log, ctx, questionsPath, clashes);
|
|
246
482
|
for (const item of recorded) {
|
|
247
483
|
markSuperseded(log, ctx, questionsPath, item.block, item.fact);
|
|
248
484
|
}
|
|
249
|
-
return captured
|
|
485
|
+
return captured.map((answer) => {
|
|
486
|
+
const conflict = raised.get(answer.q);
|
|
487
|
+
return conflict === undefined ? answer : { ...answer, conflict };
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
function raiseAll(log, ctx, questionsPath, clashes) {
|
|
491
|
+
const raised = new Map;
|
|
492
|
+
for (const clash of clashes) {
|
|
493
|
+
const conflict = raiseConflictQuestion({
|
|
494
|
+
runDir: ctx.runDir,
|
|
495
|
+
questionsPath,
|
|
496
|
+
area: clash.area,
|
|
497
|
+
at: ctx.at,
|
|
498
|
+
newFactId: clash.factId,
|
|
499
|
+
oldFactId: clash.hit.fact.id,
|
|
500
|
+
oldFactText: clash.hit.fact.fact,
|
|
501
|
+
score: clash.hit.score,
|
|
502
|
+
answeredQ: clash.block.id
|
|
503
|
+
});
|
|
504
|
+
raised.set(clash.block.id, conflict);
|
|
505
|
+
log.tryAppend({
|
|
506
|
+
ts: ctx.at,
|
|
507
|
+
run: ctx.run,
|
|
508
|
+
stage: null,
|
|
509
|
+
type: "fact.conflict_raised",
|
|
510
|
+
actor: ctx.actor,
|
|
511
|
+
cost_usd: 0,
|
|
512
|
+
payload: {
|
|
513
|
+
fact: clash.factId,
|
|
514
|
+
conflicts_with: clash.hit.fact.id,
|
|
515
|
+
score: clash.hit.score,
|
|
516
|
+
q: clash.block.id,
|
|
517
|
+
raised: conflict.q
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
return raised;
|
|
250
522
|
}
|
|
251
523
|
function markSuperseded(log, ctx, questionsPath, block, fact) {
|
|
252
524
|
try {
|
|
@@ -285,7 +557,7 @@ await runHook("answer-capture", async () => {
|
|
|
285
557
|
if (event === "PostToolUse" && payload.tool_name !== "Write" && payload.tool_name !== "Edit")
|
|
286
558
|
return;
|
|
287
559
|
const filePath = filePathOf(payload);
|
|
288
|
-
if (!filePath.endsWith("questions.md") || !
|
|
560
|
+
if (!filePath.endsWith("questions.md") || !existsSync5(filePath))
|
|
289
561
|
return;
|
|
290
562
|
const location = locateWork(filePath);
|
|
291
563
|
if (location === null)
|
|
@@ -295,10 +567,12 @@ await runHook("answer-capture", async () => {
|
|
|
295
567
|
runDir: location.runDir,
|
|
296
568
|
run: location.run,
|
|
297
569
|
actor: currentActor(),
|
|
298
|
-
at: nowRfc3339()
|
|
570
|
+
at: nowRfc3339(),
|
|
571
|
+
repoNames: new Set(loadWorkspace(location.root).repos.keys())
|
|
299
572
|
});
|
|
300
573
|
if (captured.length === 0)
|
|
301
574
|
return;
|
|
302
|
-
|
|
575
|
+
const unresolved = unresolvedEntries(captured);
|
|
576
|
+
postContext(`tldrx: recorded ${captured.map((c) => `${c.q} \u2192 ${c.fact}`).join(", ")}` + (unresolved.length === 0 ? "" : ` \u2014 ${unresolved.join("; ")}`));
|
|
303
577
|
});
|
|
304
578
|
allow();
|