@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,517 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The first real use of code-forge (plan v1.3 §10.4 B17, R13): one L1 block, "add `clamp()` with
|
|
4
|
+
* tests", end to end on a COPY of the example repository shipped in this package
|
|
5
|
+
* (`examples/node-lib/`). Never on this package's tree, never on a project of the user's: the copy
|
|
6
|
+
* lives in `<tmp-root>/first-use/<run>/`, where `<tmp-root>` is `--tmp-root` or the default temp
|
|
7
|
+
* base (`<os tmpdir>/code-forge`, the directory run roots live in). A given `--tmp-root` is also
|
|
8
|
+
* written to the copy's `tmp.root`, so the worker's and the sessions' run roots sit beside the copy.
|
|
9
|
+
*
|
|
10
|
+
* node scripts/first-use.mjs [--engine harness|subprocess] [--coder scripted|harness|spawn]
|
|
11
|
+
* [--tmp-root <abs dir>] [--run <id>] [--until <step>]
|
|
12
|
+
*
|
|
13
|
+
* Steps (each prints `== <n>. <step> ==`):
|
|
14
|
+
* 1 copy copy the example, `git init`, one base commit, `.env.example` → `.env` (untracked)
|
|
15
|
+
* 2 init `code-forge init --no-interaction …` writes `.code-forge.yml` in the copy; the
|
|
16
|
+
* engine banner of `skill/references/degraded.md` §2 is printed once
|
|
17
|
+
* 3 facts `code-forge facts --brief plans/clamp.md` (the facts rule: before any author)
|
|
18
|
+
* 4 author `code-forge author --job plan --brief … --facts …` → `plans/clamp.plan.md`
|
|
19
|
+
* 5 start `code-forge run start` (launches the review worker)
|
|
20
|
+
* 6 open `code-forge block open B1 --level L1 --owned … --acceptance … --brief …`
|
|
21
|
+
* 7 code the coder: `scripted` applies `examples/first-use/coder/` (the automated test);
|
|
22
|
+
* `harness` waits for the harness subagent (Agent tool) to finish — it appends its
|
|
23
|
+
* sentinel line to `.code-forge/runs/<run>/B1.log`; `spawn` runs
|
|
24
|
+
* `code-forge spawn --role coder` (engine subprocess)
|
|
25
|
+
* 8 review `code-forge review-file <file>` + `review-file --wait` per owned file
|
|
26
|
+
* 9 proof `proof export` (lists the export-ignore paths it restored), the test gate inside the
|
|
27
|
+
* export, `proof red-green` per test the acceptance names, `proof export --remove`
|
|
28
|
+
* 10 close `code-forge block close B1` — then the cost of the completed block is printed
|
|
29
|
+
* 11 report `code-forge report --slug <slug>`
|
|
30
|
+
* then `code-forge run end` (always, once the run started: it stops the worker).
|
|
31
|
+
*
|
|
32
|
+
* Engines: `harness` is the default (R2: the no-Solo default). `subprocess` is OPT-IN: it is
|
|
33
|
+
* never chosen unless `--engine subprocess` is given, and then it is written to `.code-forge.yml`
|
|
34
|
+
* as an explicit config edit (init never writes it, R2). Solo is manual evidence only: this script
|
|
35
|
+
* refuses `--engine solo` — run the skill under Solo instead.
|
|
36
|
+
*
|
|
37
|
+
* Output: the step lines, then one final line `FIRST-USE {json}` (run, copy, slug, approvals,
|
|
38
|
+
* proofs, sections, cost). Exit 0 when every step up to `--until` passed, 1 on a failed step,
|
|
39
|
+
* 2 on usage. Every child is an argv array (never a shell string); git runs with every inherited
|
|
40
|
+
* `GIT_*` stripped and no system/global config.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { randomBytes } from 'node:crypto';
|
|
44
|
+
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import os from 'node:os';
|
|
46
|
+
import path from 'node:path';
|
|
47
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
import { parse as parseYAML, parseDocument } from 'yaml';
|
|
50
|
+
import { readAllRows } from '../src/ledger/write.mjs';
|
|
51
|
+
import { estimateCostUsd } from '../src/ledger/prices.mjs';
|
|
52
|
+
import { exec } from '../src/util/exec.mjs';
|
|
53
|
+
import { runRoot, setRunRoot, tmpBase } from '../src/util/tmp.mjs';
|
|
54
|
+
|
|
55
|
+
const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
56
|
+
const BIN = path.join(PKG, 'bin', 'code-forge.mjs');
|
|
57
|
+
export const EXAMPLE_DIR = path.join(PKG, 'examples', 'node-lib');
|
|
58
|
+
export const INPUTS_DIR = path.join(PKG, 'examples', 'first-use');
|
|
59
|
+
const DEGRADED_DOC = path.join(PKG, 'skill', 'references', 'degraded.md');
|
|
60
|
+
|
|
61
|
+
export const BLOCK = 'B1';
|
|
62
|
+
export const OWNED = Object.freeze(['src/math.mjs', 'test/clamp.test.mjs']);
|
|
63
|
+
export const STEPS = Object.freeze(['copy', 'init', 'facts', 'author', 'start', 'open', 'code', 'review', 'proof', 'close', 'report']);
|
|
64
|
+
const ENGINES = ['harness', 'subprocess'];
|
|
65
|
+
const CODERS = ['scripted', 'harness', 'spawn'];
|
|
66
|
+
const CLI_TIMEOUT_MS = 30 * 60 * 1000;
|
|
67
|
+
const GIT_TIMEOUT_MS = 60 * 1000;
|
|
68
|
+
/** How long `--coder harness` waits for the sentinel line by default (one hour). */
|
|
69
|
+
export const DEFAULT_CODER_TIMEOUT_S = 3600;
|
|
70
|
+
/** The example ships `gitignore` (npm pack drops nested `.gitignore` files); the copy renames it. */
|
|
71
|
+
const SHIPPED_GITIGNORE = 'gitignore';
|
|
72
|
+
|
|
73
|
+
export const USAGE = [
|
|
74
|
+
'usage: node scripts/first-use.mjs [--engine harness|subprocess] [--coder scripted|harness|spawn]',
|
|
75
|
+
' [--tmp-root <abs dir>] [--run <id>] [--until <step>] [--coder-timeout <s>]',
|
|
76
|
+
' --engine harness (default, R2) · subprocess (OPT-IN: never chosen unless given here; the',
|
|
77
|
+
' script writes `engine: subprocess` to the copy\'s .code-forge.yml, which init never',
|
|
78
|
+
' does) · Solo is manual evidence only: run the skill under Solo, not this script',
|
|
79
|
+
' --coder scripted: the deterministic edit in examples/first-use/coder (the automated test)',
|
|
80
|
+
' harness: wait for the harness subagent; it appends its ===BLOCK B1 COMPLETE===',
|
|
81
|
+
' (or FAILED) line to .code-forge/runs/<run>/B1.log (default for harness)',
|
|
82
|
+
' spawn: `code-forge spawn --role coder` (default for subprocess)',
|
|
83
|
+
' --tmp-root where <tmp-root>/first-use/<run>/ is made (default: the code-forge temp base);',
|
|
84
|
+
' when given it is also written to the copy\'s tmp.root, so the run roots live there too',
|
|
85
|
+
` --coder-timeout seconds --coder harness waits for the sentinel line (default ${DEFAULT_CODER_TIMEOUT_S})`,
|
|
86
|
+
` --until stop after this step: ${STEPS.join(' | ')}`,
|
|
87
|
+
].join('\n');
|
|
88
|
+
|
|
89
|
+
class UsageError extends Error {}
|
|
90
|
+
export class StepFailed extends Error {}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string[]} argv
|
|
94
|
+
* @returns {{engine: string, coder: string, tmpRoot: string | undefined, run: string, until: string, coderTimeoutMs: number, help: boolean}}
|
|
95
|
+
* @throws {UsageError}
|
|
96
|
+
*/
|
|
97
|
+
export function parseArgs(argv) {
|
|
98
|
+
/** @type {Record<string, string>} */
|
|
99
|
+
const flags = {};
|
|
100
|
+
let help = false;
|
|
101
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
102
|
+
const arg = argv[i];
|
|
103
|
+
if (arg === '--help' || arg === '-h') {
|
|
104
|
+
help = true;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const name = arg.startsWith('--') ? arg.slice(2) : '';
|
|
108
|
+
if (!['engine', 'coder', 'tmp-root', 'run', 'until', 'coder-timeout'].includes(name)) throw new UsageError(`unknown argument ${JSON.stringify(arg)}`);
|
|
109
|
+
const value = argv[i + 1];
|
|
110
|
+
if (value === undefined || value.startsWith('--')) throw new UsageError(`--${name} needs a value`);
|
|
111
|
+
if (Object.hasOwn(flags, name)) throw new UsageError(`--${name} given more than once`);
|
|
112
|
+
flags[name] = value;
|
|
113
|
+
i += 1;
|
|
114
|
+
}
|
|
115
|
+
const engine = flags.engine ?? 'harness';
|
|
116
|
+
if (engine === 'solo') throw new UsageError('--engine solo: Solo is manual evidence only — run the code-forge skill under Solo, not this script');
|
|
117
|
+
if (!ENGINES.includes(engine)) throw new UsageError(`--engine must be ${ENGINES.join(' or ')}`);
|
|
118
|
+
const coder = flags.coder ?? (engine === 'subprocess' ? 'spawn' : 'harness');
|
|
119
|
+
if (!CODERS.includes(coder)) throw new UsageError(`--coder must be one of ${CODERS.join(', ')}`);
|
|
120
|
+
if (coder === 'spawn' && engine !== 'subprocess') throw new UsageError('--coder spawn needs --engine subprocess');
|
|
121
|
+
if (flags['tmp-root'] !== undefined && !path.isAbsolute(flags['tmp-root'])) throw new UsageError('--tmp-root must be an absolute path');
|
|
122
|
+
const run = flags.run ?? defaultRunId();
|
|
123
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(run)) throw new UsageError('--run must match ^[a-z0-9][a-z0-9-]{0,63}$');
|
|
124
|
+
const until = flags.until ?? 'report';
|
|
125
|
+
if (!STEPS.includes(until)) throw new UsageError(`--until must be one of ${STEPS.join(', ')}`);
|
|
126
|
+
const coderTimeout = flags['coder-timeout'] ?? String(DEFAULT_CODER_TIMEOUT_S);
|
|
127
|
+
if (!/^[1-9]\d{0,5}$/.test(coderTimeout)) throw new UsageError('--coder-timeout must be a whole number of seconds (1..999999)');
|
|
128
|
+
return { engine, coder, tmpRoot: flags['tmp-root'], run, until, coderTimeoutMs: Number(coderTimeout) * 1000, help };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @returns {string} `fu-<yyyymmdd>-<hhmmss>-<hex4>` */
|
|
132
|
+
function defaultRunId() {
|
|
133
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
|
|
134
|
+
return `fu-${stamp}-${randomBytes(2).toString('hex')}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The §2 banner of `skill/references/degraded.md` (the text the skill prints once per run), for
|
|
139
|
+
* this engine.
|
|
140
|
+
* @param {string} engine
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
export function degradedBanner(engine) {
|
|
144
|
+
const doc = readFileSync(DEGRADED_DOC, 'utf8');
|
|
145
|
+
const section = doc.slice(doc.indexOf('## §2'));
|
|
146
|
+
const open = section.indexOf('```\n');
|
|
147
|
+
const close = section.indexOf('\n```', open + 4);
|
|
148
|
+
if (!section.startsWith('## §2') || open < 0 || close < 0) throw new Error('degraded.md §2 has no banner block');
|
|
149
|
+
return section.slice(open + 4, close).replace('<harness|subprocess>', engine);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `process.env` minus every inherited `GIT_*` (a git hook's `GIT_DIR`/`GIT_INDEX_FILE` must never
|
|
154
|
+
* redirect a child into another repository). Every child this script starts gets it.
|
|
155
|
+
* @returns {Record<string, string>}
|
|
156
|
+
*/
|
|
157
|
+
export function scrubbedEnv() {
|
|
158
|
+
/** @type {Record<string, string>} */
|
|
159
|
+
const env = {};
|
|
160
|
+
for (const [key, value] of Object.entries(process.env)) if (!key.startsWith('GIT_') && value !== undefined) env[key] = value;
|
|
161
|
+
return env;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** {@link scrubbedEnv} plus no system/global git config. */
|
|
165
|
+
function gitEnv() {
|
|
166
|
+
return { ...scrubbedEnv(), GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {string} text @returns {any} the last line of `text` parsed as JSON, or null */
|
|
170
|
+
function lastJSON(text) {
|
|
171
|
+
const line = text.trim().split('\n').at(-1) ?? '';
|
|
172
|
+
try {
|
|
173
|
+
return JSON.parse(line);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The cost of one block from its run's ledger rows: every row that carries a token count and a
|
|
181
|
+
* provider + level is priced with the static table (`src/ledger/prices.mjs`) — dollars are
|
|
182
|
+
* estimates, tokens are facts. Rows of the run without a block (facts, author) are the block's
|
|
183
|
+
* planning cost here, since this run has one block.
|
|
184
|
+
* @param {Array<Record<string, any>>} rows - the run's rows
|
|
185
|
+
* @param {string} block
|
|
186
|
+
* @returns {{usd: number, tokens_in: number, tokens_out: number, by_role: Record<string, number>, priced_rows: number}}
|
|
187
|
+
*/
|
|
188
|
+
export function blockCost(rows, block) {
|
|
189
|
+
let usd = 0;
|
|
190
|
+
let tokensIn = 0;
|
|
191
|
+
let tokensOut = 0;
|
|
192
|
+
let priced = 0;
|
|
193
|
+
/** @type {Record<string, number>} */
|
|
194
|
+
const byRole = {};
|
|
195
|
+
for (const row of rows) {
|
|
196
|
+
if (row.block !== block && row.block !== null && row.block !== undefined) continue;
|
|
197
|
+
const tIn = typeof row.tokens_in === 'number' ? row.tokens_in : 0;
|
|
198
|
+
const tOut = typeof row.tokens_out === 'number' ? row.tokens_out : 0;
|
|
199
|
+
if (row.event !== 'session' || typeof row.provider !== 'string' || typeof row.level !== 'string' || tIn + tOut === 0) continue;
|
|
200
|
+
const cost = estimateCostUsd({ provider: row.provider, level: row.level, tokensIn: tIn, tokensOut: tOut });
|
|
201
|
+
usd += cost;
|
|
202
|
+
tokensIn += tIn;
|
|
203
|
+
tokensOut += tOut;
|
|
204
|
+
priced += 1;
|
|
205
|
+
const role = typeof row.role === 'string' ? row.role : 'other';
|
|
206
|
+
byRole[role] = Math.round(((byRole[role] ?? 0) + cost) * 10000) / 10000;
|
|
207
|
+
}
|
|
208
|
+
return { usd: Math.round(usd * 10000) / 10000, tokens_in: tokensIn, tokens_out: tokensOut, by_role: byRole, priced_rows: priced };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {string[]} argv
|
|
213
|
+
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [io]
|
|
214
|
+
* @returns {Promise<number>}
|
|
215
|
+
*/
|
|
216
|
+
export async function main(argv, io = {}) {
|
|
217
|
+
const stdout = io.stdout ?? process.stdout;
|
|
218
|
+
const stderr = io.stderr ?? process.stderr;
|
|
219
|
+
const say = (/** @type {string} */ s) => stdout.write(`${s}\n`);
|
|
220
|
+
/** @type {ReturnType<typeof parseArgs>} */
|
|
221
|
+
let opts;
|
|
222
|
+
try {
|
|
223
|
+
opts = parseArgs(argv);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (!(err instanceof UsageError)) throw err;
|
|
226
|
+
stderr.write(`first-use: ${err.message}\n${USAGE}\n`);
|
|
227
|
+
return 2;
|
|
228
|
+
}
|
|
229
|
+
if (opts.help) {
|
|
230
|
+
say(USAGE);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
const { engine, coder, run, until } = opts;
|
|
234
|
+
|
|
235
|
+
// The copy lives in `<tmp-root>/first-use/<run>/`; `first-use/` is a temp root this process owns
|
|
236
|
+
// (owner.json + pid registry), so every child `exec` starts is registered there and the next
|
|
237
|
+
// sweep after this process ends removes the copies (§9.6).
|
|
238
|
+
const container = runRoot('first-use', { root: tmpBase(opts.tmpRoot) });
|
|
239
|
+
setRunRoot(container);
|
|
240
|
+
const copy = path.join(container, run);
|
|
241
|
+
if (existsSync(copy)) {
|
|
242
|
+
stderr.write(`first-use: ${copy} already exists — pick another --run\n`);
|
|
243
|
+
return 2;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** @type {Record<string, any>} */
|
|
247
|
+
const summary = { ok: false, run, engine, coder, copy, slug: null, steps: [] };
|
|
248
|
+
let started = false;
|
|
249
|
+
let blockOpen = false;
|
|
250
|
+
const abort = new AbortController();
|
|
251
|
+
let stepNo = 0;
|
|
252
|
+
const step = (/** @type {string} */ name) => {
|
|
253
|
+
stepNo += 1;
|
|
254
|
+
summary.steps.push(name);
|
|
255
|
+
say(`== ${stepNo}. ${name} ==`);
|
|
256
|
+
};
|
|
257
|
+
const done = (/** @type {string} */ name) => name === until;
|
|
258
|
+
|
|
259
|
+
/** @param {string[]} args @param {{cwd?: string, ok?: number[]}} [o] */
|
|
260
|
+
const cf = async (args, o = {}) => {
|
|
261
|
+
if (abort.signal.aborted) throw new StepFailed(String(abort.signal.reason));
|
|
262
|
+
const res = await exec([process.execPath, BIN, ...args], { cwd: o.cwd ?? copy, env: scrubbedEnv(), timeoutMs: CLI_TIMEOUT_MS });
|
|
263
|
+
if (abort.signal.aborted) throw new StepFailed(String(abort.signal.reason));
|
|
264
|
+
const ok = o.ok ?? [0];
|
|
265
|
+
if (!ok.includes(res.code ?? -1)) {
|
|
266
|
+
stderr.write(res.stderr);
|
|
267
|
+
throw new StepFailed(`code-forge ${args.slice(0, 2).join(' ')} exited ${res.code}`);
|
|
268
|
+
}
|
|
269
|
+
return res;
|
|
270
|
+
};
|
|
271
|
+
/** @param {string[]} args */
|
|
272
|
+
const git = async (args) => {
|
|
273
|
+
const res = await exec(['git', '-c', 'user.name=code-forge first use', '-c', 'user.email=first-use@example.invalid', '-c', 'commit.gpgsign=false', ...args], {
|
|
274
|
+
cwd: copy,
|
|
275
|
+
env: gitEnv(),
|
|
276
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
277
|
+
});
|
|
278
|
+
if (res.result !== 'ok') throw new StepFailed(`git ${args[0]} failed (exit ${res.code})`);
|
|
279
|
+
return res.stdout;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/** @returns {Promise<number>} */
|
|
283
|
+
const body = async () => {
|
|
284
|
+
// 1 — copy + git init + one base commit
|
|
285
|
+
step('copy');
|
|
286
|
+
cpSync(EXAMPLE_DIR, copy, { recursive: true, filter: (src) => !['node_modules', '.code-forge', '.git', '.env', '.gitignore'].includes(path.basename(src)) });
|
|
287
|
+
renameSync(path.join(copy, SHIPPED_GITIGNORE), path.join(copy, '.gitignore'));
|
|
288
|
+
await git(['init', '-q']);
|
|
289
|
+
await git(['add', '-A']);
|
|
290
|
+
await git(['commit', '-q', '-m', 'base: examples/node-lib']);
|
|
291
|
+
const baseSha = (await git(['rev-parse', 'HEAD'])).trim();
|
|
292
|
+
writeFileSync(path.join(copy, '.env'), readFileSync(path.join(copy, '.env.example'))); // untracked by design (.gitignore)
|
|
293
|
+
say(`copy ${realpathSync(copy)} · base ${baseSha.slice(0, 12)}`);
|
|
294
|
+
if (done('copy')) return 0;
|
|
295
|
+
|
|
296
|
+
// 2 — init (non-interactive), then the engine banner once
|
|
297
|
+
step('init');
|
|
298
|
+
const initArgs = ['init', '--no-interaction', '--tools', 'current', '--harness', 'claude', '-p', '--engine', engine === 'harness' ? 'harness' : 'auto', '--no-jev', '--proof', 'copy_untracked=.env', '--skip-doctor'];
|
|
299
|
+
const init = await cf(initArgs);
|
|
300
|
+
const initJSON = lastJSON(init.stdout);
|
|
301
|
+
if (initJSON && initJSON.ok !== true) throw new StepFailed('init did not report ok');
|
|
302
|
+
const configFile = path.join(copy, '.code-forge.yml');
|
|
303
|
+
if (engine === 'subprocess' || opts.tmpRoot !== undefined) {
|
|
304
|
+
const doc = parseDocument(readFileSync(configFile, 'utf8'));
|
|
305
|
+
// R2: subprocess is never automatic — this is the explicit config edit the stop text asks for
|
|
306
|
+
if (engine === 'subprocess') doc.set('engine', 'subprocess');
|
|
307
|
+
// the run roots (worker, facts, author) go where the copy is
|
|
308
|
+
if (opts.tmpRoot !== undefined) doc.setIn(['tmp', 'root'], opts.tmpRoot);
|
|
309
|
+
writeFileSync(configFile, doc.toString());
|
|
310
|
+
}
|
|
311
|
+
const config = parseYAML(readFileSync(configFile, 'utf8'));
|
|
312
|
+
summary.slug = config?.project?.slug ?? null;
|
|
313
|
+
say(`.code-forge.yml written · engine ${config?.engine} · slug ${summary.slug}${config?.tmp?.root ? ` · tmp.root ${config.tmp.root}` : ''}`);
|
|
314
|
+
say(degradedBanner(engine).replace('<slug>', String(summary.slug)));
|
|
315
|
+
if (done('init')) return 0;
|
|
316
|
+
|
|
317
|
+
// 3 + 4 — the facts rule: the sheet exists before the author designs anything
|
|
318
|
+
mkdirSync(path.join(copy, 'plans'), { recursive: true });
|
|
319
|
+
cpSync(path.join(INPUTS_DIR, 'clamp.brief.md'), path.join(copy, 'plans', 'clamp.md'));
|
|
320
|
+
cpSync(path.join(INPUTS_DIR, 'clamp.acceptance.yml'), path.join(copy, 'plans', 'clamp.acceptance.yml'));
|
|
321
|
+
step('facts');
|
|
322
|
+
const facts = lastJSON((await cf(['facts', '--brief', 'plans/clamp.md', '--run', run])).stdout);
|
|
323
|
+
say(`facts sheet ${path.relative(copy, facts?.out ?? '')} · claims ${facts?.claims} · verified ${facts?.verified}`);
|
|
324
|
+
if (done('facts')) return 0;
|
|
325
|
+
|
|
326
|
+
step('author');
|
|
327
|
+
const author = lastJSON((await cf(['author', '--job', 'plan', '--brief', 'plans/clamp.md', '--facts', 'plans/clamp.facts.md', '--run', run])).stdout);
|
|
328
|
+
say(`plan draft ${path.relative(copy, author?.draft ?? '')} · questions ${author?.questions?.length ?? 0} · cost ${JSON.stringify(author?.cost ?? null)}`);
|
|
329
|
+
if (done('author')) return 0;
|
|
330
|
+
|
|
331
|
+
// 5 + 6 — the run (worker) and the block
|
|
332
|
+
step('start');
|
|
333
|
+
started = true;
|
|
334
|
+
say((await cf(['run', 'start', '--run', run, '--engine', engine])).stdout.trimEnd());
|
|
335
|
+
if (done('start')) return 0;
|
|
336
|
+
|
|
337
|
+
step('open');
|
|
338
|
+
const opened = await cf(['block', 'open', BLOCK, '--run', run, '--level', 'L1', '--owned', ...OWNED, '--acceptance', 'plans/clamp.acceptance.yml', '--brief', 'plans/clamp.plan.md', '--lines', '40']);
|
|
339
|
+
blockOpen = true;
|
|
340
|
+
say(opened.stdout.trimEnd());
|
|
341
|
+
const pointer = opened.stdout.split('\n').find((l) => l.startsWith('BRIEF ')) ?? '';
|
|
342
|
+
if (done('open')) return 0;
|
|
343
|
+
|
|
344
|
+
// 7 — the coder
|
|
345
|
+
step('code');
|
|
346
|
+
const logFile = path.join(copy, '.code-forge', 'runs', run, `${BLOCK}.log`);
|
|
347
|
+
mkdirSync(path.dirname(logFile), { recursive: true });
|
|
348
|
+
await runCoder({ coder, copy, run, pointer, logFile, cf, say, signal: abort.signal, timeoutMs: opts.coderTimeoutMs });
|
|
349
|
+
if (done('code')) return 0;
|
|
350
|
+
|
|
351
|
+
// 8 — one review per owned file; the approval is the signed row the gate reads
|
|
352
|
+
step('review');
|
|
353
|
+
summary.approvals = [];
|
|
354
|
+
for (const file of OWNED) {
|
|
355
|
+
const queued = lastJSON((await cf(['review-file', file, '--block', BLOCK, '--run', run])).stdout);
|
|
356
|
+
if (!queued?.ticket) throw new StepFailed(`review-file ${file}: ${queued?.status ?? 'no answer'}`);
|
|
357
|
+
const waited = lastJSON((await cf(['review-file', '--wait', queued.ticket, '--max', '600s'])).stdout);
|
|
358
|
+
const result = waited?.result ?? {};
|
|
359
|
+
say(`review ${file} · ${waited?.status} · round ${result.round ?? '?'} ${result.kind ?? ''} · approved ${result.approved === true}`);
|
|
360
|
+
if (waited?.status !== 'done' || result.approved !== true) {
|
|
361
|
+
throw new StepFailed(`review of ${file} is not approved (${waited?.status}; ${waited?.fix_list?.length ?? 0} finding(s)${waited?.stop ? `; stopped ${waited.stop}` : ''})`);
|
|
362
|
+
}
|
|
363
|
+
summary.approvals.push(file);
|
|
364
|
+
}
|
|
365
|
+
if (done('review')) return 0;
|
|
366
|
+
|
|
367
|
+
// 9 — proof, in the measurement export
|
|
368
|
+
step('proof');
|
|
369
|
+
const exp = lastJSON((await cf(['proof', 'export', BLOCK, '--run', run])).stdout);
|
|
370
|
+
summary.export = { restored: exp?.restored ?? [], untracked: exp?.untracked ?? [] };
|
|
371
|
+
say(`export ${exp?.dir} · restored ${summary.export.restored.join(', ')} · untracked ${summary.export.untracked.join(', ')}`);
|
|
372
|
+
const gate = lastJSON((await cf(['gates', 'run', '--cwd', exp.dir], { ok: [0, 1] })).stdout);
|
|
373
|
+
const gates = Array.isArray(gate?.results) ? gate.results.filter((/** @type {any} */ r) => r.skipped !== true) : [];
|
|
374
|
+
summary.export_gates_ok = gates.length > 0 && gates.every((/** @type {any} */ r) => r.ok === true);
|
|
375
|
+
say(`gates in the export · ${gates.map((/** @type {any} */ r) => `${r.gate} ${r.ok ? 'ok' : 'red'}`).join(' · ') || 'none ran'}`);
|
|
376
|
+
if (summary.export_gates_ok !== true) throw new StepFailed('the example suite does not pass in the measurement export');
|
|
377
|
+
summary.proofs = [];
|
|
378
|
+
for (const test of acceptanceTests(path.join(copy, 'plans', 'clamp.acceptance.yml'))) {
|
|
379
|
+
const res = await cf(['proof', 'red-green', BLOCK, '--run', run, '--test', test], { ok: [0, 1] });
|
|
380
|
+
const proof = lastJSON(res.stdout);
|
|
381
|
+
say(`${res.stderr.trimEnd()}\nproof ${test} · ${proof?.mechanism} · red ${proof?.red} (${proof?.red_kind}) · green ${proof?.green}`);
|
|
382
|
+
summary.proofs.push({ test, mechanism: proof?.mechanism, red_kind: proof?.red_kind, proven: proof?.proven === true });
|
|
383
|
+
if (proof?.proven !== true) throw new StepFailed(`red→green not proven for ${test}`);
|
|
384
|
+
}
|
|
385
|
+
await cf(['proof', 'export', BLOCK, '--run', run, '--remove']);
|
|
386
|
+
if (done('proof')) return 0;
|
|
387
|
+
|
|
388
|
+
// 10 — the gate, then what the completed block cost
|
|
389
|
+
step('close');
|
|
390
|
+
say((await cf(['block', 'close', BLOCK, '--run', run])).stdout.trimEnd());
|
|
391
|
+
blockOpen = false;
|
|
392
|
+
const rows = (await readAllRows(/** @type {string} */ (summary.slug))).filter((r) => r.run === run);
|
|
393
|
+
summary.cost = blockCost(rows, BLOCK);
|
|
394
|
+
const roles = Object.entries(summary.cost.by_role).map(([role, usd]) => `${role} $${usd}`).join(' · ');
|
|
395
|
+
say(
|
|
396
|
+
`cost per completed block: ${BLOCK} $${summary.cost.usd} (estimated from ${summary.cost.priced_rows} priced session row(s); ${roles || 'no priced rows'}) · tokens ${summary.cost.tokens_in} in / ${summary.cost.tokens_out} out` +
|
|
397
|
+
(coder === 'scripted' ? ' · coder: scripted, no session' : coder === 'harness' ? ' · coder: harness subagent, its tokens are not in the ledger' : ''),
|
|
398
|
+
);
|
|
399
|
+
if (done('close')) return 0;
|
|
400
|
+
|
|
401
|
+
// 11 — the report
|
|
402
|
+
step('report');
|
|
403
|
+
const report = await cf(['report', '--slug', /** @type {string} */ (summary.slug)]);
|
|
404
|
+
stdout.write(report.stdout);
|
|
405
|
+
summary.sections = report.stdout.split('\n').filter((l) => /^== [a-z0-9_]+ ==$/.test(l)).length;
|
|
406
|
+
say(`report sections: ${summary.sections}`);
|
|
407
|
+
return 0;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// SIGINT/SIGTERM while the run is active abort the current step (a wait ends at once, a CLI child
|
|
411
|
+
// gets the signal forwarded by `exec`), so the `finally` below still stops the block and ends the run.
|
|
412
|
+
const onSignal = (/** @type {NodeJS.Signals} */ signal) => abort.abort(`interrupted by ${signal}`);
|
|
413
|
+
process.on('SIGINT', onSignal);
|
|
414
|
+
process.on('SIGTERM', onSignal);
|
|
415
|
+
let code;
|
|
416
|
+
try {
|
|
417
|
+
code = await body();
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (!(err instanceof StepFailed)) throw err;
|
|
420
|
+
stderr.write(`first-use: FAILED at ${summary.steps.at(-1)}: ${err.message}\n`);
|
|
421
|
+
code = 1;
|
|
422
|
+
} finally {
|
|
423
|
+
if (started && blockOpen) {
|
|
424
|
+
// a failed run leaves no open block behind: `run end` refuses one, and the worker would outlive us
|
|
425
|
+
const stopped = await exec([process.execPath, BIN, 'block', 'stop', BLOCK, '--run', run, '--reason', `first-use failed at ${summary.steps.at(-1)}`], { cwd: copy, env: scrubbedEnv(), timeoutMs: 60000 });
|
|
426
|
+
say(`block stop · exit ${stopped.code}`);
|
|
427
|
+
}
|
|
428
|
+
if (started) {
|
|
429
|
+
const ended = await exec([process.execPath, BIN, 'run', 'end', '--run', run], { cwd: copy, env: scrubbedEnv(), timeoutMs: 60000 });
|
|
430
|
+
say(`run end · exit ${ended.code} · ${ended.stdout.trimEnd() || ended.stderr.trimEnd()}`);
|
|
431
|
+
summary.run_ended = ended.code === 0;
|
|
432
|
+
}
|
|
433
|
+
process.off('SIGINT', onSignal);
|
|
434
|
+
process.off('SIGTERM', onSignal);
|
|
435
|
+
}
|
|
436
|
+
summary.ok = code === 0;
|
|
437
|
+
say(`FIRST-USE ${JSON.stringify(summary)}`);
|
|
438
|
+
return code;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The test ids the acceptance file names, in order (`<file>::<case>` or `<file>`), deduplicated.
|
|
443
|
+
* @param {string} file
|
|
444
|
+
* @returns {string[]}
|
|
445
|
+
*/
|
|
446
|
+
export function acceptanceTests(file) {
|
|
447
|
+
const clauses = parseYAML(readFileSync(file, 'utf8'));
|
|
448
|
+
if (!Array.isArray(clauses)) throw new StepFailed('the acceptance file is not a list of clauses');
|
|
449
|
+
return [...new Set(clauses.flatMap((c) => (Array.isArray(c?.tests) ? c.tests.map(String) : [])))];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The coder step.
|
|
454
|
+
* @param {{coder: string, copy: string, run: string, pointer: string, logFile: string, cf: (args: string[], o?: {cwd?: string, ok?: number[]}) => Promise<import('../src/util/exec.mjs').ExecResult>, say: (s: string) => void, signal: AbortSignal, timeoutMs: number}} o
|
|
455
|
+
*/
|
|
456
|
+
async function runCoder({ coder, copy, run, pointer, logFile, cf, say, signal, timeoutMs }) {
|
|
457
|
+
const sha8 = /sha=([0-9a-f]{8})/.exec(pointer)?.[1] ?? '????????';
|
|
458
|
+
const lines = /lines=(\d+)/.exec(pointer)?.[1] ?? '?';
|
|
459
|
+
if (coder === 'scripted') {
|
|
460
|
+
appendFileSync(logFile, `ACK ${sha8} lines=${lines}\n`);
|
|
461
|
+
for (const file of OWNED) {
|
|
462
|
+
cpSync(path.join(INPUTS_DIR, 'coder', file), path.join(copy, file));
|
|
463
|
+
appendFileSync(logFile, `wrote ${file}\n`);
|
|
464
|
+
}
|
|
465
|
+
appendFileSync(logFile, `===BLOCK ${BLOCK} COMPLETE===\n`);
|
|
466
|
+
say(`scripted coder: ${OWNED.join(', ')} written · ACK ${sha8}`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (coder === 'spawn') {
|
|
470
|
+
const res = await cf(['spawn', '--level', 'L1', '--role', 'coder', '--brief', 'plans/clamp.plan.md', '--run', run, '--block', BLOCK, '--cwd', copy, '--timeout', '1800'], { ok: [0, 1, 3] });
|
|
471
|
+
appendFileSync(logFile, `${res.stdout}${res.stderr}`);
|
|
472
|
+
if (res.code !== 0) throw new StepFailed(`the spawned coder exited ${res.code}`);
|
|
473
|
+
say(`spawned coder done · ${res.stdout.trim().split('\n').at(-1)}`);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// harness: the orchestrator runs the Agent tool; this script waits for the sentinel in the log
|
|
477
|
+
say(`harness coder: run Agent(subagent_type: general-purpose, model: <alias of L1>, prompt: <the line below>) from the harness`);
|
|
478
|
+
say(`read the brief at the pointer; reply ACK ${sha8} lines=${lines} first; append a progress line to .code-forge/runs/${run}/${BLOCK}.log after every file, and your final ===BLOCK ${BLOCK} COMPLETE=== (or FAILED) line there too.`);
|
|
479
|
+
say(pointer);
|
|
480
|
+
say(`waiting up to ${timeoutMs / 1000}s for ===BLOCK ${BLOCK} COMPLETE=== in ${logFile} (Ctrl-C stops; the run is ended either way)`);
|
|
481
|
+
await waitForSentinel({ logFile, signal, timeoutMs });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Poll `logFile` until its last `===BLOCK B1 …===` line is COMPLETE (return) or anything else
|
|
486
|
+
* (throw). Throws `StepFailed` when `signal` aborts or `timeoutMs` passes. Each poll sleeps with
|
|
487
|
+
* `timers/promises` bound to `signal`, so no listener outlives its poll.
|
|
488
|
+
* @param {{logFile: string, signal: AbortSignal, timeoutMs: number, pollMs?: number}} o
|
|
489
|
+
* @returns {Promise<void>}
|
|
490
|
+
*/
|
|
491
|
+
export async function waitForSentinel({ logFile, signal, timeoutMs, pollMs = 1000 }) {
|
|
492
|
+
const deadline = Date.now() + timeoutMs;
|
|
493
|
+
for (;;) {
|
|
494
|
+
if (signal.aborted) throw new StepFailed(String(signal.reason));
|
|
495
|
+
const log = existsSync(logFile) ? readFileSync(logFile, 'utf8') : '';
|
|
496
|
+
const sentinel = log.split('\n').filter((l) => l.startsWith(`===BLOCK ${BLOCK} `)).at(-1);
|
|
497
|
+
if (sentinel === `===BLOCK ${BLOCK} COMPLETE===`) return;
|
|
498
|
+
if (sentinel) throw new StepFailed(`the coder reported ${sentinel}`);
|
|
499
|
+
if (Date.now() >= deadline) throw new StepFailed(`no ===BLOCK ${BLOCK} COMPLETE=== line within ${timeoutMs / 1000}s`);
|
|
500
|
+
try {
|
|
501
|
+
await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())), undefined, { signal });
|
|
502
|
+
} catch (err) {
|
|
503
|
+
if (/** @type {Error} */ (err)?.name !== 'AbortError') throw err;
|
|
504
|
+
// aborted: the check at the top of the loop throws StepFailed with the reason
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const isMain = (() => {
|
|
510
|
+
try {
|
|
511
|
+
return process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
512
|
+
} catch {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
})();
|
|
516
|
+
|
|
517
|
+
if (isMain) process.exitCode = await main(process.argv.slice(2));
|