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 +8 -4
- package/bin/cli.js +428 -10
- package/bin/contract.d.ts +7 -0
- package/bin/contract.js +9 -0
- package/bin/dashboard.d.ts +44 -1
- package/bin/dashboard.js +264 -28
- package/bin/diff.d.ts +4 -11
- package/bin/diff.js +7 -90
- package/bin/doctor-tree.js +2 -2
- package/bin/engine.d.ts +1 -0
- package/bin/engine.js +45 -13
- package/bin/finding-state.d.ts +83 -0
- package/bin/finding-state.js +202 -0
- package/bin/identity.d.ts +12 -0
- package/bin/identity.js +74 -1
- package/bin/palette.d.ts +1 -0
- package/bin/palette.js +1 -1
- package/bin/prompts.d.ts +1 -1
- package/bin/prompts.js +11 -7
- package/bin/report.d.ts +40 -2
- package/bin/report.js +51 -19
- package/bin/review.d.ts +47 -0
- package/bin/review.js +112 -0
- package/bin/runner.js +12 -1
- package/bin/scan-capture.d.ts +21 -0
- package/bin/scan-capture.js +90 -0
- package/bin/score.d.ts +2 -0
- package/bin/score.js +9 -2
- package/bin/summary.js +6 -0
- package/docs/HANDOFF.md +17 -2
- package/docs/decisions.md +105 -0
- package/docs/features.md +11 -0
- package/package.json +1 -1
- package/skill/agent-usage.md +78 -0
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
|
|
364
|
-
|
|
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
|
|
373
|
-
changed
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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({
|
|
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;
|
package/bin/dashboard.d.ts
CHANGED
|
@@ -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
|
|
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;
|