@bigsteele/the-prospect 0.2.0 → 0.3.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/README.md +40 -17
- package/dist/check.d.ts +20 -6
- package/dist/check.js +77 -23
- package/dist/cli.js +105 -27
- package/dist/detect/costs.js +48 -6
- package/dist/detect/database.js +30 -5
- package/dist/detect/deadweight.js +60 -15
- package/dist/detect/handrolled.js +23 -14
- package/dist/detect/stack.d.ts +2 -0
- package/dist/detect/types.d.ts +33 -0
- package/dist/detect/vendors.js +95 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +24 -1
- package/dist/report.js +28 -9
- package/dist/score.js +9 -5
- package/dist/verdicts.d.ts +80 -0
- package/dist/verdicts.js +159 -0
- package/package.json +1 -1
- package/prompt/THE-PROSPECT.md +168 -119
- package/dist/detect/deps 2.d.ts +0 -25
- package/dist/detect/deps 2.js +0 -198
- package/dist/detect/types 2.d.ts +0 -106
- package/dist/detect/types 2.js +0 -11
- package/dist/walk 2.d.ts +0 -44
- package/dist/walk 2.js +0 -123
package/README.md
CHANGED
|
@@ -48,32 +48,55 @@ Step 0 runs offline, reads everything, and writes two files into your repo:
|
|
|
48
48
|
- **Your industry fingerprint** - the domain vocabulary your own schema and
|
|
49
49
|
routes use, which is what the second half researches against.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
Step 0 takes seconds, and it is a hypothesis machine: it reads shapes and
|
|
52
|
+
prints them, every one with an id. The read is the second command.
|
|
52
53
|
|
|
53
54
|
```
|
|
54
|
-
npx @bigsteele/the-prospect --run
|
|
55
|
+
npx @bigsteele/the-prospect --run # or --protocol, to paste THE-PROSPECT.md into any agent
|
|
55
56
|
```
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
This is a large read, not a quick check, and it says so before it starts.
|
|
59
|
+
Claude Code opens with the protocol and the agent, read-only: establishes
|
|
60
|
+
your North Star from the pricing page, the schema and the code before it
|
|
61
|
+
judges anything; takes every Step 0 finding to the code and rules on it,
|
|
62
|
+
CONFIRMED with evidence, REFUTED with the sentence that names the shape
|
|
63
|
+
that lied, ON_RECORD with the deciding line, or UNKNOWN with what would
|
|
64
|
+
settle it; walks the whole tree for the inventories the scan cannot finish
|
|
65
|
+
on its own - every vendor with the job it does and whose money it touches,
|
|
66
|
+
every subsystem built by hand and what keeping it buys, every dependency's
|
|
67
|
+
job, every unreached file's real status, every multiplying call with a
|
|
68
|
+
price where one is public, the database rulings against the grants, and
|
|
69
|
+
the shape of the bill; researches your market in five lanes with sources
|
|
70
|
+
and dates; ranks everything against the North Star; and writes
|
|
71
|
+
**The Prospect - <App Name>.md** at the repository root with the next ten
|
|
72
|
+
actions, the inventories in full, the evidence register, and the math.
|
|
73
|
+
|
|
74
|
+
Every suggestion keeps the fixed shape - *Since you* (a fact from your
|
|
75
|
+
code, file cited), *Have you considered* (always two options, or one vendor
|
|
76
|
+
against building it yourself), *Because* (the industry fact, with a source
|
|
77
|
+
URL and the year it was checked), *Your customer gets* (the benefit in the
|
|
78
|
+
customer's terms), *First test* (a one-week test needing nobody's
|
|
79
|
+
permission, and the observation that would kill the idea). A suggestion
|
|
80
|
+
missing any leg is cut, not softened. And:
|
|
69
81
|
|
|
70
82
|
```
|
|
71
83
|
npx @bigsteele/the-prospect --check
|
|
72
84
|
```
|
|
73
85
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
is the second opinion. It reads the deliverable and
|
|
87
|
+
`.planning/prospect/VERDICTS.json`, fails any finding without a ruling,
|
|
88
|
+
any ruling without evidence, any evidence the report does not show, any
|
|
89
|
+
unsourced or undated claim, any single-vendor pitch, any advice verb, and
|
|
90
|
+
recomputes the score from the verdicts with the math printed: a refuted
|
|
91
|
+
finding costs nothing, a recorded one costs nothing, an unknown one still
|
|
92
|
+
does. Exit 0 when it holds, 2 when it does not.
|
|
93
|
+
|
|
94
|
+
The scan's Lane 1 is calibrated on real repositories and the calibration
|
|
95
|
+
notes say what it got wrong and when it was fixed: a category is not a job
|
|
96
|
+
(Stripe running Connect beside Square billing the platform is two jobs),
|
|
97
|
+
one `.ilike()` is not a search engine, a socket is not a paid call, a
|
|
98
|
+
`revoke ... from anon, authenticated` in a migration is a caller check, and
|
|
99
|
+
a shadcn component nobody imports is scaffold, weighed a quarter.
|
|
77
100
|
|
|
78
101
|
## Standalone
|
|
79
102
|
|
package/dist/check.d.ts
CHANGED
|
@@ -7,20 +7,34 @@
|
|
|
7
7
|
* is a model. So the three-legged law is not a request in a prompt, it is
|
|
8
8
|
* a set of greps:
|
|
9
9
|
*
|
|
10
|
-
* - every R&D suggestion carries all
|
|
11
|
-
* - every "Because" line carries a source URL and a
|
|
10
|
+
* - every R&D suggestion carries all six anatomy lines, and names its lane
|
|
11
|
+
* - every "Because" line carries a source URL and a dated check
|
|
12
12
|
* - every "Have you considered" line carries at least two options
|
|
13
13
|
* - banned register never appears (advice verbs, em dashes, horoscope)
|
|
14
|
-
* - READ and RESEARCHED badges both exist
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* - READ and RESEARCHED badges both exist
|
|
15
|
+
*
|
|
16
|
+
* And, once the protocol has run (0.3.1, from the first real deliverable):
|
|
17
|
+
*
|
|
18
|
+
* - "Show the math" holds the gate's OWN output and says the verdicts are
|
|
19
|
+
* complete. The first deliverable typed "Final: 82/100" by hand while the
|
|
20
|
+
* gate said 86 and "not final", and the chat said 86. Nothing on the page
|
|
21
|
+
* is typed by hand.
|
|
22
|
+
* - Lane 2 names a lane per suggestion and at least one is about the MARKET
|
|
23
|
+
* (table stakes, adjacent value, regulatory), because three internal
|
|
24
|
+
* cleanups with docs links passed the three-legged grep and taught the
|
|
25
|
+
* founder nothing about the territory.
|
|
26
|
+
* - the inventories are at least as long as the scan's counts: every vendor,
|
|
27
|
+
* every runtime dependency, every unreached file, every multiplying call.
|
|
28
|
+
* "Every" is a number the scan already knows.
|
|
17
29
|
*/
|
|
30
|
+
import type { Prospect } from "./index.js";
|
|
18
31
|
export interface CheckFinding {
|
|
19
32
|
where: string;
|
|
20
33
|
problem: string;
|
|
21
34
|
line?: string;
|
|
22
35
|
}
|
|
23
|
-
export declare
|
|
36
|
+
export declare const LANES: readonly ["rails", "table stakes", "adjacent value", "regulatory", "stack cut"];
|
|
37
|
+
export declare function checkReport(md: string, scan?: Prospect): {
|
|
24
38
|
pass: boolean;
|
|
25
39
|
findings: CheckFinding[];
|
|
26
40
|
};
|
package/dist/check.js
CHANGED
|
@@ -1,20 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The gate: measure a FINISHED Prospect report - the one the agent wrote -
|
|
3
|
-
* and fail it mechanically before a founder ever reads it.
|
|
4
|
-
*
|
|
5
|
-
* The family lesson from every scan before this one: a quality bar nothing
|
|
6
|
-
* measures drifts straight back to the writer's habits, and the writer here
|
|
7
|
-
* is a model. So the three-legged law is not a request in a prompt, it is
|
|
8
|
-
* a set of greps:
|
|
9
|
-
*
|
|
10
|
-
* - every R&D suggestion carries all five anatomy lines
|
|
11
|
-
* - every "Because" line carries a source URL and a year
|
|
12
|
-
* - every "Have you considered" line carries at least two options
|
|
13
|
-
* - banned register never appears (advice verbs, em dashes, horoscope)
|
|
14
|
-
* - READ and RESEARCHED badges both exist - a report with no RESEARCHED
|
|
15
|
-
* line skipped the research, and a report with no READ line skipped
|
|
16
|
-
* the repository
|
|
17
|
-
*/
|
|
18
1
|
const BANNED = [
|
|
19
2
|
{ name: "advice (you should)", re: /\byou should\b/i },
|
|
20
3
|
{ name: "advice (you must)", re: /\byou must\b/i },
|
|
@@ -24,7 +7,27 @@ const BANNED = [
|
|
|
24
7
|
{ name: "horoscope register", re: /\b(game.?changer|revolutioni[sz]e|unlock the power|supercharge|10x your)\b/i },
|
|
25
8
|
];
|
|
26
9
|
const ANATOMY = ["Since you", "Have you considered", "Because", "Your customer gets", "First test"];
|
|
27
|
-
export
|
|
10
|
+
export const LANES = ["rails", "table stakes", "adjacent value", "regulatory", "stack cut"];
|
|
11
|
+
const MARKET_LANES = new Set(["table stakes", "adjacent value", "regulatory"]);
|
|
12
|
+
/** A dated check: 2026-09, 2026-09-14, September 2026, Sep 2026. A bare year is a decade. */
|
|
13
|
+
const DATED = /\b20\d{2}-\d{2}(-\d{2})?\b|\b(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+20\d{2}\b/i;
|
|
14
|
+
/** Rows of the first markdown table under a heading, header and rule excluded. */
|
|
15
|
+
function tableRows(section) {
|
|
16
|
+
const lines = section.split("\n").filter((l) => /^\s*\|/.test(l));
|
|
17
|
+
if (lines.length < 2)
|
|
18
|
+
return 0;
|
|
19
|
+
return lines.filter((l) => !/^\s*\|\s*-{2,}/.test(l)).length - 1;
|
|
20
|
+
}
|
|
21
|
+
function sectionAfter(md, heading) {
|
|
22
|
+
const m = heading.exec(md);
|
|
23
|
+
if (!m)
|
|
24
|
+
return null;
|
|
25
|
+
const rest = md.slice(m.index + m[0].length);
|
|
26
|
+
const level = (m[0].match(/^#+/m)?.[0].length ?? 2);
|
|
27
|
+
const next = new RegExp(`^#{1,${level}} `, "m").exec(rest);
|
|
28
|
+
return next ? rest.slice(0, next.index) : rest;
|
|
29
|
+
}
|
|
30
|
+
export function checkReport(md, scan) {
|
|
28
31
|
const findings = [];
|
|
29
32
|
for (const b of BANNED) {
|
|
30
33
|
const lines = md.split("\n");
|
|
@@ -37,8 +40,9 @@ export function checkReport(md) {
|
|
|
37
40
|
findings.push({ where: "whole report", problem: "no [READ] badge - the repository half is missing or unmarked" });
|
|
38
41
|
}
|
|
39
42
|
// Lane 2: each suggestion block (### heading under the territory section).
|
|
40
|
-
|
|
41
|
-
const
|
|
43
|
+
// Step 0 titles the lane "what the territory holds"; the protocol's deliverable titles it "Lane 2, researched".
|
|
44
|
+
const lane2Heading = /^## .*(territory|lane 2).*$/im.exec(md)?.[0] ?? "";
|
|
45
|
+
const lane2 = md.split(/^## .*(?:territory|lane 2).*$/im)[1]?.split(/^## /m)[0] ?? "";
|
|
42
46
|
const ranProtocol = !/not yet run/i.test(lane2Heading + lane2);
|
|
43
47
|
if (ranProtocol && lane2.trim()) {
|
|
44
48
|
if (!/\[RESEARCHED\]/.test(lane2Heading + lane2)) {
|
|
@@ -48,6 +52,7 @@ export function checkReport(md) {
|
|
|
48
52
|
if (blocks.length === 0) {
|
|
49
53
|
findings.push({ where: "Lane 2", problem: "protocol ran but no suggestion blocks found (### headings)" });
|
|
50
54
|
}
|
|
55
|
+
const lanesSeen = new Set();
|
|
51
56
|
blocks.forEach((block, i) => {
|
|
52
57
|
const name = block.split("\n")[0]?.trim().slice(0, 60) ?? `suggestion ${i + 1}`;
|
|
53
58
|
for (const part of ANATOMY) {
|
|
@@ -55,13 +60,21 @@ export function checkReport(md) {
|
|
|
55
60
|
findings.push({ where: name, problem: `missing anatomy line: "${part}"` });
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
|
-
// The
|
|
63
|
+
// The lane, named. A suggestion that cannot say which lane it came from
|
|
64
|
+
// came from the code, not the market.
|
|
65
|
+
const lane = /(?:\*\*Lane\*\*|^Lane)[:\s]*([a-z ]+)/im.exec(block)?.[1]?.trim().toLowerCase();
|
|
66
|
+
const known = lane ? LANES.find((l) => lane.startsWith(l)) : undefined;
|
|
67
|
+
if (!known)
|
|
68
|
+
findings.push({ where: name, problem: `missing or unknown Lane line - one of: ${LANES.join(", ")}` });
|
|
69
|
+
else
|
|
70
|
+
lanesSeen.add(known);
|
|
71
|
+
// The Because leg needs a source and a dated check, or it is a vibe.
|
|
59
72
|
const because = /(?:\*\*Because\*\*|^Because)[:\s]([\s\S]*?)(?=\n\s*(?:\*\*|$))/im.exec(block)?.[1] ?? "";
|
|
60
73
|
if (because && !/https?:\/\//.test(because)) {
|
|
61
74
|
findings.push({ where: name, problem: "Because line has no source URL" });
|
|
62
75
|
}
|
|
63
|
-
if (because &&
|
|
64
|
-
findings.push({ where: name, problem: "Because line has no
|
|
76
|
+
if (because && !DATED.test(because)) {
|
|
77
|
+
findings.push({ where: name, problem: "Because line has no dated check (year and month at least) - an undated industry claim is a rumour" });
|
|
65
78
|
}
|
|
66
79
|
// Two options, or it reads as an ad.
|
|
67
80
|
const considered = /(?:\*\*Have you considered\*\*|^Have you considered)[:\s]([\s\S]*?)(?=\n\s*(?:\*\*|$))/im.exec(block)?.[1] ?? "";
|
|
@@ -72,6 +85,47 @@ export function checkReport(md) {
|
|
|
72
85
|
if (blocks.length > 5) {
|
|
73
86
|
findings.push({ where: "Lane 2", problem: `${blocks.length} suggestions - the strongest five belong here, the rest in an appendix` });
|
|
74
87
|
}
|
|
88
|
+
if (blocks.length && ![...lanesSeen].some((l) => MARKET_LANES.has(l))) {
|
|
89
|
+
findings.push({ where: "Lane 2", problem: "no suggestion from a market lane (table stakes, adjacent value, regulatory) - the territory was not researched, only the code re-read" });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// THE PROTOCOL'S DELIVERABLE, measured against the scan it stands on.
|
|
93
|
+
const deliverable = /^## (Your next ten actions|The inventories|Evidence register)/im.test(md);
|
|
94
|
+
if (deliverable) {
|
|
95
|
+
const math = sectionAfter(md, /^## Show the math\s*$/im);
|
|
96
|
+
if (math === null)
|
|
97
|
+
findings.push({ where: "Show the math", problem: "section missing - run `--check` and paste its output verbatim" });
|
|
98
|
+
else if (!/THE PROSPECT - recomputed from/.test(math))
|
|
99
|
+
findings.push({ where: "Show the math", problem: "not the gate's own output - nothing on the page is typed by hand; run `--check` and paste it verbatim" });
|
|
100
|
+
else if (!/verdicts: complete/.test(math))
|
|
101
|
+
findings.push({ where: "Show the math", problem: "the pasted math says the verdicts are not complete - fix the rulings, run `--check` again, paste again" });
|
|
102
|
+
const inv = sectionAfter(md, /^## The inventories\s*$/im);
|
|
103
|
+
if (inv === null)
|
|
104
|
+
findings.push({ where: "The inventories", problem: "section missing" });
|
|
105
|
+
else {
|
|
106
|
+
const need = [
|
|
107
|
+
{ heading: /^### Every vendor\b.*$/im, label: "Every vendor", min: scan?.vendors.length ?? 0 },
|
|
108
|
+
{ heading: /^### Every subsystem built by hand\b.*$/im, label: "Every subsystem built by hand", min: 0 },
|
|
109
|
+
{ heading: /^### Every dependency\b.*$/im, label: "Every dependency", min: scan?.totals.runtime_deps ?? 0 },
|
|
110
|
+
{ heading: /^### Every file no entrypoint reaches\b.*$/im, label: "Every file no entrypoint reaches", min: scan?.dead.length ?? 0 },
|
|
111
|
+
{ heading: /^### Every duplicate cluster\b.*$/im, label: "Every duplicate cluster", min: scan?.duplicates.filter((d) => !d.deliberate && !d.parallel).length ?? 0 },
|
|
112
|
+
{ heading: /^### Every call whose cost multiplies\b.*$/im, label: "Every call whose cost multiplies", min: scan?.cost_surfaces.length ?? 0 },
|
|
113
|
+
{ heading: /^### The database\b.*$/im, label: "The database", min: 0 },
|
|
114
|
+
{ heading: /^### The bill\b.*$/im, label: "The bill", min: 0 },
|
|
115
|
+
];
|
|
116
|
+
for (const n of need) {
|
|
117
|
+
const sec = sectionAfter(inv, n.heading);
|
|
118
|
+
if (sec === null) {
|
|
119
|
+
findings.push({ where: "The inventories", problem: `missing "### ${n.label}"` });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (n.min > 0) {
|
|
123
|
+
const rows = tableRows(sec);
|
|
124
|
+
if (rows < n.min)
|
|
125
|
+
findings.push({ where: `The inventories / ${n.label}`, problem: `${rows} row(s); the scan counted ${n.min} - "every" is a number, and this is not it` });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
75
129
|
}
|
|
76
130
|
return { pass: findings.length === 0, findings };
|
|
77
131
|
}
|
package/dist/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ import { mkdir, writeFile, readFile, appendFile } from "node:fs/promises";
|
|
|
17
17
|
import { dirname, join, resolve } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
-
import { runProspect, toMarkdown, secretShaped, checkReport, VERSION } from "./index.js";
|
|
20
|
+
import { runProspect, toMarkdown, secretShaped, checkReport, checkVerdicts, rescore, showMath, VERSION } from "./index.js";
|
|
21
21
|
const log = (s = "") => process.stdout.write(s + "\n");
|
|
22
22
|
const args = process.argv.slice(2);
|
|
23
23
|
const has = (f) => args.includes(f);
|
|
@@ -27,15 +27,17 @@ const valueOf = (f) => {
|
|
|
27
27
|
};
|
|
28
28
|
const HELP = `The Prospect ${VERSION} - a prospector's read of your codebase and your market.
|
|
29
29
|
|
|
30
|
-
npx @bigsteele/the-prospect [dir] Step 0: scan and report (offline, read-only)
|
|
31
|
-
npx @bigsteele/the-prospect --run open Claude Code with the
|
|
32
|
-
npx @bigsteele/the-prospect --
|
|
30
|
+
npx @bigsteele/the-prospect [dir] Step 0: scan and report (offline, read-only, seconds)
|
|
31
|
+
npx @bigsteele/the-prospect --run open Claude Code with the protocol (the deep read: long)
|
|
32
|
+
npx @bigsteele/the-prospect --protocol drop THE-PROSPECT.md into the repo to paste into any agent
|
|
33
|
+
npx @bigsteele/the-prospect --check rule on a finished report: verdicts, evidence, legs, the math
|
|
33
34
|
|
|
34
|
-
--repo <dir>
|
|
35
|
-
--out <dir>
|
|
36
|
-
--report <file>
|
|
37
|
-
--
|
|
38
|
-
--
|
|
35
|
+
--repo <dir> the repository to read (default: here)
|
|
36
|
+
--out <dir> where the report goes (default: the repository root)
|
|
37
|
+
--report <file> the report --check should measure (default: the deliverable, else the newest scan)
|
|
38
|
+
--verdicts <file> the verdicts --check should re-score from (default: .planning/prospect/VERDICTS.json)
|
|
39
|
+
--stdout print the report (or, with --protocol, the protocol) instead of writing it
|
|
40
|
+
--yes skip the confirmation on --run
|
|
39
41
|
|
|
40
42
|
Exit 0 when it passes, 2 when it does not, 1 when it refuses.`;
|
|
41
43
|
const slug = (s) => s.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "app";
|
|
@@ -47,42 +49,113 @@ async function main() {
|
|
|
47
49
|
const flagsWithValue = ["--repo", "--out", "--report"];
|
|
48
50
|
const positional = args.find((a) => !a.startsWith("-") && !flagsWithValue.includes(args[args.indexOf(a) - 1] ?? ""));
|
|
49
51
|
const repoDir = resolve(valueOf("--repo") ?? positional ?? process.cwd());
|
|
52
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
const protocol = resolve(here, "..", "prompt", "THE-PROSPECT.md");
|
|
54
|
+
if (has("--protocol")) {
|
|
55
|
+
// The protocol is a file, and the file is the product: drop it where any
|
|
56
|
+
// agent can read it, the way The Big Sean does.
|
|
57
|
+
const text = await readFile(protocol, "utf8");
|
|
58
|
+
if (has("--stdout")) {
|
|
59
|
+
log(text);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
await writeFile(join(repoDir, "THE-PROSPECT.md"), text, "utf8");
|
|
63
|
+
log(`Wrote THE-PROSPECT.md. Open Claude Code in this folder and paste the whole file in, or run --run.`);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
50
66
|
if (has("--check")) {
|
|
51
67
|
const file = valueOf("--report");
|
|
52
68
|
let path = file ? resolve(file) : "";
|
|
53
69
|
if (!path) {
|
|
54
|
-
// The newest
|
|
70
|
+
// The deliverable if the protocol wrote one, else the newest Step 0 report.
|
|
55
71
|
const { readdir } = await import("node:fs/promises");
|
|
56
|
-
const
|
|
57
|
-
|
|
72
|
+
const all = await readdir(repoDir);
|
|
73
|
+
const deliverable = all.filter((n) => /^The Prospect - .*\.md$/.test(n)).sort();
|
|
74
|
+
const step0 = all.filter((n) => /^the-prospect-.*\.md$/.test(n)).sort();
|
|
75
|
+
const pick = deliverable[deliverable.length - 1] ?? step0[step0.length - 1];
|
|
76
|
+
if (!pick) {
|
|
58
77
|
log("No report to check. Run the scan first, then the protocol, then this.");
|
|
59
78
|
return 2;
|
|
60
79
|
}
|
|
61
|
-
path = join(repoDir,
|
|
80
|
+
path = join(repoDir, pick);
|
|
62
81
|
}
|
|
63
82
|
const md = await readFile(path, "utf8");
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
83
|
+
// The scan the deliverable stands on, when it can be found: the inventories
|
|
84
|
+
// are measured against its counts.
|
|
85
|
+
let scanForMd;
|
|
86
|
+
try {
|
|
87
|
+
const { readdir } = await import("node:fs/promises");
|
|
88
|
+
const names = (await readdir(repoDir)).filter((n) => /^the-prospect-.*\.json$/.test(n)).sort();
|
|
89
|
+
if (names.length)
|
|
90
|
+
scanForMd = JSON.parse(await readFile(join(repoDir, names[names.length - 1]), "utf8"));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// no scan beside the report; the inventories are checked for presence only
|
|
68
94
|
}
|
|
95
|
+
const { pass, findings } = checkReport(md, scanForMd);
|
|
69
96
|
for (const f of findings)
|
|
70
97
|
log(`FAIL ${f.where}: ${f.problem}${f.line ? ` | ${f.line}` : ""}`);
|
|
98
|
+
// THE VERDICTS, WHEN THE PROTOCOL LEFT THEM (0.3). Every Step 0 finding
|
|
99
|
+
// ruled on, every ruling with evidence, and the score recomputed from the
|
|
100
|
+
// rulings with the math printed. Missing verdicts fail the check the moment
|
|
101
|
+
// a verdicts file exists at all: half a ruling is no ruling.
|
|
102
|
+
const verdictsPath = resolve(valueOf("--verdicts") ?? join(repoDir, ".planning", "prospect", "VERDICTS.json"));
|
|
103
|
+
let verdictProblems = 0;
|
|
104
|
+
let scanPath = "";
|
|
105
|
+
try {
|
|
106
|
+
const v = JSON.parse(await readFile(verdictsPath, "utf8"));
|
|
107
|
+
scanPath = v.scan ? resolve(repoDir, v.scan) : "";
|
|
108
|
+
if (!scanPath) {
|
|
109
|
+
const { readdir } = await import("node:fs/promises");
|
|
110
|
+
const names = (await readdir(repoDir)).filter((n) => /^the-prospect-.*\.json$/.test(n)).sort();
|
|
111
|
+
scanPath = names.length ? join(repoDir, names[names.length - 1]) : "";
|
|
112
|
+
}
|
|
113
|
+
if (!scanPath) {
|
|
114
|
+
log(`FAIL verdicts: no scan JSON to rule on (the-prospect-<app>.json)`);
|
|
115
|
+
verdictProblems++;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const scan = JSON.parse(await readFile(scanPath, "utf8"));
|
|
119
|
+
const c = checkVerdicts(scan, v, md);
|
|
120
|
+
for (const p of c.problems)
|
|
121
|
+
log(`FAIL verdict: ${p}`);
|
|
122
|
+
verdictProblems = c.problems.length;
|
|
123
|
+
const r = rescore(scan, v);
|
|
124
|
+
log("");
|
|
125
|
+
log(showMath(scan, v, r, c));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
if (e.code !== "ENOENT") {
|
|
130
|
+
log(`FAIL verdicts: ${verdictsPath} could not be read: ${e instanceof Error ? e.message : String(e)}`);
|
|
131
|
+
verdictProblems++;
|
|
132
|
+
}
|
|
133
|
+
else if (/^The Prospect - /.test(path.split("/").pop() ?? "")) {
|
|
134
|
+
log(`FAIL verdicts: the protocol's deliverable exists but ${verdictsPath} does not; every finding needs a ruling`);
|
|
135
|
+
verdictProblems++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (pass && verdictProblems === 0) {
|
|
139
|
+
log("");
|
|
140
|
+
log(`${path} holds the law: every finding ruled on with evidence, every suggestion on three legs, every claim sourced.`);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
71
143
|
log("");
|
|
72
|
-
log(`${findings.length} finding(s). A suggestion missing a leg is cut, not softened.`);
|
|
144
|
+
log(`${findings.length + verdictProblems} finding(s). A suggestion missing a leg is cut, not softened; a finding without a ruling is unchecked.`);
|
|
73
145
|
return 2;
|
|
74
146
|
}
|
|
75
147
|
if (has("--run")) {
|
|
76
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
77
|
-
const protocol = resolve(here, "..", "prompt", "THE-PROSPECT.md");
|
|
78
148
|
if (!has("--yes")) {
|
|
79
149
|
log("");
|
|
80
|
-
log("
|
|
81
|
-
log("It
|
|
82
|
-
log("
|
|
150
|
+
log("BEFORE YOU RUN THIS, KNOW WHAT IT IS. This is a large scan, not a quick check.");
|
|
151
|
+
log("It opens Claude Code with the protocol: the agent reads everything in this repository it can");
|
|
152
|
+
log("reach, takes every Step 0 finding to the code and rules on it with evidence, inventories every");
|
|
153
|
+
log("vendor, subsystem and dependency, researches your market with sources and dates, and writes a");
|
|
154
|
+
log("long report. Expect a long session and a lot of tokens. It changes nothing: no edits, no commits,");
|
|
155
|
+
log("no deploys, no messages, no spending. Run the scan first so the facts exist. Ctrl-C to stop.");
|
|
83
156
|
log("");
|
|
84
157
|
}
|
|
85
|
-
const child = spawn("claude", [`Follow the protocol in ${protocol}. The repository is ${repoDir}.`], {
|
|
158
|
+
const child = spawn("claude", [`Follow the protocol in ${protocol}, every step, to the end. The repository is ${repoDir}.`], {
|
|
86
159
|
stdio: "inherit",
|
|
87
160
|
shell: false,
|
|
88
161
|
});
|
|
@@ -141,11 +214,16 @@ async function main() {
|
|
|
141
214
|
log(`${p.score.total}/100 (${p.score.grade}) - Level ${p.score.level.n}: ${p.score.level.name}.`);
|
|
142
215
|
if (noRef)
|
|
143
216
|
log(`${noRef} of ${p.totals.runtime_deps} runtime dependencies show no reference anywhere.`);
|
|
144
|
-
|
|
145
|
-
|
|
217
|
+
const paidTwice = p.overlaps.filter((o) => !o.distinct).length;
|
|
218
|
+
const twoJobs = p.overlaps.length - paidTwice;
|
|
219
|
+
if (paidTwice)
|
|
220
|
+
log(`${paidTwice} categor${paidTwice > 1 ? "ies" : "y"} of work paid for twice.`);
|
|
221
|
+
if (twoJobs)
|
|
222
|
+
log(`${twoJobs} vendor pair${twoJobs > 1 ? "s" : ""} sharing a category but doing different jobs: listed, not charged.`);
|
|
146
223
|
if (p.handrolled.length)
|
|
147
224
|
log(`${p.handrolled.length} subsystem(s) built by hand where the market sells a rail.`);
|
|
148
|
-
log(`
|
|
225
|
+
log(`This was the scan: seconds, shapes, every finding with an id. The read is the next command.`);
|
|
226
|
+
log(`Next: npx @bigsteele/the-prospect --run (the deep read: every finding ruled on, every vendor and subsystem inventoried, the market researched)`);
|
|
149
227
|
return 0;
|
|
150
228
|
}
|
|
151
229
|
main().then((code) => process.exit(code), (err) => {
|
package/dist/detect/costs.js
CHANGED
|
@@ -1,15 +1,57 @@
|
|
|
1
1
|
import { scopeFor } from "../walk.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* A PAID call, which is the whole point (0.2.1). `\.send\(` on its own matched
|
|
4
|
+
* `ws.send(...)` to a local Chrome DevTools socket in four build scripts, and
|
|
5
|
+
* the report billed them as four paid loops. A call is paid when it names a
|
|
6
|
+
* paid host, a paid SDK method, or a sender object that is plainly a mail or
|
|
7
|
+
* SMS client. A socket is not a vendor.
|
|
8
|
+
*/
|
|
9
|
+
const PAID_CALL = /\bfetch\(\s*[`"']https?:\/\/(api\.openai\.com|api\.anthropic\.com|generativelanguage\.googleapis\.com|api\.stripe\.com|api\.twilio\.com|api\.resend\.com|api\.sendgrid\.com|api\.cloudflare\.com|api\.replicate\.com|api\.elevenlabs\.io)|\.(messages|completions|chat|embeddings|images|predictions)\.create\(|generateContent|\b(sendEmail|sendMail|sendSms)\(|\b(resend|sendgrid|sgMail|postmark|mailgun|transporter|mailer|twilio|ses|sns)\.\w*[sS]end\w*\(|\.emails\.send\(/;
|
|
10
|
+
const LOOP_HEAD = /\bfor(\s+await)?\s*\(|\bwhile\s*\(|\.(map|forEach|flatMap)\(\s*(async\b|\()/;
|
|
4
11
|
const HANDLER = /(^|\/)(functions|api|routes?|handlers?)\//i;
|
|
5
12
|
const SCHEDULE = /\bsetInterval\s*\(|\bcron\b|schedule/i;
|
|
6
13
|
function hostOrCall(line) {
|
|
7
14
|
const host = /https?:\/\/([a-z0-9.-]+)/i.exec(line)?.[1];
|
|
8
15
|
if (host)
|
|
9
16
|
return host;
|
|
10
|
-
const call = /\.((?:messages|completions|chat|embeddings|images)\.create|generateContent|send
|
|
17
|
+
const call = /\.((?:messages|completions|chat|embeddings|images|predictions)\.create|generateContent|emails\.send|\w*[sS]end\w*)\(/.exec(line)?.[1];
|
|
11
18
|
return call ?? "external call";
|
|
12
19
|
}
|
|
20
|
+
const count = (s, re) => (s.match(re) ?? []).length;
|
|
21
|
+
/**
|
|
22
|
+
* INSIDE the loop, not below it (0.2.1). "A loop head within the twelve lines
|
|
23
|
+
* above" read `slots.map((s) => s.role).join(', ')` - a prompt being built - as
|
|
24
|
+
* the loop around the Gemini call three lines later, and reported the call as
|
|
25
|
+
* per-row twice on the first repository it met. A loop encloses the call only
|
|
26
|
+
* if the brackets it opened are still open where the call is made.
|
|
27
|
+
*/
|
|
28
|
+
function insideLoop(lines, at) {
|
|
29
|
+
for (let j = at - 1; j >= Math.max(0, at - 80); j--) {
|
|
30
|
+
if (!LOOP_HEAD.test(lines[j]))
|
|
31
|
+
continue;
|
|
32
|
+
// Walk forward from the loop head until the brackets it opened close. If
|
|
33
|
+
// they have not closed by the call, the call is inside. Stopping at the
|
|
34
|
+
// close means the call's OWN `fetch(` on the line above its URL is never
|
|
35
|
+
// mistaken for the loop still being open.
|
|
36
|
+
let depth = 0;
|
|
37
|
+
let opened = false;
|
|
38
|
+
let closed = false;
|
|
39
|
+
for (let k = j; k < at && !closed; k++) {
|
|
40
|
+
const opens = count(lines[k], /[{(]/g);
|
|
41
|
+
depth += opens - count(lines[k], /[})]/g);
|
|
42
|
+
// The head line opening and closing on the same line (`.map((x) => x.id).join(', ')`)
|
|
43
|
+
// is a loop that already closed; without this, the walk ran on past it and
|
|
44
|
+
// tripped over a prose parenthesis inside a prompt string forty lines later.
|
|
45
|
+
if ((k === j && opens > 0) || depth > 0)
|
|
46
|
+
opened = true;
|
|
47
|
+
if (opened && depth <= 0)
|
|
48
|
+
closed = true;
|
|
49
|
+
}
|
|
50
|
+
if (opened && !closed)
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
13
55
|
export async function detectCosts(repo) {
|
|
14
56
|
const out = [];
|
|
15
57
|
for (const f of scopeFor(repo.files, "implementation")) {
|
|
@@ -23,13 +65,13 @@ export async function detectCosts(repo) {
|
|
|
23
65
|
continue;
|
|
24
66
|
const target = hostOrCall(line);
|
|
25
67
|
const evidence = `${i + 1}: ${line.trim().slice(0, 110)}`;
|
|
26
|
-
// per-row:
|
|
27
|
-
|
|
28
|
-
if (LOOP_HEAD.test(above)) {
|
|
68
|
+
// per-row: the call sits inside a loop that has not closed.
|
|
69
|
+
if (insideLoop(lines, i)) {
|
|
29
70
|
out.push({ file: f, shape: "per-row", target, line: evidence });
|
|
30
71
|
continue;
|
|
31
72
|
}
|
|
32
73
|
// per-schedule: the call sits under an interval or a cron marker.
|
|
74
|
+
const above = lines.slice(Math.max(0, i - 12), i).join("\n");
|
|
33
75
|
if (SCHEDULE.test(above) || (SCHEDULE.test(text.slice(0, 800)) && SCHEDULE.test(f))) {
|
|
34
76
|
out.push({ file: f, shape: "per-schedule", target, line: evidence });
|
|
35
77
|
continue;
|
package/dist/detect/database.js
CHANGED
|
@@ -75,9 +75,21 @@ export async function detectDatabase(repo) {
|
|
|
75
75
|
const findings = [];
|
|
76
76
|
let policies = 0;
|
|
77
77
|
let definerFunctions = 0;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
/**
|
|
79
|
+
* THE GRANTS ARE IN THE MIGRATIONS (0.2.1). The caller check was looked for
|
|
80
|
+
* inside the function body only, and 60 definer functions on the first real
|
|
81
|
+
* repository were reported as stating no check when each one was followed by
|
|
82
|
+
* `revoke all on function ... from public, anon, authenticated` - a caller
|
|
83
|
+
* check by another statement. Postgres grants EXECUTE to public by default,
|
|
84
|
+
* so a revoke from public/anon/authenticated is the statement that narrows
|
|
85
|
+
* who may call; a bare `grant execute ... to service_role` narrows nothing.
|
|
86
|
+
*/
|
|
87
|
+
const executeRevoked = new Set();
|
|
88
|
+
let guard;
|
|
89
|
+
// Two passes: every `alter table` and every `revoke` in the repository is
|
|
90
|
+
// collected first, because a table is very often created in one migration
|
|
91
|
+
// and secured in the next, and reading them in file order would flag every
|
|
92
|
+
// such table.
|
|
81
93
|
for (const f of files) {
|
|
82
94
|
const raw = await repo.read(f);
|
|
83
95
|
if (!raw)
|
|
@@ -90,6 +102,16 @@ export async function detectDatabase(repo) {
|
|
|
90
102
|
if (/enable/i.test(m[2]))
|
|
91
103
|
rlsEnabled.add(t);
|
|
92
104
|
}
|
|
105
|
+
for (const m of sql.matchAll(/revoke\s+(?:all|execute)(?:\s+privileges)?\s+on\s+function\s+([a-z0-9_."]+)\s*(?:\([^)]*\))?\s+from\s+([a-z_,\s"]+)/gi)) {
|
|
106
|
+
if (/\b(public|anon|authenticated)\b/i.test(m[2]))
|
|
107
|
+
executeRevoked.add(qualify(m[1]));
|
|
108
|
+
}
|
|
109
|
+
for (const g of sql.matchAll(/create\s+(?:or\s+replace\s+)?function\s+([a-z0-9_."]*(?:definer_guard|guard_scan|grant_scan)[a-z0-9_]*)\s*\(/gi)) {
|
|
110
|
+
const name = `${qualify(g[1])}()`;
|
|
111
|
+
// The one that names definers wins; otherwise the first guard seen.
|
|
112
|
+
if (!guard || (/definer/i.test(name) && !/definer/i.test(guard)))
|
|
113
|
+
guard = name;
|
|
114
|
+
}
|
|
93
115
|
}
|
|
94
116
|
for (const f of files) {
|
|
95
117
|
const raw = await repo.read(f);
|
|
@@ -134,12 +156,13 @@ export async function detectDatabase(repo) {
|
|
|
134
156
|
if (/create\s+(or\s+replace\s+)?function/i.test(stmt) && /security\s+definer/i.test(stmt)) {
|
|
135
157
|
definerFunctions++;
|
|
136
158
|
const name = /create\s+(?:or\s+replace\s+)?function\s+([a-z0-9_."]+)\s*\(/i.exec(stmt)?.[1];
|
|
137
|
-
if (name && !CALLER_CHECK.test(withComments)) {
|
|
159
|
+
if (name && !CALLER_CHECK.test(withComments) && !executeRevoked.has(qualify(name))) {
|
|
138
160
|
findings.push({
|
|
139
161
|
kind: "definer_without_check",
|
|
140
162
|
subject: qualify(name),
|
|
141
163
|
file: f,
|
|
142
|
-
note: "runs with the definer's rights
|
|
164
|
+
note: "runs with the definer's rights, states no caller check in its body, and no migration revokes its EXECUTE from public, anon or authenticated. Either it is meant to be callable by anyone, or a grant applied outside the migrations is the only thing standing between it and one." +
|
|
165
|
+
(guard ? ` The repository carries \`${guard}\`, which checks the live grants; this scan reads only what the migrations state.` : ""),
|
|
143
166
|
});
|
|
144
167
|
}
|
|
145
168
|
}
|
|
@@ -168,6 +191,8 @@ export async function detectDatabase(repo) {
|
|
|
168
191
|
tables: tablesSeen.size,
|
|
169
192
|
policies,
|
|
170
193
|
definer_functions: definerFunctions,
|
|
194
|
+
definer_execute_revoked: executeRevoked.size,
|
|
195
|
+
guard,
|
|
171
196
|
findings: findings.sort((a, b) => a.kind.localeCompare(b.kind) || a.subject.localeCompare(b.subject)).slice(0, 60),
|
|
172
197
|
};
|
|
173
198
|
}
|