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 +8 -4
- package/bin/cli.js +474 -16
- package/bin/cohort.d.ts +1 -0
- package/bin/cohort.js +5 -1
- package/bin/contract.d.ts +8 -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/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/prompts.d.ts +1 -1
- package/bin/prompts.js +11 -7
- package/bin/report.d.ts +40 -2
- package/bin/report.js +64 -21
- package/bin/review.d.ts +47 -0
- package/bin/review.js +112 -0
- package/bin/scan-capture.d.ts +21 -0
- package/bin/scan-capture.js +90 -0
- package/bin/score.d.ts +1 -0
- package/bin/score.js +2 -2
- package/bin/summary.d.ts +3 -0
- package/bin/summary.js +5 -2
- package/docs/HANDOFF.md +17 -2
- package/docs/decisions.md +105 -0
- package/docs/features.md +11 -0
- package/doctors/async-doctor.mjs +3 -0
- package/doctors/convex-doctor.mjs +18 -3
- package/doctors/effect-v4-doctor.mjs +10 -0
- package/doctors/openrouter-doctor.mjs +5 -0
- package/doctors/slop-doctor.mjs +8 -0
- package/package.json +1 -1
- package/skill/agent-usage.md +97 -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,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 {
|
|
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
|
-
|
|
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((
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|