any-doctor 0.0.9 → 0.1.1

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,15 +4,19 @@ 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";
14
18
  import { brokenDoctors, discoverDoctors, globalDoctorsDir, resolveDoctorPath, unsafeSlugs, scopeLabel } from "./discover.js";
15
- import { causeSummaryLine, describeRunnerError, isRunnerError, verifyDoctor } from "./runner.js";
19
+ import { causeSummaryLine, describeRunnerError, isRunnerError, metaDoctor, verifyDoctor } from "./runner.js";
16
20
  import { scanDoctorFile, capabilitySummary } from "./capabilities.js";
17
21
  import { selectDoctor } from "./select.js";
18
22
  import { pickItemsOn } from "./picker.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.)
@@ -69,8 +151,10 @@ async function runOrReport(work) {
69
151
  }
70
152
  }
71
153
  function warnBrokenDoctors(skipped) {
154
+ // stderr, always: a console.log here put the warning INSIDE --format
155
+ // json stdout, corrupting the machine surface (agent-review probe).
72
156
  for (const b of skipped) {
73
- console.log(YELLOW + "\u26a0 skipping broken doctor " + b.slug + RESET + dim(" — " + causeSummaryLine(b.cause)));
157
+ warn("\u26a0 skipping broken doctor " + b.slug + dim(" — " + causeSummaryLine(b.cause)));
74
158
  }
75
159
  }
