done-done-axi 0.5.1 โ†’ 0.5.2

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 CHANGED
@@ -158,6 +158,8 @@ done-done check --outcome "it starts" ~/.done-done/evidence/your-app/*/*.log
158
158
 
159
159
  Each command prints one machine-readable result. By default it is [TOON](https://github.com/toon-format/spec) (the compact AXI convention). `run` shows `result: recorded`: it captured evidence, and says nothing about whether it proves the claim. `check` starts your judge on each capture, and only a check the judge passes shows `result: pass`; with no judge set it asks for one, and a judge that fails stops with the cause (exit 2), never a result. Add `--json` for JSON, or `--full` to list every evidence file or finding instead of the first few. After later runs, use the `run_id` the result prints in place of the first `*`.
160
160
 
161
+ Not sure which journeys a change touches? `done-done affected` lists them from the verify skill's feature map and the paths each feature cites (the core journey when it cannot tell, never fewer), and exits 1 until a receipt for HEAD covers them. Bare `done-done`, which the session hooks run, names them too.
162
+
161
163
  When a check passes, save a receipt and re-verify it later:
162
164
 
163
165
  ```sh
@@ -177,6 +179,7 @@ Run `done-done` with no arguments to see the current config and the next step.
177
179
 
178
180
  - ๐Ÿงช **The real product, not code checks**: Proof must come from driving the app, CLI or site the way a user does. A config whose journeys only run tests, typecheck, lint or a build, or proof whose captures all come from such runs, is refused by `check`, `receipt` and `gate`, and `doctor` flags it ([rule](docs/reference.md#real-product-evidence)).
179
181
  - ๐ŸŽฏ **The core journey and the upgrade users take**: Each app names the one flow a user cannot live without, driven end to end on the real target, and an app that ships an installable artifact also names the upgrade over its previous release. `doctor` and the verify skill `init` writes require both ([rule](docs/reference.md#core-journey-and-release-path)).
182
+ - ๐Ÿงญ **The journeys your change needs**: `done-done affected [<base>]` reads the diff since the last receipt (or the default branch), maps each changed file to the journeys whose command runs it and the verify skill features whose file cites it, adds the core journey when a file is in no feature or journey, and exits 1 until a receipt for HEAD has a run of each. It flags the files no feature covers, so the feature map grows with the code ([how](docs/reference.md#affected-journeys)).
180
183
  - ๐Ÿ”— **Tied to a commit**: Every run records the commit, and whether there were uncommitted changes.
181
184
  - ๐Ÿ–ผ๏ธ **Real media**: Empty, cut-off, blank or tiny screenshots fail. Videos must hold real frames (needs `ffprobe`).
182
185
  - โฑ๏ธ **Fresh**: Proof from an older commit fails. Commits that only touch docs can be set to keep it fresh.
@@ -216,7 +219,7 @@ cat "$(npm root -g)/done-done-axi/integrations/firstmate/brief-include.md" >> "$
216
219
  cp "$(npm root -g)/done-done-axi/integrations/firstmate/merge-gate" "$FM_HOME/config/merge-gate"
217
220
  ```
218
221
 
219
- - **`brief-include.md`**: Firstmate adds `config/brief-include.md` to every worker brief. It tells the worker to set up done-done, run a journey on its committed change, write a `## Checks` block, pass `check`, and record a `receipt`. The evidence and receipt stay in `~/.done-done/evidence/<app>` after teardown deletes the worktree.
222
+ - **`brief-include.md`**: Firstmate adds `config/brief-include.md` to every worker brief. It tells the worker to set up done-done, run the journeys `done-done affected` lists for its committed change, write a `## Checks` block, pass `check`, record a `receipt`, and see `affected` exit 0. The evidence and receipt stay in `~/.done-done/evidence/<app>` after teardown deletes the worktree.
220
223
  - **`merge-gate`** (optional): `merge-gate <task-id> <worktree|-> <head> <pr-url|->`. In the worktree, it refuses a PR whose head is not `<head>`, then runs `done-done check --from <PR URL or $FM_HOME/data/<task-id>/checks.md> --trusted-ref <default branch>` (`origin/HEAD`, else `origin/main` or `origin/master`, else the local `main` or `master` in a repo with no remote), then `done-done receipt --verify <head>`. Both paths read done-done's rules from your default branch, never from the task's own branch, so merge `.done-done.yaml` there before you rely on it. The gate does not fetch: it reads the local `origin` refs, so run `git fetch origin` before you run it. It needs `done-done` on PATH (the `npm install` line above). A nonzero exit refuses the merge, and done-done's output says why. A worktree of `-` means the task recorded none, so there is no repo to check: the gate refuses (exit 2) rather than check whatever folder it was called from. Its `check` always starts the judge the user set, which must pass each capture; with none set, the gate refuses (exit 2). Upstream Firstmate does not call a merge gate yet, so this file has no effect until it does; you can run it by hand before a merge.
221
224
 
222
225
  Another fleet works the same way: add the include text to whatever your workers read first, and run `merge-gate` before each merge, with `FM_HOME` set to the folder that holds `data/<task-id>/checks.md` for local-only tasks.
@@ -230,7 +233,7 @@ Another fleet works the same way: add the include text to whatever your workers
230
233
 
231
234
  ## What's next
232
235
 
233
- So far: `init`, `doctor`, `run` (including `--open` and `--close`), `check`, `receipt`, `lint` (a no-model gate on a Checks block), `gate` (a no-model CI check of a committed receipt), `verify` (by journey or by your coding agent, with a gate you answer with `respond`), `maintain`, the capture judge (always on), the `design` visual check, the `look` step, the run tools (`status`, `attach`, `logs`, `rerun`, `abort`, `signoff`, `history`) and the opt-in session hooks for Claude Code, Codex, opencode, Gemini CLI and pi. Coming next: help writing the proof steps for your app.
236
+ So far: `init`, `doctor`, `run` (including `--open` and `--close`), `check`, `receipt`, `affected` (the journeys a diff needs), `lint` (a no-model gate on a Checks block), `gate` (a no-model CI check of a committed receipt), `verify` (by journey or by your coding agent, with a gate you answer with `respond`), `maintain`, the capture judge (always on), the `design` visual check, the `look` step, the run tools (`status`, `attach`, `logs`, `rerun`, `abort`, `signoff`, `history`) and the opt-in session hooks for Claude Code, Codex, opencode, Gemini CLI and pi. Coming next: help writing the proof steps for your app.
234
237
 
235
238
  ## Develop
236
239
 
@@ -0,0 +1,94 @@
1
+ import { posix } from 'node:path';
2
+ import { codeOnly } from './proof.js';
3
+ // The tracked files and folders a text names. Paths are read relative to the repo, then to `from` (a feature file's folder,
4
+ // for its markdown links); a folder counts only when written with a slash (src/), so a prose word never names one.
5
+ // ponytail: a path with brackets or parens (pages/[id].tsx) is not read; add those characters when a repo cites one.
6
+ function refs(text, tracked, from) {
7
+ const files = new Set();
8
+ const dirs = new Set();
9
+ const exact = new Set(tracked);
10
+ for (let tok of text.match(/[\w@.\/+-]+/g) ?? []) {
11
+ tok = tok.replace(/\.+$/, '');
12
+ if (!tok)
13
+ continue;
14
+ if (!tok.includes('/')) {
15
+ if (/\.[A-Za-z]\w*$/.test(tok))
16
+ for (const t of tracked)
17
+ if (t === tok || t.endsWith(`/${tok}`))
18
+ files.add(t);
19
+ continue;
20
+ }
21
+ for (const p of [posix.normalize(tok), ...(from ? [posix.join(from, tok)] : [])]) {
22
+ const clean = p.replace(/^\.\//, '').replace(/\/+$/, '');
23
+ if (!clean || clean.startsWith('..'))
24
+ continue;
25
+ if (exact.has(clean))
26
+ files.add(clean);
27
+ else if (tracked.some((t) => t.startsWith(`${clean}/`)))
28
+ dirs.add(clean);
29
+ }
30
+ }
31
+ return { files, dirs: [...dirs] };
32
+ }
33
+ const covers = (r, p) => r.files.has(p) || r.dirs.some((d) => p.startsWith(`${d}/`));
34
+ // The journeys and features `changed` needs. features: each feature file's text by name; skill: the skill folder; coreFeature:
35
+ // the feature the feature map marks (core journey), for the fallback; noBase: why there is no diff to read, if so.
36
+ export function mapChange(o) {
37
+ // A code-only journey is no proof, so the change never needs one.
38
+ const real = Object.keys(o.journeys).filter((n) => !codeOnly(o.journeys[n].command));
39
+ const jRefs = new Map(real.map((n) => [n, refs(o.journeys[n].command, o.tracked)]));
40
+ const featDir = posix.join(o.skill, 'features');
41
+ const fRefs = new Map(Object.entries(o.features).map(([n, text]) => [n, refs(text, o.tracked, featDir)]));
42
+ // The journeys each feature names: `run <journey>` or `verify <journey>`, or a file its command runs that the feature cites too.
43
+ const named = new Map(Object.entries(o.features).map(([n, text]) => {
44
+ const said = new Set([...text.matchAll(/\b(?:run|verify)\s+`?([\w.-]+)/g)].map((m) => m[1]));
45
+ const r = fRefs.get(n);
46
+ return [n, real.filter((j) => said.has(j) || [...jRefs.get(j).files].some((f) => r.files.has(f)))];
47
+ }));
48
+ const journeys = new Map();
49
+ const features = new Map();
50
+ const hit = (m, name, why) => m.set(name, [...(m.get(name) ?? []), why]);
51
+ const unmapped = [];
52
+ for (const c of o.changed) {
53
+ let found = false;
54
+ for (const [j, r] of jRefs)
55
+ if (covers(r, c)) {
56
+ hit(journeys, j, `its command runs ${c}`);
57
+ found = true;
58
+ }
59
+ // The skill's own SKILL.md and feature map are what every feature follows, so they need every feature.
60
+ const shared = c.startsWith(`${o.skill}/`) && !(c.startsWith(`${featDir}/`) && posix.basename(c, '.md') in o.features);
61
+ for (const [f, r] of fRefs) {
62
+ if (!shared && c !== posix.join(featDir, `${f}.md`) && !covers(r, c))
63
+ continue;
64
+ found = true;
65
+ const why = shared ? `every feature follows ${c}` : c === posix.join(featDir, `${f}.md`) ? `feature ${f} changed` : `feature ${f} cites ${c}`;
66
+ const js = named.get(f);
67
+ if (js.length)
68
+ for (const j of js)
69
+ hit(journeys, j, why);
70
+ else
71
+ hit(features, f, why);
72
+ }
73
+ if (!found)
74
+ unmapped.push(c);
75
+ }
76
+ // Uncertain: the core journey runs too; with no core journey named, every real journey does, so the answer is never less.
77
+ const uncertain = o.noBase ?? (unmapped.length ? `${unmapped.length} changed ${unmapped.length === 1 ? 'file is' : 'files are'} in no feature or journey` : '');
78
+ let fallback = null;
79
+ if (uncertain) {
80
+ const core = real.filter((j) => o.journeys[j].core);
81
+ const why = `uncertain: ${uncertain}`;
82
+ if (core.length)
83
+ core.forEach((j) => hit(journeys, j, why));
84
+ else if (o.coreFeature && o.coreFeature in o.features)
85
+ (named.get(o.coreFeature).length ? named.get(o.coreFeature).forEach((j) => hit(journeys, j, why)) : hit(features, o.coreFeature, why));
86
+ else if (real.length)
87
+ real.forEach((j) => hit(journeys, j, `${why}, and no core journey is named`));
88
+ else
89
+ Object.keys(o.features).forEach((f) => hit(features, f, `${why}, and no core journey is named`));
90
+ fallback = why;
91
+ }
92
+ const list = (m) => [...m].map(([name, why]) => ({ name, why: [...new Set(why)] }));
93
+ return { journeys: list(journeys), features: list(features), unmapped, fallback };
94
+ }
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ import { domIssues, domRecord, isDomRecord, lookIssues, SQUASHED_TEXT } from './
21
21
  import { lint } from './lint.js';
22
22
  import { look } from './look.js';
23
23
  import { scanSecrets } from './secrets.js';
24
+ import { mapChange } from './affected.js';
24
25
  import { codeOnly, codeOnlyJourneys, configFault, MARK, namedJourney, releaseArtifacts, RULE, runsFault } from './proof.js';
25
26
  const PASS = 0, FAIL = 1, USAGE = 2, INCONCLUSIVE = 3, WAITING = 4;
26
27
  // The shape of a journey, so a new user or agent can write one without opening the reference.
@@ -237,6 +238,19 @@ const COMMANDS = {
237
238
  a "moved-from: <owner>/<repo>@<ref> <dst>=<src>" body line says came from a repo of the same owner; both are off by default.
238
239
  checks.generated_paths: ["gen/**", "*.snap"] adds your own globs to the generated files above; the default is none.
239
240
  Every run is appended to lint.log next to the store. Exit 0 PASS or WARN, 1 FAIL, 2 usage error. Needs gh for a PR URL`],
241
+ },
242
+ affected: {
243
+ usage: [['done-done affected [<base>]', "the journeys a change needs, from the verify skill's feature map and the paths it cites (the core journey when unsure); exit 1 until a receipt for HEAD covers them"]],
244
+ flags: [],
245
+ more: [`affected: model-free. The change is the diff from <base>, else from the newest receipt before HEAD (the commits since the last proof),
246
+ else from the merge base with the default branch (origin/HEAD, origin/main, origin/master, main, master), plus uncommitted changes to tracked files;
247
+ evidence.ignore_paths and .done-done-receipts/ need nothing. A journey is needed when its command runs a changed file; a feature
248
+ (features/<name>.md) when it cites a changed file (a repo path, a markdown link, a folder written with a slash such as src/, or a bare
249
+ file name) or is the changed file, and then each journey it names (run <journey>, or a file the journey's command runs); a feature
250
+ that names no journey is driven by your agent (verify --feature <name>). A changed file nothing covers, or no base, is uncertain:
251
+ the core journey is needed too (every journey when none is core), never less, and the file is flagged for the feature map.
252
+ Result: fresh (exit 0) when the receipt for HEAD has a run of every needed journey and feature and nothing is uncommitted;
253
+ stale (a receipt for an earlier commit, a receipt that misses a needed journey, or uncommitted changes) or missing (no receipt) exit 1`],
240
254
  },
241
255
  status: {
242
256
  usage: [['done-done status [--run <run-id>]', "show a run's steps, result, findings and next step"]],
@@ -788,7 +802,7 @@ async function runCheck(argv, run) {
788
802
  continue;
789
803
  }
790
804
  const rec = (atNow.length ? atNow : claims).reduce((a, b) => (b.started_at > a.started_at ? b : a));
791
- runs.set(rec.run_id, { run_id: rec.run_id, journey: rec.journey, command: rec.command });
805
+ runs.set(rec.run_id, { run_id: rec.run_id, journey: rec.journey, command: rec.command, ...(rec.feature && { feature: rec.feature }) });
792
806
  // Only a capture tied to a run record can reach the judge, so a stray cited file is never sent.
793
807
  const c = { name, bytes, sha256: hash, media: v };
794
808
  if (v.status === 'ok' && !record && pair)
@@ -1506,12 +1520,14 @@ async function runSteps(dir) {
1506
1520
  const b = await drive(where, evidence, env, cfg.base_ref);
1507
1521
  return b.ok ? String(b.answer?.blocker ?? '').trim() : `${author.agent} did not finish the drive: ${b.error}`;
1508
1522
  });
1509
- const sealed = closeRun(at, cfg, rec);
1510
- if (!fix)
1511
- run.record = join(recordsDir(cfg), `${rec.run_id}.json`);
1512
1523
  const picked = typeof answer?.feature === 'string' ? answer.feature.replace(/^.*features\//, '').replace(/\.md$/, '') : '';
1513
1524
  if (skillFeatures(at, cfg).includes(picked))
1514
1525
  run.feature ??= picked;
1526
+ if (run.feature)
1527
+ rec.feature = run.feature;
1528
+ const sealed = closeRun(at, cfg, rec);
1529
+ if (!fix)
1530
+ run.record = join(recordsDir(cfg), `${rec.run_id}.json`);
1515
1531
  log('drive', { agent: author.agent, model: author.model, feature: run.feature ?? 'none', ...(a.ok ? { captures: answer?.captures, blocker } : { error: a.error }), record: a.record ?? a.recordError });
1516
1532
  const why = !a.ok ? `${author.agent} did not finish the drive: ${a.error}${a.record ? `; call record ${a.record}` : ''}`
1517
1533
  : blocker ? `${author.agent} could not drive the feature: ${blocker}`
@@ -3759,6 +3775,124 @@ async function cmdDesign(argv) {
3759
3775
  });
3760
3776
  return code;
3761
3777
  }
3778
+ // The receipts in the evidence root that are well-formed passes filed under their own head, by head.
3779
+ function receiptsByHead(cfg) {
3780
+ const dir = receiptsDir(cfg);
3781
+ const found = new Map();
3782
+ for (const name of existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith('.json')) : []) {
3783
+ try {
3784
+ const rec = JSON.parse(readFileSync(join(dir, name), 'utf8'));
3785
+ if (!receiptFault(rec, name) && name === `${rec.head}.json`)
3786
+ found.set(rec.head, rec);
3787
+ }
3788
+ catch {
3789
+ // A receipt that cannot be read vouches for nothing; receipt --verify names its fault.
3790
+ }
3791
+ }
3792
+ return found;
3793
+ }
3794
+ // The default branch to diff a change against, as the Firstmate merge gate picks it: origin/HEAD, else origin/main or
3795
+ // origin/master, else the local main or master. '' when there is none.
3796
+ function defaultBranch(repo) {
3797
+ try {
3798
+ return git(repo, 'symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD');
3799
+ }
3800
+ catch {
3801
+ return ['origin/main', 'origin/master', 'main', 'master'].find((r) => spawnSync('git', ['rev-parse', '-q', '--verify', `${r}^{commit}`], { cwd: repo, stdio: 'ignore' }).status === 0) ?? '';
3802
+ }
3803
+ }
3804
+ const isAncestor = (repo, a, b) => spawnSync('git', ['merge-base', '--is-ancestor', a, b], { cwd: repo, stdio: 'ignore' }).status === 0;
3805
+ // Which journeys the change needs (src/affected.ts) and whether HEAD's proof covers them. The change is read from `baseArg`,
3806
+ // else from the newest receipt before HEAD (the commits since the last proof), else from the merge base with the default
3807
+ // branch; with none, the mapping is uncertain and the core journey is needed.
3808
+ function affectedOf(repo, cfg, baseArg) {
3809
+ const now = head(repo);
3810
+ const receipts = receiptsByHead(cfg);
3811
+ let base = null;
3812
+ if (baseArg !== undefined) {
3813
+ try {
3814
+ base = { commit: git(repo, 'rev-parse', '--verify', `${baseArg}^{commit}`), from: baseArg };
3815
+ }
3816
+ catch {
3817
+ throw new UsageError(`affected: ${baseArg} is not a commit in this checkout`, 'Pass a branch, tag or commit: done-done affected [<base>]');
3818
+ }
3819
+ }
3820
+ else {
3821
+ // ponytail: one git call per receipt to find the ones before HEAD; index receipts by commit when a repo keeps thousands.
3822
+ const older = [...receipts.keys()].filter((h) => h !== now.commit && isAncestor(repo, h, now.commit));
3823
+ const nearest = older.find((h) => older.every((o) => isAncestor(repo, o, h)));
3824
+ const ref = nearest ? '' : defaultBranch(repo);
3825
+ const mb = ref ? spawnSync('git', ['merge-base', ref, 'HEAD'], { cwd: repo, encoding: 'utf8' }).stdout.trim() : '';
3826
+ if (nearest)
3827
+ base = { commit: nearest, from: `the last receipt, for ${nearest.slice(0, 12)}` };
3828
+ else if (mb && mb !== now.commit)
3829
+ base = { commit: mb, from: `the merge base with ${ref}` };
3830
+ }
3831
+ const list = (...a) => git(repo, ...a).split('\0').filter(Boolean);
3832
+ const tracked = list('ls-files', '-z');
3833
+ // ignore_paths and committed gate receipts need no journey.
3834
+ const counts = (p) => !p.startsWith(`${GATE_DIR}/`) && !cfg.evidence.ignore_paths.some((g) => matchesGlob(p, g));
3835
+ // Untracked files are no part of the change until added, as everywhere else in done-done.
3836
+ const uncommitted = list('diff', '--name-only', '--no-renames', '-z', 'HEAD').filter(counts);
3837
+ const changed = [...new Set([...(base ? list('diff', '--name-only', '--no-renames', '-z', base.commit, 'HEAD').filter(counts) : []), ...uncommitted])];
3838
+ const featDir = join(repo, cfg.skill, 'features');
3839
+ const features = Object.fromEntries(skillFeatures(repo, cfg).map((n) => [n, repoText(join(featDir, `${n}.md`)) ?? '']));
3840
+ const coreLine = featureMap(repo, cfg).split('\n').find((l) => /^\s*[-*] /.test(l) && l.includes(MARK.core));
3841
+ const m = mapChange({ changed, tracked, journeys: cfg.journeys, skill: cfg.skill, features, coreFeature: coreLine?.match(/\]\(([^)#]+)\.md\)/)?.[1], noBase: base ? undefined : 'no receipt before HEAD and no default branch to diff against' });
3842
+ const needed = [...m.journeys.map((j) => j.name), ...m.features.map((f) => `feature ${f.name}`)];
3843
+ // Proof: a valid receipt for HEAD whose runs include every journey and feature the change needs, with nothing uncommitted.
3844
+ const verified = verifyReceipt(repo, cfg, 'HEAD');
3845
+ const runs = verified.code === PASS ? receipts.get(now.commit)?.runs ?? [] : [];
3846
+ const covered = (n) => n.startsWith('feature ') ? runs.some((r) => r.feature === n.slice(8)) : runs.some((r) => r.journey === n || r.command === cfg.journeys[n].command.replaceAll('${RUN_ID}', r.run_id));
3847
+ const missing = verified.code === PASS && !uncommitted.length ? needed.filter((n) => !covered(n)) : needed;
3848
+ const stale = base && receipts.has(base.commit) ? base.commit : null;
3849
+ const proof = verified.code !== PASS ? stale ? `stale: the receipt is for ${stale.slice(0, 12)}, and HEAD is ${now.commit.slice(0, 12)}` : `missing: ${verified.result.reason}`
3850
+ : uncommitted.length ? `stale: uncommitted changes (${named(uncommitted)}); proof counts only from a commit`
3851
+ : missing.length ? `stale: the receipt for HEAD has no run of ${missing.join(', ')}`
3852
+ : 'fresh';
3853
+ return { now, base, changed, m, proof, missing };
3854
+ }
3855
+ // `done-done affected [<base>]`: the journeys a change needs, and exit 1 until a receipt for HEAD covers them.
3856
+ function cmdAffected(argv) {
3857
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true });
3858
+ if (positionals.length > 1)
3859
+ throw new UsageError('affected takes at most one base', 'Re-run as: done-done affected [<base>]');
3860
+ const repo = repoRoot();
3861
+ const cfg = loadConfig(repo);
3862
+ const a = affectedOf(repo, cfg, positionals[0]);
3863
+ const fresh = a.proof === 'fresh';
3864
+ const changed = cap(a.changed);
3865
+ const why = (w) => (FULL || w.length <= 3 ? w.join('; ') : `${w.slice(0, 3).join('; ')}; +${w.length - 3} more (--full lists them)`);
3866
+ const drive = [
3867
+ ...a.m.journeys.filter((j) => a.missing.includes(j.name)).map((j) => `done-done run ${j.name}`),
3868
+ ...a.m.features.filter((f) => a.missing.includes(`feature ${f.name}`)).map((f) => `done-done verify --feature ${f.name} --outcome "<what a user can now do>"`),
3869
+ ];
3870
+ emit({
3871
+ command: 'affected',
3872
+ result: fresh ? 'fresh' : a.proof.split(':')[0],
3873
+ head: a.now.commit,
3874
+ base: a.base ? `${a.base.commit.slice(0, 12)} (${a.base.from})` : 'none',
3875
+ proof: a.proof,
3876
+ changed: changed.items,
3877
+ ...(changed.omitted && { changed_omitted: changed.omitted }),
3878
+ journeys: a.m.journeys.map((j) => ({ name: j.name, why: why(j.why) })),
3879
+ ...(a.m.features.length && { features: a.m.features.map((f) => ({ name: f.name, drive: 'verify --feature', why: why(f.why) })) }),
3880
+ ...(a.m.unmapped.length && { unmapped: a.m.unmapped }),
3881
+ ...(a.m.fallback && { fallback: `${a.m.fallback}, so the core journey runs too` }),
3882
+ help: [
3883
+ ...(fresh
3884
+ ? [`The receipt for HEAD covers every journey this change needs (${[...a.m.journeys.map((j) => j.name), ...a.m.features.map((f) => f.name)].join(', ') || 'none'})`]
3885
+ : [
3886
+ ...(a.proof.startsWith('stale: uncommitted') ? ['Commit the change first: proof counts only from a commit'] : []),
3887
+ ...(drive.length ? [`Capture fresh proof: ${drive.join(' && ')}`] : ['No journey needs a new run: re-run check and receipt for HEAD citing the evidence that still counts']),
3888
+ 'Then check it and record the receipt: done-done receipt --from <the PR URL or checks.md>',
3889
+ ]),
3890
+ // A fresh receipt does not close the gap: the feature map still misses these files.
3891
+ ...(a.m.unmapped.length ? [`Flag the gap: ${named(a.m.unmapped)} ${a.m.unmapped.length === 1 ? 'is' : 'are'} in no feature or journey; add ${a.m.unmapped.length === 1 ? 'it' : 'them'} to the feature file that drives ${a.m.unmapped.length === 1 ? 'it' : 'them'} in ${cfg.skill}/features (done-done maintain reports what no feature covers), or record a journey: done-done init --journey <name> -- <command>`] : []),
3892
+ ],
3893
+ });
3894
+ return fresh ? PASS : FAIL;
3895
+ }
3762
3896
  // Bare `done-done`: orient a new user or agent with the config state and the next step.
3763
3897
  function cmdBare() {
3764
3898
  let repo;
@@ -3786,11 +3920,26 @@ function cmdBare() {
3786
3920
  const check = '`done-done check --outcome "<what you proved>" <files it recorded>`';
3787
3921
  const receipt = verifyReceipt(repo, cfg, 'HEAD');
3788
3922
  result.receipt = receipt.code === PASS ? `valid for HEAD ${String(receipt.result.head).slice(0, 12)}` : receipt.result.reason ?? receipt.result.result;
3789
- result.help = receipt.code === PASS
3790
- ? [`HEAD has a valid receipt for "${receipt.result.claim}"; prove the next commit with done-done verify --outcome "<what a user can now do>"`]
3791
- : journeys.length
3792
- ? [`Capture proof with \`done-done run ${journeys[0]}\`, then check it with ${check}`]
3793
- : [...driveHint(repo, cfg), `Add a journey to .done-done.yaml (${journeyExample}), commit, then \`done-done run smoke\` and ${check}`];
3923
+ // The journeys this change still needs (done-done affected), so a session hook shows them with no extra call.
3924
+ let needs = [];
3925
+ try {
3926
+ const a = affectedOf(repo, cfg);
3927
+ if (a.proof !== 'fresh') {
3928
+ needs = a.missing;
3929
+ result.affected = { proof: a.proof, needs };
3930
+ }
3931
+ }
3932
+ catch (e) {
3933
+ result.affected = `not read: ${(e.stderr || e.message).trim()}`;
3934
+ }
3935
+ const runs = needs.filter((n) => !n.startsWith('feature ')).map((n) => `done-done run ${n}`);
3936
+ result.help = runs.length
3937
+ ? [`This change needs fresh proof: ${runs.join(' && ')} (done-done affected says why), then check it with ${check}`]
3938
+ : receipt.code === PASS
3939
+ ? [`HEAD has a valid receipt for "${receipt.result.claim}"; prove the next commit with done-done verify --outcome "<what a user can now do>"`]
3940
+ : journeys.length
3941
+ ? [`Capture proof with \`done-done run ${journeys[0]}\`, then check it with ${check}`]
3942
+ : [...driveHint(repo, cfg), `Add a journey to .done-done.yaml (${journeyExample}), commit, then \`done-done run smoke\` and ${check}`];
3794
3943
  emit(result);
3795
3944
  return PASS;
3796
3945
  }
@@ -3837,6 +3986,8 @@ async function main(argv) {
3837
3986
  return cmdLint(rest);
3838
3987
  if (cmd === 'gate')
3839
3988
  return cmdGate(rest);
3989
+ if (cmd === 'affected')
3990
+ return cmdAffected(rest);
3840
3991
  if (cmd === 'status')
3841
3992
  return cmdStatus(rest);
3842
3993
  if (cmd === 'attach')
package/docs/index.html CHANGED
@@ -108,6 +108,7 @@
108
108
  <ul class="plain">
109
109
  <li><b>The real product, not code checks.</b> Proof comes from driving the app the way a user does; a journey that only runs tests, typecheck or lint is refused.</li>
110
110
  <li><b>The core journey and the upgrade users take.</b> Each app names the one flow a user cannot live without, end to end on the real target; an app users install also proves the upgrade over its last release, not only a fresh install.</li>
111
+ <li><b>The journeys your change needs.</b> <code>affected</code> maps a diff to the journeys it touches through the verify skill's feature map, runs the core journey when it cannot tell, and fails until a receipt for the commit covers them.</li>
111
112
  <li><b>Tied to a commit.</b> Every run records the commit, and whether there were uncommitted changes.</li>
112
113
  <li><b>Real media.</b> Empty, cut-off, blank or tiny screenshots fail. Videos must hold real frames.</li>
113
114
  <li><b>Fresh, and not reused.</b> Proof from an older commit fails, and so does proof reused for a different claim.</li>
package/docs/reference.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # done-done reference
2
2
 
3
- Everything `init`, `doctor`, `run`, `check`, `receipt` and the config files do. For the overview and quick start, see the [README](../README.md).
3
+ Everything `init`, `doctor`, `run`, `affected`, `check`, `receipt` and the config files do. For the overview and quick start, see the [README](../README.md).
4
4
 
5
5
  ## Install
6
6
 
@@ -276,6 +276,22 @@ journeys:
276
276
  upgrade: true
277
277
  ```
278
278
 
279
+ ### Affected journeys
280
+
281
+ ```sh
282
+ done-done affected # the change since the last receipt, else since the default branch
283
+ done-done affected origin/main
284
+ ```
285
+
286
+ `affected [<base>]` says which journeys a change needs and whether HEAD's proof covers them. It is model-free and reads git, `.done-done.yaml` and the verify skill only.
287
+
288
+ - **The change.** The files that differ from `<base>`, else from the newest receipt whose head is before HEAD (the commits since the last proof), else from the merge base with the default branch (`origin/HEAD`, `origin/main`, `origin/master`, then the local `main` or `master`, the order the [Firstmate merge gate](../README.md#use-with-firstmate-or-another-agent-fleet) uses), plus uncommitted changes to tracked files. Untracked files are not part of the change until added. A file that matches `evidence.ignore_paths`, or a committed receipt under `.done-done-receipts/` ([CI gate](#ci-gate)), needs nothing.
289
+ - **The mapping.** A journey is needed when its command runs the changed file (`node journeys/e2e.mjs` runs `journeys/e2e.mjs`). A feature of the verify skill (`features/<name>.md`) is needed when its file cites the changed file: a repo path, a markdown link (read from the feature's folder), a folder written with a slash (`src/` covers every file under it) or a bare file name, which matches each tracked file of that name as `maintain` reads one; or when the feature file itself changed. The skill's `SKILL.md` and `features/README.md` are what every feature follows, so a change to them needs every feature. A needed feature brings each journey it names (`run <journey>` or `verify <journey>` in its text, or a file the journey's command runs that it cites too); a feature that names no journey is driven by your agent: `done-done verify --feature <name>`. A code-only journey ([Real-product evidence](#real-product-evidence)) is never needed.
290
+ - **Uncertain means more, never less.** A changed file that no feature or journey covers, or no base to diff against, makes the mapping uncertain: the core journey ([Core journey and release path](#core-journey-and-release-path)) is needed too, or the feature the feature map marks `(core journey)`, or every journey when none is named. Each such file is listed under `unmapped` and flagged in `help`: add it to the feature file that drives it (`done-done maintain` reports recent files no feature covers), or record a journey for it.
291
+ - **The proof.** `fresh` (exit 0) when the receipt for HEAD verifies ([Record a receipt](#record-a-receipt)), its `runs` include a run of every needed journey (by name, or by the journey's command) and every needed feature (a `verify --feature` drive, which the receipt records as the run's `feature`), and no tracked file has an uncommitted change. Otherwise exit 1: `stale` when the newest receipt is for an earlier commit (it names that commit and the journeys the new commits need), when the receipt for HEAD misses a needed journey or feature, or when there are uncommitted changes; `missing` when there is no receipt at all. `help` gives the commands that capture the missing proof (`done-done run <journey>`, `done-done verify --feature <name>`), then the receipt step.
292
+
293
+ The result lists the base and where it came from, the changed files (`--full` for more than five), each needed journey and feature with why (the first three reasons; `--full` for all), the unmapped files and the fallback. Bare `done-done` adds an `affected` block with the proof and the journeys still needed whenever the proof is not fresh, so every [session hook](#set-up-a-repo) shows them with no extra call. A `<base>` that is not a commit is a usage error (exit 2).
294
+
279
295
  ### Check a claim
280
296
 
281
297
  ```sh
@@ -503,7 +519,7 @@ The result is `clean` (no findings but notices, exit 0), `drift` (findings to fi
503
519
 
504
520
  Every command prints one machine-readable result on stdout. By default it is [TOON](https://github.com/toon-format/spec) in the AXI convention (the shape `no-mistakes axi status` uses): scalar `key: value` lines, tabular arrays, and a `help` list that names the exact next command. `--json` prints the same result as JSON. `--full` lists every evidence file or finding; without it, lists longer than five rows are truncated and the count of dropped rows is reported (`evidence_omitted`, `findings_omitted`, `history_omitted` or `log_omitted`). `doctor` always lists every check: its rows (eight checks, nine with --probe, plus `tool_gate` when the agent is pi or codex) never truncate.
505
521
 
506
- Running `done-done` with no subcommand prints the repo's config state (`config: present | missing | none`), its journeys, its run count (the runs `history` lists), whether `HEAD` has a valid receipt (`receipt: valid for HEAD <sha>`, or why not), and the next step; with a valid receipt the next step is to prove the next commit. `done-done --version` prints the version. Usage and config errors also print one result: the `command` that failed, an `error` field and a `help` line with the recovery command (for example `done-done init` when no config exists, or the command's valid flags after an unknown one).
522
+ Running `done-done` with no subcommand prints the repo's config state (`config: present | missing | none`), its journeys, its run count (the runs `history` lists), whether `HEAD` has a valid receipt (`receipt: valid for HEAD <sha>`, or why not), when the proof is not fresh the journeys the change still needs (`affected`, see [Affected journeys](#affected-journeys)), and the next step; with a valid receipt that covers them the next step is to prove the next commit. `done-done --version` prints the version. Usage and config errors also print one result: the `command` that failed, an `error` field and a `help` line with the recovery command (for example `done-done init` when no config exists, or the command's valid flags after an unknown one).
507
523
 
508
524
  `done-done --help` lists every command. `done-done <command> --help` (or `-h`) prints that command's usage lines and flags in the same shape for every command (plus the journey format for `init` and `run`, and the Checks block for `check` and `receipt`), and exits 0.
509
525
 
@@ -15,8 +15,11 @@ wherever this text or the skill says `done-done`. If done-done cannot run, repor
15
15
  installs the previous release and upgrades over it in place before it drives the core journey; `done-done doctor`
16
16
  fails until both are named. Commit `.done-done.yaml` and the journey script. If `init` says no agent is set, ask the user which agent
17
17
  and model to use and run the command it prints; never choose one yourself.
18
- 2. Commit your change, then run `done-done run <journey>` from the repo root. Proof only counts from a clean,
19
- committed tree.
18
+ 2. Commit your change, then run `done-done affected` from the repo root: it lists the journeys and verify-skill
19
+ features your diff needs (the core journey when it cannot tell). Run each listed journey with
20
+ `done-done run <journey>` and drive each listed feature with `done-done verify --feature <name>`. Proof only counts
21
+ from a clean, committed tree. When it flags files that no feature or journey covers, cite them in the feature file
22
+ that drives them (or record a journey for them) and commit that too.
20
23
  3. Write a Checks block (the shape `done-done --help` prints) that cites the run's files by the paths `run` printed:
21
24
  ```
22
25
  ## Checks
@@ -37,4 +40,5 @@ wherever this text or the skill says `done-done`. If done-done cannot run, repor
37
40
 
38
41
  Exit 1 (fail), 2 (stopped before a verdict) or 3 (inconclusive) is never done: fix the proof and re-run, or report what done-done found.
39
42
  `evidence: not proven: <cause>` stops `check` with exit 2 before the judge is asked, with no result and no receipt: report the task as not proven, never as done.
40
- Any commit after the receipt makes the proof stale: run, check and receipt again on the new head.
43
+ Any commit after the receipt makes the proof stale: run, check and receipt again on the new head. Before you report
44
+ done, `done-done affected` must exit 0: the receipt for your head covers every journey the change needs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "done-done-axi",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Proof that the real product works before a task is called done: commit-bound evidence runs and evidence checks.",
6
6
  "type": "module",
@@ -41,6 +41,9 @@ driven end to end on the real target (for a remote terminal: pair, open a pane,
41
41
  does not show the app works. An app that ships something users install and upgrade (an npm CLI, an Android app, a host service) also
42
42
  names an upgrade journey (`--upgrade`): install the previous release, upgrade over it in place, then drive the core journey; a fresh
43
43
  install alone is not release proof. `doctor` fails until both are named.
44
+ To know which journeys a change needs, run `done-done affected` after you commit: it maps the diff through the verify skill's feature map
45
+ (the paths each feature cites, the journeys it names), adds the core journey when a changed file is in no feature or journey (never fewer),
46
+ and exits 1 until the receipt for HEAD has a run of each. Cite a file it flags in the feature file that drives it.
44
47
  A run waiting at its judge gate (`awaiting_approval`) did not pass, and nothing moves it past its judge: show the user
45
48
  its findings and captures, and end it with `done-done respond --action fix` only when they say so.
46
49
  A journey that `spends` runs only after the user approves spending: `done-done run <journey>` refuses it, so ask the user and use `verify`.