any-doctor 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTEXT.md CHANGED
@@ -360,8 +360,11 @@ and applicability rules.
360
360
  digest, indentation-relative column, and innermost enclosing function span —
361
361
  computed per comparison from one post-scan read, never persisted.
362
362
  - **Observation:** evidence that a finding was detected in a particular scan.
363
- - **Decision:** a reasoned accepted/not-applicable disposition with local or project
364
- scope; it changes review state, not the raw observation.
363
+ - **Decision** *(landed locally, D31)*: a reasoned accepted/not-applicable
364
+ disposition with a required reason, stored in the local decisions file,
365
+ reversible, attached to a Finding identity — it changes review state
366
+ (the active list), never the raw observation, and never the gate.
367
+ Project scope (Git-shared) is M3.
365
368
  - **Continuing** *(landed in the diff path)*: a head occurrence matched to a
366
369
  compatible base occurrence by identity — movement is not addition. Matches
367
370
  resting on content alone are flagged contextFallback; identical copies
@@ -369,5 +372,6 @@ and applicability rules.
369
372
  - **No longer detected** *(landed in the diff path)*: absence established by
370
373
  compatible, completed coverage.
371
374
  - **Claimed fix:** a recorded explanation of remediation, separate from rescan evidence.
372
- - **Reassessment:** a decision requires review because identity or applicability is
373
- changed, conflicting, or uncertain.
375
+ - **Reassessment** *(landed locally)*: a decision requires review because
376
+ its evidence changed the finding resurfaces with a warning; the
377
+ decision is never silently carried.
package/bin/cli.js CHANGED
@@ -4,10 +4,14 @@ import * as path from "path";
4
4
  import { fileURLToPath, pathToFileURL } from "url";
5
5
  import { DOCTOR_FILE_RE } from "./contract.js";
6
6
  import { renderJson, renderReport, renderVerifyResult, reportDiffOf, unsafeSkipLine } from "./report.js";
7
+ import { readKeyFor, resolveFinding } from "./contract.js";
7
8
  import { runCohort } from "./cohort.js";
8
9
  import { countsOfSeverities, gateVerdict, isFailOn } from "./gate.js";
9
- import { captureHeadScan, runDiff } from "./diff.js";
10
+ import { runDiff } from "./diff.js";
10
11
  import { digestTextFile, doctorDigests } from "./identity.js";
12
+ import { decisionsPath, loadDecisions, recordDecision, reverseDecision, decodeDecisionKey, encodeDecisionKey } from "./finding-state.js";
13
+ import { reviewOf } from "./review.js";
14
+ import { captureScan } from "./scan-capture.js";
11
15
  import { deriveSummary } from "./summary.js";
12
16
  import { copyToClipboard } from "./clipboard.js";
13
17
  import { runDashboard } from "./dashboard.js";
@@ -42,6 +46,19 @@ function skillText() {
42
46
  return null;
43
47
  }
44
48
  }
