@codedology/code-forge 0.8.0 → 0.10.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 +114 -0
- package/README.md +46 -7
- package/docs/reference/blocks.json +618 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +21 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +29 -4
- package/skill/references/security.md +5 -5
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -2
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +300 -33
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +264 -13
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +7 -5
- package/src/doctor/local.mjs +116 -8
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/ledger/write.mjs +7 -1
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +117 -31
- package/src/review/fixloop.mjs +70 -28
- package/src/review/gate-check.mjs +378 -80
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +256 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/stop-exits.mjs +15 -0
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +11 -1
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +82 -15
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +9 -1
package/src/cli/block.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `code-forge block open|attempt|rebase|claim|close|stop` (plan §4.9). The skill calls
|
|
2
|
+
* `code-forge block open|attempt|rebase|claim|close|stop|waive|reopen` (plan §4.9). The skill calls
|
|
3
3
|
* `block open` before every spawn in every engine; it prints the brief pointer (≤ 200 bytes).
|
|
4
4
|
*
|
|
5
5
|
* block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file>
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* block claim <id> <path> --run <r>
|
|
11
11
|
* block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
|
|
12
12
|
* block stop <id> --run <r> --reason <text>
|
|
13
|
-
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9)
|
|
13
|
+
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9; asks at a terminal, B80)
|
|
14
|
+
* block reopen <id> --run <r> --file <path> --reason <text> (human-only, B72; asks at a terminal, B80)
|
|
14
15
|
*
|
|
15
16
|
* B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
|
|
16
17
|
* text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
|
|
@@ -21,7 +22,8 @@
|
|
|
21
22
|
* `--remove-worktree`). On
|
|
22
23
|
* failure the exit code and stderr are unchanged and stdout stays empty, except a close the gate
|
|
23
24
|
* refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
|
|
24
|
-
* error, a thrown refusal, a cancelled `--no-require-reviews`
|
|
25
|
+
* error, a thrown refusal, a cancelled `--no-require-reviews`, `block waive` or `block reopen` (or
|
|
26
|
+
* one with no terminal, B80) and a failed `--remove-worktree`
|
|
25
27
|
* print nothing on stdout. Without `--json` every output is byte-identical to before.
|
|
26
28
|
*
|
|
27
29
|
* B64b block end: a close that closes or stops the block and `block stop` write
|
|
@@ -77,6 +79,19 @@
|
|
|
77
79
|
* else still refuses — `unowned_change`, `unproven`, `no_changes`, the transcript grep and
|
|
78
80
|
* `closeBlock`'s own rows. With `--no-require-reviews` the human path runs exactly as without the key.
|
|
79
81
|
*
|
|
82
|
+
* B70 (issue #9 a): a file with no approval for its current hash whose real review (a signed
|
|
83
|
+
* `review.round` at that hash) left findings that are all waived — `block waive`, or an autopilot
|
|
84
|
+
* waiver its grant covered for a non-critical — passes as `reviewed_by_waiver`, never approved
|
|
85
|
+
* (`checkBlockReviews`). When the block closes, one signed `gate.review_waived_all {file,
|
|
86
|
+
* content_hash, findings, by}` row per such file is written just before `block.close` (after any
|
|
87
|
+
* other close row); a refused close writes none. The close prints `block <id>: <file>
|
|
88
|
+
* reviewed_by_waiver (<ids>; by <who>)` before the closed line, and `--json` adds
|
|
89
|
+
* `reviewed_by_waiver: [{file, content_hash, status, findings, by}]` (only when there is one).
|
|
90
|
+
* B71: a finding a signed `review.answered` row clears (another owned file resolved it, judged by
|
|
91
|
+
* a reviewer) counts like a waived one, listed under `answered` next to `findings` in the close
|
|
92
|
+
* row and the `--json` entry; the line then reads `(<ids>; by <who>; answered <ids>)`, or
|
|
93
|
+
* `(answered <ids>)` when nothing was waived (`by` is then null).
|
|
94
|
+
*
|
|
80
95
|
* B63b: `context.require_coder_tests: true` in the run's config snapshot (immutable like
|
|
81
96
|
* `review.external`; the live config is never read for it) adds one check after the report check:
|
|
82
97
|
* `--report` must be given and readable, its last sentinel for the block must be COMPLETE, and a
|
|
@@ -128,6 +143,36 @@
|
|
|
128
143
|
* before anything is asked or written. The ledger, the signed result files and the high paths stay
|
|
129
144
|
* in the main tree. Nothing is checked out, pushed or merged.
|
|
130
145
|
*
|
|
146
|
+
* B72 (issues #12, #15): `block reopen <id> --run <r> --file <path> --reason <text>` is the
|
|
147
|
+
* human's restart of a file whose review STOPPED or whose state is LOST (`review/reopen.mjs`):
|
|
148
|
+
* coder-forbidden like `block waive`. The done marker of the current content's ticket is removed
|
|
149
|
+
* (the ticket of the block's worktree when it has one), then one signed `review.reopened {block,
|
|
150
|
+
* file, from, stop_reason, at_seq, from_hash, to_hash, reason, by: human}` row is written, so the
|
|
151
|
+
* next `review-file` runs a full round 1. Refused (exit 1, no row): `unknown_block` and
|
|
152
|
+
* `reopen-block-not-open` (checked before anything is read), a `worktree-*` code (the block's tree
|
|
153
|
+
* cannot be read), `reopen-config` (the run's config snapshot or `tmp.root` cannot be read),
|
|
154
|
+
* `reopen-not-owned`, `reopen-tampered`, `reopen-not-stopped`, `reopen-same-content`,
|
|
155
|
+
* `reopen-already-recorded`, `reopen-write-failed`; a missing `--file` or `--reason` is a usage
|
|
156
|
+
* error (exit 2). It prints `block <id> reopened <file> (from
|
|
157
|
+
* <from>[ <stop reason>]; by: human)`; `--json`: `{"block", "event": "reopened", "file", "from",
|
|
158
|
+
* "stop_reason", "at_seq", "from_hash", "to_hash", "ticket_cleared", "by"}`.
|
|
159
|
+
*
|
|
160
|
+
* B80 (D1 = a): `block waive` and `block reopen` need the human at a terminal, through the same
|
|
161
|
+
* confirm as `--no-require-reviews` (`confirmAtTerminal`). Right after the usage checks and the run
|
|
162
|
+
* record read — before the autopilot expiry checks or anything else is written — stdin or stdout
|
|
163
|
+
* not a TTY prints `block <verb> needs the human at a terminal to confirm; nothing was recorded
|
|
164
|
+
* (there is no --yes)` (exit 2). At a terminal, what the verb would refuse is refused before the
|
|
165
|
+
* question: a `--file` or finding id with a control character or of the wrong form (`usage`, exit
|
|
166
|
+
* 2, `checkFileForm` / `checkFindingId` — the verbs' own checks), an unknown block, for reopen a
|
|
167
|
+
* block that is not open or a file it does not own (`reopen-not-owned`), for waive a file the block
|
|
168
|
+
* does not own (`waive-not-owned`) or a finding id no review row of the block names for the file
|
|
169
|
+
* (`waive-unknown-finding`; `proof` is exempt) — exit 1. A "no" or a cancel prints `block <id>
|
|
170
|
+
* <verb>: cancelled — nothing was recorded` (exit 1); a "yes" runs the verb exactly as before (the
|
|
171
|
+
* same signed row). There is no `--yes` (an unknown flag, exit 2). An active autopilot grant
|
|
172
|
+
* changes nothing here — autopilot waives through its own path (`autopilot waive`, `by:
|
|
173
|
+
* autopilot`). The prompt checks only that stdin and stdout are terminals; a pseudo-terminal
|
|
174
|
+
* passes it, so it does not prove a human. The coder deny-list entries stay.
|
|
175
|
+
*
|
|
131
176
|
* `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
|
|
132
177
|
* rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
|
|
133
178
|
* through `closeBlock`'s `extraChecks`.
|
|
@@ -146,7 +191,7 @@ import { configInForce, externalReviewer, requireCoderTests } from '../state/con
|
|
|
146
191
|
import { StateError } from '../state/paths.mjs';
|
|
147
192
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
148
193
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
149
|
-
import { blockFileSet, changedFiles, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
194
|
+
import { PROOF_FINDING, blockFileSet, changedFiles, openFindings, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
150
195
|
import { checkExpiry } from '../autopilot/grant.mjs';
|
|
151
196
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
152
197
|
import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
|
|
@@ -154,19 +199,79 @@ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
|
154
199
|
import { validateConfig } from '../config/validate.mjs';
|
|
155
200
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
156
201
|
import { exec } from '../util/exec.mjs';
|
|
157
|
-
import { gitChildEnv } from '../worker/ticket.mjs';
|
|
202
|
+
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
203
|
+
import { clearDone } from '../worker/queue.mjs';
|
|
204
|
+
import { loadState } from '../worker/review-state.mjs';
|
|
205
|
+
import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
206
|
+
import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
|
|
207
|
+
import { runRootPath } from '../util/tmp.mjs';
|
|
158
208
|
import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
|
|
159
209
|
import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
|
|
160
210
|
import { blockOpenFindings } from '../review/open-findings.mjs';
|
|
161
211
|
import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
162
212
|
|
|
213
|
+
/**
|
|
214
|
+
* B72: `block reopen` — the file's content is read where the block works (its worktree, else the
|
|
215
|
+
* main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
|
|
216
|
+
* the checks, the done marker's removal and then the signed row are `reopenFile`'s.
|
|
217
|
+
* @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
|
|
218
|
+
* @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
|
|
219
|
+
*/
|
|
220
|
+
async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
|
|
221
|
+
// the stable block codes come first, before any tree, config, key or ledger read
|
|
222
|
+
const entry = openBlockEntry(record, runId, id);
|
|
223
|
+
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
224
|
+
const mainTop = await real(await repoTop(record.workspace));
|
|
225
|
+
const wt = await blockTree(record.workspace, entry).catch((thrown) => {
|
|
226
|
+
if (typeof thrown?.code === 'string' && thrown.code.startsWith('worktree-')) throw new StateError(thrown.code, `block ${id}: ${thrown.message}, so the file's content cannot be read; nothing was recorded`);
|
|
227
|
+
throw thrown;
|
|
228
|
+
});
|
|
229
|
+
const contentRoot = wt === null ? mainTop : wt.top;
|
|
230
|
+
const wsPrefix = path.relative(mainTop, await real(record.workspace)).split(path.sep).join('/');
|
|
231
|
+
// the state is read where the worker keeps it: an unreadable snapshot or tmp.root is refused,
|
|
232
|
+
// never guessed (a wrong root would read as "no state"); only an absent tmp.root (undefined or
|
|
233
|
+
// null) means the default root, any other non-string is refused (B79)
|
|
234
|
+
/** @type {string} */
|
|
235
|
+
let runRootDir;
|
|
236
|
+
try {
|
|
237
|
+
const root = configInForce(record)?.tmp?.root;
|
|
238
|
+
if (root !== undefined && root !== null && typeof root !== 'string') throw new TypeError('tmp.root is not a string');
|
|
239
|
+
runRootDir = runRootPath(runId, typeof root === 'string' ? { root } : {});
|
|
240
|
+
} catch {
|
|
241
|
+
throw new StateError('reopen-config', `reopen-config: the run's config snapshot or its tmp.root cannot be read, so the review state of ${file} cannot be found; nothing was recorded`);
|
|
242
|
+
}
|
|
243
|
+
const key = await loadKey(runId);
|
|
244
|
+
const rows = await readAllRows(record.project);
|
|
245
|
+
const tree = contentRoot === mainTop ? {} : { tree: contentRoot };
|
|
246
|
+
const { row, cleared } = await reopenFile({
|
|
247
|
+
runId,
|
|
248
|
+
id,
|
|
249
|
+
file,
|
|
250
|
+
reason,
|
|
251
|
+
record,
|
|
252
|
+
rows,
|
|
253
|
+
key,
|
|
254
|
+
writeRow,
|
|
255
|
+
writeSigned,
|
|
256
|
+
io: {
|
|
257
|
+
owns: ownsAt(wsPrefix),
|
|
258
|
+
loadState: (where) => loadState({ .../** @type {any} */ (where), runRootDir, contentRoot, migrate: false }),
|
|
259
|
+
contentHash: (f) => contentHash(contentRoot, f),
|
|
260
|
+
// C11 (synchronous): the cached result of the current content's ticket would repeat the stop
|
|
261
|
+
clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
return { row, cleared };
|
|
265
|
+
}
|
|
266
|
+
|
|
163
267
|
const USAGE =
|
|
164
268
|
'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] [--tests <paths…>]\n' +
|
|
165
269
|
' 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' +
|
|
166
270
|
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
|
|
271
|
+
' code-forge block reopen <id> --run <r> --file <path> --reason <text>\n' +
|
|
167
272
|
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
168
273
|
|
|
169
|
-
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'];
|
|
274
|
+
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen'];
|
|
170
275
|
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
|
|
171
276
|
|
|
172
277
|
/**
|
|
@@ -178,7 +283,8 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
178
283
|
* removeBlockTree?: typeof removeBlockTree,
|
|
179
284
|
* }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
|
|
180
285
|
* (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
|
|
181
|
-
* `@clack/prompts`):
|
|
286
|
+
* `@clack/prompts`): the human's confirmation (B52 `--no-require-reviews`, B80 `block waive` and
|
|
287
|
+
* `block reopen`), which needs both to be TTYs.
|
|
182
288
|
* `removeBlockTree` (B64b, tests only): the `--remove-worktree` step, default the real one.
|
|
183
289
|
* @returns {Promise<number>}
|
|
184
290
|
*/
|
|
@@ -214,11 +320,26 @@ export async function runBlock(args, deps = {}) {
|
|
|
214
320
|
}
|
|
215
321
|
if (sub !== 'close' && flags['remove-worktree'] === true) throw new StateError('usage', '--remove-worktree is a block close flag');
|
|
216
322
|
if (sub === 'claim' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block claim needs exactly one path');
|
|
217
|
-
if ((sub === 'stop' || sub === 'waive') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
|
|
323
|
+
if ((sub === 'stop' || sub === 'waive' || sub === 'reopen') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
|
|
218
324
|
if (sub === 'waive' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block waive needs exactly one finding id');
|
|
325
|
+
if (sub === 'reopen' && positionals.length !== 1) throw new StateError('usage', 'block reopen takes one block id (the file goes in --file)');
|
|
326
|
+
if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
|
|
219
327
|
if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
|
|
220
328
|
const record = await readRun(runId);
|
|
221
|
-
|
|
329
|
+
// B80: waive and reopen are the human's — asked at a terminal before anything is written (not
|
|
330
|
+
// even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
|
|
331
|
+
if (sub === 'waive' || sub === 'reopen') {
|
|
332
|
+
const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps);
|
|
333
|
+
if (asked === 'no-tty') {
|
|
334
|
+
err(`${humanVerbNoTTY(sub)}\n`);
|
|
335
|
+
return 2;
|
|
336
|
+
}
|
|
337
|
+
if (asked === 'cancelled') {
|
|
338
|
+
err(`block ${id} ${sub}: cancelled — nothing was recorded\n`);
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
222
343
|
// B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
|
|
223
344
|
// then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
|
|
224
345
|
// done file is the only addition: no row, and every output stays exactly as before.
|
|
@@ -367,6 +488,14 @@ export async function runBlock(args, deps = {}) {
|
|
|
367
488
|
if (json) emit({ block: id, event: 'waived', finding: extra, file: flags.file, by: 'human' });
|
|
368
489
|
return 0;
|
|
369
490
|
}
|
|
491
|
+
if (sub === 'reopen') {
|
|
492
|
+
const file = /** @type {string} */ (flags.file);
|
|
493
|
+
const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow });
|
|
494
|
+
const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
|
|
495
|
+
say(`block ${id} reopened ${file} (from ${fromText}; by: human)\n`);
|
|
496
|
+
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: 'human' });
|
|
497
|
+
return 0;
|
|
498
|
+
}
|
|
370
499
|
if (sub === 'close') {
|
|
371
500
|
const entry = record.blocks?.[id];
|
|
372
501
|
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
@@ -430,6 +559,22 @@ export async function runBlock(args, deps = {}) {
|
|
|
430
559
|
const fileSet = await blockFileSet({ repoRoot: top, base, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
|
|
431
560
|
return { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed };
|
|
432
561
|
})());
|
|
562
|
+
// B58: the config waiver row is written by `closeBlock` under the run lock, just before
|
|
563
|
+
// `block.close` and only when every other check passed — never by a refused close
|
|
564
|
+
/** @type {Record<string, any>[]} */
|
|
565
|
+
const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
|
|
566
|
+
/** @type {import('../review/gate-check.mjs').WaivedAll[]} */
|
|
567
|
+
let waivedAll = [];
|
|
568
|
+
// B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
|
|
569
|
+
// `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
|
|
570
|
+
// when the block closes — a refused close writes none)
|
|
571
|
+
const onWaivedAll = (/** @type {import('../review/gate-check.mjs').WaivedAll[]} */ files) => {
|
|
572
|
+
// idempotent: the waived rows are rebuilt from the final list, never appended per call
|
|
573
|
+
// (closeBlock reads closeRows only after every extra check passed)
|
|
574
|
+
waivedAll = files;
|
|
575
|
+
const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
|
|
576
|
+
closeRows.splice(0, closeRows.length, ...others, ...files.map((f) => ({ event: 'gate.review_waived_all', block: id, file: f.file, content_hash: f.content_hash, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })));
|
|
577
|
+
};
|
|
433
578
|
const reviews = reviewGateCheck(async () => {
|
|
434
579
|
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
435
580
|
if (transcript === null) {
|
|
@@ -479,7 +624,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
479
624
|
proofFiles: fileSet,
|
|
480
625
|
highPaths,
|
|
481
626
|
};
|
|
482
|
-
});
|
|
627
|
+
}, { onWaivedAll });
|
|
483
628
|
// B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
|
|
484
629
|
const report = async () => {
|
|
485
630
|
if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
|
|
@@ -493,10 +638,6 @@ export async function runBlock(args, deps = {}) {
|
|
|
493
638
|
const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
|
|
494
639
|
return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
495
640
|
};
|
|
496
|
-
// B58: the config waiver row is written by `closeBlock` under the run lock, just before
|
|
497
|
-
// `block.close` and only when every other check passed — never by a refused close
|
|
498
|
-
/** @type {Record<string, any>[]} */
|
|
499
|
-
const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
|
|
500
641
|
/** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
|
|
501
642
|
const extraChecks = [reviews, report];
|
|
502
643
|
// B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
|
|
@@ -530,10 +671,28 @@ export async function runBlock(args, deps = {}) {
|
|
|
530
671
|
// does — also when a later step (the output, `--remove-worktree`) throws; it never changes
|
|
531
672
|
// the exit code or stdout (`runHook` never throws)
|
|
532
673
|
try {
|
|
674
|
+
// B70: each file that passed as reviewed_by_waiver is named before the closed line
|
|
675
|
+
// B71: an answered finding is named as answered; a file with only answered findings has no `by`
|
|
676
|
+
if (result.ok) {
|
|
677
|
+
for (const f of waivedAll) {
|
|
678
|
+
const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
|
|
679
|
+
if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
|
|
680
|
+
say(`block ${id}: ${f.file} reviewed_by_waiver (${parts.join('; ')})\n`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
533
683
|
(result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
534
684
|
// B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
|
|
535
685
|
if (json && !result.ok) emit({ block: id, ok: false, reason: result.reason ?? null, ...(end?.json ?? {}) });
|
|
536
|
-
const closedJson = {
|
|
686
|
+
const closedJson = {
|
|
687
|
+
block: id,
|
|
688
|
+
event: result.status,
|
|
689
|
+
status: result.status,
|
|
690
|
+
reason: result.reason ?? null,
|
|
691
|
+
close_event: result.event ?? null,
|
|
692
|
+
...(end?.json ?? {}),
|
|
693
|
+
// B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
|
|
694
|
+
...(result.ok && waivedAll.length > 0 ? { reviewed_by_waiver: waivedAll.map((f) => ({ file: f.file, content_hash: f.content_hash, status: f.status, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })) } : {}),
|
|
695
|
+
};
|
|
537
696
|
if (!result.ok || !removeTree) {
|
|
538
697
|
if (json && result.ok) emit(closedJson);
|
|
539
698
|
return result.ok ? 0 : 1;
|
|
@@ -606,31 +765,139 @@ async function coderTests(reportFlag, id) {
|
|
|
606
765
|
export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a terminal to confirm; the block stays open (there is no --yes)';
|
|
607
766
|
|
|
608
767
|
/**
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
* script, the delegate's tooling), so the human confirms at a terminal and there is NO `--yes`.
|
|
612
|
-
* No terminal — stdin AND stdout must both be TTYs (a piped stdout means nobody may see the
|
|
613
|
-
* question) — ⇒ `usage` (exit 2); an active autopilot grant on the run ⇒ `autopilot-active`
|
|
614
|
-
* (exit 1; the owner stops the grant first) — nothing is written either way. `deps.stdinIsTTY` /
|
|
615
|
-
* `deps.stdoutIsTTY` inject each side; `deps.isTTY` injects both at once.
|
|
616
|
-
* @param {string} runId @param {string} id
|
|
617
|
-
* @param {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} deps
|
|
618
|
-
* @returns {Promise<'confirmed' | 'cancelled'>}
|
|
619
|
-
* @throws {StateError} `usage`, `autopilot-active`
|
|
768
|
+
* B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
|
|
769
|
+
* @param {'waive' | 'reopen'} verb @returns {string}
|
|
620
770
|
*/
|
|
621
|
-
|
|
771
|
+
export const humanVerbNoTTY = (verb) => `block ${verb} needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`;
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* @typedef {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} ConfirmDeps
|
|
775
|
+
*/
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* The ONE human confirm of the `block` verb (B52 `--no-require-reviews`, B80 `block waive` and
|
|
779
|
+
* `block reopen`): a flag or a verb can be run by any process (the orchestrator, a script, a
|
|
780
|
+
* coder), so the human answers a yes/no question at a terminal and there is NO `--yes`. No
|
|
781
|
+
* terminal — stdin AND stdout must both be TTYs (a piped stdout means nobody may see the
|
|
782
|
+
* question) — is `no-tty`, decided before anything is read or asked. `before` runs between the
|
|
783
|
+
* terminal check and the question (a refusal it throws means the human is never asked).
|
|
784
|
+
* `deps.stdinIsTTY` / `deps.stdoutIsTTY` inject each side, `deps.isTTY` both at once, `deps.ui`
|
|
785
|
+
* the prompt (default `@clack/prompts`). A "no" or a cancel (Ctrl-C) is `cancelled`.
|
|
786
|
+
* @param {ConfirmDeps} deps @param {string} message
|
|
787
|
+
* @param {() => Promise<unknown> | unknown} [before]
|
|
788
|
+
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
789
|
+
*/
|
|
790
|
+
async function confirmAtTerminal(deps, message, before) {
|
|
622
791
|
const stdinTTY = deps.stdinIsTTY ?? deps.isTTY ?? process.stdin.isTTY === true;
|
|
623
792
|
const stdoutTTY = deps.stdoutIsTTY ?? deps.isTTY ?? process.stdout.isTTY === true;
|
|
624
|
-
if (!(stdinTTY && stdoutTTY))
|
|
625
|
-
|
|
626
|
-
if (grant.state === 'active') {
|
|
627
|
-
throw new StateError('autopilot-active', `--no-require-reviews is refused while an autopilot grant is active on run ${runId} (stop it first: code-forge autopilot stop --run ${runId})`);
|
|
628
|
-
}
|
|
793
|
+
if (!(stdinTTY && stdoutTTY)) return 'no-tty';
|
|
794
|
+
if (before) await before();
|
|
629
795
|
const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
630
|
-
const go = await ui.confirm({ message
|
|
796
|
+
const go = await ui.confirm({ message, initialValue: false });
|
|
631
797
|
return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
|
|
632
798
|
}
|
|
633
799
|
|
|
800
|
+
/**
|
|
801
|
+
* B52: `--no-require-reviews` lifts every per-file review check, so it is the human's alone — the
|
|
802
|
+
* same rule as `autopilot approve` (`confirmAtTerminal`). No terminal ⇒ `usage` (exit 2); an
|
|
803
|
+
* active autopilot grant on the run ⇒ `autopilot-active` (exit 1; the owner stops the grant
|
|
804
|
+
* first) — nothing is written either way.
|
|
805
|
+
* @param {string} runId @param {string} id @param {ConfirmDeps} deps
|
|
806
|
+
* @returns {Promise<'confirmed' | 'cancelled'>}
|
|
807
|
+
* @throws {StateError} `usage`, `autopilot-active`
|
|
808
|
+
*/
|
|
809
|
+
async function confirmNoReviews(runId, id, deps) {
|
|
810
|
+
const asked = await confirmAtTerminal(deps, `Close block ${id} of run ${runId} WITHOUT the per-file review checks? Only the owner may say yes.`, async () => {
|
|
811
|
+
const grant = await checkExpiry(runId, (deps.now ?? (() => new Date()))());
|
|
812
|
+
if (grant.state === 'active') {
|
|
813
|
+
throw new StateError('autopilot-active', `--no-require-reviews is refused while an autopilot grant is active on run ${runId} (stop it first: code-forge autopilot stop --run ${runId})`);
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
if (asked === 'no-tty') throw new StateError('usage', NO_REVIEWS_NO_TTY);
|
|
817
|
+
return asked;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* B80: `block waive` and `block reopen` are the human's alone (D1 = a): the human confirms at a
|
|
822
|
+
* terminal through `confirmAtTerminal`, exactly like `--no-require-reviews`, but an active
|
|
823
|
+
* autopilot grant changes nothing (autopilot waives through its own path, `by: autopilot`). The
|
|
824
|
+
* pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
|
|
825
|
+
* asked about a waive or reopen the verb would refuse that way.
|
|
826
|
+
* @param {'waive' | 'reopen'} verb @param {string} runId @param {string} id
|
|
827
|
+
* @param {{finding?: string, file: string}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
828
|
+
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
829
|
+
*/
|
|
830
|
+
function confirmHumanVerb(verb, runId, id, what, record, deps) {
|
|
831
|
+
const message =
|
|
832
|
+
verb === 'waive'
|
|
833
|
+
? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
834
|
+
: `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
|
|
835
|
+
return confirmAtTerminal(deps, message, async () => {
|
|
836
|
+
if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
|
|
837
|
+
else await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Does the block own `file`, by the rule both verbs use (`ownsAt` with the workspace's prefix
|
|
843
|
+
* inside its repo)?
|
|
844
|
+
* @param {Record<string, any>} record @param {Record<string, any>} entry @param {string} file
|
|
845
|
+
* @returns {Promise<boolean>}
|
|
846
|
+
*/
|
|
847
|
+
async function blockOwnsFile(record, entry, file) {
|
|
848
|
+
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
849
|
+
const mainTop = await real(await repoTop(record.workspace));
|
|
850
|
+
const wsPrefix = path.relative(mainTop, await real(record.workspace)).split(path.sep).join('/');
|
|
851
|
+
const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
|
|
852
|
+
return ownsAt(wsPrefix)(owned, file, entry.owned_kinds);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* B80: the checks a reopen must pass BEFORE the human is asked, in `reopenFile`'s order: the
|
|
857
|
+
* `--file` form (`checkFileForm`, the reopen rule: `usage`), the block (`openBlockEntry`:
|
|
858
|
+
* `unknown_block`, `reopen-block-not-open`) and ownership (`reopen-not-owned`, the same message).
|
|
859
|
+
* The state checks that read the key and the ledger stay the verb's.
|
|
860
|
+
* @param {Record<string, any>} record @param {string} runId @param {string} id @param {string} file
|
|
861
|
+
* @returns {Promise<void>}
|
|
862
|
+
* @throws {StateError}
|
|
863
|
+
*/
|
|
864
|
+
async function reopenPrecheck(record, runId, id, file) {
|
|
865
|
+
checkFileForm(file, { normalized: true });
|
|
866
|
+
const entry = openBlockEntry(record, runId, id);
|
|
867
|
+
if (!(await blockOwnsFile(record, entry, file))) throw new StateError('reopen-not-owned', `reopen-not-owned: block ${id} does not own ${file}; nothing was recorded`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* B80: the checks a waive must pass BEFORE the human is asked, so the human is never asked about a
|
|
872
|
+
* waive the verb would refuse, in `waiveFinding`'s order: the finding id and `--file` have the
|
|
873
|
+
* forms `waiveFinding` takes (`checkFindingId`, `checkFileForm`: `usage`, the same helpers); the
|
|
874
|
+
* block is in the run (`unknown_block`); the block owns the file
|
|
875
|
+
* (`waive-not-owned`, the reopen's ownership rule through `ownsAt`); and, except for the proof
|
|
876
|
+
* finding (`proof`, which no review row names), a review row of this run and block for the file
|
|
877
|
+
* names the id (`waive-unknown-finding`): `review.triage` / `review.late_finding` `finding`, a
|
|
878
|
+
* `review.result` `open` id (as the gate reads it, `openFindings`) or a `review.cap` `open` id.
|
|
879
|
+
* @param {Record<string, any>} record @param {string} runId @param {string} id
|
|
880
|
+
* @param {string} finding @param {string} file
|
|
881
|
+
* @returns {Promise<void>}
|
|
882
|
+
* @throws {StateError}
|
|
883
|
+
*/
|
|
884
|
+
async function waivePrecheck(record, runId, id, finding, file) {
|
|
885
|
+
checkFindingId(finding);
|
|
886
|
+
checkFileForm(file, { normalized: false });
|
|
887
|
+
const entry = record.blocks?.[id];
|
|
888
|
+
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
889
|
+
if (!(await blockOwnsFile(record, entry, file))) throw new StateError('waive-not-owned', `waive-not-owned: block ${id} does not own ${file}; nothing was recorded`);
|
|
890
|
+
if (finding === PROOF_FINDING) return;
|
|
891
|
+
const named = (await readAllRows(record.project)).some((r) => {
|
|
892
|
+
if (r?.run !== runId || r.block !== id || r.file !== file) return false;
|
|
893
|
+
if (r.event === 'review.triage' || r.event === 'review.late_finding') return r.finding === finding;
|
|
894
|
+
if (r.event === 'review.result') return Array.isArray(r.open) && openFindings(r.open).some((o) => o.id === finding);
|
|
895
|
+
if (r.event === 'review.cap') return Array.isArray(r.open) && r.open.includes(finding);
|
|
896
|
+
return false;
|
|
897
|
+
});
|
|
898
|
+
if (!named) throw new StateError('waive-unknown-finding', `waive-unknown-finding: no review row of block ${id} names finding ${finding} in ${file}; nothing was recorded`);
|
|
899
|
+
}
|
|
900
|
+
|
|
634
901
|
/**
|
|
635
902
|
* B59: the rules lane for a brief-mode block — `fallbackLane` over the facts of its owned entries,
|
|
636
903
|
* the `--lines` estimate and the snapshot's `proof.tiers.high.paths` (none for a record from
|
package/src/cli/jev.mjs
CHANGED
|
@@ -23,11 +23,17 @@
|
|
|
23
23
|
* subfolder thus writes to the project's ledger. An unknown flag is a usage error.
|
|
24
24
|
*
|
|
25
25
|
* Resolves the Jev key through B2's chain (`resolveKey`, earlier batch — the seam rule allows a
|
|
26
|
-
* Wave 1 module to import a block in an earlier batch statically)
|
|
26
|
+
* Wave 1 module to import a block in an earlier batch statically) with the project's
|
|
27
|
+
* `keys.cache_hours` (B84; an invalid value warns once and uses 8 h), asks Jev, runs the answer
|
|
27
28
|
* through `thresholds.decide()`, and appends one `{event: 'decision', ...}` row through B6's
|
|
28
29
|
* writer (also an earlier batch) — the shape `ledger/calibration.mjs` already expects:
|
|
29
30
|
* `decision_id`, `question`, `confidence` (plan §6.3's calibration buckets read exactly these).
|
|
30
31
|
*
|
|
32
|
+
* B85: when no key resolved because the cached one expired and 1Password did not answer, and the
|
|
33
|
+
* reference is `op://…`, the generic "no Jev key resolved" line is replaced by one re-link line
|
|
34
|
+
* naming the expired time and the exact `code-forge keys set jev --op <ref>` command (the
|
|
35
|
+
* 1Password reasons still print first; same text with or without a TTY).
|
|
36
|
+
*
|
|
31
37
|
* Never prints the key: it only ever reaches the `Authorization` header inside `askJev`, and
|
|
32
38
|
* `resolveKey` has already registered it with `redact()` before this module sees it.
|
|
33
39
|
*/
|
|
@@ -40,7 +46,7 @@ import { buildQuestionPayload, isKnownQuestion, isQuestionDisabled } from '../de
|
|
|
40
46
|
import { askJev as realAskJev } from '../decide/jev-client.mjs';
|
|
41
47
|
import { decide } from '../decide/thresholds.mjs';
|
|
42
48
|
import { resolveDispatchQuestionsByRules } from '../decide/fallback-rules.mjs';
|
|
43
|
-
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
49
|
+
import { cacheHoursFrom, createDefaultKeyStore, isTime, resolveKey, shellWord } from '../keys/store.mjs';
|
|
44
50
|
import { appendRow as realAppendRow } from '../ledger/write.mjs';
|
|
45
51
|
import { writeSafe } from '../util/redact.mjs';
|
|
46
52
|
|
|
@@ -113,6 +119,7 @@ function badRuleFacts(state) {
|
|
|
113
119
|
* @param {object} deps
|
|
114
120
|
* @param {import('../keys/store.mjs').KeyStore} [deps.store]
|
|
115
121
|
* @param {typeof loadConfigFile} [deps.loadConfig]
|
|
122
|
+
* @param {typeof import('../keys/onepassword.mjs').opRead} [deps.opRead] - the 1Password reader (tests inject a fake).
|
|
116
123
|
* @param {typeof realAskJev} [deps.askJev]
|
|
117
124
|
* @param {typeof realAppendRow} [deps.appendRow]
|
|
118
125
|
* @param {{write: (s: string) => unknown}} [deps.stdout]
|
|
@@ -126,6 +133,7 @@ export async function runJev(args, deps = {}) {
|
|
|
126
133
|
const {
|
|
127
134
|
store,
|
|
128
135
|
loadConfig = loadConfigFile,
|
|
136
|
+
opRead,
|
|
129
137
|
askJev: ask = realAskJev,
|
|
130
138
|
appendRow: write = realAppendRow,
|
|
131
139
|
stdout = process.stdout,
|
|
@@ -218,9 +226,14 @@ export async function runJev(args, deps = {}) {
|
|
|
218
226
|
|
|
219
227
|
const keyStore = store ?? (await createDefaultKeyStore(env));
|
|
220
228
|
const keyRef = keyRefFlag.value ?? cfg?.keys?.jev;
|
|
229
|
+
// B84: the project's cache time; an invalid one warns once and falls back to the default
|
|
230
|
+
const cache = cacheHoursFrom(cfg);
|
|
231
|
+
if (cache.problem !== null) {
|
|
232
|
+
err(`WARN jev ask: ${cache.problem}; caching for the default ${cache.hours} h\n`);
|
|
233
|
+
}
|
|
221
234
|
let resolved;
|
|
222
235
|
try {
|
|
223
|
-
resolved = await resolveKey('jev', { store: keyStore, ref: keyRef, env, now: now() });
|
|
236
|
+
resolved = await resolveKey('jev', { store: keyStore, ref: keyRef, env, now: now(), cacheHours: cache.hours, ...(opRead ? { opRead } : {}) });
|
|
224
237
|
} catch (thrown) {
|
|
225
238
|
err(`jev ask: ${thrown?.message ?? String(thrown)}\n`);
|
|
226
239
|
return 2;
|
|
@@ -229,6 +242,12 @@ export async function runJev(args, deps = {}) {
|
|
|
229
242
|
for (const reason of resolved.errors) {
|
|
230
243
|
err(`jev ask: ${reason}\n`);
|
|
231
244
|
}
|
|
245
|
+
// `expired` is the dropped entry's exp in ms; anything that is not a valid time gives the
|
|
246
|
+
// generic line
|
|
247
|
+
if (isTime(resolved.expired) && typeof keyRef === 'string' && keyRef.startsWith('op://')) {
|
|
248
|
+
err(`jev ask: the cached jev key expired at ${new Date(resolved.expired).toISOString()} and 1Password did not answer; re-link: code-forge keys set jev --op ${shellWord(keyRef)}\n`);
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
232
251
|
err('jev ask: no Jev key resolved (set one with "code-forge keys set jev")\n');
|
|
233
252
|
return 1;
|
|
234
253
|
}
|