@codedology/code-forge 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/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge block open|attempt|rebase|claim|close|stop` (plan §4.9). The skill calls
|
|
3
|
+
* `block open` before every spawn in every engine; it prints the brief pointer (≤ 200 bytes).
|
|
4
|
+
*
|
|
5
|
+
* block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file>
|
|
6
|
+
* [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>]
|
|
7
|
+
* block attempt <id> --run <r>
|
|
8
|
+
* block rebase <id> --run <r> [--head <ref>]
|
|
9
|
+
* block claim <id> <path> --run <r>
|
|
10
|
+
* block close <id> --run <r> [--worker-pid <pid>]
|
|
11
|
+
* block stop <id> --run <r> --reason <text>
|
|
12
|
+
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9)
|
|
13
|
+
*
|
|
14
|
+
* `block close [--transcript <file>] [--no-require-reviews]` also runs B12b's review rows
|
|
15
|
+
* (`review/gate-check.mjs`): a signed `review.approved` row for the current content hash of every
|
|
16
|
+
* changed owned file (deletions included) — `--no-require-reviews` is the human's way out, is
|
|
17
|
+
* forbidden to coders (`mergeForbidden`), and leaves a signed `gate.reviews_waived {by: human}`
|
|
18
|
+
* row before the close (never a silent skip); late findings ruled; `review_cap` findings waived; no
|
|
19
|
+
* `rule_break`, plus the transcript grep. The transcript is `--transcript`, else the block's
|
|
20
|
+
* `transcript` in the run record, else `.code-forge/runs/<run>/<block>.log`; none ⇒ a signed
|
|
21
|
+
* `gate.transcript_missing` row and a WARN line (never a silent pass, never a refusal). An id the
|
|
22
|
+
* run does not know is `unknown_block` before any check runs. B20: every changed owned file of
|
|
23
|
+
* tier `high` (§7.1: `proof.tiers.high.paths`, a recorded `risk ≥ 2`, or `security_sensitive`)
|
|
24
|
+
* needs a signed, proven red→green `proof` row (`checkBlockProof`), else `unproven <file>`; the
|
|
25
|
+
* human's way out is `block waive <id> proof --file <path>`. The high paths are the union of the
|
|
26
|
+
* `.code-forge.yml` at the block's base and the current one (`highPathsAt`); either that exists
|
|
27
|
+
* but does not load or validate refuses the close (a typo must never drop a high path), and a
|
|
28
|
+
* base that is missing or not a commit of the workspace refuses it too (`git-failed`).
|
|
29
|
+
*
|
|
30
|
+
* `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
|
|
31
|
+
* rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
|
|
32
|
+
* through `closeBlock`'s `extraChecks`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { parse as parseYAML } from 'yaml';
|
|
38
|
+
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
39
|
+
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, stopBlock } from '../state/block.mjs';
|
|
40
|
+
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
41
|
+
import { StateError } from '../state/paths.mjs';
|
|
42
|
+
import { findOverlap } from '../state/registry.mjs';
|
|
43
|
+
import { readRun, writeSigned } from '../state/run.mjs';
|
|
44
|
+
import { loadKey } from '../state/signer.mjs';
|
|
45
|
+
import { blockFileSet, reviewGateCheck, waiveFinding } from '../review/gate-check.mjs';
|
|
46
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
47
|
+
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
48
|
+
import { validateConfig } from '../config/validate.mjs';
|
|
49
|
+
import { migrateConfig } from '../config/migrate.mjs';
|
|
50
|
+
import { exec } from '../util/exec.mjs';
|
|
51
|
+
import { gitChildEnv } from '../worker/ticket.mjs';
|
|
52
|
+
|
|
53
|
+
const USAGE =
|
|
54
|
+
'usage: code-forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>]\n' +
|
|
55
|
+
' code-forge block attempt|rebase|close <id> --run <r> · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
|
|
56
|
+
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n';
|
|
57
|
+
|
|
58
|
+
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'];
|
|
59
|
+
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript'];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string[]} args
|
|
63
|
+
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe}} [deps]
|
|
64
|
+
* @returns {Promise<number>}
|
|
65
|
+
*/
|
|
66
|
+
export async function runBlock(args, deps = {}) {
|
|
67
|
+
const { stdout = process.stdout, stderr = process.stderr, probe } = deps;
|
|
68
|
+
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
69
|
+
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
70
|
+
const [sub, ...rest] = args;
|
|
71
|
+
if (!SUBCOMMANDS.includes(sub)) {
|
|
72
|
+
err(USAGE);
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned'], booleans: ['no-require-reviews'] });
|
|
77
|
+
const [id, extra] = positionals;
|
|
78
|
+
const runId = typeof flags.run === 'string' ? flags.run : undefined;
|
|
79
|
+
if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
|
|
80
|
+
if (sub === 'open' && (typeof flags.acceptance !== 'string' || !Array.isArray(flags.owned) || typeof flags.level !== 'string')) {
|
|
81
|
+
throw new StateError('usage', 'block open needs --level, --owned and --acceptance');
|
|
82
|
+
}
|
|
83
|
+
if (sub === 'claim' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block claim needs exactly one path');
|
|
84
|
+
if ((sub === 'stop' || sub === 'waive') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
|
|
85
|
+
if (sub === 'waive' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block waive needs exactly one finding id');
|
|
86
|
+
if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
|
|
87
|
+
const record = await readRun(runId);
|
|
88
|
+
const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
89
|
+
|
|
90
|
+
if (sub === 'open') {
|
|
91
|
+
const acceptance = parseYAML(await readFile(/** @type {string} */ (flags.acceptance), 'utf8'), { prettyErrors: false });
|
|
92
|
+
let brief;
|
|
93
|
+
if (typeof flags.brief === 'string') {
|
|
94
|
+
const absolute = await realpath(flags.brief);
|
|
95
|
+
const relative = path.relative(record.workspace, absolute);
|
|
96
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new StateError('usage', '--brief must be a file inside the workspace');
|
|
97
|
+
brief = { path: relative, content: await readFile(absolute) };
|
|
98
|
+
}
|
|
99
|
+
const { block, pointer } = await openBlock({
|
|
100
|
+
runId,
|
|
101
|
+
id,
|
|
102
|
+
level: /** @type {string} */ (flags.level),
|
|
103
|
+
owned: /** @type {string[]} */ (flags.owned),
|
|
104
|
+
acceptance,
|
|
105
|
+
attempt: intFlag(flags.attempt, 'attempt'),
|
|
106
|
+
base: typeof flags.base === 'string' ? flags.base : undefined,
|
|
107
|
+
lines: intFlag(flags.lines, 'lines') ?? null,
|
|
108
|
+
brief,
|
|
109
|
+
writeRow,
|
|
110
|
+
});
|
|
111
|
+
out(`block ${id} open · ${block.level} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}\n`);
|
|
112
|
+
out(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (sub === 'attempt') {
|
|
116
|
+
out(`block ${id} attempt ${await attemptBlock({ runId, id, writeRow })}\n`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (sub === 'rebase') {
|
|
120
|
+
const moved = await rebaseBlock({ runId, id, head: typeof flags.head === 'string' ? flags.head : undefined, writeRow });
|
|
121
|
+
out(`block ${id} rebased ${moved.old_base.slice(0, 12)} → ${moved.new_base.slice(0, 12)}\n`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
if (sub === 'claim') {
|
|
125
|
+
await claimPath({ runId, id, file: extra, writeRow });
|
|
126
|
+
out(`block ${id} claimed ${extra}\n`);
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
if (sub === 'stop') {
|
|
130
|
+
await stopBlock({ runId, id, reason: /** @type {string} */ (flags.reason), writeRow });
|
|
131
|
+
out(`block ${id} stopped\n`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
if (sub === 'waive') {
|
|
135
|
+
await waiveFinding({ runId, id, finding: extra, reason: /** @type {string} */ (flags.reason), file: /** @type {string} */ (flags.file), writeRow });
|
|
136
|
+
out(`block ${id} waived ${extra} in ${flags.file} (by: human)\n`);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
if (sub === 'close') {
|
|
140
|
+
const entry = record.blocks?.[id];
|
|
141
|
+
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
142
|
+
const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
|
|
143
|
+
const reviewsWaived = flags['no-require-reviews'] === true;
|
|
144
|
+
const reviews = reviewGateCheck(async () => {
|
|
145
|
+
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
146
|
+
if (transcript === null) {
|
|
147
|
+
await writeSigned(runId, writeRow, { event: 'gate.transcript_missing', block: id });
|
|
148
|
+
err(`WARN block ${id}: no coder transcript found (--transcript, run record, .code-forge/runs/${runId}/${id}.log); the transcript grep did not run\n`);
|
|
149
|
+
}
|
|
150
|
+
// the human's way out of the per-file review check leaves a signed audit row before the close
|
|
151
|
+
if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
|
|
152
|
+
// B20: the high paths come first — a base the workspace does not know refuses the close here
|
|
153
|
+
const highPaths = await highPathsAt(record.workspace, entry.base_sha);
|
|
154
|
+
const fileSet = await blockFileSet({ repoRoot: record.workspace, base: entry.base_sha, owned: entry.owned_files, matchOwned: (f) => findOverlap(entry.owned_files, [f]) !== null });
|
|
155
|
+
return {
|
|
156
|
+
block: id,
|
|
157
|
+
runId,
|
|
158
|
+
rows,
|
|
159
|
+
key: await loadKey(runId),
|
|
160
|
+
transcript,
|
|
161
|
+
files: reviewsWaived ? [] : fileSet,
|
|
162
|
+
// B20: high-tier files need a proven red→green row; `--no-require-reviews` does not lift it
|
|
163
|
+
proofFiles: fileSet,
|
|
164
|
+
highPaths,
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews] });
|
|
168
|
+
(result.ok ? out : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
169
|
+
return result.ok ? 0 : 1;
|
|
170
|
+
}
|
|
171
|
+
} catch (thrown) {
|
|
172
|
+
err(`block ${sub ?? ''}: ${thrown?.message ?? String(thrown)}\n`);
|
|
173
|
+
return thrown instanceof StateError && thrown.code === 'usage' ? 2 : 1;
|
|
174
|
+
}
|
|
175
|
+
err(USAGE);
|
|
176
|
+
return 2;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The block's coder transcript: `--transcript`, else the run record's `blocks.<id>.transcript`
|
|
181
|
+
* (workspace-relative), else `.code-forge/runs/<run>/<id>.log`; null when none exists.
|
|
182
|
+
* @param {Record<string, any>} record @param {string} id @param {string | null} given
|
|
183
|
+
* @returns {Promise<string | null>}
|
|
184
|
+
*/
|
|
185
|
+
async function findTranscript(record, id, given) {
|
|
186
|
+
if (given !== null) return readFile(given, 'utf8');
|
|
187
|
+
const recorded = record.blocks?.[id]?.transcript;
|
|
188
|
+
const candidates = [
|
|
189
|
+
...(typeof recorded === 'string' && recorded.length > 0 ? [path.resolve(record.workspace, recorded)] : []),
|
|
190
|
+
path.join(record.workspace, '.code-forge', 'runs', record.run_id, `${id}.log`),
|
|
191
|
+
];
|
|
192
|
+
for (const file of candidates) {
|
|
193
|
+
try {
|
|
194
|
+
return await readFile(file, 'utf8');
|
|
195
|
+
} catch (thrown) {
|
|
196
|
+
if (/** @type {any} */ (thrown)?.code !== 'ENOENT') throw thrown;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The key paths of a config's validation errors (never values).
|
|
204
|
+
* @param {Record<string, any>} config @param {string} where
|
|
205
|
+
* @returns {string[]} `proof.tiers.high.paths`
|
|
206
|
+
* @throws {StateError} `bad-config`
|
|
207
|
+
*/
|
|
208
|
+
function validHighPaths(config, where) {
|
|
209
|
+
const result = validateConfig(config);
|
|
210
|
+
if (!result.valid) {
|
|
211
|
+
const at = [...new Set(result.errors.map((e) => (typeof e.path === 'string' ? e.path : e.rule)))].join(', ');
|
|
212
|
+
throw new StateError('bad-config', `${where} is invalid (${result.errors.length} error(s) at ${at}) — run code-forge validate`);
|
|
213
|
+
}
|
|
214
|
+
const paths = config?.proof?.tiers?.high?.paths;
|
|
215
|
+
return Array.isArray(paths) ? paths.filter((p) => typeof p === 'string') : [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* B20: `proof.tiers.high.paths` for the close — the UNION of the `.code-forge.yml` committed at
|
|
220
|
+
* the block's base (read with `git show <base>:.code-forge.yml`, argv only, GIT_* stripped) and
|
|
221
|
+
* the one in the working tree, so a coder that edits or deletes the high paths in its block can
|
|
222
|
+
* only ever add to them. Fails closed on the base: a base that is not a non-empty string, or that
|
|
223
|
+
* `git rev-parse --verify` does not know as a commit, refuses the close (`git-failed`), and so does
|
|
224
|
+
* any git call that times out, fails to spawn or exits non-zero. The base config counts as absent
|
|
225
|
+
* ONLY when a clean `git ls-tree <base> -- .code-forge.yml` lists nothing; the current config may
|
|
226
|
+
* be absent too. A side that exists but does not load or validate refuses the close (key paths
|
|
227
|
+
* only, never values).
|
|
228
|
+
* @param {string} workspace @param {string | undefined} base
|
|
229
|
+
* @returns {Promise<string[]>}
|
|
230
|
+
* @throws {StateError} `bad-config`, `git-failed`
|
|
231
|
+
*/
|
|
232
|
+
export async function highPathsAt(workspace, base) {
|
|
233
|
+
if (typeof base !== 'string' || base.length === 0 || base.startsWith('-')) throw new StateError('git-failed', 'the block has no base commit — the close cannot read the base config');
|
|
234
|
+
const paths = new Set();
|
|
235
|
+
const loaded = await loadProjectConfig(workspace);
|
|
236
|
+
if (loaded.ok && loaded.config) for (const p of validHighPaths(loaded.config, DEFAULT_CONFIG_FILENAME)) paths.add(p);
|
|
237
|
+
else if (loaded.error !== 'not-found') throw new StateError('bad-config', `${DEFAULT_CONFIG_FILENAME} does not load (${loaded.error ?? 'no config'}) — run code-forge validate`);
|
|
238
|
+
|
|
239
|
+
const git = { cwd: workspace, env: gitChildEnv(), timeoutMs: 30000 };
|
|
240
|
+
const known = await exec(['git', 'rev-parse', '--verify', '--quiet', `${base}^{commit}`], git);
|
|
241
|
+
if (known.result !== 'ok') throw new StateError('git-failed', 'the block base is not a commit of the workspace — the close cannot read the base config');
|
|
242
|
+
const listed = await exec(['git', 'ls-tree', base, '--', DEFAULT_CONFIG_FILENAME], git);
|
|
243
|
+
if (listed.result !== 'ok') throw new StateError('git-failed', `git could not check for ${DEFAULT_CONFIG_FILENAME} at the block base`);
|
|
244
|
+
if (listed.stdout.trim().length === 0) return [...paths]; // a clean git said the base has no config
|
|
245
|
+
const spec = `${base}:${DEFAULT_CONFIG_FILENAME}`;
|
|
246
|
+
const shown = await exec(['git', 'show', spec], git);
|
|
247
|
+
if (shown.result !== 'ok') throw new StateError('git-failed', `git could not read ${DEFAULT_CONFIG_FILENAME} at the block base`);
|
|
248
|
+
const where = `${DEFAULT_CONFIG_FILENAME} at the block base`;
|
|
249
|
+
let parsed;
|
|
250
|
+
try {
|
|
251
|
+
parsed = migrateConfig(parseYAML(shown.stdout, { prettyErrors: false }));
|
|
252
|
+
} catch {
|
|
253
|
+
throw new StateError('bad-config', `${where} does not load — run code-forge validate`);
|
|
254
|
+
}
|
|
255
|
+
for (const p of validHighPaths(parsed, where)) paths.add(p);
|
|
256
|
+
return [...paths];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @param {string[]} args @returns {Promise<number>} */
|
|
260
|
+
export default async function block(args) {
|
|
261
|
+
return runBlock(args);
|
|
262
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge doctor [--quick] [--json] [--cwd <dir>]` (plan §2.3; block B13b).
|
|
3
|
+
*
|
|
4
|
+
* Prints one row per check (`<STATUS> <label>: <detail>`) on stdout, or with `--json` ONE JSON
|
|
5
|
+
* document `{ok, counts, rows}`; a one-line summary goes to stderr. Exit 0 when no row FAILs,
|
|
6
|
+
* 1 when one does, 2 on a usage error. The full run adopts a private temp root for its probes
|
|
7
|
+
* (every child it spawns is registered there) and removes it before exiting.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { rmSync } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { loadProjectConfig } from '../config/load.mjs';
|
|
14
|
+
import { runDoctor } from '../doctor/index.mjs';
|
|
15
|
+
import { counts, exitCode, renderJSON, renderText } from '../doctor/rows.mjs';
|
|
16
|
+
import { parseFlags } from '../state/cli-args.mjs';
|
|
17
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
18
|
+
import { runRoot, setRunRoot } from '../util/tmp.mjs';
|
|
19
|
+
|
|
20
|
+
const USAGE = 'usage: code-forge doctor [--quick] [--json] [--cwd <dir>]\n';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string[]} args
|
|
24
|
+
* @param {import('../doctor/index.mjs').DoctorDeps & {stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
|
|
25
|
+
* @param {boolean} [adopt] - the real verb: adopt one temp root so exec registers every probe
|
|
26
|
+
* child there (in-process callers keep their own run root).
|
|
27
|
+
* @returns {Promise<number>}
|
|
28
|
+
*/
|
|
29
|
+
export async function runDoctorCli(args, deps = {}, adopt = false) {
|
|
30
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
31
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = parseFlags(args, { values: ['cwd'], booleans: ['quick', 'json'] });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
writeSafe(stderr, `doctor: ${err.message}\n${USAGE}`);
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
if (parsed.positionals.length > 0) {
|
|
40
|
+
writeSafe(stderr, USAGE);
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
const { flags } = parsed;
|
|
44
|
+
const cwd = path.resolve(typeof flags.cwd === 'string' ? flags.cwd : process.cwd());
|
|
45
|
+
/** @type {string | undefined} */
|
|
46
|
+
let root;
|
|
47
|
+
if (adopt && !flags.quick) {
|
|
48
|
+
const loaded = await loadProjectConfig(cwd);
|
|
49
|
+
const tmpRoot = loaded.ok && typeof loaded.config?.tmp?.root === 'string' ? loaded.config.tmp.root : undefined;
|
|
50
|
+
try {
|
|
51
|
+
root = runRoot(`doctor-${process.pid}-${randomBytes(3).toString('hex')}`, { root: tmpRoot });
|
|
52
|
+
setRunRoot(root);
|
|
53
|
+
} catch {
|
|
54
|
+
root = undefined; // runDoctor reports the missing temp root as a FAIL row
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const rows = await runDoctor({ cwd, quick: flags.quick === true, ...(root ? { runRootDir: root } : {}) }, deps);
|
|
59
|
+
writeSafe(stdout, flags.json ? renderJSON(rows) : renderText(rows));
|
|
60
|
+
const c = counts(rows);
|
|
61
|
+
writeSafe(stderr, `doctor: ${c.OK} ok, ${c.WARN} warn, ${c.FAIL} fail\n`);
|
|
62
|
+
return exitCode(rows);
|
|
63
|
+
} finally {
|
|
64
|
+
if (root) {
|
|
65
|
+
setRunRoot(null);
|
|
66
|
+
rmSync(root, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {string[]} args @returns {Promise<number>} */
|
|
72
|
+
export default async function doctor(args) {
|
|
73
|
+
return runDoctorCli(args, {}, true);
|
|
74
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge facts --brief <file> [--sources <path>…] [--out <file>] [--run <id>] [--timeout <s>]`
|
|
3
|
+
* (plan §3.8 step 1–3; block B9b). Builds the facts sheet: claim tokens, one L0 read-only delegate
|
|
4
|
+
* session (run in a read-only snapshot of the project at HEAD plus the in-project `--sources`,
|
|
5
|
+
* B9b.1), validation, render. Default `--out`: `<project.plans_dir | plans>/<brief name>.facts.md`.
|
|
6
|
+
* Exit codes: 0 written, 1 the session failed, the answer was refused, a file vanished mid-run or
|
|
7
|
+
* the run raised a `SessionError`, 2 usage (only pre-run argument errors: flags, config, an
|
|
8
|
+
* unreadable `--brief` / `--sources`, a bad `--run` id), 3 L0 and every fallback unavailable
|
|
9
|
+
* (`exitCodeFor`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { loadProjectConfig } from '../config/load.mjs';
|
|
15
|
+
import { buildFacts, FactsError } from '../session/facts.mjs';
|
|
16
|
+
import { runRootFor, SessionError } from '../session/spawn.mjs';
|
|
17
|
+
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
18
|
+
import { StateError } from '../state/paths.mjs';
|
|
19
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
20
|
+
import { slugFor } from './run.mjs';
|
|
21
|
+
import { exitCodeFor } from './spawn.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} file @param {{fileOnly?: boolean}} [opts] - `fileOnly`: a directory does not count.
|
|
25
|
+
* @returns {boolean} a readable file (or directory, unless `fileOnly`).
|
|
26
|
+
*/
|
|
27
|
+
function readable(file, { fileOnly = false } = {}) {
|
|
28
|
+
try {
|
|
29
|
+
accessSync(file, constants.R_OK);
|
|
30
|
+
const st = statSync(file);
|
|
31
|
+
return fileOnly ? st.isFile() : st.isFile() || st.isDirectory();
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const USAGE = 'usage: code-forge facts --brief <file> [--sources <path>...] [--out <file>] [--run <id>] [--timeout <seconds>]\n';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string[]} args
|
|
41
|
+
* @param {import('../session/spawn.mjs').SessionDeps & {stdout?: {write: (s: string) => unknown}, cwd?: string}} [deps]
|
|
42
|
+
* @returns {Promise<number>}
|
|
43
|
+
*/
|
|
44
|
+
export async function runFactsVerb(args, deps = {}) {
|
|
45
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
46
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
47
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
48
|
+
try {
|
|
49
|
+
const { flags, positionals } = parseFlags(args, { values: ['brief', 'out', 'run', 'timeout'], multi: ['sources'] });
|
|
50
|
+
if (positionals.length > 0 || typeof flags.brief !== 'string') {
|
|
51
|
+
writeSafe(stderr, USAGE);
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
const loaded = await loadProjectConfig(cwd);
|
|
55
|
+
if (!loaded.ok) {
|
|
56
|
+
writeSafe(stderr, `facts: ${loaded.message}\n`);
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
const cfg = /** @type {Record<string, any>} */ (loaded.config);
|
|
60
|
+
const briefPath = path.resolve(cwd, flags.brief);
|
|
61
|
+
const plansDir = path.resolve(cwd, cfg.project?.plans_dir ?? 'plans');
|
|
62
|
+
const outPath = typeof flags.out === 'string' ? path.resolve(cwd, flags.out) : path.join(plansDir, `${path.basename(briefPath, path.extname(briefPath))}.facts.md`);
|
|
63
|
+
const sources = Array.isArray(flags.sources) ? flags.sources.map((s) => path.resolve(cwd, s)) : [];
|
|
64
|
+
// --brief must be a regular file; a --sources path may be a file or a directory
|
|
65
|
+
if (!readable(briefPath, { fileOnly: true })) {
|
|
66
|
+
writeSafe(stderr, 'facts: --brief cannot be read\n');
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
for (const source of sources) {
|
|
70
|
+
if (!readable(source)) {
|
|
71
|
+
writeSafe(stderr, 'facts: --sources path cannot be read\n');
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const timeout = intFlag(flags.timeout, 'timeout');
|
|
76
|
+
const run = typeof flags.run === 'string' ? flags.run : undefined;
|
|
77
|
+
// pre-run argument checks: a bad --run id or an unusable project state is usage (exit 2)
|
|
78
|
+
const runRoot = run ? runRootFor(run, cfg?.tmp?.root) : undefined;
|
|
79
|
+
const slug = slugFor(cfg, cwd);
|
|
80
|
+
let result;
|
|
81
|
+
try {
|
|
82
|
+
result = await buildFacts(
|
|
83
|
+
{ cfg, briefPath, sources, outPath, run, runRoot, slug, projectDir: cwd, timeoutMs: timeout === undefined ? undefined : timeout * 1000 },
|
|
84
|
+
{ ...deps, stderr },
|
|
85
|
+
);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (err instanceof FactsError) {
|
|
88
|
+
writeSafe(stderr, `facts: ${err.message}\n`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
if (err instanceof SessionError) {
|
|
92
|
+
// raised by the run, not by the arguments: a session failure (3 only when it says unavailable)
|
|
93
|
+
writeSafe(stderr, `facts: ${err.message}\n`);
|
|
94
|
+
return exitCodeFor({ status: err.code === 'unavailable' ? 'unavailable' : 'failed' });
|
|
95
|
+
}
|
|
96
|
+
const e = /** @type {NodeJS.ErrnoException} */ (err);
|
|
97
|
+
if (e.code === 'ENOENT' || e.code === 'EACCES' || e.code === 'EISDIR') {
|
|
98
|
+
// the inputs were readable files a moment ago (checked above): this is a run failure, not usage
|
|
99
|
+
const kind = e.path === briefPath ? 'the brief' : sources.includes(e.path ?? '') ? 'a source' : 'a working file';
|
|
100
|
+
writeSafe(stderr, `facts: ${kind} could not be read while building the sheet (${e.code})\n`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
if (result.status !== 'ok') {
|
|
106
|
+
writeSafe(stderr, `facts: ${result.status}${result.reason ? ` (${result.reason})` : ''}\n`);
|
|
107
|
+
return exitCodeFor(result);
|
|
108
|
+
}
|
|
109
|
+
const facts = result.facts ?? [];
|
|
110
|
+
writeSafe(stdout, `${JSON.stringify({ out: outPath, claims: result.claims.length, verified: facts.filter((f) => f.tag === 'VERIFIED').length })}\n`);
|
|
111
|
+
return 0;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err instanceof StateError || err instanceof SessionError) {
|
|
114
|
+
writeSafe(stderr, `facts: ${/** @type {Error} */ (err).message}\n`);
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @param {string[]} args */
|
|
122
|
+
export default async function factsVerb(args) {
|
|
123
|
+
return runFactsVerb(args);
|
|
124
|
+
}
|