76
160
  function selectionOutcome(sel) {
@@ -224,20 +308,25 @@ function wantsTui(parsed) {
224
308
  // partial), the gate's bar judges findings, skips fail quietly. Each
225
309
  // surface calls this where its timing wants the lines printed.
226
310
  function exitAfterSurface(outcome, gate) {
227
- var _a;
311
+ var _a, _b, _c, _d;
228
312
  if (outcome.crashed.length > 0) {
229
313
  for (const c of outcome.crashed)
230
314
  fail(`doctor crashed (results above are partial): ${c.id}`);
231
315
  return 1;
232
316
  }
317
+ if (((_b = (_a = outcome.broken) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0) {
318
+ for (const b of (_c = outcome.broken) !== null && _c !== void 0 ? _c : [])
319
+ fail(`broken doctor (not scanned): ${b.id}`);
320
+ return 1;
321
+ }
233
322
  if (gate.fails) {
234
- fail((_a = gate.reason) !== null && _a !== void 0 ? _a : "gate failed");
323
+ fail((_d = gate.reason) !== null && _d !== void 0 ? _d : "gate failed");
235
324
  return 1;
236
325
  }
237
326
  return outcome.skippedUnsafe.length > 0 ? 1 : 0;
238
327
  }
239
328
  async function cmdRun(args) {
240
- var _a;
329
+ var _a, _b, _c;
241
330
  const parsed = parseArgs(args);
242
331
  if (parsed.global) {
243
332
  fail("--global is a generate-only flag");
@@ -266,6 +355,7 @@ async function cmdRun(args) {
266
355
  // Cohort, which owns everything from first spawn to last settle.
267
356
  let doctors;
268
357
  let skippedUnsafe;
358
+ let broken = [];
269
359
  // The live line is mode-based, not count-based: an explicit path is a
270
360
  // scan of one (label + elapsed, no counts, no settle notes); picker
271
361
  // and --all are cohort runs (counts + per-settle notes) even when the
@@ -285,14 +375,27 @@ async function cmdRun(args) {
285
375
  // report use.
286
376
  doctors = [{ id: path.basename(selection.doctorPath, ".mjs"), programPath: selection.doctorPath }];
287
377
  skippedUnsafe = [];
378
+ broken = sel.kind === "doctor" ? sel.skipped : [];
288
379
  }
289
380
  else {
290
381
  const cohort = await gatherDoctors();
291
382
  warnBrokenDoctors(cohort.broken);
292
- if (cohortUnusable(cohort))
383
+ if (cohortUnusable(cohort)) {
384
+ // Structured failure even here: the JSON surface still gets its
385
+ // object with the broken array (agent-review probe: stdout was
386
+ // empty when every doctor was broken).
387
+ if (parsed.format === "json") {
388
+ const failed = {
389
+ groups: [], crashed: [], broken: cohort.broken.map(b => ({ id: b.slug, detail: causeSummaryLine(b.cause) })),
390
+ skippedUnsafe: cohort.skippedUnsafe, doctorPaths: new Map(), fileCount: 0, durationMs: 0, targetDir: parsed.targetDir,
391
+ };
392
+ console.log(renderJson(failed, deriveSummary(failed), gateVerdict(parsed.failOn, { error: 0, warning: 0, info: 0 }, "full")));
393
+ }
293
394
  return 1;
395
+ }
294
396
  let valid = cohort.valid;
295
397
  skippedUnsafe = cohort.skippedUnsafe;
398
+ broken = cohort.broken;
296
399
  // The cold start is opt-in: the selector opens with nothing
297
400
  // pre-selected, space selects, a selects every filtered row, and
298
401
  // Enter runs the selection — narrowing to one doctor is one space,
@@ -317,7 +420,7 @@ async function cmdRun(args) {
317
420
  // rejects the batch must not leave a hidden cursor behind. Settle
318
421
  // notes name doctors by the spec's ids — one naming rule, shared with
319
422
  // the crash report.
320
- const spec = { doctors, targetDir: parsed.targetDir, includeTests: parsed.includeTests, skippedUnsafe };
423
+ const spec = { doctors, targetDir: parsed.targetDir, includeTests: parsed.includeTests, skippedUnsafe, broken };
321
424
  const idOf = new Map(doctors.map(d => [d.programPath, d.id]));
322
425
  // JSON mode paints nothing on stdout — not even the live line. The
323
426
  // picker and dashboard get the same refusal from wantsTui; the
@@ -347,6 +450,13 @@ async function cmdRun(args) {
347
450
  finally {
348
451
  spin === null || spin === void 0 ? void 0 : spin.stop();
349
452
  }
453
+ // Broken doctors fail ALWAYS, like crashes: a doctor whose program
454
+ // cannot even be read is an infrastructure failure, and a local broken
455
+ // file shadowing a bundled doctor must never read as that doctor
456
+ // scanning clean (the agent-review probe: exit 0, score 100).
457
+ for (const b of broken) {
458
+ warn("\u26a0 broken doctor " + b.slug + " — " + causeSummaryLine(b.cause));
459
+ }
350
460
  // Crash detail prints before any surface: "details above" in the
351
461
  // report's every-crashed line stays true, and the dashboard's own
352
462
  // rendering (findings and skips, not crashes) stays clean.
@@ -363,7 +473,7 @@ async function cmdRun(args) {
363
473
  let diff;
364
474
  if (parsed.base !== undefined && outcome.crashed.length === 0) {
365
475
  try {
366
- const head = captureHeadScan(spec.targetDir, summary.groups, (_a = outcome.analysisAvailable) !== null && _a !== void 0 ? _a : false, headDigests);
476
+ const head = captureScan(spec.targetDir, summary.groups, (_a = outcome.analysisAvailable) !== null && _a !== void 0 ? _a : false, headDigests);
367
477
  diff = await runDiff(spec, parsed.base, head);
368
478
  }
369
479
  catch (e) {
@@ -371,22 +481,56 @@ async function cmdRun(args) {
371
481
  return 1;
372
482
  }
373
483
  }
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
484
  // Report-vs-dashboard policy: --all is the batch/report mode; JSON is
379
485
  // a machine surface and never opens a TUI; otherwise a real terminal
380
486
  // with room and no headless override gets the tree.
381
487
  const interactive = wantsTui(parsed);
488
+ // Remembered decisions: computed when state exists (headless) or when
489
+ // the dashboard may record one (it needs the identity keys either way).
490
+ // A corrupt file fails the run loudly — never ignored, never reset.
491
+ let review;
492
+ // JSON is the agent surface: findings carry decisionKey from the very
493
+ // first run, so agents decide without a resolving scan.
494
+ if (fs.existsSync(decisionsPath(parsed.targetDir)) || interactive || parsed.format === "json") {
495
+ const capture = captureScan(parsed.targetDir, summary.groups, (_b = outcome.analysisAvailable) !== null && _b !== void 0 ? _b : false, []);
496
+ const provenance = scanProvenanceOf(spec, summary.groups);
497
+ const r = computeReview(parsed.targetDir, capture, provenance);
498
+ if (!r.ok) {
499
+ fail(r.error);
500
+ return 1;
501
+ }
502
+ review = r.review;
503
+ }
504
+ // The Gate: advisory findings by default (--fail-on none), crashes
505
+ // and skips always fail, diff mode judges only what the change
506
+ // ADDED.
507
+ const gate = gateVerdict(parsed.failOn, diff !== undefined ? countsOfSeverities(diff.added.map(a => a.severity)) : summary.severityCounts, diff !== undefined ? "diff" : "full");
382
508
  // The machine surface: exactly one JSON object on stdout, diagnostics
383
509
  // on stderr, the gate verdict data not prose.
510
+ // Surfaces carry decision info only when there is any — an emptied
511
+ // store (last decision reversed) leaves no machinery behind.
512
+ const reviewActive = review !== undefined
513
+ && (review.accepted + review.notApplicable > 0
514
+ || review.reassessing.length > 0
515
+ || review.ambiguous.length > 0);
384
516
  if (parsed.format === "json") {
385
- console.log(renderJson(outcome, summary, gate, diff));
517
+ console.log(renderJson(outcome, summary, gate, diff, review !== undefined ? jsonReviewOf(review) : undefined));
386
518
  return exitAfterSurface(outcome, gate);
387
519
  }
388
520
  if (!interactive) {
389
- console.log(renderReport(outcome, useColor(), diff !== undefined ? reportDiffOf(diff) : undefined));
521
+ // Agents and pipes get one pointer to the machine surface — stderr,
522
+ // so JSON purity and report pipes are untouched. The TTY check keeps
523
+ // terminal humans (whose stdout IS a tty) free of it.
524
+ if (parsed.format === "report" && !process.stdout.isTTY) {
525
+ warn(dim("any-doctor: non-interactive output — agents: run with --format json (findings carry decisionKey); the full workflow: any-doctor help agents"));
526
+ }
527
+ // The report renders the ACTIVE list: decided findings are hidden,
528
+ // with the reviewed line keeping the hiding honest. The gate above
529
+ // still judged the raw findings — local decisions never change CI.
530
+ 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
531
+ ? { accepted: review.accepted, notApplicable: review.notApplicable,
532
+ reassessing: review.reassessing, ambiguous: review.ambiguous }
533
+ : undefined));
390
534
  return exitAfterSurface(outcome, gate);
391
535
  }
392
536
  const invoker = process.argv[1] ? `node "${fs.realpathSync(process.argv[1])}"` : "any-doctor";
@@ -394,7 +538,16 @@ async function cmdRun(args) {
394
538
  // renders findings and skips, not crashes — and the dashboard ignores
395
539
  // diff mode: it is the review experience, not the gate.
396
540
  const code = exitAfterSurface(outcome, gate);
397
- await runDashboard({ outcome, invoker, useColor: useColor() });
541
+ const stateExistedBeforeDashboard = fs.existsSync(decisionsPath(parsed.targetDir));
542
+ await runDashboard({
543
+ outcome,
544
+ invoker,
545
+ useColor: useColor(),
546
+ ...(review !== undefined ? { view: review } : {}),
547
+ });
548
+ if (!stateExistedBeforeDashboard && fs.existsSync(decisionsPath(parsed.targetDir))) {
549
+ 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"));
550
+ }
398
551
  return code;
399
552
  }
400
553
  async function cmdVerify(args) {
@@ -554,6 +707,294 @@ async function cmdGenerate(args) {
554
707
  console.log(dim(' node "' + cliJs + '" verify "' + doctorAbs + '"'));
555
708
  return 0;
556
709
  }
710
+ function parseDecideArgs(args) {
711
+ const out = { actor: "cli", targetDir: path.resolve("."), all: false };
712
+ for (let i = 0; i < args.length; i++) {
713
+ const a = args[i];
714
+ const v = args[i + 1];
715
+ if (a === "--key" || a === "--file" || a === "--check" || a === "--reason" || a === "--actor") {
716
+ if (v === undefined || v.startsWith("--"))
717
+ return { error: `${a} needs a value` };
718
+ if (a === "--key")
719
+ out.key = v;
720
+ else if (a === "--file")
721
+ out.file = v;
722
+ else if (a === "--check")
723
+ out.check = v;
724
+ else if (a === "--reason")
725
+ out.reason = v;
726
+ else
727
+ out.actor = v;
728
+ i += 1;
729
+ }
730
+ else if (a === "--line") {
731
+ if (v === undefined || !/^\d+$/.test(v))
732
+ return { error: "--line needs a numeric value" };
733
+ out.line = Number(v);
734
+ i += 1;
735
+ }
736
+ else if (a === "--accepted")
737
+ out.disposition = "accepted";
738
+ else if (a === "--not-applicable")
739
+ out.disposition = "not-applicable";
740
+ else if (a === "--all")
741
+ out.all = true;
742
+ else if (out.doctorPath === undefined && (DOCTOR_FILE_RE.test(a) || isBareDoctorSlug(a)))
743
+ out.doctorPath = a;
744
+ else if (out.targetDir === path.resolve("."))
745
+ out.targetDir = path.resolve(a);
746
+ else
747
+ return { error: `unexpected argument: ${a}` };
748
+ }
749
+ if (out.disposition === undefined)
750
+ return { error: "choose --accepted or --not-applicable" };
751
+ if (out.reason === undefined || out.reason.trim() === "")
752
+ return { error: "--reason is required — a decision without a reason is a suppression" };
753
+ if (out.key === undefined && (out.file === undefined || out.line === undefined)) {
754
+ return { error: "pass --key <decisionKey from a scan's JSON>, or --file and --line to resolve against a fresh scan" };
755
+ }
756
+ return out;
757
+ }
758
+ async function cmdDecide(args) {
759
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
760
+ const parsed = parseDecideArgs(args);
761
+ if ("error" in parsed) {
762
+ fail("any-doctor decide: " + parsed.error);
763
+ return 1;
764
+ }
765
+ let key = parsed.key;
766
+ let scanProvenanceAtDecide;
767
+ if (key !== undefined) {
768
+ // Keys cross shells as base64url (raw keys contain NUL separators
769
+ // that cannot traverse argv). A non-decodable value only matches if
770
+ // some record literally holds it — otherwise refuse loudly.
771
+ const decoded = decodeDecisionKey(key);
772
+ if (decoded === null) {
773
+ const raw = loadDecisions(parsed.targetDir);
774
+ const exact = raw.ok && raw.decisions.some((d) => d.key === key);
775
+ if (!exact) {
776
+ fail("any-doctor decide: --key expects the base64url decisionKey a scan's JSON or 'any-doctor decisions' prints");
777
+ return 1;
778
+ }
779
+ }
780
+ else {
781
+ key = decoded;
782
+ }
783
+ }
784
+ if (scanProvenanceAtDecide === undefined && parsed.key !== undefined && key !== undefined) {
785
+ // --key decisions still record what runs NOW: the decision names a
786
+ // checkKey, whose doctor is discoverable by id — digest its current
787
+ // program so a later changed doctor resurfaces this decision (the
788
+ // agent flow's version of the resolving scan's provenance).
789
+ const doctorId = (_a = key.split("\u0000")[0]) === null || _a === void 0 ? void 0 : _a.split("/")[0];
790
+ // An explicit doctor path wins (the caller knows what ran); else
791
+ // discover by the id the checkKey names.
792
+ const slug = parsed.doctorPath !== undefined
793
+ ? path.resolve(process.cwd(), parsed.doctorPath)
794
+ : doctorId !== undefined ? resolveDoctorPath(doctorId, process.cwd()) : null;
795
+ if (slug !== null) {
796
+ const bytes = digestTextFile(slug);
797
+ // Prefer the check's declared semantic revision (the churn escape):
798
+ // a --key decision must survive cosmetic doctor edits exactly like
799
+ // a scan-resolved one — digest-only recording churned (smoke catch).
800
+ const metaRead = await metaDoctor({ programPath: slug });
801
+ const checkId = (_b = key.split("\u0000")[0]) === null || _b === void 0 ? void 0 : _b.split("/")[1];
802
+ const declared = (_e = (_d = (_c = metaRead.meta) === null || _c === void 0 ? void 0 : _c.checks) === null || _d === void 0 ? void 0 : _d.find(ch => ch.id === checkId)) === null || _e === void 0 ? void 0 : _e.revision;
803
+ scanProvenanceAtDecide = declared !== undefined
804
+ ? { revision: declared }
805
+ : { programDigest: doctorDigests([{ id: doctorId !== null && doctorId !== void 0 ? doctorId : "x", programPath: slug }], () => bytes)[0].digest };
806
+ }
807
+ }
808
+ let checkKey = "";
809
+ if (key === undefined) {
810
+ // Resolve by scanning: the decision must attach to the evidence a
811
+ // finding has NOW, so --file/--line re-runs the doctors first.
812
+ const badTarget = unusableTargetReason(parsed.targetDir);
813
+ if (badTarget !== null) {
814
+ fail(badTarget);
815
+ return 1;
816
+ }
817
+ let doctors;
818
+ if (parsed.doctorPath) {
819
+ const sel = await selectDoctor(parsed.doctorPath, { cwd: process.cwd(), useColor: useColor(), env: processTtyEnv() });
820
+ const selection = selectionOutcome(sel);
821
+ if ("exit" in selection)
822
+ return selection.exit;
823
+ doctors = [{ id: path.basename(selection.doctorPath, ".mjs"), programPath: selection.doctorPath }];
824
+ }
825
+ else {
826
+ const cohort = await gatherDoctors();
827
+ warnBrokenDoctors(cohort.broken);
828
+ if (cohortUnusable(cohort))
829
+ return 1;
830
+ let valid = cohort.valid;
831
+ if (!parsed.all && valid.length > 1) {
832
+ fail("multiple doctors discovered — pass a doctor path (or --all) so the resolving scan matches what you ran");
833
+ return 1;
834
+ }
835
+ doctors = valid.map((d) => ({ id: d.meta.id, programPath: d.path }));
836
+ }
837
+ const spec = { doctors, targetDir: parsed.targetDir, includeTests: false, skippedUnsafe: [] };
838
+ const ran = await runCohort(spec);
839
+ if (ran.crashed.length > 0) {
840
+ for (const c of ran.crashed)
841
+ fail(c.detail);
842
+ fail("any-doctor decide: the resolving scan crashed — a decision attaches to evidence, and there is none");
843
+ return 1;
844
+ }
845
+ const groups = deriveSummary(ran).groups;
846
+ const capture = captureScan(parsed.targetDir, groups, (_f = ran.analysisAvailable) !== null && _f !== void 0 ? _f : false, []);
847
+ const scanProv = scanProvenanceOf(spec, deriveSummary(ran).groups);
848
+ const view = reviewOf(capture, [], encodeDecisionKey, scanProv);
849
+ const candidates = capture.entries
850
+ .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)) }); })
851
+ .filter((c2) => { var _a; return ((_a = c2.row) === null || _a === void 0 ? void 0 : _a.stale) !== true; })
852
+ .filter(({ e }) => e.f.file === parsed.file && e.f.line === parsed.line
853
+ && (parsed.check === undefined || e.checkKey === parsed.check || e.f.rule === parsed.check || e.checkKey.endsWith("/" + parsed.check)));
854
+ if (candidates.length === 0) {
855
+ fail(`no current finding at ${parsed.file}:${parsed.line}${parsed.check !== undefined ? " for " + parsed.check : ""} — findings move; run a scan and use its decisionKey`);
856
+ return 1;
857
+ }
858
+ const distinct = new Set(candidates.map((c) => c.e.checkKey));
859
+ if (distinct.size > 1) {
860
+ fail(`multiple findings at ${parsed.file}:${parsed.line} — pass --check: ${[...distinct].join(", ")}`);
861
+ return 1;
862
+ }
863
+ key = candidates[0].key;
864
+ checkKey = candidates[0].e.checkKey;
865
+ const rev = scanProv.revisions.get(checkKey);
866
+ const doctorId = checkKey.split("/")[0];
867
+ scanProvenanceAtDecide = {
868
+ ...(rev !== undefined ? { revision: rev } : { programDigest: scanProv.programDigests.get(doctorId) }),
869
+ };
870
+ }
871
+ // With --key, checkKey and file come from the DECODED key itself (its
872
+ // first two NUL-separated fields); line is display-only and unknown
873
+ // here (0). Splitting the encoded argv form would store the whole
874
+ // blob as checkKey and the decision would sit dormant forever
875
+ // (loop-4's catch).
876
+ const [keyCheck, keyFile] = key.split("\u0000");
877
+ const stateExistedBefore = fs.existsSync(decisionsPath(parsed.targetDir));
878
+ const recorded = recordDecision(parsed.targetDir, {
879
+ key: key,
880
+ checkKey: checkKey !== "" ? checkKey : keyCheck,
881
+ file: (_h = (_g = parsed.file) !== null && _g !== void 0 ? _g : keyFile) !== null && _h !== void 0 ? _h : "",
882
+ line: (_j = parsed.line) !== null && _j !== void 0 ? _j : 0,
883
+ disposition: parsed.disposition,
884
+ reason: parsed.reason,
885
+ actor: parsed.actor,
886
+ // Scan-resolved decisions record what ran (revision-or-digest);
887
+ // --key decisions carry no provenance the command can see — later
888
+ // scans treat absence as incompatible until re-decided (visible).
889
+ ...(scanProvenanceAtDecide !== undefined ? { provenance: scanProvenanceAtDecide } : {}),
890
+ });
891
+ if (!recorded.ok) {
892
+ fail(recorded.error);
893
+ return 1;
894
+ }
895
+ ok(`decision recorded (${parsed.disposition}): ${truncReason(parsed.reason)} — hidden from the active list on the next scan; any-doctor decisions --reverse <key> to undo`);
896
+ if (!stateExistedBefore) {
897
+ // The one-time adoption nudge: the highest-trust channel for agents
898
+ // is the repo's own AGENTS.md — we never write it; we point at the
899
+ // paste source.
900
+ 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"));
901
+ }
902
+ return 0;
903
+ }
904
+ function truncReason(s) {
905
+ return s.length <= 60 ? s : s.slice(0, 59) + "…";
906
+ }
907
+ async function cmdDecisions(args) {
908
+ let targetDir = path.resolve(".");
909
+ let reverse;
910
+ let json = false;
911
+ for (let i = 0; i < args.length; i++) {
912
+ const a = args[i];
913
+ if (a === "--json")
914
+ json = true;
915
+ else if (a === "--reverse") {
916
+ const v = args[i + 1];
917
+ if (v === undefined || v.startsWith("--")) {
918
+ fail("--reverse needs a decision key (any-doctor decisions lists them)");
919
+ return 1;
920
+ }
921
+ reverse = v;
922
+ i += 1;
923
+ }
924
+ else if (a.startsWith("--")) {
925
+ fail(`any-doctor decisions: unknown flag ${a} (known: --json, --reverse <key>)`);
926
+ return 1;
927
+ }
928
+ else
929
+ targetDir = path.resolve(a);
930
+ }
931
+ const loaded = loadDecisions(targetDir);
932
+ if (!loaded.ok) {
933
+ fail(loaded.error);
934
+ return 1;
935
+ }
936
+ if (reverse !== undefined) {
937
+ const r = reverseByKey(targetDir, reverse);
938
+ if (!r.ok) {
939
+ fail(r.error);
940
+ return 1;
941
+ }
942
+ ok("decision reversed — the finding returns to the active list on the next scan");
943
+ return 0;
944
+ }
945
+ if (json) {
946
+ // Machine surface: JSON in EVERY state (empty included — prose here
947
+ // broke parsers), keys shell-safe (base64url, matching decisionKey —
948
+ // raw keys hold NULs argv cannot carry).
949
+ console.log(JSON.stringify({
950
+ schema: 1,
951
+ decisions: loaded.decisions.map((d) => ({ ...d, key: encodeDecisionKey(d.key) })),
952
+ }, null, 2));
953
+ return 0;
954
+ }
955
+ if (loaded.decisions.length === 0) {
956
+ console.log(dim("no decisions recorded — they are created from the dashboard (a/x) or any-doctor decide"));
957
+ return 0;
958
+ }
959
+ // Bounded output: a wall of decisions is a denial of service on the
960
+ // reader; --json is the unbounded export path.
961
+ const LIST_CAP = 500;
962
+ for (const d of loaded.decisions.slice(0, LIST_CAP)) {
963
+ console.log(`${d.disposition === "accepted" ? "✓ accepted" : "⊘ not-applicable"} ${d.checkKey} ${d.file}:${d.line}`);
964
+ console.log(dim(` reason: ${d.reason}`));
965
+ console.log(dim(` actor: ${d.actor} · updated ${d.updatedAt} · key: ${encodeDecisionKey(d.key)}`));
966
+ }
967
+ if (loaded.decisions.length > LIST_CAP) {
968
+ console.log(dim(`… and ${loaded.decisions.length - LIST_CAP} more — any-doctor decisions --json`));
969
+ }
970
+ return 0;
971
+ }
972
+ // Reverse by exact key or a unique prefix (full identity keys are long).
973
+ function reverseByKey(targetDir, key) {
974
+ const loaded = loadDecisions(targetDir);
975
+ if (!loaded.ok)
976
+ return loaded;
977
+ // The printed keys are base64url-encoded (raw keys hold NULs argv
978
+ // cannot carry); match encoded-prefix-unique, encoded-exact, or raw.
979
+ const candidates = [];
980
+ const decoded = decodeDecisionKey(key);
981
+ if (decoded !== null)
982
+ candidates.push(decoded);
983
+ candidates.push(key);
984
+ for (const candidate of candidates) {
985
+ const exact = loaded.decisions.find((d) => d.key === candidate);
986
+ if (exact !== undefined)
987
+ return reverseDecision(targetDir, exact.key);
988
+ }
989
+ for (const candidate of candidates) {
990
+ const prefixed = loaded.decisions.filter((d) => encodeDecisionKey(d.key).startsWith(candidate) || d.key.startsWith(candidate));
991
+ if (prefixed.length === 1)
992
+ return reverseDecision(targetDir, prefixed[0].key);
993
+ if (prefixed.length > 1)
994
+ return { ok: false, error: `key prefix is ambiguous (${prefixed.length} decisions) — use more characters` };
995
+ }
996
+ return { ok: false, error: "no decision matches that key — any-doctor decisions lists them (copy the printed key)" };
997
+ }
557
998
  const STOP_WORDS = new Set(["a", "an", "the", "find", "flag", "all", "that", "which", "is", "are", "in", "on", "of", "to", "and", "or", "not"]);
558
999
  function slugify(intent) {
559
1000
  const words = intent.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").trim().split(/\s+/);
@@ -566,9 +1007,13 @@ function usage() {
566
1007
  console.log(' generate "<intent>" [--global] print the exact prompt for your agent to build a doctor');
567
1008
  console.log(" run [--all] [--include-tests] [doctor.(m)js] [dir] scan; no argument = every doctor in one review tree");
568
1009
  console.log(" verify [--all] [doctor.(m)js] fixture gate (no doctor: fuzzy picker; --all: every doctor)");
1010
+ console.log(" decide (--key K | --file F --line N [--check C]) (--accepted|--not-applicable) --reason R");
1011
+ console.log(" record a decision on a finding (resolves by scanning unless --key)");
1012
+ console.log(" decisions [dir] [--json] [--reverse K] list remembered decisions; reverse one");
569
1013
  console.log("");
570
1014
  console.log(dim("doctors live in ./doctors/ (repo), ~/.any-doctor/doctors/ (global), and the bundled pack (lowest priority)."));
571
1015
  console.log(dim("generation delegates to your installed agent — run and verify never touch a model."));
1016
+ console.log(dim("agents: 'any-doctor help agents' prints the machine interface (JSON scan, decide, decisions)."));
572
1017
  }
573
1018
  export async function main(argv = process.argv.slice(2)) {
574
1019
  const major = Number(process.versions.node.split(".")[0]);
@@ -579,6 +1024,15 @@ export async function main(argv = process.argv.slice(2)) {
579
1024
  const cmd = argv[0];
580
1025
  const rest = argv.slice(1);
581
1026
  if (cmd === "help" || cmd === "--help") {
1027
+ if (rest[0] === "agents") {
1028
+ const doc = agentUsageText();
1029
+ if (doc === null) {
1030
+ fail("agent usage doc not found (skill/agent-usage.md missing).");
1031
+ return 1;
1032
+ }
1033
+ console.log(doc.trimEnd());
1034
+ return 0;
1035
+ }
582
1036
  usage();
583
1037
  return 0;
584
1038
  }
@@ -594,6 +1048,10 @@ export async function main(argv = process.argv.slice(2)) {
594
1048
  return await cmdRun(rest);
595
1049
  if (cmd === "verify")
596
1050
  return await cmdVerify(rest);
1051
+ if (cmd === "decide")
1052
+ return await cmdDecide(rest);
1053
+ if (cmd === "decisions")
1054
+ return await cmdDecisions(rest);
597
1055
  }
598
1056
  catch (e) {
599
1057
  if (e instanceof ExitCode)
package/bin/cohort.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface CohortSpec {
9
9
  targetDir: string;
10
10
  includeTests: boolean;
11
11
  skippedUnsafe?: readonly string[];
12
+ broken?: readonly import("./discover.js").BrokenDoctor[];
12
13
  }
13
14
  export type DoctorExecutor = typeof runDoctorCohort;
14
15
  export declare function runCohort(spec: CohortSpec, onProgress?: (p: CohortProgress) => void, exec?: DoctorExecutor): Promise<RunOutcome>;