forgedock 1.26.0 → 1.27.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/bin/engine/outcome-codes.mjs +3 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +6 -0
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +78 -20
- package/bin/engine.mjs +5 -1
- package/bin/forgedock.mjs +94 -30
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/batch.mjs +2 -1
- package/bin/lean/batch.test.mjs +49 -1
- package/bin/lean/conformance.mjs +76 -10
- package/bin/lean/conformance.test.mjs +32 -3
- package/bin/lean/effects.mjs +96 -11
- package/bin/lean/lean.mjs +326 -33
- package/bin/lean/lean.test.mjs +618 -10
- package/bin/lean/ledger.mjs +67 -0
- package/bin/lean/ledger.test.mjs +65 -0
- package/bin/lean/orchestrate.mjs +9 -0
- package/bin/lean/prompts.mjs +42 -8
- package/bin/lean/render.mjs +1 -1
- package/bin/lean/verify.mjs +113 -0
- package/bin/lean/verify.test.mjs +95 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +3 -8
- package/commands/forgedock-init.md +118 -6
- package/commands/review-pr.md +2 -2
- package/docs/CONFIG.md +52 -3
- package/docs/OUTCOME-CODES.md +3 -0
- package/package.json +1 -1
- package/scripts/bootstrap-validate.mjs +287 -0
- package/scripts/check-devdocs-freshness.sh +120 -0
- package/scripts/check-devdocs-freshness.test.sh +120 -0
- package/scripts/ci-local/run.mjs +13 -2
- package/scripts/ci-local.test.sh +13 -1
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +150 -0
- package/scripts/structure-delta.test.sh +135 -0
|
@@ -36,9 +36,11 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
36
36
|
BASE_CONFLICT: "base.conflict",
|
|
37
37
|
RATE_LIMITED: "rate_limited",
|
|
38
38
|
HOST_BUSY: "host.busy",
|
|
39
|
+
GH_UNAVAILABLE: "gh.unavailable", // forge#4157: `gh` unreachable after retries during the --repo identity check; run deferred, nothing posted
|
|
39
40
|
MERGE_TRANSIENT: "merge.transient",
|
|
40
41
|
PUSH_TRANSIENT: "push.transient",
|
|
41
42
|
CLI_IDLE: "cli.idle",
|
|
43
|
+
RUN_INTERRUPTED: "run.interrupted", // forge#4179: SIGTERM/SIGINT on a core run; teardown ran from the ledger, lane requeued
|
|
42
44
|
PATH_SWITCH_CONFLICT: "path.switch_conflict",
|
|
43
45
|
ENGINE_ERROR: "engine.error",
|
|
44
46
|
INFRA_DISK_QUOTA: "infra.disk_quota",
|
|
@@ -74,6 +76,7 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
74
76
|
CONFORMANCE_UNJUSTIFIED_DEFERRAL: "conformance.unjustified_deferral",
|
|
75
77
|
CONFORMANCE_PRESERVE_VIOLATED: "conformance.preserve_violated",
|
|
76
78
|
CONFORMANCE_REGRESSION: "conformance.regression",
|
|
79
|
+
STRUCTURE_UNDECLARED_EDGE: "structure.undeclared_edge", // forge#4159: a workspace package.json edge the contract does not name
|
|
77
80
|
FOLLOWUP_UNAVAILABLE: "followup.unavailable",
|
|
78
81
|
MERGE_HEAD_MISMATCH: "merge.head_mismatch",
|
|
79
82
|
// Non-failure terminals, so a RUN_TERMINAL for a healthy run is not counted as a gap.
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -349,12 +349,48 @@ export async function openDependency(issue, io) {
|
|
|
349
349
|
return { unreadable: true, detail: `dependency #${dep} of issue ${issue} could not be read: ${e?.message || e}` };
|
|
350
350
|
}
|
|
351
351
|
const st = typeof d?.state === "string" ? d.state.toLowerCase() : "";
|
|
352
|
-
if (st === "open")
|
|
352
|
+
if (st === "open") {
|
|
353
|
+
// #4153: an open decomposed parent is a tracker; the edge resolves to its sub-issues (satisfied when all close).
|
|
354
|
+
const subs = isDecomposedRecord(d) ? await decomposedSubissues(dep, io) : null;
|
|
355
|
+
if (!subs) { open.push(dep); continue; }
|
|
356
|
+
for (const s of subs) {
|
|
357
|
+
let sr;
|
|
358
|
+
try { sr = await readIssueRecord(s, io); } catch (e) {
|
|
359
|
+
return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} could not be read: ${e?.message || e}` };
|
|
360
|
+
}
|
|
361
|
+
const ss = typeof sr?.state === "string" ? sr.state.toLowerCase() : "";
|
|
362
|
+
if (ss === "open") open.push(s);
|
|
363
|
+
else if (ss !== "closed") return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} has no readable state` };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
353
366
|
else if (st !== "closed") return { unreadable: true, detail: `dependency #${dep} of issue ${issue} has no readable state` };
|
|
354
367
|
}
|
|
355
|
-
return open.length ? { open } : null;
|
|
368
|
+
return open.length ? { open: [...new Set(open)].filter((n) => n !== Number(issue)) } : null;
|
|
356
369
|
}
|
|
357
370
|
|
|
371
|
+
/**
|
|
372
|
+
* #4153: sub-issues of a decomposed parent, from the `- #N` lines of its newest trusted `<!-- FORGE:DECOMPOSED -->`
|
|
373
|
+
* comment (lean `- #N title` and decompose.md `- #N: title`). `null` when there is none, it lists no issue, it was
|
|
374
|
+
* revoked, or the trail is unreadable: the caller then keeps the parent as the dependency (fail closed).
|
|
375
|
+
*/
|
|
376
|
+
export async function decomposedSubissues(issue, io) {
|
|
377
|
+
let comments;
|
|
378
|
+
try { comments = (await issueMarkers(issue, io)).comments; } catch { return null; }
|
|
379
|
+
const head = /^\s*<!--\s*FORGE:DECOMPOSED\s*-->/;
|
|
380
|
+
const revoked = /^\s*<!--\s*FORGE:DECOMPOSED:REVOKED(?![A-Za-z0-9_])/;
|
|
381
|
+
let rec = null;
|
|
382
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
383
|
+
if (revoked.test(comments[i])) return null;
|
|
384
|
+
if (head.test(comments[i])) { rec = comments[i]; break; }
|
|
385
|
+
}
|
|
386
|
+
if (!rec) return null;
|
|
387
|
+
const subs = [...new Set([...rec.matchAll(/^[ \t]*[-*][ \t]+#(\d+)\b/gm)].map((m) => Number(m[1])))].filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
|
|
388
|
+
return subs.length ? subs : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** True when a REST/`gh --json` issue record carries the `workflow:decomposed` label. */
|
|
392
|
+
export const isDecomposedRecord = (rec) => (rec?.labels ?? []).some((l) => (l?.name ?? l) === "workflow:decomposed");
|
|
393
|
+
|
|
358
394
|
/**
|
|
359
395
|
* Decompose loop guard (mirrors decompose.md Guard 1; forge#3676). Returns a
|
|
360
396
|
* short reason string when the issue must NOT be routed to decompose, else null:
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* seam onto FORGE:TELEMETRY events so `forgedock stats` can rebuild Cost, Time and outcome codes from GitHub alone:
|
|
6
6
|
* PHASE_ATTEMPT_USAGE -> `phase_attempt` (attempt, model, turns, duration_ms, cost_usd, *_tokens)
|
|
7
7
|
* RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms)
|
|
8
|
+
* STRUCTURE_DELTA -> `structure_delta` (edges_added/removed, import_files_added/removed, boundary_changed/loosened 0|1; forge#4159)
|
|
8
9
|
* Other records are ignored. The returned function NEVER throws and honours FORGE_TELEMETRY=off. Telemetry values
|
|
9
10
|
* are scalars (the emitter stringifies objects), so the `usage` object is flattened here; absent values are omitted.
|
|
10
11
|
*/
|
|
@@ -37,6 +38,11 @@ export function mapRecord(rec, wallMs) {
|
|
|
37
38
|
if (rec.event === "RUN_TERMINAL") {
|
|
38
39
|
return { event: "run_terminal", phase: null, payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs }) };
|
|
39
40
|
}
|
|
41
|
+
if (rec.event === "STRUCTURE_DELTA") {
|
|
42
|
+
const bit = (v) => (typeof v === "boolean" ? Number(v) : v);
|
|
43
|
+
return { event: "structure_delta", phase: rec.phase ?? "implement", payload: compact({ edges_added: rec.edges_added, edges_removed: rec.edges_removed,
|
|
44
|
+
import_files_added: rec.import_files_added, import_files_removed: rec.import_files_removed, boundary_changed: bit(rec.boundary_changed), boundary_loosened: bit(rec.boundary_loosened) }) };
|
|
45
|
+
}
|
|
40
46
|
if (rec.event === "CI_PENDING_WAIT") {
|
|
41
47
|
return { event: "ci_pending_wait", phase: rec.phase ?? "review", payload: compact({ pr: rec.pr, minutes_waited: rec.minutes_waited, result: rec.result, polls: rec.polls, rate_limit_sleeps: rec.rate_limit_sleeps }) };
|
|
42
48
|
}
|
|
@@ -37,6 +37,10 @@ describe("engine telemetry seam", () => {
|
|
|
37
37
|
assert.deepEqual(calls[0][1], { reason: "needs-human", code: "review.escalated", wall_ms: 60000 });
|
|
38
38
|
assert.equal(calls[0][2].phase, undefined);
|
|
39
39
|
});
|
|
40
|
+
it("maps STRUCTURE_DELTA (forge#4159) to structure_delta with scalar counts and 0/1 flags", () => {
|
|
41
|
+
const m = mapRecord({ event: "STRUCTURE_DELTA", issue: 7, edges_added: 1, edges_removed: 0, import_files_added: 8, import_files_removed: 2, boundary_changed: true, boundary_loosened: false }, 10);
|
|
42
|
+
assert.deepEqual(m, { event: "structure_delta", phase: "implement", payload: { edges_added: 1, edges_removed: 0, import_files_added: 8, import_files_removed: 2, boundary_changed: 1, boundary_loosened: 0 } });
|
|
43
|
+
});
|
|
40
44
|
it("null usage yields a record with no token fields", () => {
|
|
41
45
|
assert.deepEqual(mapRecord({ event: "PHASE_ATTEMPT_USAGE", phase: "build", attempt: 1, usage: null, duration_ms: 5 }).payload, { attempt: 1, duration_ms: 5 });
|
|
42
46
|
});
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -17,7 +17,9 @@ import { assertOpenCodeNativeRuntime } from "./runner.mjs";
|
|
|
17
17
|
import { parseForgeYaml } from "./forge-utils.mjs";
|
|
18
18
|
import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
|
|
19
19
|
import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
|
|
20
|
+
import { parseVerify } from "./lean/verify.mjs";
|
|
20
21
|
import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
|
|
22
|
+
import { OUTCOME_CODES } from "./engine/outcome-codes.mjs";
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
|
|
@@ -147,6 +149,16 @@ export function readMergeWait(cwd = process.cwd()) {
|
|
|
147
149
|
return ["required", "all", "none"].includes(v) ? v : undefined;
|
|
148
150
|
} catch { return undefined; }
|
|
149
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* forge.yaml `verify:` section (#4167, block style: `verify:` then ` mode: ci`, ` workflow: verify.yml`, ` paths_input`,
|
|
154
|
+
* ` skip_paths` comma-separated, ` max_rounds`) for the core path; undefined when the section is absent or unreadable.
|
|
155
|
+
*/
|
|
156
|
+
export function readVerify(cwd = process.cwd()) {
|
|
157
|
+
try {
|
|
158
|
+
const sec = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.verify;
|
|
159
|
+
return sec && typeof sec === "object" ? parseVerify(sec) : undefined;
|
|
160
|
+
} catch { return undefined; }
|
|
161
|
+
}
|
|
150
162
|
export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
|
|
151
163
|
|
|
152
164
|
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
@@ -256,21 +268,36 @@ export function formatTerminalDiagnostics(dir, issue) {
|
|
|
256
268
|
return lines.join("\n");
|
|
257
269
|
}
|
|
258
270
|
|
|
271
|
+
// forge#4157: backoff between `gh repo view` attempts. Under host load one call can exceed makeIo's exec timeout.
|
|
272
|
+
export const REPO_VIEW_BACKOFF_MS = [2000, 4000];
|
|
273
|
+
// forge#4157: how long an orchestrate lane waits before rerunning after `gh` stayed unreachable.
|
|
274
|
+
export const GH_UNAVAILABLE_RETRY_S = 60;
|
|
275
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
276
|
+
|
|
259
277
|
/**
|
|
260
278
|
* Resolves the repo `gh` would target by default (i.e. resolved from the cwd
|
|
261
279
|
* git remote), via `gh repo view`. Used to validate an explicit `--repo` flag
|
|
262
|
-
* against the ambient context — see `assertRepoMatchesCwd`.
|
|
280
|
+
* against the ambient context — see `assertRepoMatchesCwd`. A failed call is
|
|
281
|
+
* retried with backoff (forge#4157: it is usually a transient timeout under load).
|
|
263
282
|
* @param {{gh: Function}} io
|
|
264
|
-
* @
|
|
283
|
+
* @param {{sleep?: (ms:number)=>Promise<void>, detail?: boolean}} [opts] - `detail: true` returns
|
|
284
|
+
* `{repo, failed}` so a caller can tell "every attempt errored" from an answer.
|
|
285
|
+
* @returns {Promise<string|null|{repo:string|null, failed:boolean}>} "owner/repo", or null if it can't be determined
|
|
265
286
|
*/
|
|
266
|
-
async function resolveDefaultRepo(io) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
287
|
+
async function resolveDefaultRepo(io, { sleep = realSleep, detail = false } = {}) {
|
|
288
|
+
let failed = false;
|
|
289
|
+
for (let attempt = 0; ; attempt++) {
|
|
290
|
+
try {
|
|
291
|
+
const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
292
|
+
const repo = String(out).trim() || null;
|
|
293
|
+
return detail ? { repo, failed: false } : repo;
|
|
294
|
+
} catch {
|
|
295
|
+
failed = true;
|
|
296
|
+
if (attempt >= REPO_VIEW_BACKOFF_MS.length) break;
|
|
297
|
+
await sleep(REPO_VIEW_BACKOFF_MS[attempt]);
|
|
298
|
+
}
|
|
273
299
|
}
|
|
300
|
+
return detail ? { repo: null, failed } : null;
|
|
274
301
|
}
|
|
275
302
|
|
|
276
303
|
/**
|
|
@@ -298,16 +325,20 @@ export async function assertLaneNotDefault(io, lane, repo = null, cwd = process.
|
|
|
298
325
|
* default, refuse to run at all rather than silently mixing repos.
|
|
299
326
|
* @param {{gh: Function}} io
|
|
300
327
|
* @param {string|null} repo
|
|
328
|
+
* @param {{sleep?: Function}} [opts] - backoff seam for the `gh repo view` retries
|
|
301
329
|
* @returns {Promise<void>}
|
|
302
330
|
*/
|
|
303
|
-
async function assertRepoMatchesCwd(io, repo) {
|
|
331
|
+
async function assertRepoMatchesCwd(io, repo, { sleep } = {}) {
|
|
304
332
|
if (!repo) return;
|
|
305
|
-
const defaultRepo = await resolveDefaultRepo(io);
|
|
333
|
+
const { repo: defaultRepo, failed } = await resolveDefaultRepo(io, { sleep, detail: true });
|
|
306
334
|
if (defaultRepo === null) {
|
|
307
|
-
|
|
335
|
+
const err = new Error(
|
|
308
336
|
`--repo ${repo} was given, but the current repo could not be determined (\`gh repo view\` failed) to ` +
|
|
309
337
|
`verify it matches. Refusing to run cross-repo without verification.`
|
|
310
338
|
);
|
|
339
|
+
// forge#4157: `gh` kept failing after retries; transient, so run-issue defers instead of erroring.
|
|
340
|
+
if (failed) err.code = OUTCOME_CODES.GH_UNAVAILABLE;
|
|
341
|
+
throw err;
|
|
311
342
|
}
|
|
312
343
|
if (defaultRepo !== repo) {
|
|
313
344
|
throw new Error(
|
|
@@ -495,7 +526,17 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
495
526
|
// The full path takes the lane as given (its spec runs classify-lane), so it must be explicit; no default.
|
|
496
527
|
if (enginePath === "full" && !lane) throw new Error("--lane is required with --path full: staging or milestone/<slug>. No default to prevent accidental production targeting.");
|
|
497
528
|
const io = deps.io ?? makeIo();
|
|
498
|
-
|
|
529
|
+
const resultFile = flag(argv, "--result-file");
|
|
530
|
+
const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
|
|
531
|
+
try {
|
|
532
|
+
await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
|
|
533
|
+
} catch (e) {
|
|
534
|
+
if (e?.code !== OUTCOME_CODES.GH_UNAVAILABLE) throw e;
|
|
535
|
+
// forge#4157: a scheduling fact, not an engine error; the batch requeues it and the breaker never sees it.
|
|
536
|
+
const res = report({ terminalReason: "deferred", code: OUTCOME_CODES.GH_UNAVAILABLE, detail: e.message, retryInSeconds: GH_UNAVAILABLE_RETRY_S });
|
|
537
|
+
console.log(`issue #${issue} → deferred (${res.detail})`);
|
|
538
|
+
return res;
|
|
539
|
+
}
|
|
499
540
|
await assertLaneNotDefault(io, lane, repo);
|
|
500
541
|
const runIssueFn = deps.runIssue ?? runIssue;
|
|
501
542
|
const agentId = `cli_${sanitizeLeaseHost(hostname())}_${process.pid}`;
|
|
@@ -529,14 +570,31 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
529
570
|
const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
|
|
530
571
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
|
|
531
572
|
// #4121: a batch lane reports its result through --result-file (written atomically, also for a thrown run).
|
|
532
|
-
|
|
533
|
-
|
|
573
|
+
// #4179: SIGTERM/SIGINT run the lane's teardown (ledger, checkpoint, worktree, lease), report deferred run.interrupted, exit 128+signo.
|
|
574
|
+
let teardown = null, interrupted = false;
|
|
575
|
+
const on = deps.processOn ?? ((s, f) => process.on(s, f)), off = deps.processOff ?? ((s, f) => process.removeListener(s, f));
|
|
576
|
+
const exit = deps.exit ?? ((c) => process.exit(c));
|
|
577
|
+
const SIGNO = { SIGINT: 2, SIGTERM: 15 };
|
|
578
|
+
const handlers = Object.fromEntries(Object.keys(SIGNO).map((sig) => [sig, async () => {
|
|
579
|
+
if (interrupted) return;
|
|
580
|
+
interrupted = true;
|
|
581
|
+
let r = null;
|
|
582
|
+
try { r = teardown ? await teardown(sig) : null; } catch { r = null; }
|
|
583
|
+
try { report({ ...(r ?? {}), terminalReason: "deferred", code: OUTCOME_CODES.RUN_INTERRUPTED, detail: r?.detail ?? `interrupted by ${sig}` }); } catch { /* exit anyway */ }
|
|
584
|
+
console.log(`issue #${issue} → deferred (interrupted by ${sig})`);
|
|
585
|
+
exit(128 + SIGNO[sig]);
|
|
586
|
+
}]));
|
|
587
|
+
for (const [sig, f] of Object.entries(handlers)) on(sig, f);
|
|
588
|
+
const env = deps.env ?? process.env;
|
|
534
589
|
let lres;
|
|
535
590
|
try {
|
|
536
591
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
592
|
+
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}),
|
|
537
593
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
538
|
-
admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
539
|
-
} catch (e) { report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
594
|
+
admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
595
|
+
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
596
|
+
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
597
|
+
if (interrupted) return lres; // the signal handler reported and exits
|
|
540
598
|
report(lres);
|
|
541
599
|
if (lres.code === "host.busy") { console.log(`issue #${issue} → deferred (${lres.detail})`); return lres; }
|
|
542
600
|
if (lres.terminalReason === "lane-refused") { console.log(`issue #${issue} → lane-refused ${lres.code} (${lres.detail})`); return lres; }
|
|
@@ -629,7 +687,7 @@ export async function resumeStalledFromCli(argv, deps = {}) {
|
|
|
629
687
|
const repo = flag(argv, "--repo");
|
|
630
688
|
|
|
631
689
|
const io = deps.io ?? makeIo();
|
|
632
|
-
await assertRepoMatchesCwd(io, repo);
|
|
690
|
+
await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
|
|
633
691
|
await assertLaneNotDefault(io, lane, repo);
|
|
634
692
|
const dispatch = deps.dispatch ?? runFromCli;
|
|
635
693
|
const projector = makeProjector(io);
|
|
@@ -786,8 +844,8 @@ export async function orchestrateFromCli(argv, deps = {}) {
|
|
|
786
844
|
if (deps.leanModule) {
|
|
787
845
|
const { runCore } = deps.leanModule;
|
|
788
846
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
789
|
-
lanes = { adopt: null, runOne: (n, ln) => runCore({ issue: n, repo, lane: ln, laneOverride, branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry,
|
|
790
|
-
admission: readAdmission(cwd), ...readCoreLimits(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
847
|
+
lanes = { adopt: null, runOne: (n, ln) => runCore({ issue: n, repo, lane: ln, laneOverride, branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
848
|
+
admission: readAdmission(cwd), ...readCoreLimits(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
791
849
|
} else {
|
|
792
850
|
lanes = batchMod.detachedLanes({ batch, cwd, script: deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url)), bin: deps.laneBin, pollMs: deps.pollMs,
|
|
793
851
|
laneArgs: (n, ln) => ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : [])] });
|
package/bin/engine.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postC
|
|
|
13
13
|
import { reconcileState } from "./engine/reconcile.mjs";
|
|
14
14
|
import { makeProjector } from "./engine/projector.mjs";
|
|
15
15
|
import { VALID_BACKENDS, resolveRunTmpRoot, runTmpDir } from "./runner.mjs";
|
|
16
|
+
import { resolveClaudeConfigDir } from "./journey.mjs";
|
|
16
17
|
import { OUTCOME_CODES, UNCLASSIFIED, normalizeCode, codeForRunnerError, codeForTerminalReason, isDiskQuotaError, isDiskQuotaText, isTransientSpawnError } from "./engine/outcome-codes.mjs";
|
|
17
18
|
|
|
18
19
|
// Exported (forge#2175) so bin/engine-cli.mjs can render "failed N/M attempts"
|
|
@@ -82,7 +83,10 @@ export function ciRateLimitSleepMs(rl, nowMs, rand = Math.random) {
|
|
|
82
83
|
*/
|
|
83
84
|
export function backendIdentity({ backend, env = process.env } = {}) {
|
|
84
85
|
const sha = (s) => createHash("sha256").update(String(s)).digest("hex");
|
|
85
|
-
|
|
86
|
+
// Shared resolver (forge#4173). The fallback keeps its legacy
|
|
87
|
+
// `${homedir()}/.claude` spelling so identities recorded before the shared
|
|
88
|
+
// resolver existed still match on Windows (path.join would use "\\").
|
|
89
|
+
const cfg = env.CLAUDE_CONFIG_DIR ? resolveClaudeConfigDir(env) : `${homedir()}/.claude`;
|
|
86
90
|
const key = env.ANTHROPIC_API_KEY
|
|
87
91
|
? scryptSync(String(env.ANTHROPIC_API_KEY), "forgedock-backend-identity", 16).toString("hex") : "";
|
|
88
92
|
return sha(JSON.stringify([backend ?? "auto", cfg, key])).slice(0, 16);
|
package/bin/forgedock.mjs
CHANGED
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
writeInstallReceipt,
|
|
35
35
|
persistHome,
|
|
36
36
|
PIPELINE_SCRIPTS,
|
|
37
|
+
resolveClaudeConfigDir,
|
|
38
|
+
setupAfterRead,
|
|
37
39
|
} from "./journey.mjs";
|
|
38
40
|
import {
|
|
39
41
|
deriveWorkflowLabels,
|
|
@@ -176,7 +178,14 @@ const HOME = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
|
176
178
|
// existing invocations, but it is now a no-op — global is the only supported
|
|
177
179
|
// install location.
|
|
178
180
|
|
|
179
|
-
|
|
181
|
+
//
|
|
182
|
+
// The directory itself honours CLAUDE_CONFIG_DIR (forge#4173): Claude Code
|
|
183
|
+
// reads commands/settings from there when it is set, so install, doctor,
|
|
184
|
+
// status and uninstall all resolve it through the one shared helper the
|
|
185
|
+
// journey installer uses — never a hard-coded ~/.claude.
|
|
186
|
+
const GLOBAL_CLAUDE_DIR = resolveClaudeConfigDir(process.env, HOME);
|
|
187
|
+
/** Display form of GLOBAL_CLAUDE_DIR for user-facing messages. */
|
|
188
|
+
const CLAUDE_DIR_LABEL = GLOBAL_CLAUDE_DIR === join(HOME, ".claude") ? "~/.claude" : GLOBAL_CLAUDE_DIR;
|
|
180
189
|
|
|
181
190
|
/**
|
|
182
191
|
* Resolve the install target directories for the current invocation.
|
|
@@ -242,7 +251,7 @@ const restArgs = cmdIdx === -1 ? [] : rawArgs.slice(cmdIdx + 1).filter((a) => a
|
|
|
242
251
|
* Path to the user-level Claude Code settings file.
|
|
243
252
|
* This is where the SessionStart hook entry is written/removed.
|
|
244
253
|
*/
|
|
245
|
-
const CLAUDE_SETTINGS_PATH = join(
|
|
254
|
+
const CLAUDE_SETTINGS_PATH = join(GLOBAL_CLAUDE_DIR, "settings.json");
|
|
246
255
|
|
|
247
256
|
/**
|
|
248
257
|
* Check whether a hook command string refers to the ForgeDock SessionStart hook.
|
|
@@ -1004,6 +1013,11 @@ async function statusScreen(c) {
|
|
|
1004
1013
|
* otherwise falls back to the Anthropic API when ANTHROPIC_API_KEY is set,
|
|
1005
1014
|
* otherwise skips enrichment.
|
|
1006
1015
|
*
|
|
1016
|
+
* Repository setup (forge#4173): after detection, interactive runs with no
|
|
1017
|
+
* GitHub remote, no commits, or no `staging` branch are offered repo
|
|
1018
|
+
* creation, an initial push of `main`, and `staging` creation — see
|
|
1019
|
+
* journey.mjs setupRepo(). Non-TTY runs are unchanged.
|
|
1020
|
+
*
|
|
1007
1021
|
* Env:
|
|
1008
1022
|
* FORGEDOCK_INIT_BACKEND Override ("cli"|"api"|"none"|"auto"; default
|
|
1009
1023
|
* "auto") — pins the enrichment backend instead of
|
|
@@ -1042,7 +1056,7 @@ async function initFlow(c) {
|
|
|
1042
1056
|
// Staging semantics preserved (#1148): detection → minimal template with
|
|
1043
1057
|
// only the three required sections. No review screen — this is the
|
|
1044
1058
|
// power-user escape hatch for a ~20-line forge.yaml.
|
|
1045
|
-
const { draft, description } = await read(c);
|
|
1059
|
+
const { draft, description } = await setupAfterRead(c, await read(c));
|
|
1046
1060
|
// Hard-fail on unresolved identity placeholders when overwriting an
|
|
1047
1061
|
// existing config (forge#1850 spec item 4) — never destroy a working
|
|
1048
1062
|
// forge.yaml in favor of one that can't address a repo.
|
|
@@ -1071,7 +1085,7 @@ async function initFlow(c) {
|
|
|
1071
1085
|
|
|
1072
1086
|
if (flags.includes("--manual")) {
|
|
1073
1087
|
// Manual escape hatch: plain prompts, detection values as defaults.
|
|
1074
|
-
const { draft, description } = await read(c);
|
|
1088
|
+
const { draft, description } = await setupAfterRead(c, await read(c));
|
|
1075
1089
|
const v = {
|
|
1076
1090
|
owner: await input("GitHub owner", draft.project.owner.value),
|
|
1077
1091
|
repo: await input("Repository", draft.project.repo.value),
|
|
@@ -1097,7 +1111,7 @@ async function initFlow(c) {
|
|
|
1097
1111
|
await maybeOfferDemo(c);
|
|
1098
1112
|
return 0;
|
|
1099
1113
|
}
|
|
1100
|
-
const { draft, description } = await read(c);
|
|
1114
|
+
const { draft, description } = await setupAfterRead(c, await read(c));
|
|
1101
1115
|
const reviewed = await review(c, draft, description);
|
|
1102
1116
|
celebrate(c, reviewed);
|
|
1103
1117
|
if (!reviewed.aborted) await maybeOfferDemo(c);
|
|
@@ -1110,7 +1124,7 @@ async function uninstall() {
|
|
|
1110
1124
|
console.log("");
|
|
1111
1125
|
|
|
1112
1126
|
const { targetDir, scriptsTargetDir, manifestPath } = detectInstallPaths();
|
|
1113
|
-
console.log(` Mode: global (
|
|
1127
|
+
console.log(` Mode: global (${CLAUDE_DIR_LABEL})`);
|
|
1114
1128
|
console.log(` ${dim(getLogoTagline("uninstall"))}`);
|
|
1115
1129
|
console.log("");
|
|
1116
1130
|
|
|
@@ -1244,19 +1258,19 @@ async function uninstall() {
|
|
|
1244
1258
|
|
|
1245
1259
|
// Remove the SessionStart hook from ~/.claude/settings.json via the
|
|
1246
1260
|
// hardened settings-hook module (never writes through malformed JSON).
|
|
1247
|
-
const settingsJsonPath =
|
|
1261
|
+
const settingsJsonPath = CLAUDE_SETTINGS_PATH;
|
|
1248
1262
|
const { status: hookRemoveResult } = removeSessionStartHook(settingsJsonPath);
|
|
1249
1263
|
if (hookRemoveResult === "removed") {
|
|
1250
1264
|
console.log(
|
|
1251
|
-
` ${GREEN}✔${RESET} Removed SessionStart hook from ${CYAN}
|
|
1265
|
+
` ${GREEN}✔${RESET} Removed SessionStart hook from ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET}`,
|
|
1252
1266
|
);
|
|
1253
1267
|
} else if (hookRemoveResult === "absent") {
|
|
1254
1268
|
console.log(
|
|
1255
|
-
` ✔ SessionStart hook already absent from
|
|
1269
|
+
` ✔ SessionStart hook already absent from ${CLAUDE_DIR_LABEL}/settings.json`,
|
|
1256
1270
|
);
|
|
1257
1271
|
} else {
|
|
1258
1272
|
console.log(
|
|
1259
|
-
` ${YELLOW}⚠${RESET} Could not update
|
|
1273
|
+
` ${YELLOW}⚠${RESET} Could not update ${CLAUDE_DIR_LABEL}/settings.json — remove the hook manually.`,
|
|
1260
1274
|
);
|
|
1261
1275
|
}
|
|
1262
1276
|
|
|
@@ -1264,19 +1278,19 @@ async function uninstall() {
|
|
|
1264
1278
|
// engine adapter, and SubagentStop annotation verifier.
|
|
1265
1279
|
const { status: preToolUseRemoveResult } = removePreToolUseHook(settingsJsonPath);
|
|
1266
1280
|
if (preToolUseRemoveResult === "removed") {
|
|
1267
|
-
console.log(` ${GREEN}✔${RESET} Removed PreToolUse enforcement hook from ${CYAN}
|
|
1281
|
+
console.log(` ${GREEN}✔${RESET} Removed PreToolUse enforcement hook from ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET}`);
|
|
1268
1282
|
} else if (preToolUseRemoveResult !== "absent") {
|
|
1269
|
-
console.log(` ${YELLOW}⚠${RESET} Could not remove PreToolUse hook — check ${CYAN}
|
|
1283
|
+
console.log(` ${YELLOW}⚠${RESET} Could not remove PreToolUse hook — check ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET} manually.`);
|
|
1270
1284
|
}
|
|
1271
1285
|
const { status: subagentStopRemoveResult } = removeSubagentStopHook(settingsJsonPath);
|
|
1272
1286
|
if (subagentStopRemoveResult === "removed") {
|
|
1273
|
-
console.log(` ${GREEN}✔${RESET} Removed SubagentStop hook from ${CYAN}
|
|
1287
|
+
console.log(` ${GREEN}✔${RESET} Removed SubagentStop hook from ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET}`);
|
|
1274
1288
|
}
|
|
1275
1289
|
const { status: subagentStopEnforceRemoveResult } = removeSubagentStopEnforceHook(settingsJsonPath);
|
|
1276
1290
|
if (subagentStopEnforceRemoveResult === "removed") {
|
|
1277
|
-
console.log(` ${GREEN}✔${RESET} Removed SubagentStop enforcement hook from ${CYAN}
|
|
1291
|
+
console.log(` ${GREEN}✔${RESET} Removed SubagentStop enforcement hook from ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET}`);
|
|
1278
1292
|
} else if (subagentStopEnforceRemoveResult !== "absent") {
|
|
1279
|
-
console.log(` ${YELLOW}⚠${RESET} Could not remove SubagentStop enforcement hook — check ${CYAN}
|
|
1293
|
+
console.log(` ${YELLOW}⚠${RESET} Could not remove SubagentStop enforcement hook — check ${CYAN}${CLAUDE_DIR_LABEL}/settings.json${RESET} manually.`);
|
|
1280
1294
|
}
|
|
1281
1295
|
console.log("");
|
|
1282
1296
|
|
|
@@ -2111,6 +2125,44 @@ function resolveLabelsRepo(subArgs) {
|
|
|
2111
2125
|
return null;
|
|
2112
2126
|
}
|
|
2113
2127
|
|
|
2128
|
+
/** Fixed colors for well-known domain labels (forge#4174); anything else gets DOMAIN_LABEL_DEFAULT_COLOR. */
|
|
2129
|
+
const DOMAIN_LABEL_COLORS = {
|
|
2130
|
+
frontend: "1D76DB",
|
|
2131
|
+
backend: "0E8A16",
|
|
2132
|
+
infra: "5319E7",
|
|
2133
|
+
design: "D4C5F9",
|
|
2134
|
+
"legal-gated": "B60205",
|
|
2135
|
+
};
|
|
2136
|
+
const DOMAIN_LABEL_DEFAULT_COLOR = "EDEDED";
|
|
2137
|
+
|
|
2138
|
+
/**
|
|
2139
|
+
* Parse a `--domain a,b,c` value into domain-label entries (forge#4174).
|
|
2140
|
+
* Names are trimmed and deduped (first occurrence wins). An empty name, a name
|
|
2141
|
+
* over 50 characters (GitHub's label-name limit), or a missing value is an
|
|
2142
|
+
* error — the caller exits before any gh call.
|
|
2143
|
+
*
|
|
2144
|
+
* @param {string|undefined} raw - the value after --domain
|
|
2145
|
+
* @returns {{ labels?: Array<{name: string, color: string, description: string}>, error?: string }}
|
|
2146
|
+
*/
|
|
2147
|
+
function buildDomainLabels(raw) {
|
|
2148
|
+
if (raw === undefined || raw.startsWith("--")) return { error: "--domain needs a comma-separated list, e.g. --domain frontend,backend" };
|
|
2149
|
+
const seen = new Set();
|
|
2150
|
+
const labels = [];
|
|
2151
|
+
for (const part of raw.split(",")) {
|
|
2152
|
+
const name = part.trim();
|
|
2153
|
+
if (!name) return { error: `--domain contains an empty label name: "${raw}"` };
|
|
2154
|
+
if (name.length > 50) return { error: `--domain label name exceeds 50 characters: "${name}"` };
|
|
2155
|
+
if (seen.has(name)) continue;
|
|
2156
|
+
seen.add(name);
|
|
2157
|
+
labels.push({
|
|
2158
|
+
name,
|
|
2159
|
+
color: DOMAIN_LABEL_COLORS[name] ?? DOMAIN_LABEL_DEFAULT_COLOR,
|
|
2160
|
+
description: `Domain: ${name}. Managed by ForgeDock.`,
|
|
2161
|
+
});
|
|
2162
|
+
}
|
|
2163
|
+
return { labels };
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2114
2166
|
/**
|
|
2115
2167
|
* Idempotently create or update all ForgeDock-managed labels on a GitHub repo.
|
|
2116
2168
|
*
|
|
@@ -2122,10 +2174,15 @@ function resolveLabelsRepo(subArgs) {
|
|
|
2122
2174
|
* --force makes the call idempotent: it creates the label if absent, or updates
|
|
2123
2175
|
* its color and description if it already exists. Safe to re-run at any time.
|
|
2124
2176
|
*
|
|
2177
|
+
* Optional per-repo domain labels (forge#4174) are appended after the
|
|
2178
|
+
* manifest — see buildDomainLabels(). They are never part of bin/labels.json,
|
|
2179
|
+
* so doctor's required-label check ignores them.
|
|
2180
|
+
*
|
|
2125
2181
|
* @param {string} repo - "owner/repo" string
|
|
2182
|
+
* @param {Array<{name: string, color: string, description: string}>} [domainLabels]
|
|
2126
2183
|
* @returns {{ created: number, failed: string[] }}
|
|
2127
2184
|
*/
|
|
2128
|
-
async function labelsSetup(repo) {
|
|
2185
|
+
async function labelsSetup(repo, domainLabels = []) {
|
|
2129
2186
|
const manifestPath = join(__dirname, "labels.json");
|
|
2130
2187
|
|
|
2131
2188
|
if (!existsSync(manifestPath)) {
|
|
@@ -2141,6 +2198,8 @@ async function labelsSetup(repo) {
|
|
|
2141
2198
|
console.log(`${RED}Failed to parse labels.json: ${err.message}${RESET}`);
|
|
2142
2199
|
process.exit(1);
|
|
2143
2200
|
}
|
|
2201
|
+
const manifestNames = new Set(labels.map((l) => l.name));
|
|
2202
|
+
labels = labels.concat(domainLabels.filter((l) => !manifestNames.has(l.name)));
|
|
2144
2203
|
|
|
2145
2204
|
console.log("");
|
|
2146
2205
|
console.log(`${BOLD}ForgeDock Label Bootstrap${RESET}`);
|
|
@@ -2210,7 +2269,7 @@ async function labelsSetup(repo) {
|
|
|
2210
2269
|
* 2. gh CLI installed and authenticated
|
|
2211
2270
|
* 3. git configured (user.name + user.email)
|
|
2212
2271
|
* 4. forge.yaml exists and has required keys
|
|
2213
|
-
* 5. SessionStart hook registered in ~/.claude/settings.json
|
|
2272
|
+
* 5. SessionStart hook registered in ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR)
|
|
2214
2273
|
* 6. CLAUDE.md legacy managed block (cwd) — informational only; the
|
|
2215
2274
|
* journey install never injects one, session context comes from the
|
|
2216
2275
|
* SessionStart hook
|
|
@@ -2247,7 +2306,7 @@ async function doctor(fix = false) {
|
|
|
2247
2306
|
console.log(
|
|
2248
2307
|
isGitCloneInstall
|
|
2249
2308
|
? ` Mode: git clone at ${CYAN}${FORGE_HOME}${RESET}`
|
|
2250
|
-
: ` Mode: global (
|
|
2309
|
+
: ` Mode: global (${CLAUDE_DIR_LABEL})`,
|
|
2251
2310
|
);
|
|
2252
2311
|
console.log(` Source: ${formatSourceIdentity(sourceBranch)}`);
|
|
2253
2312
|
console.log("");
|
|
@@ -2301,7 +2360,7 @@ async function doctor(fix = false) {
|
|
|
2301
2360
|
console.log(` ${CYAN}↻${RESET} ${BOLD}${label}${RESET}${suffix}`);
|
|
2302
2361
|
}
|
|
2303
2362
|
|
|
2304
|
-
const settingsPath =
|
|
2363
|
+
const settingsPath = CLAUDE_SETTINGS_PATH;
|
|
2305
2364
|
|
|
2306
2365
|
/**
|
|
2307
2366
|
* Detect whether the registered SessionStart hook entry (if any) points at
|
|
@@ -2671,17 +2730,17 @@ async function doctor(fix = false) {
|
|
|
2671
2730
|
}
|
|
2672
2731
|
|
|
2673
2732
|
if (hookReg.error) {
|
|
2674
|
-
fail("SessionStart hook", `Cannot read
|
|
2733
|
+
fail("SessionStart hook", `Cannot read ${CLAUDE_DIR_LABEL}/settings.json: ${hookReg.error}. Run: npx forgedock install`);
|
|
2675
2734
|
} else if (hookReg.ok) {
|
|
2676
2735
|
if (fix && hookRegWasBroken) {
|
|
2677
|
-
fixed("SessionStart hook",
|
|
2736
|
+
fixed("SessionStart hook", `registered in ${CLAUDE_DIR_LABEL}/settings.json (repaired)`);
|
|
2678
2737
|
} else {
|
|
2679
|
-
pass("SessionStart hook",
|
|
2738
|
+
pass("SessionStart hook", `registered in ${CLAUDE_DIR_LABEL}/settings.json`);
|
|
2680
2739
|
}
|
|
2681
2740
|
} else {
|
|
2682
2741
|
fail(
|
|
2683
2742
|
"SessionStart hook",
|
|
2684
|
-
|
|
2743
|
+
`Run: npx forgedock doctor --fix (or: npx forgedock install) (writes hook entry to ${CLAUDE_DIR_LABEL}/settings.json)`,
|
|
2685
2744
|
);
|
|
2686
2745
|
}
|
|
2687
2746
|
}
|
|
@@ -3007,7 +3066,7 @@ async function doctor(fix = false) {
|
|
|
3007
3066
|
// server presence is orthogonal to the core ForgeDock install — but missing
|
|
3008
3067
|
// it causes /qa-sweep to silently fail mid-sweep.
|
|
3009
3068
|
{
|
|
3010
|
-
const mcpServersPath = join(
|
|
3069
|
+
const mcpServersPath = join(GLOBAL_CLAUDE_DIR, "mcp_servers.json");
|
|
3011
3070
|
let playwrightFound = false;
|
|
3012
3071
|
let fileReadable = false;
|
|
3013
3072
|
let hasMcpServers = false;
|
|
@@ -3040,12 +3099,12 @@ async function doctor(fix = false) {
|
|
|
3040
3099
|
if (!fileReadable) {
|
|
3041
3100
|
warn(
|
|
3042
3101
|
"Playwright MCP",
|
|
3043
|
-
|
|
3102
|
+
`No MCP servers file found (${CLAUDE_DIR_LABEL}/mcp_servers.json). Register Playwright MCP to enable /qa-sweep: claude mcp add playwright npx @playwright/mcp@latest`,
|
|
3044
3103
|
);
|
|
3045
3104
|
} else if (!hasMcpServers) {
|
|
3046
3105
|
warn(
|
|
3047
3106
|
"Playwright MCP",
|
|
3048
|
-
|
|
3107
|
+
`MCP servers file found (${CLAUDE_DIR_LABEL}/mcp_servers.json) but it has no 'mcpServers' key. Register Playwright MCP to enable /qa-sweep: claude mcp add playwright npx @playwright/mcp@latest`,
|
|
3049
3108
|
);
|
|
3050
3109
|
} else if (playwrightFound) {
|
|
3051
3110
|
pass("Playwright MCP", "registered in Claude Code MCP servers");
|
|
@@ -3214,7 +3273,7 @@ function help() {
|
|
|
3214
3273
|
["npx forgedock gc", "Prune stale per-run scratch dirs under ~/.forge/tmp/runs (older than 6h, owner dead)"],
|
|
3215
3274
|
["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
|
|
3216
3275
|
["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
|
|
3217
|
-
["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
|
|
3276
|
+
["npx forgedock labels [setup] [--repo owner/repo] [--domain a,b,c]", "Bootstrap ForgeDock-managed labels on a GitHub repo, plus optional per-repo domain labels (idempotent)"],
|
|
3218
3277
|
["npx forgedock config migrate [dir]", "Backfill missing optional sections into an existing forge.yaml (idempotent)"],
|
|
3219
3278
|
["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
|
|
3220
3279
|
["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
|
|
@@ -4048,19 +4107,24 @@ switch (command) {
|
|
|
4048
4107
|
const subcommand = positional[1];
|
|
4049
4108
|
if (!subcommand || subcommand === "setup" || subcommand.startsWith("--")) {
|
|
4050
4109
|
const repo = resolveLabelsRepo(restArgs);
|
|
4051
|
-
|
|
4110
|
+
const domainIdx = restArgs.indexOf("--domain");
|
|
4111
|
+
const domain = domainIdx === -1 ? { labels: [] } : buildDomainLabels(restArgs[domainIdx + 1]);
|
|
4112
|
+
if (domain.error) {
|
|
4113
|
+
console.log(`${RED}${domain.error}${RESET}`);
|
|
4114
|
+
exitCode = 1;
|
|
4115
|
+
} else if (!repo) {
|
|
4052
4116
|
console.log(
|
|
4053
4117
|
`${RED}No repository specified.${RESET}\n` +
|
|
4054
4118
|
` Pass ${cyan("--repo owner/repo")} or run from a directory with ${cyan("forge.yaml")}.`,
|
|
4055
4119
|
);
|
|
4056
4120
|
exitCode = 1;
|
|
4057
4121
|
} else {
|
|
4058
|
-
await labelsSetup(repo);
|
|
4122
|
+
await labelsSetup(repo, domain.labels);
|
|
4059
4123
|
}
|
|
4060
4124
|
} else {
|
|
4061
4125
|
console.log(`${RED}Unknown labels subcommand: ${subcommand}${RESET}`);
|
|
4062
4126
|
console.log(
|
|
4063
|
-
`Usage: ${CYAN}npx forgedock labels [setup] [--repo owner/repo]${RESET}`,
|
|
4127
|
+
`Usage: ${CYAN}npx forgedock labels [setup] [--repo owner/repo] [--domain a,b,c]${RESET}`,
|
|
4064
4128
|
);
|
|
4065
4129
|
exitCode = 1;
|
|
4066
4130
|
}
|