49
+ // The agent usage doc — the machine-facing interface, printable on demand
50
+ // (`any-doctor help agents`) so npx-only users need no installation to
51
+ // discover it. Shipped in the package; always in sync with the version
52
+ // that printed it.
53
+ function agentUsageText() {
54
+ const p = fileURLToPath(new URL("../skill/agent-usage.md", import.meta.url));
55
+ try {
56
+ return fs.readFileSync(p, "utf8");
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
45
62
  function useColor() {
46
63
  return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
47
64
  }
@@ -55,6 +72,71 @@ class ExitCode extends Error {
55
72
  this.code = code;
56
73
  }
57
74
  }
75
+ // ---- remembered decisions (M2) ------------------------------------------
76
+ //
77
+ // Local decisions suppress findings from the ACTIVE list (report display,
78
+ // dashboard tree) when the identity key matches exactly; gates and exit
79
+ // codes stay on the RAW findings — a private decision never changes CI
80
+ // (that is M3's project scope). Evidence-changed findings resurface with
81
+ // a reassessment warning; a corrupt decisions file fails the run loudly
82
+ // rather than silently ignoring the user's records.
83
+ // The JSON adapter over the view: per-readKey rows carry the printable
84
+ // key (decision only where applied); the decisions block renders only
85
+ // when it has content — renderJson decides from this payload.
86
+ function jsonReviewOf(view) {
87
+ const annotations = new Map();
88
+ for (const [readKey, row] of view.rows) {
89
+ annotations.set(readKey, {
90
+ ...(row.key !== undefined ? { decisionKey: row.key } : {}),
91
+ ...(row.decision !== undefined ? { decision: row.decision } : {}),
92
+ ...(row.stale === true ? { stale: true } : {}),
93
+ });
94
+ }
95
+ return {
96
+ annotations,
97
+ reassessing: view.reassessing,
98
+ ambiguous: view.ambiguous.map((a) => ({ checkKey: a.checkKey, file: a.file, occurrences: a.occurrences })),
99
+ dormant: view.dormant,
100
+ };
101
+ }
102
+ // The command layer's adapter: one ReviewView, surfaces read it. The
103
+ // derivation lives in review.ts — one derivation, N adapters.
104
+ function computeReview(targetDir, capture, provenance, load = loadDecisions) {
105
+ const loaded = load(targetDir);
106
+ if (!loaded.ok)
107
+ return loaded;
108
+ return { ok: true, review: reviewOf(capture, loaded.decisions, encodeDecisionKey, provenance) };
109
+ }
110
+ // The scan's doctor provenance: program digests from the spec (the same
111
+ // pre-scan digest discipline the diff uses) — the command layer alone
112
+ // holds program paths.
113
+ function scanProvenanceOf(spec, groups) {
114
+ var _a;
115
+ const digests = doctorDigests(spec.doctors, digestTextFile);
116
+ const programDigests = new Map(digests.map((d) => [d.doctorId, d.digest]));
117
+ const revisions = new Map();
118
+ for (const g of groups) {
119
+ for (const check of (_a = g.meta.checks) !== null && _a !== void 0 ? _a : []) {
120
+ if (check.revision !== undefined)
121
+ revisions.set(`${g.meta.id}/${check.id}`, check.revision);
122
+ }
123
+ }
124
+ return { revisions, programDigests };
125
+ }
126
+ // The display outcome: decided findings removed, everything else the raw
127
+ // truth. The report and dashboard render this; the gate renders raw.
128
+ function outcomeWithoutDecided(outcome, suppressedReadKeys) {
129
+ if (suppressedReadKeys.size === 0)
130
+ return outcome;
131
+ const groups = outcome.groups.map((g) => ({
132
+ ...g,
133
+ findings: g.findings.filter((f) => {
134
+ const j = resolveFinding(g.meta, f);
135
+ return !suppressedReadKeys.has(readKeyFor(j.checkKey, f.file, f.line, f.column));
136
+ }),
137
+ }));
138
+ return { ...outcome, groups };
139
+ }
58
140
  // Verify's crossing of the Runner seam: a failure there is a failure of
59
141
  // the whole command, so it renders and aborts. (Run mode crosses the
60
142
  // seam through the Cohort, whose crashes ride the RunOutcome as data.)
@@ -237,7 +319,7 @@ function exitAfterSurface(outcome, gate) {
237
319
  return outcome.skippedUnsafe.length > 0 ? 1 : 0;
238
320
  }
239
321
  async function cmdRun(args) {
240
- var _a;
322
+ var _a, _b, _c;
241
323
  const parsed = parseArgs(args);
242
324
  if (parsed.global) {
243
325
  fail("--global is a generate-only flag");
@@ -363,7 +445,7 @@ async function cmdRun(args) {
363
445
  let diff;
364
446
  if (parsed.base !== undefined && outcome.crashed.length === 0) {
365
447
  try {
366
- const head = captureHeadScan(spec.targetDir, summary.groups, (_a = outcome.analysisAvailable) !== null && _a !== void 0 ? _a : false, headDigests);
448
+ const head = captureScan(spec.targetDir, summary.groups, (_a = outcome.analysisAvailable) !== null && _a !== void 0 ? _a : false, headDigests);
367
449
  diff = await runDiff(spec, parsed.base, head);
368
450
  }
369
451
  catch (e) {
@@ -371,22 +453,56 @@ async function cmdRun(args) {
371
453
  return 1;
372
454
  }
373
455
  }
374
- // The Gate: advisory findings by default (--fail-on none), crashes
375
- // and skips always fail, diff mode judges only what the change
376
- // ADDED.
377
- const gate = gateVerdict(parsed.failOn, diff !== undefined ? countsOfSeverities(diff.added.map(a => a.severity)) : summary.severityCounts, diff !== undefined ? "diff" : "full");
378
456
  // Report-vs-dashboard policy: --all is the batch/report mode; JSON is
379
457
  // a machine surface and never opens a TUI; otherwise a real terminal
380
458
  // with room and no headless override gets the tree.
381
459
  const interactive = wantsTui(parsed);
460
+ // Remembered decisions: computed when state exists (headless) or when
461
+ // the dashboard may record one (it needs the identity keys either way).
462
+ // A corrupt file fails the run loudly — never ignored, never reset.
463
+ let review;
464
+ // JSON is the agent surface: findings carry decisionKey from the very
465
+ // first run, so agents decide without a resolving scan.
466
+ if (fs.existsSync(decisionsPath(parsed.targetDir)) || interactive || parsed.format === "json") {
467
+ const capture = captureScan(parsed.targetDir, summary.groups, (_b = outcome.analysisAvailable) !== null && _b !== void 0 ? _b : false, []);
468
+ const provenance = scanProvenanceOf(spec, summary.groups);
469
+ const r = computeReview(parsed.targetDir, capture, provenance);
470
+ if (!r.ok) {
471
+ fail(r.error);
472
+ return 1;
473
+ }
474
+ review = r.review;
475
+ }
476
+ // The Gate: advisory findings by default (--fail-on none), crashes
477
+ // and skips always fail, diff mode judges only what the change
478
+ // ADDED.
479
+ const gate = gateVerdict(parsed.failOn, diff !== undefined ? countsOfSeverities(diff.added.map(a => a.severity)) : summary.severityCounts, diff !== undefined ? "diff" : "full");
382
480
  // The machine surface: exactly one JSON object on stdout, diagnostics
383
481
  // on stderr, the gate verdict data not prose.
482
+ // Surfaces carry decision info only when there is any — an emptied
483
+ // store (last decision reversed) leaves no machinery behind.
484
+ const reviewActive = review !== undefined
485
+ && (review.accepted + review.notApplicable > 0
486
+ || review.reassessing.length > 0
487
+ || review.ambiguous.length > 0);
384
488
  if (parsed.format === "json") {
385
- console.log(renderJson(outcome, summary, gate, diff));
489
+ console.log(renderJson(outcome, summary, gate, diff, review !== undefined ? jsonReviewOf(review) : undefined));
386
490
  return exitAfterSurface(outcome, gate);
387
491
  }
388
492
  if (!interactive) {
389
- console.log(renderReport(outcome, useColor(), diff !== undefined ? reportDiffOf(diff) : undefined));
493
+ // Agents and pipes get one pointer to the machine surface — stderr,
494
+ // so JSON purity and report pipes are untouched. The TTY check keeps
495
+ // terminal humans (whose stdout IS a tty) free of it.
496
+ if (parsed.format === "report" && !process.stdout.isTTY) {
497
+ warn(dim("any-doctor: non-interactive output — agents: run with --format json (findings carry decisionKey); the full workflow: any-doctor help agents"));
498
+ }
499
+ // The report renders the ACTIVE list: decided findings are hidden,
500
+ // with the reviewed line keeping the hiding honest. The gate above
501
+ // still judged the raw findings — local decisions never change CI.
502
+ console.log(renderReport(outcomeWithoutDecided(outcome, (_c = review === null || review === void 0 ? void 0 : review.suppressedReadKeys) !== null && _c !== void 0 ? _c : new Set()), useColor(), diff !== undefined ? reportDiffOf(diff) : undefined, reviewActive && review !== undefined
503
+ ? { accepted: review.accepted, notApplicable: review.notApplicable,
504
+ reassessing: review.reassessing, ambiguous: review.ambiguous }
505
+ : undefined));
390
506
  return exitAfterSurface(outcome, gate);
391
507
  }
392
508
  const invoker = process.argv[1] ? `node "${fs.realpathSync(process.argv[1])}"` : "any-doctor";
@@ -394,7 +510,12 @@ async function cmdRun(args) {
394
510
  // renders findings and skips, not crashes — and the dashboard ignores
395
511
  // diff mode: it is the review experience, not the gate.
396
512
  const code = exitAfterSurface(outcome, gate);
397
- await runDashboard({ outcome, invoker, useColor: useColor() });
513
+ await runDashboard({
514
+ outcome,
515
+ invoker,
516
+ useColor: useColor(),
517
+ ...(review !== undefined ? { view: review } : {}),
518
+ });
398
519
  return code;
399
520
  }
400
521
  async function cmdVerify(args) {
@@ -554,6 +675,286 @@ async function cmdGenerate(args) {
554
675
  console.log(dim(' node "' + cliJs + '" verify "' + doctorAbs + '"'));
555
676
  return 0;
556
677
  }
678
+ function parseDecideArgs(args) {
679
+ const out = { actor: "cli", targetDir: path.resolve("."), all: false };
680
+ for (let i = 0; i < args.length; i++) {
681
+ const a = args[i];
682
+ const v = args[i + 1];
683
+ if (a === "--key" || a === "--file" || a === "--check" || a === "--reason" || a === "--actor") {
684
+ if (v === undefined || v.startsWith("--"))
685
+ return { error: `${a} needs a value` };
686
+ if (a === "--key")
687
+ out.key = v;
688
+ else if (a === "--file")
689
+ out.file = v;
690
+ else if (a === "--check")
691
+ out.check = v;
692
+ else if (a === "--reason")
693
+ out.reason = v;
694
+ else
695
+ out.actor = v;
696
+ i += 1;
697
+ }
698
+ else if (a === "--line") {
699
+ if (v === undefined || !/^\d+$/.test(v))
700
+ return { error: "--line needs a numeric value" };
701
+ out.line = Number(v);
702
+ i += 1;
703
+ }
704
+ else if (a === "--accepted")
705
+ out.disposition = "accepted";
706
+ else if (a === "--not-applicable")
707
+ out.disposition = "not-applicable";
708
+ else if (a === "--all")
709
+ out.all = true;
710
+ else if (out.doctorPath === undefined && (DOCTOR_FILE_RE.test(a) || isBareDoctorSlug(a)))
711
+ out.doctorPath = a;
712
+ else if (out.targetDir === path.resolve("."))
713
+ out.targetDir = path.resolve(a);
714
+ else
715
+ return { error: `unexpected argument: ${a}` };
716
+ }
717
+ if (out.disposition === undefined)
718
+ return { error: "choose --accepted or --not-applicable" };
719
+ if (out.reason === undefined || out.reason.trim() === "")
720
+ return { error: "--reason is required — a decision without a reason is a suppression" };
721
+ if (out.key === undefined && (out.file === undefined || out.line === undefined)) {
722
+ return { error: "pass --key <decisionKey from a scan's JSON>, or --file and --line to resolve against a fresh scan" };
723
+ }
724
+ return out;
725
+ }
726
+ async function cmdDecide(args) {
727
+ var _a, _b, _c, _d, _e;
728
+ const parsed = parseDecideArgs(args);
729
+ if ("error" in parsed) {
730
+ fail("any-doctor decide: " + parsed.error);
731
+ return 1;
732
+ }
733
+ let key = parsed.key;
734
+ let scanProvenanceAtDecide;
735
+ if (key !== undefined) {
736
+ // Keys cross shells as base64url (raw keys contain NUL separators
737
+ // that cannot traverse argv). A non-decodable value only matches if
738
+ // some record literally holds it — otherwise refuse loudly.
739
+ const decoded = decodeDecisionKey(key);
740
+ if (decoded === null) {
741
+ const raw = loadDecisions(parsed.targetDir);
742
+ const exact = raw.ok && raw.decisions.some((d) => d.key === key);
743
+ if (!exact) {
744
+ fail("any-doctor decide: --key expects the base64url decisionKey a scan's JSON or 'any-doctor decisions' prints");
745
+ return 1;
746
+ }
747
+ }
748
+ else {
749
+ key = decoded;
750
+ }
751
+ }
752
+ if (scanProvenanceAtDecide === undefined && parsed.key !== undefined && key !== undefined) {
753
+ // --key decisions still record what runs NOW: the decision names a
754
+ // checkKey, whose doctor is discoverable by id — digest its current
755
+ // program so a later changed doctor resurfaces this decision (the
756
+ // agent flow's version of the resolving scan's provenance).
757
+ const doctorId = (_a = key.split("\u0000")[0]) === null || _a === void 0 ? void 0 : _a.split("/")[0];
758
+ // An explicit doctor path wins (the caller knows what ran); else
759
+ // discover by the id the checkKey names.
760
+ const slug = parsed.doctorPath !== undefined
761
+ ? path.resolve(process.cwd(), parsed.doctorPath)
762
+ : doctorId !== undefined ? resolveDoctorPath(doctorId, process.cwd()) : null;
763
+ if (slug !== null) {
764
+ const bytes = digestTextFile(slug);
765
+ scanProvenanceAtDecide = { programDigest: doctorDigests([{ id: doctorId !== null && doctorId !== void 0 ? doctorId : "x", programPath: slug }], () => bytes)[0].digest };
766
+ }
767
+ }
768
+ let checkKey = "";
769
+ if (key === undefined) {
770
+ // Resolve by scanning: the decision must attach to the evidence a
771
+ // finding has NOW, so --file/--line re-runs the doctors first.
772
+ const badTarget = unusableTargetReason(parsed.targetDir);
773
+ if (badTarget !== null) {
774
+ fail(badTarget);
775
+ return 1;
776
+ }
777
+ let doctors;
778
+ if (parsed.doctorPath) {
779
+ const sel = await selectDoctor(parsed.doctorPath, { cwd: process.cwd(), useColor: useColor(), env: processTtyEnv() });
780
+ const selection = selectionOutcome(sel);
781
+ if ("exit" in selection)
782
+ return selection.exit;
783
+ doctors = [{ id: path.basename(selection.doctorPath, ".mjs"), programPath: selection.doctorPath }];
784
+ }
785
+ else {
786
+ const cohort = await gatherDoctors();
787
+ warnBrokenDoctors(cohort.broken);
788
+ if (cohortUnusable(cohort))
789
+ return 1;
790
+ let valid = cohort.valid;
791
+ if (!parsed.all && valid.length > 1) {
792
+ fail("multiple doctors discovered — pass a doctor path (or --all) so the resolving scan matches what you ran");
793
+ return 1;
794
+ }
795
+ doctors = valid.map((d) => ({ id: d.meta.id, programPath: d.path }));
796
+ }
797
+ const spec = { doctors, targetDir: parsed.targetDir, includeTests: false, skippedUnsafe: [] };
798
+ const ran = await runCohort(spec);
799
+ if (ran.crashed.length > 0) {
800
+ for (const c of ran.crashed)
801
+ fail(c.detail);
802
+ fail("any-doctor decide: the resolving scan crashed — a decision attaches to evidence, and there is none");
803
+ return 1;
804
+ }
805
+ const groups = deriveSummary(ran).groups;
806
+ const capture = captureScan(parsed.targetDir, groups, (_b = ran.analysisAvailable) !== null && _b !== void 0 ? _b : false, []);
807
+ const scanProv = scanProvenanceOf(spec, deriveSummary(ran).groups);
808
+ const view = reviewOf(capture, [], encodeDecisionKey, scanProv);
809
+ const candidates = capture.entries
810
+ .map((e) => { var _a; return ({ e, key: (_a = view.rawKeyByReadKey.get(readKeyFor(e.checkKey, e.f.file, e.f.line, e.f.column))) !== null && _a !== void 0 ? _a : "", row: view.rows.get(readKeyFor(e.checkKey, e.f.file, e.f.line, e.f.column)) }); })
811
+ .filter((c2) => { var _a; return ((_a = c2.row) === null || _a === void 0 ? void 0 : _a.stale) !== true; })
812
+ .filter(({ e }) => e.f.file === parsed.file && e.f.line === parsed.line
813
+ && (parsed.check === undefined || e.checkKey === parsed.check || e.f.rule === parsed.check || e.checkKey.endsWith("/" + parsed.check)));
814
+ if (candidates.length === 0) {
815
+ fail(`no current finding at ${parsed.file}:${parsed.line}${parsed.check !== undefined ? " for " + parsed.check : ""} — findings move; run a scan and use its decisionKey`);
816
+ return 1;
817
+ }
818
+ const distinct = new Set(candidates.map((c) => c.e.checkKey));
819
+ if (distinct.size > 1) {
820
+ fail(`multiple findings at ${parsed.file}:${parsed.line} — pass --check: ${[...distinct].join(", ")}`);
821
+ return 1;
822
+ }
823
+ key = candidates[0].key;
824
+ checkKey = candidates[0].e.checkKey;
825
+ const rev = scanProv.revisions.get(checkKey);
826
+ const doctorId = checkKey.split("/")[0];
827
+ scanProvenanceAtDecide = {
828
+ ...(rev !== undefined ? { revision: rev } : { programDigest: scanProv.programDigests.get(doctorId) }),
829
+ };
830
+ }
831
+ // With --key, checkKey and file come from the DECODED key itself (its
832
+ // first two NUL-separated fields); line is display-only and unknown
833
+ // here (0). Splitting the encoded argv form would store the whole
834
+ // blob as checkKey and the decision would sit dormant forever
835
+ // (loop-4's catch).
836
+ const [keyCheck, keyFile] = key.split("\u0000");
837
+ const stateExistedBefore = fs.existsSync(decisionsPath(parsed.targetDir));
838
+ const recorded = recordDecision(parsed.targetDir, {
839
+ key: key,
840
+ checkKey: checkKey !== "" ? checkKey : keyCheck,
841
+ file: (_d = (_c = parsed.file) !== null && _c !== void 0 ? _c : keyFile) !== null && _d !== void 0 ? _d : "",
842
+ line: (_e = parsed.line) !== null && _e !== void 0 ? _e : 0,
843
+ disposition: parsed.disposition,
844
+ reason: parsed.reason,
845
+ actor: parsed.actor,
846
+ // Scan-resolved decisions record what ran (revision-or-digest);
847
+ // --key decisions carry no provenance the command can see — later
848
+ // scans treat absence as incompatible until re-decided (visible).
849
+ ...(scanProvenanceAtDecide !== undefined ? { provenance: scanProvenanceAtDecide } : {}),
850
+ });
851
+ if (!recorded.ok) {
852
+ fail(recorded.error);
853
+ return 1;
854
+ }
855
+ ok(`decision recorded (${parsed.disposition}): ${truncReason(parsed.reason)} — hidden from the active list on the next scan; any-doctor decisions --reverse <key> to undo`);
856
+ if (!stateExistedBefore) {
857
+ // The one-time adoption nudge: the highest-trust channel for agents
858
+ // is the repo's own AGENTS.md — we never write it; we point at the
859
+ // paste source.
860
+ console.log(dim("tip: add the agent workflow to this repo's AGENTS.md so your agents use decisions — 'any-doctor help agents' prints ready-to-paste markdown"));
861
+ }
862
+ return 0;
863
+ }
864
+ function truncReason(s) {
865
+ return s.length <= 60 ? s : s.slice(0, 59) + "…";
866
+ }
867
+ async function cmdDecisions(args) {
868
+ let targetDir = path.resolve(".");
869
+ let reverse;
870
+ let json = false;
871
+ for (let i = 0; i < args.length; i++) {
872
+ const a = args[i];
873
+ if (a === "--json")
874
+ json = true;
875
+ else if (a === "--reverse") {
876
+ const v = args[i + 1];
877
+ if (v === undefined || v.startsWith("--")) {
878
+ fail("--reverse needs a decision key (any-doctor decisions lists them)");
879
+ return 1;
880
+ }
881
+ reverse = v;
882
+ i += 1;
883
+ }
884
+ else if (a.startsWith("--")) {
885
+ fail(`any-doctor decisions: unknown flag ${a} (known: --json, --reverse <key>)`);
886
+ return 1;
887
+ }
888
+ else
889
+ targetDir = path.resolve(a);
890
+ }
891
+ const loaded = loadDecisions(targetDir);
892
+ if (!loaded.ok) {
893
+ fail(loaded.error);
894
+ return 1;
895
+ }
896
+ if (reverse !== undefined) {
897
+ const r = reverseByKey(targetDir, reverse);
898
+ if (!r.ok) {
899
+ fail(r.error);
900
+ return 1;
901
+ }
902
+ ok("decision reversed — the finding returns to the active list on the next scan");
903
+ return 0;
904
+ }
905
+ if (json) {
906
+ // Machine surface: JSON in EVERY state (empty included — prose here
907
+ // broke parsers), keys shell-safe (base64url, matching decisionKey —
908
+ // raw keys hold NULs argv cannot carry).
909
+ console.log(JSON.stringify({
910
+ schema: 1,
911
+ decisions: loaded.decisions.map((d) => ({ ...d, key: encodeDecisionKey(d.key) })),
912
+ }, null, 2));
913
+ return 0;
914
+ }
915
+ if (loaded.decisions.length === 0) {
916
+ console.log(dim("no decisions recorded — they are created from the dashboard (a/x) or any-doctor decide"));
917
+ return 0;
918
+ }
919
+ // Bounded output: a wall of decisions is a denial of service on the
920
+ // reader; --json is the unbounded export path.
921
+ const LIST_CAP = 500;
922
+ for (const d of loaded.decisions.slice(0, LIST_CAP)) {
923
+ console.log(`${d.disposition === "accepted" ? "✓ accepted" : "⊘ not-applicable"} ${d.checkKey} ${d.file}:${d.line}`);
924
+ console.log(dim(` reason: ${d.reason}`));
925
+ console.log(dim(` actor: ${d.actor} · updated ${d.updatedAt} · key: ${encodeDecisionKey(d.key)}`));
926
+ }
927
+ if (loaded.decisions.length > LIST_CAP) {
928
+ console.log(dim(`… and ${loaded.decisions.length - LIST_CAP} more — any-doctor decisions --json`));
929
+ }
930
+ return 0;
931
+ }
932
+ // Reverse by exact key or a unique prefix (full identity keys are long).
933
+ function reverseByKey(targetDir, key) {
934
+ const loaded = loadDecisions(targetDir);
935
+ if (!loaded.ok)
936
+ return loaded;
937
+ // The printed keys are base64url-encoded (raw keys hold NULs argv
938
+ // cannot carry); match encoded-prefix-unique, encoded-exact, or raw.
939
+ const candidates = [];
940
+ const decoded = decodeDecisionKey(key);
941
+ if (decoded !== null)
942
+ candidates.push(decoded);
943
+ candidates.push(key);
944
+ for (const candidate of candidates) {
945
+ const exact = loaded.decisions.find((d) => d.key === candidate);
946
+ if (exact !== undefined)
947
+ return reverseDecision(targetDir, exact.key);
948
+ }
949
+ for (const candidate of candidates) {
950
+ const prefixed = loaded.decisions.filter((d) => encodeDecisionKey(d.key).startsWith(candidate) || d.key.startsWith(candidate));
951
+ if (prefixed.length === 1)
952
+ return reverseDecision(targetDir, prefixed[0].key);
953
+ if (prefixed.length > 1)
954
+ return { ok: false, error: `key prefix is ambiguous (${prefixed.length} decisions) — use more characters` };
955
+ }
956
+ return { ok: false, error: "no decision matches that key — any-doctor decisions lists them (copy the printed key)" };
957
+ }
557
958
  const STOP_WORDS = new Set(["a", "an", "the", "find", "flag", "all", "that", "which", "is", "are", "in", "on", "of", "to", "and", "or", "not"]);
558
959
  function slugify(intent) {
559
960
  const words = intent.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").trim().split(/\s+/);
@@ -566,9 +967,13 @@ function usage() {
566
967
  console.log(' generate "<intent>" [--global] print the exact prompt for your agent to build a doctor');
567
968
  console.log(" run [--all] [--include-tests] [doctor.(m)js] [dir] scan; no argument = every doctor in one review tree");
568
969
  console.log(" verify [--all] [doctor.(m)js] fixture gate (no doctor: fuzzy picker; --all: every doctor)");
970
+ console.log(" decide (--key K | --file F --line N [--check C]) (--accepted|--not-applicable) --reason R");
971
+ console.log(" record a decision on a finding (resolves by scanning unless --key)");
972
+ console.log(" decisions [dir] [--json] [--reverse K] list remembered decisions; reverse one");
569
973
  console.log("");
570
974
  console.log(dim("doctors live in ./doctors/ (repo), ~/.any-doctor/doctors/ (global), and the bundled pack (lowest priority)."));
571
975
  console.log(dim("generation delegates to your installed agent — run and verify never touch a model."));
976
+ console.log(dim("agents: 'any-doctor help agents' prints the machine interface (JSON scan, decide, decisions)."));
572
977
  }
573
978
  export async function main(argv = process.argv.slice(2)) {
574
979
  const major = Number(process.versions.node.split(".")[0]);
@@ -579,6 +984,15 @@ export async function main(argv = process.argv.slice(2)) {
579
984
  const cmd = argv[0];
580
985
  const rest = argv.slice(1);
581
986
  if (cmd === "help" || cmd === "--help") {
987
+ if (rest[0] === "agents") {
988
+ const doc = agentUsageText();
989
+ if (doc === null) {
990
+ fail("agent usage doc not found (skill/agent-usage.md missing).");
991
+ return 1;
992
+ }
993
+ console.log(doc.trimEnd());
994
+ return 0;
995
+ }
582
996
  usage();
583
997
  return 0;
584
998
  }
@@ -594,6 +1008,10 @@ export async function main(argv = process.argv.slice(2)) {
594
1008
  return await cmdRun(rest);
595
1009
  if (cmd === "verify")
596
1010
  return await cmdVerify(rest);
1011
+ if (cmd === "decide")
1012
+ return await cmdDecide(rest);
1013
+ if (cmd === "decisions")
1014
+ return await cmdDecisions(rest);
597
1015
  }
598
1016
  catch (e) {
599
1017
  if (e instanceof ExitCode)
package/bin/contract.d.ts CHANGED
@@ -22,6 +22,12 @@ export interface CheckMeta {
22
22
  onUnknown?: "narrow" | "skip";
23
23
  /** Unit counted by certification; occurrence checks require a two-location witness. */
24
24
  reportingUnit?: "occurrence" | "file" | "project";
25
+ /** Semantic revision of this check's MEANING, authored by hand: bump it
26
+ * when the check detects something different; cosmetic doctor edits do
27
+ * not bump it. Recorded decisions stay compatible while the revision is
28
+ * unchanged; without a revision, the whole-program digest governs (and
29
+ * any doctor edit resurfaces decisions — the churn revision prevents). */
30
+ revision?: number;
25
31
  }
26
32
  export interface DoctorMeta {
27
33
  id: string;
@@ -318,6 +324,7 @@ export declare function includeTestsFor(mode: Mode): boolean;
318
324
  export declare function runCommandFor(doctorPath: string, root: string, invoker?: string): string;
319
325
  export declare function compareFindings(expected: ExpectedFinding[], actual: Finding[]): FixtureDiff;
320
326
  export declare function narrowedCheckIds(meta: DoctorMeta): string[];
327
+ export declare function readKeyFor(checkKey: string, file: string, line: number, column?: number): string;
321
328
  export interface JoinedFinding {
322
329
  doctorId: string;
323
330
  checkId: string;
package/bin/contract.js CHANGED
@@ -159,6 +159,15 @@ export function narrowedCheckIds(meta) {
159
159
  var _a;
160
160
  return ((_a = meta.checks) !== null && _a !== void 0 ? _a : []).filter((c) => c.needs !== undefined && c.needs.length > 0).map((c) => c.id);
161
161
  }
162
+ // The within-run occurrence handle: checkKey plus coordinates, COLUMN
163
+ // INCLUDED — two findings on one line are two occurrences, and one
164
+ // decision must never hide both (the review-probe bug: this key dropped
165
+ // columns, so display filters suppressed by line). One home — the
166
+ // identity layer keys evidence by it, decisions address findings by it,
167
+ // and every surface composes it from these parts.
168
+ export function readKeyFor(checkKey, file, line, column) {
169
+ return `${checkKey}@${file}:${line}${column !== undefined ? `:${column}` : ""}`;
170
+ }
162
171
  export function resolveFinding(meta, finding) {
163
172
  var _a, _b, _c, _d, _e, _f, _g;
164
173
  const checkId = (_a = finding.rule) !== null && _a !== void 0 ? _a : meta.id;
@@ -1,4 +1,5 @@
1
1
  import { Severity } from "./contract.js";
2
+ import { Disposition } from "./finding-state.js";
2
3
  import { CheckSummary, DoctorSummary, DoctorTree, SiteFinding } from "./doctor-tree.js";
3
4
  import { TtyStdin, TtyStdout } from "./tty.js";
4
5
  import { RunOutcome } from "./contract.js";
@@ -7,6 +8,7 @@ export interface DashboardInput {
7
8
  outcome: RunOutcome;
8
9
  invoker?: string;
9
10
  useColor: boolean;
11
+ view?: import("./review.js").ReviewView;
10
12
  }
11
13
  export declare function scoreBar(score: number, width: number): string;
12
14
  export interface DashboardLayout {
@@ -28,13 +30,30 @@ interface ListRow {
28
30
  check?: CheckSummary;
29
31
  doctor?: DoctorSummary;
30
32
  toggleKey?: string;
33
+ reviewed?: Disposition;
34
+ reassess?: boolean;
31
35
  }
32
- export declare function buildListRows(tree: DoctorTree, useColor: boolean, selectedRow: number, readKeys: Set<string>, expanded?: ReadonlySet<string>): ListRow[];
36
+ export declare function buildListRows(tree: DoctorTree, useColor: boolean, selectedRow: number, readKeys: Set<string>, expanded?: ReadonlySet<string>, review?: {
37
+ dispositionByReadKey?: Map<string, Disposition>;
38
+ reassessPairs?: Set<string>;
39
+ ambiguousReadKeys?: Map<string, number>;
40
+ }): ListRow[];
33
41
  export interface FrameSource {
34
42
  (file: string): string[] | null;
35
43
  }
36
44
  export interface DashboardFrameState {
37
45
  tree: DoctorTree;
46
+ dispositionByReadKey?: Map<string, Disposition>;
47
+ reassessPairs?: Set<string>;
48
+ prompt?: {
49
+ disposition: Disposition;
50
+ buffer: string;
51
+ file: string;
52
+ line: number;
53
+ error?: boolean;
54
+ };
55
+ reasonByReadKey?: Map<string, string>;
56
+ ambiguousReadKeys?: Map<string, number>;
38
57
  selectedRow: number;
39
58
  readKeys: Set<string>;
40
59
  readSource: FrameSource;
@@ -51,6 +70,30 @@ export declare function dashboardFrame(state: DashboardFrameState): string;
51
70
  export declare function runDashboard(input: DashboardInput): Promise<void>;
52
71
  export interface DashboardDeps {
53
72
  copy?: (text: string) => boolean;
73
+ decide?: (input: {
74
+ key: string;
75
+ checkKey: string;
76
+ file: string;
77
+ line: number;
78
+ disposition: Disposition;
79
+ reason: string;
80
+ actor: string;
81
+ provenance?: {
82
+ revision?: number;
83
+ programDigest?: string;
84
+ };
85
+ }) => {
86
+ ok: true;
87
+ } | {
88
+ ok: false;
89
+ error: string;
90
+ };
91
+ reverse?: (key: string) => {
92
+ ok: true;
93
+ } | {
94
+ ok: false;
95
+ error: string;
96
+ };
54
97
  }
55
98
  export declare function runDashboardOn(env: {
56
99
  stdin: TtyStdin;