@codedology/code-forge 0.15.2 → 0.15.3
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 +56 -0
- package/README.md +5 -1
- package/docs/README.md +2 -0
- package/docs/reference/blocks.json +154 -1
- package/docs/reference/config.md +27 -14
- package/package.json +1 -1
- package/schema/code-forge.schema.json +57 -2
- package/schema/outcome.schema.json +45 -0
- package/schema/pipeline.schema.json +30 -0
- package/scripts/first-use.mjs +8 -0
- package/scripts/gen-config-doc.mjs +1 -1
- package/skill/references/code.md +15 -0
- package/skill/references/ledger.md +13 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +146 -30
- package/src/cli/context.mjs +17 -7
- package/src/cli/gates.mjs +76 -10
- package/src/cli/help.mjs +4 -0
- package/src/cli/outcome.mjs +87 -0
- package/src/cli/pipeline.mjs +61 -0
- package/src/cli/proof.mjs +10 -7
- package/src/cli/report.mjs +53 -6
- package/src/cli/review-file.mjs +168 -38
- package/src/cli/run.mjs +3 -2
- package/src/cli/sweep.mjs +28 -0
- package/src/cli/validate.mjs +6 -0
- package/src/config/validate.mjs +83 -1
- package/src/context/pack.mjs +53 -3
- package/src/doctor/local.mjs +22 -4
- package/src/engines/builders/codex.mjs +27 -7
- package/src/engines/builders/grok.mjs +11 -5
- package/src/engines/codex-home.mjs +18 -15
- package/src/engines/sentinel.mjs +11 -0
- package/src/gates/detect.mjs +6 -3
- package/src/gates/run.mjs +295 -27
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +143 -5
- package/src/outcome/outcome.mjs +108 -0
- package/src/pipeline/pipeline.mjs +112 -0
- package/src/proof/red-green.mjs +41 -13
- package/src/proof/runners.mjs +28 -4
- package/src/proof/worktree.mjs +25 -15
- package/src/review/agent-waiver.mjs +18 -4
- package/src/review/engine.mjs +25 -5
- package/src/review/gate-check.mjs +39 -1
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/packet.mjs +34 -6
- package/src/review/related.mjs +112 -0
- package/src/review/reopen.mjs +4 -3
- package/src/review/session-retry.mjs +59 -0
- package/src/session/spawn.mjs +54 -3
- package/src/state/block.mjs +101 -34
- package/src/state/config-snapshot.mjs +13 -3
- package/src/state/run.mjs +3 -2
- package/src/util/exec.mjs +48 -31
- package/src/util/reaper.mjs +238 -8
- package/src/worker/engine.mjs +7 -3
- package/src/worker/loop.mjs +67 -14
- package/src/worker/queue.mjs +106 -23
- package/src/worker/watch.mjs +2 -2
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://code-forge.dev/schema/pipeline.schema.json",
|
|
4
|
+
"title": "Specialist skill pipeline",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"root": { "type": "string" },
|
|
8
|
+
"steps": {
|
|
9
|
+
"type": "array",
|
|
10
|
+
"minItems": 1,
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"properties": {
|
|
14
|
+
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
|
|
15
|
+
"skill": { "type": "string" },
|
|
16
|
+
"needs": { "type": "array", "items": { "type": "string" }, "default": [] },
|
|
17
|
+
"outcome": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"pattern": "^(?![\\\\/])(?![A-Za-z]:)(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$"
|
|
20
|
+
},
|
|
21
|
+
"approval": { "enum": ["human", "none"] }
|
|
22
|
+
},
|
|
23
|
+
"required": ["id", "skill", "outcome", "approval"],
|
|
24
|
+
"additionalProperties": false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"required": ["root", "steps"],
|
|
29
|
+
"additionalProperties": false
|
|
30
|
+
}
|
package/scripts/first-use.mjs
CHANGED
|
@@ -50,7 +50,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
50
50
|
import { parse as parseYAML, parseDocument } from 'yaml';
|
|
51
51
|
import { readAllRows } from '../src/ledger/write.mjs';
|
|
52
52
|
import { estimateCostUsd } from '../src/ledger/prices.mjs';
|
|
53
|
+
import { readRun } from '../src/state/run.mjs';
|
|
53
54
|
import { exec } from '../src/util/exec.mjs';
|
|
55
|
+
import { stopMembers } from '../src/util/reaper.mjs';
|
|
54
56
|
import { runRoot, setRunRoot, tmpBase } from '../src/util/tmp.mjs';
|
|
55
57
|
|
|
56
58
|
const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -435,6 +437,12 @@ export async function main(argv, io = {}) {
|
|
|
435
437
|
const ended = await exec([process.execPath, BIN, 'run', 'end', '--run', run], { cwd: copy, env: scrubbedEnv(), timeoutMs: 60000 });
|
|
436
438
|
say(`run end · exit ${ended.code} · ${ended.stdout.trimEnd() || ended.stderr.trimEnd()}`);
|
|
437
439
|
summary.run_ended = ended.code === 0;
|
|
440
|
+
if (summary.run_ended) {
|
|
441
|
+
// `run end` sends SIGTERM; delivery is not exit. Wait for the pinned worker's
|
|
442
|
+
// cleanup before this script reports completion, rechecking identity before signals.
|
|
443
|
+
const record = await readRun(run);
|
|
444
|
+
await stopMembers(new Map((record.workers ?? (record.worker ? [record.worker] : [])).map((worker) => [worker.pid, worker.started_at])));
|
|
445
|
+
}
|
|
438
446
|
}
|
|
439
447
|
process.off('SIGINT', onSignal);
|
|
440
448
|
process.off('SIGTERM', onSignal);
|
|
@@ -64,7 +64,7 @@ function notesLabel(nodeIn, defs) {
|
|
|
64
64
|
if (node.exclusiveMinimum !== undefined) parts.push(`> ${node.exclusiveMinimum}`);
|
|
65
65
|
if (node.minLength !== undefined) parts.push(`minLength ${node.minLength}`);
|
|
66
66
|
if (node.minItems !== undefined) parts.push(`minItems ${node.minItems}`);
|
|
67
|
-
if (typeof node.description === 'string'
|
|
67
|
+
if (typeof node.description === 'string') parts.push(node.description);
|
|
68
68
|
return parts.join('; ');
|
|
69
69
|
}
|
|
70
70
|
|
package/skill/references/code.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Small changes may use `block open <id> --kind light`. Close allows at most `light.max_files` owned changed files (default 5) and `light.max_lines` added plus removed lines (default 300), including new files. Auto ticket lines are unchanged. `gates run --block` runs the block's tests through `{test_files}`, ignoring the full-suite threshold; supply block tests and a test command containing that item. A close refusal `light-limits <files> files, <lines> lines` means the block outgrew this kind: the orchestrator stops the open attempt, then opens the same id with `block open <id> --kind code --attempt <n+1>` and the original base, ownership and acceptance.
|
|
4
4
|
|
|
5
|
+
For website checks, configure `gates.custom` with up to 20 unique named `{name, argv, env?, evidence?}` gates, such as build, lighthouse and links. They run independently after the four fixed gates, including on light blocks and with `--changed`; custom argv refuses `{test_files}` and `{changed_files}`. The `gates-not-argv-array` error rule validates entries. Evidence paths/globs stay under cwd; after pass or fail, up to 200 sorted regular files are recorded as path, bytes and SHA-256 without copying content. Missing patterns are listed without failing the gate. `gate.done` carries custom summaries and, with `--run/--block`, the evidence map; red custom gates carry their name and `custom: true`.
|
|
6
|
+
|
|
5
7
|
## §1 The coder's first deliverable is a facts diff
|
|
6
8
|
|
|
7
9
|
Before any forecast or code, the coder reads the code it will call and reports **every contradiction between the plan and the code** in one message — a signature, a flag, a key, a version. One stop for facts, never one per discovery. Contract corrections (a key, a rule, a signature) are the architect's alone and are recorded, not asked; a ruling reaches the human only when it changes user-visible behaviour, money semantics or an approved decision. A coder that stops three times for three true facts did nothing wrong; the protocol did.
|
|
@@ -14,6 +16,19 @@ The coder then forecasts **cases** (tests) and **lines**. Compute the floor your
|
|
|
14
16
|
|
|
15
17
|
Acceptance (`block open --acceptance`) is a YAML/JSON list. Code clauses need `tests: [<test ids>]`. A device check instead uses `- clause: "Check the layout on a phone"` with `kind: human_check` on the next indented line and no `tests` key (even an empty list is refused). These texts are recorded as the block's `human_checks` and skipped by test-coverage checks; every other clause still needs tests.
|
|
16
18
|
|
|
19
|
+
For design first, put `{clause: "Owner approves the design", kind: human_check}` in `design.acc.yml`, then follow this order:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
code-forge block open DESIGN --run <r> --level L1 --kind docs --owned docs/design.md --acceptance design.acc.yml --brief <file> --lines <n>
|
|
23
|
+
# Finish the design and pass its code gates, then:
|
|
24
|
+
code-forge block close DESIGN --run <r>
|
|
25
|
+
# The owner approves the design in their terminal:
|
|
26
|
+
code-forge block human-pass DESIGN --run <r> --note "Design approved"
|
|
27
|
+
code-forge block open CODE --run <r> --level L2 --owned src/feature.mjs --acceptance code.acc.yml --after DESIGN --brief <file> --lines <n>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`--after <id> [<id> ...]` collects ids like `--owned` and removes duplicates. Every dependency must be `closed` in this run, checked under the same run lock as the other open checks. Open refuses with `after-not-done`, naming every blocker and its status (`open`, `awaiting_human_check`, `stopped`, or `not in run`); no rows or worktree are created. With `--json`, the refusal also prints `{block, event: "refused", code: "after-not-done", message}` (self-dependency uses `bad-after`). Only a supplied flag adds `after: [ids]` to the entry and `dispatch` row. Supply it again on each later attempt that must wait for the design; an attempt without it does not recheck dependencies.
|
|
31
|
+
|
|
17
32
|
Run a Codex coder with `codex exec --json` in its worktree, save stdout to a log, and pass that
|
|
18
33
|
log to `block close --transcript <file>`. A Codex JSON log (every non-empty line is an object
|
|
19
34
|
with a string `type`, first event `thread.started`) is checked by its commands: command items'
|
|
@@ -10,7 +10,19 @@ Events you will read most: `run.start`, `run.mode {mode: brief, plan: none}` (a
|
|
|
10
10
|
|
|
11
11
|
`review.unwaived {block, file, finding, reason, by: human}` is a signed human undo of earlier agent waivers. `agent_waivers.acked {block, digest, count, by: human}` is a signed human acknowledgement of the current counted set. The gate verifies both rows. The digest is SHA-256 of the sorted file, finding and row signature set. New or removed waivers need a new acknowledgement when the run snapshot requires one.
|
|
12
12
|
|
|
13
|
-
Terminal output in this version. Sections: cost per block — completed and still open, with `completed` and the count of unknown-price sessions (per level and lane, split review / coder / S1+S2 / facts / proof time) · lane distribution · escalations per block with `trigger` · S1 calls per block · S2 rate and overrule rate per question · the review budget table with `missed_after_degrade` per degrade step · review depth and context-mode distribution, findings by category and severity · fix rounds per file (one block round is one round for each file it touched, `review.md` §5), rounds that stalled, files stopped at `review_cap` · `review.unavailable` by reason · proof time per block · forecast vs actual lines and cases with the measured lines-per-case · blocks stopped at L3 · `overspend` and `l3_fallback` counts · runs stopped for `no_engine` · `outside_review` (14th): per PR sorted by number, `{pr, rounds, findings, link, blocks, code_forge_usd, unknown_usd_sessions}` — `code_forge_usd` sums the `cost_per_block` totals of the PR's blocks in its run, rounded as `cost_per_block` rounds; unknown-price sessions count 0 and are counted; a PR with no blocks shows 0 and 0 unknown; `[]` with no row; in the terminal the lines print under `== outside_review ==`: `outside review: none`, or one line per PR (`PR #12 · 3 outside rounds · 7 findings · code-forge $3.75 (+1 unknown) · B1, B2`). `agent_waivers` is the 15th
|
|
13
|
+
Terminal output in this version. Sections: cost per block — completed and still open, with `completed` and the count of unknown-price sessions (per level and lane, split review / coder / S1+S2 / facts / proof time) · lane distribution · escalations per block with `trigger` · S1 calls per block · S2 rate and overrule rate per question · the review budget table with `missed_after_degrade` per degrade step · review depth and context-mode distribution, findings by category and severity · fix rounds per file (one block round is one round for each file it touched, `review.md` §5), rounds that stalled, files stopped at `review_cap` · `review.unavailable` by reason · proof time per block · forecast vs actual lines and cases with the measured lines-per-case · blocks stopped at L3 · `overspend` and `l3_fallback` counts · runs stopped for `no_engine` · `outside_review` (14th): per PR sorted by number, `{pr, rounds, findings, link, blocks, code_forge_usd, unknown_usd_sessions}` — `code_forge_usd` sums the `cost_per_block` totals of the PR's blocks in its run, rounded as `cost_per_block` rounds; unknown-price sessions count 0 and are counted; a PR with no blocks shows 0 and 0 unknown; `[]` with no row; in the terminal the lines print under `== outside_review ==`: `outside review: none`, or one line per PR (`PR #12 · 3 outside rounds · 7 findings · code-forge $3.75 (+1 unknown) · B1, B2`). `agent_waivers` is the 15th section. It lists every agent waiver as `{block, file, finding, severity, reason, verdict, unwaived}`. A later human unwaive marks earlier matching rows as `unwaived: true`. A later agent waiver is a separate entry. An empty run gives `[]`. `run_time` is appended as the 16th section (shape below). `--export <dir>` writes one JSON file per section — the durable dataset a future UI reads. After the sections the terminal output prints the spend per run (open blocks included) and the total. `report` checks no row's signature in any section (an unsigned row counts like a signed one); shadow rows (`source: shadow`) are excluded from every section.
|
|
14
|
+
|
|
15
|
+
Each `cost_per_block` entry also carries `wallMs` and `humanWaitMs` in milliseconds. `wallMs` spans the first valid `dispatch.ts` through the final completion: `block.close` without a pending human check, or `block.human_pass` after `block.awaiting_human_check`. An entry whose last lifecycle row is `block.stop` has `stopped: true` and `wallMs` through the stop timestamp, with `humanWaitMs: null`; `stopped` is omitted otherwise. Stopped blocks are listed even without spend or a close. Attempts share the first dispatch; redispatching clears `stopped` and makes the block open again until its final completion, whose wall time still starts at the first dispatch. `humanWaitMs` spans the final check's `block.awaiting_human_check.ts` through `block.human_pass.ts`. Both fields are null while the block is open or awaiting a human check; `humanWaitMs` is also null with no human check. Missing or invalid endpoints give null, as do reversed endpoints. Existing grouping by run and block still applies.
|
|
16
|
+
|
|
17
|
+
In text output, a `cost_per_block` entry with no run uses `outside runs` in the run column, and spend with no run prints as ` outside runs: $<usd> · <n> session(s)` (with the same unknown-price suffix) after the real runs and before the spend total; `--json` and `--export` keep `run: null` unchanged.
|
|
18
|
+
|
|
19
|
+
`run_time` has the shape `{runs: [...], outside_runs?: {usd, rows}, total: {...}}` in `--json` and `run_time.json` from `--export`:
|
|
20
|
+
|
|
21
|
+
- `runs` lists only runs with a string run id, in first-seen ledger order. Each entry is `{run, started, ended, wallMs, usd, blocks_closed, blocks_stopped}`, with `open: true` added only when there is no valid `run.end` timestamp. `started` is the first valid `run.start.ts`, falling back to the run's first valid row timestamp, or null. `ended` is the final valid `run.end.ts`, or null. These timestamp strings preserve the row's ISO representation. `wallMs` is elapsed milliseconds to `ended`, or to the last valid row timestamp for an open run; it is null when endpoints are unavailable or reversed. `usd` reuses the existing per-run spend total, including open blocks and counting unknown-price sessions as 0. `blocks_closed` counts distinct completed blocks (pending human checks do not count); `blocks_stopped` counts distinct blocks with a `block.stop` row, even across attempts.
|
|
22
|
+
- `outside_runs` counts rows without a string run id as `{usd, rows}`: their spend from the same spend helpers and their row count. They contribute no run or wall time. The key is omitted when there are no such rows, including an empty ledger.
|
|
23
|
+
- `total` is `{runs, wallMs, usd, blocks_closed}`: the number of listed real runs, the sum of their known durations (null contributes 0), the existing whole-ledger spend total **including `outside_runs.usd`**, and the sum of their completed-block counts. With no rows it is `{runs: 0, wallMs: 0, usd: 0, blocks_closed: 0}` and the list is `[]`. One slug is one repo/site, so this is the site's cost and cumulative run time.
|
|
24
|
+
|
|
25
|
+
Only full ISO timestamps with a timezone are used for timing; missing or invalid `ts` values are ignored without dropping the row's spend or lifecycle information. Shadow rows never affect times, counts or spend. The terminal shows block dollars beside readable times (for example `12m 30s`) and a status of `completed`, `stopped` or `open`, retains the cost breakdown and proof time, and prints a short run table with its total line; `-` means unavailable time. When `outside_runs` exists, `outside runs: $<usd> (<rows> rows, no wall time)` prints immediately before the total, with no `(none)` run in the table.
|
|
14
26
|
|
|
15
27
|
## §3 Live views
|
|
16
28
|
|
package/skill/scripts/forge
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
|
|
11
11
|
# equals package.json's version.
|
|
12
12
|
# The shim holds no logic of its own: every verb, flag and message is the CLI's.
|
|
13
|
-
PINNED_VERSION="0.15.
|
|
13
|
+
PINNED_VERSION="0.15.3"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
package/src/cli/block.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file>
|
|
6
6
|
* [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>] [--kind code|docs|contract|light]
|
|
7
|
-
* [--tests <paths…>]
|
|
7
|
+
* [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]
|
|
8
8
|
* block attempt <id> --run <r>
|
|
9
9
|
* block rebase <id> --run <r> [--head <ref>]
|
|
10
10
|
* block claim <id> <path> --run <r>
|
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* close (`closed`): status, reason, close_event (the close result's own event word, as the text
|
|
21
21
|
* prints it in parentheses, else null), and `worktree {removed, path, branch}` with
|
|
22
22
|
* `--remove-worktree`). On
|
|
23
|
-
* failure the exit code and stderr are unchanged and stdout stays empty, except
|
|
24
|
-
*
|
|
23
|
+
* failure the exit code and stderr are unchanged and stdout stays empty, except an open refused
|
|
24
|
+
* for `after-not-done`, `bad-after`, `outcome-not-ready` or `bad-after-outcome`, which prints `{"block", "event": "refused", "code", "message"}`,
|
|
25
|
+
* or a close the gate refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
|
|
25
26
|
* error, a thrown refusal, a cancelled `--no-require-reviews`, `block waive` or `block reopen` (or
|
|
26
27
|
* one with no terminal, B80) and a failed `--remove-worktree`
|
|
27
28
|
* print nothing on stdout. Without `--json` every output is byte-identical to before.
|
|
@@ -194,7 +195,8 @@
|
|
|
194
195
|
* through `closeBlock`'s `extraChecks`.
|
|
195
196
|
*/
|
|
196
197
|
|
|
197
|
-
import {
|
|
198
|
+
import { createHash } from 'node:crypto';
|
|
199
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
198
200
|
import { readFile, realpath } from 'node:fs/promises';
|
|
199
201
|
import path from 'node:path';
|
|
200
202
|
import { parse as parseYAML } from 'yaml';
|
|
@@ -202,6 +204,8 @@ import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
|
|
|
202
204
|
import { fallbackLane } from '../decide/fallback-rules.mjs';
|
|
203
205
|
import { laneFacts } from '../decide/lane-facts.mjs';
|
|
204
206
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
207
|
+
import { checkOutcome } from '../outcome/outcome.mjs';
|
|
208
|
+
import { loadPipeline } from '../pipeline/pipeline.mjs';
|
|
205
209
|
import { amendAcceptanceBlock, attemptBlock, claimPath, closeBlock, humanPassBlock, openBlock, prepareAcceptanceAmendment, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
206
210
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
207
211
|
import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
|
|
@@ -209,17 +213,17 @@ import { readFileDiff } from '../review/packet.mjs';
|
|
|
209
213
|
import { StateError } from '../state/paths.mjs';
|
|
210
214
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
211
215
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
212
|
-
import { PROOF_FINDING, blockFileSet, changedFiles, checkBlockReviews, openFindings, ownsAt, recordedSeverity, resultFilesFor, reviewedFiles, reviewGateCheck, routineCloseProblem, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
216
|
+
import { PROOF_FINDING, blockFileSet, changedFiles, checkBlockReviews, lockfileProblem, openFindings, ownsAt, recordedSeverity, resultFilesFor, reviewedFiles, reviewGateCheck, routineCloseProblem, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
213
217
|
import { agentWaivers, agentWaiversAcked, prepareAckWaivers, prepareUnwaive, waiveAsAgent } from '../review/agent-waiver.mjs';
|
|
214
218
|
import { checkExpiry, grantFor, grantState } from '../autopilot/grant.mjs';
|
|
215
219
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
216
220
|
import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
|
|
217
221
|
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
218
|
-
import { validateConfig } from '../config/validate.mjs';
|
|
222
|
+
import { reviewExcludeRule, validateConfig } from '../config/validate.mjs';
|
|
219
223
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
220
224
|
import { exec } from '../util/exec.mjs';
|
|
221
225
|
import { contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
|
|
222
|
-
import { clearDone, resetAcceptanceWatch, resolveTicket } from '../worker/queue.mjs';
|
|
226
|
+
import { clearDone, enqueue, resetAcceptanceWatch, resolveTicket } from '../worker/queue.mjs';
|
|
223
227
|
import { loadState } from '../worker/review-state.mjs';
|
|
224
228
|
import { agentReopens, openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
225
229
|
import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
|
|
@@ -236,7 +240,7 @@ import { acceptedTranscriptHits, prepareAcceptHit, writeAcceptedHit } from '../r
|
|
|
236
240
|
* main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
|
|
237
241
|
* the checks, the done marker's removal and then the signed row are `reopenFile`'s.
|
|
238
242
|
* @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>, agent?: boolean, judge?: import('../review/agent-waiver.mjs').WaiverJudge}} opts
|
|
239
|
-
* @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
|
|
243
|
+
* @returns {Promise<{row: Record<string, any>, cleared: boolean, ticket?: string}>}
|
|
240
244
|
*/
|
|
241
245
|
async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agent = false, judge }) {
|
|
242
246
|
if (agent) {
|
|
@@ -268,7 +272,7 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
|
|
|
268
272
|
const key = await loadKey(runId);
|
|
269
273
|
const rows = await readAllRows(record.project);
|
|
270
274
|
const tree = contentRoot === mainTop ? {} : { tree: contentRoot };
|
|
271
|
-
const { row, cleared } = await reopenFile({
|
|
275
|
+
const { row, cleared, ticket } = await reopenFile({
|
|
272
276
|
runId,
|
|
273
277
|
id,
|
|
274
278
|
file,
|
|
@@ -286,13 +290,14 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
|
|
|
286
290
|
readFile: (f) => readRegularFileNoFollow(contentRoot, f),
|
|
287
291
|
// C11 (synchronous): the cached result of the current content's ticket would repeat the stop
|
|
288
292
|
clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
|
|
293
|
+
enqueue: (f, reopened) => enqueue({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree, after: reopened.ts }).ticket,
|
|
289
294
|
},
|
|
290
295
|
});
|
|
291
|
-
return { row, cleared };
|
|
296
|
+
return { row, cleared, ticket };
|
|
292
297
|
}
|
|
293
298
|
|
|
294
299
|
const USAGE =
|
|
295
|
-
'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract|light] [--tests <paths…>]\n' +
|
|
300
|
+
'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract|light] [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]\n' +
|
|
296
301
|
' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
|
|
297
302
|
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
|
|
298
303
|
' code-forge block reopen <id> --run <r> --file <path> --reason <text> [--yes]\n' +
|
|
@@ -303,7 +308,34 @@ const USAGE =
|
|
|
303
308
|
' code-forge block amend-acceptance <id> --run <r> --acceptance <file> --reason <text>\n' +
|
|
304
309
|
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
305
310
|
|
|
306
|
-
|
|
311
|
+
/** @type {Record<string, string>} */
|
|
312
|
+
const VERB_USAGE = {
|
|
313
|
+
open: '<id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract|light] [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]',
|
|
314
|
+
attempt: '<id> --run <r>',
|
|
315
|
+
rebase: '<id> --run <r> [--head <ref>] [--accept-changed <file>… --reason <text>]',
|
|
316
|
+
claim: '<id> <path> --run <r>',
|
|
317
|
+
close: '<id> --run <r> [--worker-pid <pid>] [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] [--decide]',
|
|
318
|
+
stop: '<id> --run <r> --reason <text>',
|
|
319
|
+
waive: '<id> <finding-id> --run <r> --file <path> --reason <text> [--yes]',
|
|
320
|
+
reopen: '<id> --run <r> --file <path> --reason <text> [--yes]',
|
|
321
|
+
'accept-hit': '<id> --run <r> [--rule <rule-id>] --line <n> --transcript <path> --reason <text>',
|
|
322
|
+
unwaive: '<id> <finding-id> --run <r> --file <path> --reason <text>',
|
|
323
|
+
'ack-waivers': '<id> --run <r>',
|
|
324
|
+
'human-pass': '<id> --run <r> --note <text> [--proof <dir-or-file>]',
|
|
325
|
+
'amend-acceptance': '<id> --run <r> --acceptance <file> --reason <text>',
|
|
326
|
+
};
|
|
327
|
+
const SUBCOMMANDS = Object.keys(VERB_USAGE);
|
|
328
|
+
|
|
329
|
+
/** @param {string} sub @returns {string} */
|
|
330
|
+
function verbUsage(sub) {
|
|
331
|
+
return `usage: code-forge block ${sub} ${VERB_USAGE[sub]} [--json] [--help|-h]\n` +
|
|
332
|
+
' --help, -h Print this usage and exit without changing state.\n' +
|
|
333
|
+
' --json Print one JSON line on success instead of text.\n' +
|
|
334
|
+
(sub === 'open' || sub === 'amend-acceptance'
|
|
335
|
+
? ' --acceptance <file> JSON/YAML list [{clause, tests}]; tests is a nonempty list of test ids.\n' +
|
|
336
|
+
' Human checks use {clause, kind: human_check} without tests.\n' : '') +
|
|
337
|
+
(sub === 'open' ? ' --brief <file> A file inside the repository, not inline text.\n' : '');
|
|
338
|
+
}
|
|
307
339
|
|
|
308
340
|
/** Refuse likely shell quoting slips before any row is written.
|
|
309
341
|
* @param {string} workspace @param {string[]} entries @param {string} label
|
|
@@ -331,6 +363,32 @@ function checkWhitespacePaths(workspace, entries, label) {
|
|
|
331
363
|
}
|
|
332
364
|
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof'];
|
|
333
365
|
|
|
366
|
+
/** Check specialist outcomes against the run's project, before state open can write anything.
|
|
367
|
+
* @param {string} root @param {string} id @param {string[]} ids
|
|
368
|
+
* @returns {{step: string, sha256: string}[]}
|
|
369
|
+
*/
|
|
370
|
+
function acceptedOutcomes(root, id, ids) {
|
|
371
|
+
const loaded = loadPipeline(root);
|
|
372
|
+
const bad = (/** @type {string} */ problem) => new StateError('bad-after-outcome', `block ${id}: --after-outcome: ${problem}`);
|
|
373
|
+
if (!loaded.exists) throw bad(`no code-forge.pipeline.yml in ${root}`);
|
|
374
|
+
if (!loaded.ok) throw bad(loaded.problems[0]);
|
|
375
|
+
const steps = [...new Set(ids)].map((stepId) => {
|
|
376
|
+
const step = loaded.pipeline.steps.find((candidate) => candidate.id === stepId);
|
|
377
|
+
if (!step) throw bad(`unknown step ${stepId}`);
|
|
378
|
+
return step;
|
|
379
|
+
});
|
|
380
|
+
return steps.map((step) => {
|
|
381
|
+
const file = path.resolve(root, step.outcome);
|
|
382
|
+
const result = existsSync(file) ? checkOutcome(file, { root }) : { ok: false, problems: ['missing outcome'] };
|
|
383
|
+
if (!result.ok) throw new StateError('outcome-not-ready', `block ${id}: --after-outcome step ${step.id}: ${result.problems[0]}`);
|
|
384
|
+
try {
|
|
385
|
+
return { step: step.id, sha256: createHash('sha256').update(readFileSync(file)).digest('hex') };
|
|
386
|
+
} catch (err) {
|
|
387
|
+
throw new StateError('outcome-not-ready', `block ${id}: --after-outcome step ${step.id}: cannot read outcome (${err.code ?? 'error'})`);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
334
392
|
/**
|
|
335
393
|
* @param {string[]} args
|
|
336
394
|
* @param {{
|
|
@@ -359,8 +417,13 @@ export async function runBlock(args, deps = {}) {
|
|
|
359
417
|
err(USAGE);
|
|
360
418
|
return 2;
|
|
361
419
|
}
|
|
420
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
421
|
+
out(verbUsage(sub));
|
|
422
|
+
return 0;
|
|
423
|
+
}
|
|
424
|
+
let jsonOpenId;
|
|
362
425
|
try {
|
|
363
|
-
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(sub === 'close' ? ['decide'] : []), ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
|
|
426
|
+
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests', ...(sub === 'rebase' ? ['accept-changed'] : []), ...(sub === 'open' ? ['after', 'after-outcome'] : [])], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(sub === 'close' ? ['decide'] : []), ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
|
|
364
427
|
// B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
|
|
365
428
|
// per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
|
|
366
429
|
const json = flags.json === true;
|
|
@@ -375,6 +438,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
375
438
|
if (t.startsWith('-')) throw new StateError('usage', `--tests: a path may not start with "-": ${JSON.stringify(t)}`);
|
|
376
439
|
}
|
|
377
440
|
const [id, extra] = positionals;
|
|
441
|
+
if (sub === 'open' && json) jsonOpenId = id;
|
|
378
442
|
const runId = typeof flags.run === 'string' ? flags.run : undefined;
|
|
379
443
|
if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
|
|
380
444
|
if (sub === 'amend-acceptance' && (positionals.length !== 1 || typeof flags.acceptance !== 'string' || typeof flags.reason !== 'string' || !flags.reason.trim() || /\p{Cc}/u.test(flags.reason))) {
|
|
@@ -481,10 +545,11 @@ export async function runBlock(args, deps = {}) {
|
|
|
481
545
|
const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
482
546
|
const reopened = async (/** @type {boolean} */ agent) => {
|
|
483
547
|
const file = /** @type {string} */ (flags.file);
|
|
484
|
-
const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
|
|
548
|
+
const { row, cleared, ticket } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
|
|
485
549
|
const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
|
|
486
550
|
say(`block ${id} reopened ${file} (from ${fromText}; by: ${row.by})\n`);
|
|
487
|
-
if (
|
|
551
|
+
if (ticket) say(`ticket ${ticket}\nnext: code-forge review-file --wait ${ticket}\n`);
|
|
552
|
+
if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: row.by, ticket });
|
|
488
553
|
return 0;
|
|
489
554
|
};
|
|
490
555
|
// Agent refusals precede even the lazy expiry checks: no rows on refusal.
|
|
@@ -542,13 +607,18 @@ export async function runBlock(args, deps = {}) {
|
|
|
542
607
|
// B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
|
|
543
608
|
// a failed check is a warning, never a reason to stop the block command
|
|
544
609
|
const requireHumanClose = sub === 'close' && configInForce(record)?.close?.require_human === true;
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
610
|
+
const expire = async () => {
|
|
611
|
+
try {
|
|
612
|
+
if (!deciding && !requireHumanClose) {
|
|
613
|
+
err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
|
|
614
|
+
}
|
|
615
|
+
} catch (thrown) {
|
|
616
|
+
err(`autopilot: WARN the expiry check failed (${thrown?.message ?? String(thrown)})\n`);
|
|
548
617
|
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
|
|
618
|
+
};
|
|
619
|
+
// B148: a refused dependency must not leave even an expiry row behind.
|
|
620
|
+
const deferExpiry = sub === 'open' && (Array.isArray(flags.after) || Array.isArray(flags['after-outcome']));
|
|
621
|
+
if (!deferExpiry) await expire();
|
|
552
622
|
|
|
553
623
|
if (sub === 'open') {
|
|
554
624
|
// an explicit --level wins; B59: else in a brief-mode run the rules lane; B47: else the level
|
|
@@ -581,6 +651,20 @@ export async function runBlock(args, deps = {}) {
|
|
|
581
651
|
}
|
|
582
652
|
// B62a: the run's isolation, from its config snapshot (fixed for the run)
|
|
583
653
|
const isolated = worktreeSettings(record);
|
|
654
|
+
// Preserve --after's self-before-unfinished order before examining specialist outcomes.
|
|
655
|
+
// State open still checks these dependencies under the run lock.
|
|
656
|
+
let afterOutcome;
|
|
657
|
+
if (Array.isArray(flags['after-outcome'])) {
|
|
658
|
+
if (Array.isArray(flags.after)) {
|
|
659
|
+
if (flags.after.includes(id)) throw new StateError('bad-after', `block ${id} cannot name itself in --after`);
|
|
660
|
+
const blocking = [...new Set(flags.after)].filter((dependency) => record.blocks[dependency]?.status !== 'closed');
|
|
661
|
+
if (blocking.length > 0) {
|
|
662
|
+
const statuses = blocking.map((dependency) => `${dependency} (${record.blocks[dependency]?.status ?? 'not in run'})`);
|
|
663
|
+
throw new StateError('after-not-done', `block ${id}: --after dependencies not closed: ${statuses.join(', ')}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
afterOutcome = acceptedOutcomes(record.workspace, id, flags['after-outcome']);
|
|
667
|
+
}
|
|
584
668
|
const { block, pointer, worktreeReused } = await openBlock({
|
|
585
669
|
runId,
|
|
586
670
|
id,
|
|
@@ -594,10 +678,13 @@ export async function runBlock(args, deps = {}) {
|
|
|
594
678
|
...(rules ? { decision: { question: 'lane', answer: rules.lane, source: 'rules', facts: rules.facts } } : {}),
|
|
595
679
|
...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
|
|
596
680
|
...(Array.isArray(flags.tests) ? { tests: flags.tests } : {}),
|
|
681
|
+
...(Array.isArray(flags.after) ? { after: flags.after } : {}),
|
|
682
|
+
...(afterOutcome ? { after_outcome: afterOutcome } : {}),
|
|
597
683
|
cfg: await openConfig(record.workspace),
|
|
598
684
|
writeRow,
|
|
599
685
|
...(isolated ? { worktree: (/** @type {{baseSha: string, previous: any}} */ ctx) => prepareWorktree({ cwd: record.workspace, settings: isolated, runId, id, ...ctx }) } : {}),
|
|
600
686
|
});
|
|
687
|
+
if (deferExpiry) await expire();
|
|
601
688
|
const raised = block.level !== level ? ` (${block.kind} floor, asked ${level})` : '';
|
|
602
689
|
const chosen = typeof flags.level === 'string' ? '' : rules ? ' (rules lane)' : ' (autopilot level)';
|
|
603
690
|
const testCount = Array.isArray(block.test_files) ? block.test_files.length : 0;
|
|
@@ -629,7 +716,17 @@ export async function runBlock(args, deps = {}) {
|
|
|
629
716
|
return 0;
|
|
630
717
|
}
|
|
631
718
|
if (sub === 'rebase') {
|
|
632
|
-
const
|
|
719
|
+
const acceptChanged = Array.isArray(flags['accept-changed']) ? [...new Set(flags['accept-changed'])] : [];
|
|
720
|
+
if (acceptChanged.length) {
|
|
721
|
+
if (positionals.length !== 1 || typeof flags.reason !== 'string' || !flags.reason.trim() || /\p{Cc}/u.test(flags.reason)) throw new StateError('usage', '--accept-changed needs one block id and a nonblank --reason without control characters');
|
|
722
|
+
for (const file of acceptChanged) checkFileForm(file, { normalized: true });
|
|
723
|
+
const asked = await confirmHumanVerb('rebase', runId, id, { file: acceptChanged.join(', ') }, record, deps, async () => {
|
|
724
|
+
if ((await checkExpiry(runId, (deps.now ?? (() => new Date()))())).state === 'active') throw new StateError('autopilot-active', 'block rebase --accept-changed is refused while an autopilot grant is active');
|
|
725
|
+
});
|
|
726
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY('rebase')}\n`); return 2; }
|
|
727
|
+
if (asked === 'cancelled') { err(`block ${id} rebase: cancelled — nothing was recorded\n`); return 1; }
|
|
728
|
+
}
|
|
729
|
+
const moved = await rebaseBlock({ runId, id, head: typeof flags.head === 'string' ? flags.head : undefined, writeRow, acceptChanged, reason: typeof flags.reason === 'string' ? flags.reason : undefined });
|
|
633
730
|
say(`block ${id} rebased ${moved.old_base.slice(0, 12)} → ${moved.new_base.slice(0, 12)}\n`);
|
|
634
731
|
if (json) emit({ block: id, event: 'rebased', old_base: moved.old_base, new_base: moved.new_base });
|
|
635
732
|
return 0;
|
|
@@ -679,7 +776,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
679
776
|
throw thrown;
|
|
680
777
|
});
|
|
681
778
|
// ONE base for the whole close: a worktree block is measured against the commit its tree was
|
|
682
|
-
// made from (kept equal to base_sha, including after a
|
|
779
|
+
// made from (kept equal to base_sha, including after a worktree rebase)
|
|
683
780
|
const base = wt !== null ? wt.base_sha : entry.base_sha;
|
|
684
781
|
const reviewsWaived = flags['no-require-reviews'] === true;
|
|
685
782
|
let routineGrant = null;
|
|
@@ -706,6 +803,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
706
803
|
// B58: the owner's other way out — `review.external.required` in the run's config snapshot
|
|
707
804
|
// (set before `run start`, immutable): no TTY, allowed under autopilot, the human flag wins
|
|
708
805
|
const external = reviewsWaived ? null : externalReviewer(record);
|
|
806
|
+
const reviewConfig = configInForce(record);
|
|
709
807
|
const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
|
|
710
808
|
// B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
|
|
711
809
|
// base is the repository top level: the worker signs every review row with a repo-root-
|
|
@@ -770,6 +868,16 @@ export async function runBlock(args, deps = {}) {
|
|
|
770
868
|
// B20: the high paths come first — a base the workspace does not know refuses the close here
|
|
771
869
|
const highPaths = await highPathsAt(record.workspace, base);
|
|
772
870
|
const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
|
|
871
|
+
const excluded = fileSet.flatMap((f) => {
|
|
872
|
+
const rule = reviewExcludeRule(reviewConfig, f.file);
|
|
873
|
+
return rule === null ? [] : [{ ...f, rule }];
|
|
874
|
+
});
|
|
875
|
+
for (const f of excluded) {
|
|
876
|
+
const problem = lockfileProblem(top, f.file, reviewConfig);
|
|
877
|
+
if (problem !== null) throw new StateError('lockfile', `lockfile ${f.file}: ${problem}`);
|
|
878
|
+
}
|
|
879
|
+
const otherRows = closeRows.filter((r) => r.event !== 'gate.review_excluded');
|
|
880
|
+
closeRows.splice(0, closeRows.length, ...otherRows, ...excluded.map((f) => ({ event: 'gate.review_excluded', block: id, ...f })));
|
|
773
881
|
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
|
|
774
882
|
const key = await loadKey(runId);
|
|
775
883
|
// B52: an empty diff with review rows is never silent — name the likely cause
|
|
@@ -801,7 +909,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
801
909
|
unowned: scope?.unowned ?? [],
|
|
802
910
|
// B52: an old ledger's review.result rows are judged by their signed result files
|
|
803
911
|
resultFiles: await resultFilesFor({ repoRoot: mainTop, runId, block: id, rows }),
|
|
804
|
-
files: reviewsWaived || external !== null ? [] : fileSet,
|
|
912
|
+
files: reviewsWaived || external !== null ? [] : fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null),
|
|
805
913
|
// B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
|
|
806
914
|
// --no-require-reviews); B58: `review.external` does not lift it
|
|
807
915
|
noChanges: !reviewsWaived && fileSet.length === 0,
|
|
@@ -852,7 +960,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
852
960
|
}
|
|
853
961
|
};
|
|
854
962
|
return await (deps.reviewedLines ?? reviewedLines)({
|
|
855
|
-
files: fileSet,
|
|
963
|
+
files: fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null),
|
|
856
964
|
waivedAll,
|
|
857
965
|
ticketOf,
|
|
858
966
|
onNoTicket: (file) => lineWarnings.push(`WARN no ticket for ${file}\n`),
|
|
@@ -879,7 +987,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
879
987
|
const { fileSet } = await tree();
|
|
880
988
|
const lines = await toolLines();
|
|
881
989
|
const given = new Set(lines.map((l) => l.file));
|
|
882
|
-
const missing = missingReviewTickets(text, fileSet.map((f) => f.file)).filter((f) => !given.has(f));
|
|
990
|
+
const missing = missingReviewTickets(text, fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null).map((f) => f.file)).filter((f) => !given.has(f));
|
|
883
991
|
if (missing.length > 0) return { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
884
992
|
for (const file of reportMismatches(text, lines)) lineWarnings.push(`WARN report ticket mismatch ${file}\n`);
|
|
885
993
|
return { ok: true };
|
|
@@ -900,7 +1008,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
900
1008
|
});
|
|
901
1009
|
if (configInForce(record)?.gates?.test != null) {
|
|
902
1010
|
const testGate = async () => {
|
|
903
|
-
|
|
1011
|
+
// A rebase changes the tested tree even when owned commit timestamps are preserved.
|
|
1012
|
+
const lastRebase = rows.reduce((last, row, index) => row.block === id && row.event === 'block.rebase' ? index : last, -1);
|
|
1013
|
+
const green = rows.filter((row, index) => index > lastRebase && row.block === id && row.event === 'gate.done' && row.all_ok === true);
|
|
904
1014
|
// Only commits after the block base that touch its owned paths belong to this block.
|
|
905
1015
|
// Older rows without timestamps remain usable, but a failed history lookup cannot prove freshness.
|
|
906
1016
|
const commit = green.length === 0 ? null : await exec(['git', 'log', '-1', '--format=%ct', `${base}..HEAD`, '--', ...entry.owned_files], {
|
|
@@ -1024,6 +1134,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
1024
1134
|
await warnMovedBase(record.workspace, entry, id, runId, err);
|
|
1025
1135
|
for (const w of lineWarnings) err(w);
|
|
1026
1136
|
for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
|
|
1137
|
+
for (const f of closeRows.filter((r) => r.event === 'gate.review_excluded')) say(`not reviewed (review.exclude ${f.rule}): ${f.file}\n`);
|
|
1027
1138
|
for (const f of waivedAll) {
|
|
1028
1139
|
const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
|
|
1029
1140
|
if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
|
|
@@ -1072,6 +1183,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
1072
1183
|
}
|
|
1073
1184
|
}
|
|
1074
1185
|
} catch (thrown) {
|
|
1186
|
+
if (jsonOpenId && thrown instanceof StateError && ['after-not-done', 'bad-after', 'outcome-not-ready', 'bad-after-outcome'].includes(thrown.code)) {
|
|
1187
|
+
out(`${JSON.stringify({ block: jsonOpenId, event: 'refused', code: thrown.code, message: thrown.message })}\n`);
|
|
1188
|
+
}
|
|
1075
1189
|
err(`block ${sub ?? ''}: ${thrown?.message ?? String(thrown)}\n`);
|
|
1076
1190
|
return thrown instanceof StateError && thrown.code === 'usage' ? 2 : 1;
|
|
1077
1191
|
}
|
|
@@ -1168,7 +1282,7 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
|
|
|
1168
1282
|
|
|
1169
1283
|
/**
|
|
1170
1284
|
* B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
|
|
1171
|
-
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'close'} verb @returns {string}
|
|
1285
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'close' | 'rebase'} verb @returns {string}
|
|
1172
1286
|
*/
|
|
1173
1287
|
export const humanVerbNoTTY = (verb) => verb === 'waive'
|
|
1174
1288
|
? 'block waive needs the human at a terminal to confirm; nothing was recorded (--yes works only for a recorded warning or nit finding, with evidence)'
|
|
@@ -1229,14 +1343,16 @@ async function confirmNoReviews(runId, id, deps) {
|
|
|
1229
1343
|
* `by: autopilot`); human-pass instead refuses an active grant. The
|
|
1230
1344
|
* pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
|
|
1231
1345
|
* asked about a waive or reopen the verb would refuse that way.
|
|
1232
|
-
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance'} verb @param {string} runId @param {string} id
|
|
1346
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'rebase'} verb @param {string} runId @param {string} id
|
|
1233
1347
|
* @param {{finding?: string, file: string, rule?: string, line?: number, hits?: {rule: string, line: number}[], oldCount?: number, newCount?: number}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
1234
1348
|
* @param {() => Promise<unknown> | unknown} [before]
|
|
1235
1349
|
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
1236
1350
|
*/
|
|
1237
1351
|
function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
|
|
1238
1352
|
const message = () =>
|
|
1239
|
-
verb === '
|
|
1353
|
+
verb === 'rebase'
|
|
1354
|
+
? `Accept rebased changes in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1355
|
+
: verb === 'amend-acceptance'
|
|
1240
1356
|
? `Amend acceptance of block ${id} (run ${runId}) from ${what.oldCount} clauses to ${what.newCount} clauses? Only the owner may say yes.`
|
|
1241
1357
|
: verb === 'human-pass'
|
|
1242
1358
|
? `Confirm all device checks of block ${id} (run ${runId}) passed? Only the owner may say yes.`
|
package/src/cli/context.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* (`tmp.root` is fixed for the run), as for the worker. The orchestrator sends the pack's text with
|
|
8
8
|
* every coder task.
|
|
9
9
|
*
|
|
10
|
-
* Prints
|
|
10
|
+
* Prints the pack text; --path-only prints the path then `tokens ~<n>` for legacy callers; with
|
|
11
11
|
* `--json` one line `{"path":…,"tokens":…,"lines":…,"cut":…,"sha256":…}`. Each pack written
|
|
12
12
|
* appends a signed `context.pack {block, path, sha256, tokens, lines, cut}` ledger row — unless
|
|
13
13
|
* the block's last verified `context.pack` row has the same sha256 and path (same pack: no row,
|
|
@@ -16,17 +16,19 @@
|
|
|
16
16
|
* `<block>.md`. A row that cannot be appended removes only the temp file, so an earlier pack stays
|
|
17
17
|
* as it was and nothing was written. A rename that fails after the row was appended says so.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
19
|
+
* The header names this install's exact node/bin command and version. With no `context` key,
|
|
20
|
+
* the pack holds the block header, code-forge section and built-in proof rule (300 lines).
|
|
20
21
|
* Exit codes: 0 written, 1 refused (a rules or lessons file missing or outside
|
|
21
22
|
* the repository, a bad `context` value, a control character, an unknown run or block, a file or
|
|
22
23
|
* ledger write that fails), 2 usage. Never runs any command it lists.
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import { createHash } from 'node:crypto';
|
|
26
|
-
import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
28
|
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
28
30
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
29
|
-
import { assemblePack, ContextError, contextSettings, packTokens, readSources } from '../context/pack.mjs';
|
|
31
|
+
import { assemblePack, ContextError, contextSettings, packTokens, readBlockBrief, readSources } from '../context/pack.mjs';
|
|
30
32
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
31
33
|
import { runRootFor } from '../session/spawn.mjs';
|
|
32
34
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
@@ -37,7 +39,7 @@ import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
|
37
39
|
import { assertBlockId, mainRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
38
40
|
import { writeSafe } from '../util/redact.mjs';
|
|
39
41
|
|
|
40
|
-
const USAGE = 'usage: code-forge context pack --run <id> --block <id> [--json]\n';
|
|
42
|
+
const USAGE = 'usage: code-forge context pack --run <id> --block <id> [--json] [--path-only]\n';
|
|
41
43
|
|
|
42
44
|
/** @param {unknown} err @returns {string} the fs error's code (or message) for a one-line refusal. */
|
|
43
45
|
const fsCode = (err) => /** @type {NodeJS.ErrnoException} */ (err)?.code ?? (err instanceof Error ? err.message : 'error');
|
|
@@ -76,7 +78,7 @@ export async function runContext(args, deps = {}) {
|
|
|
76
78
|
return 2;
|
|
77
79
|
}
|
|
78
80
|
try {
|
|
79
|
-
const { flags, positionals } = parseFlags(rest, { values: ['run', 'block'], booleans: ['json'] });
|
|
81
|
+
const { flags, positionals } = parseFlags(rest, { values: ['run', 'block'], booleans: ['json', 'path-only'] });
|
|
80
82
|
if (positionals.length > 0 || typeof flags.run !== 'string' || typeof flags.block !== 'string') throw new StateError('usage', 'context pack needs --run and --block');
|
|
81
83
|
const runId = flags.run;
|
|
82
84
|
let block;
|
|
@@ -106,7 +108,14 @@ export async function runContext(args, deps = {}) {
|
|
|
106
108
|
level: entry.level ?? null,
|
|
107
109
|
owned: Array.isArray(entry.owned_files) ? entry.owned_files : [],
|
|
108
110
|
tests: Array.isArray(entry.test_files) ? entry.test_files : null,
|
|
111
|
+
brief: readBlockBrief(repoRoot, entry),
|
|
112
|
+
acceptance: entry.acceptance,
|
|
109
113
|
worktree: entry.worktree && typeof entry.worktree.path === 'string' ? { path: entry.worktree.path, branch: entry.worktree.branch } : null,
|
|
114
|
+
codeForge: {
|
|
115
|
+
node: process.execPath,
|
|
116
|
+
bin: fileURLToPath(new URL('../../bin/code-forge.mjs', import.meta.url)),
|
|
117
|
+
version: JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version,
|
|
118
|
+
},
|
|
110
119
|
},
|
|
111
120
|
});
|
|
112
121
|
const sha256 = createHash('sha256').update(text).digest('hex');
|
|
@@ -155,7 +164,8 @@ export async function runContext(args, deps = {}) {
|
|
|
155
164
|
return 1;
|
|
156
165
|
}
|
|
157
166
|
if (flags.json === true) writeSafe(stdout, `${JSON.stringify({ path: file, tokens, lines, cut, sha256 })}\n`);
|
|
158
|
-
else writeSafe(stdout, `${file}\ntokens ~${tokens}\n`);
|
|
167
|
+
else if (flags['path-only'] === true) writeSafe(stdout, `${file}\ntokens ~${tokens}\n`);
|
|
168
|
+
else writeSafe(stdout, text);
|
|
159
169
|
return 0;
|
|
160
170
|
} catch (thrown) {
|
|
161
171
|
if (thrown instanceof StateError && thrown.code === 'usage') {
|