forgedock 1.7.0 → 1.8.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/README.md +2 -1
- package/bin/engine/admission.mjs +242 -7
- package/bin/engine/admission.test.mjs +183 -2
- package/bin/engine/phases.mjs +31 -22
- package/bin/engine/resolve.mjs +8 -1
- package/bin/engine/resolve.test.mjs +5 -0
- package/bin/engine-cli.mjs +2 -0
- package/bin/engine.mjs +26 -17
- package/bin/forgedock.mjs +124 -48
- package/bin/hooks/pre-tool-use.mjs +573 -2
- package/bin/journey.mjs +11 -7
- package/bin/labels.json +5 -0
- package/bin/opencode-adapter.mjs +652 -92
- package/bin/orchestrate-preflight.mjs +434 -0
- package/bin/runner.mjs +77 -9
- package/commands/audit-agents.md +75 -26
- package/commands/autopilot.md +4 -4
- package/commands/cleanup.md +10 -5
- package/commands/issue.md +95 -35
- package/commands/orchestrate/phase-1-resolve.md +70 -32
- package/commands/orchestrate/phase-3-dependency.md +167 -22
- package/commands/orchestrate/phase-4-execution.md +691 -62
- package/commands/orchestrate/phase-5-cleanup.md +5 -11
- package/commands/orchestrate/phase-6-report.md +27 -1
- package/commands/orchestrate/safety.md +1 -1
- package/commands/orchestrate.md +39 -1
- package/commands/quality-gate.md +30 -10
- package/commands/review-pr-agents/protocols.md +13 -0
- package/commands/review-pr-agents.md +8 -0
- package/commands/review-pr-staging.md +117 -17
- package/commands/review-pr.md +95 -23
- package/commands/work-on/build/validate.md +25 -4
- package/commands/work-on/close.md +40 -10
- package/commands/work-on/decompose.md +33 -1
- package/commands/work-on/investigate.md +33 -0
- package/commands/work-on/remediate.md +40 -5
- package/commands/work-on/review.md +27 -0
- package/commands/work-on.md +48 -5
- package/docs/CONFIG.md +51 -15
- package/docs/OPENCODE.md +128 -25
- package/docs/spec/review-protocol.md +7 -0
- package/package.json +1 -1
- package/scripts/check-admission-parity.mjs +1 -0
- package/scripts/check-command-side-effects.sh +14 -13
- package/scripts/check-shell-portability.sh +36 -0
- package/scripts/check-spec-markers.sh +21 -11
- package/scripts/checkbox-sections.test.sh +67 -0
- package/scripts/extract-affected-files.sh +85 -5
- package/scripts/extract-affected-files.test.sh +154 -2
package/bin/engine/phases.mjs
CHANGED
|
@@ -116,6 +116,23 @@ async function commitsAhead(lane, branch, io) {
|
|
|
116
116
|
*/
|
|
117
117
|
function has(blob, marker) { return blob.includes(marker); }
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The interactive workflow persists its conservative complexity decision in a
|
|
121
|
+
* FORGE:FAST_PATH comment. The engine must consume that decision too; otherwise
|
|
122
|
+
* its separate context/architect phases negate the documented trivial path.
|
|
123
|
+
* Only an exact TRIVIAL value is eligible to skip work, so malformed or absent
|
|
124
|
+
* annotations continue through the full pipeline.
|
|
125
|
+
*/
|
|
126
|
+
function complexityBand(comments) {
|
|
127
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
128
|
+
const body = comments[i];
|
|
129
|
+
if (!body?.includes("FORGE:FAST_PATH")) continue;
|
|
130
|
+
const match = body.match(/\*\*COMPLEXITY_BAND\*\*:\s*([A-Z_]+)/);
|
|
131
|
+
if (match) return match[1];
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
/**
|
|
120
137
|
* Fetch the issue's live `state` (OPEN/CLOSED) and `labels` in one call.
|
|
121
138
|
*
|
|
@@ -254,10 +271,13 @@ export const PHASES = [
|
|
|
254
271
|
command: "work-on/build/context",
|
|
255
272
|
entryCondition: (s) => s.committed.includes("investigate"),
|
|
256
273
|
async reconcile(state, io) {
|
|
274
|
+
const markers = await issueMarkers(state.issue, io);
|
|
275
|
+
if (complexityBand(markers.comments) === "TRIVIAL") {
|
|
276
|
+
return { satisfied: true, outputs: { skipped: "trivial-complexity-band", phase: "context" } };
|
|
277
|
+
}
|
|
257
278
|
// Idempotent resume: FORGE:CONTEXT:COMPLETE present → skip the LLM re-run.
|
|
258
279
|
// Bare FORGE:CONTEXT matches a partial/interrupted annotation — require :COMPLETE.
|
|
259
|
-
|
|
260
|
-
return has(blob, PHASE_MARKERS.context.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
280
|
+
return has(markers.blob, PHASE_MARKERS.context.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
261
281
|
},
|
|
262
282
|
async detectOutcome(state, io) {
|
|
263
283
|
const { blob } = await issueMarkers(state.issue, io);
|
|
@@ -271,10 +291,13 @@ export const PHASES = [
|
|
|
271
291
|
command: "work-on/build/architect",
|
|
272
292
|
entryCondition: (s) => s.committed.includes("context"),
|
|
273
293
|
async reconcile(state, io) {
|
|
294
|
+
const markers = await issueMarkers(state.issue, io);
|
|
295
|
+
if (complexityBand(markers.comments) === "TRIVIAL") {
|
|
296
|
+
return { satisfied: true, outputs: { skipped: "trivial-complexity-band", phase: "architect" } };
|
|
297
|
+
}
|
|
274
298
|
// Idempotent resume: FORGE:ARCHITECT:COMPLETE present → skip the LLM re-run.
|
|
275
299
|
// Bare FORGE:ARCHITECT matches a partial/interrupted annotation — require :COMPLETE.
|
|
276
|
-
|
|
277
|
-
return has(blob, PHASE_MARKERS.architect.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
300
|
+
return has(markers.blob, PHASE_MARKERS.architect.completionMarker) ? { satisfied: true } : { satisfied: false };
|
|
278
301
|
},
|
|
279
302
|
async detectOutcome(state, io) {
|
|
280
303
|
const { blob } = await issueMarkers(state.issue, io);
|
|
@@ -356,7 +379,7 @@ export const PHASES = [
|
|
|
356
379
|
if (!pr) return { status: "failed", detail: "no PR created" };
|
|
357
380
|
if (pr.merged) return { status: "committed", outputs: { pr: pr.number } };
|
|
358
381
|
if (pr.needsHuman) return { status: "blocked", detail: "review escalated", outputs: { pr: pr.number } };
|
|
359
|
-
return { status: "failed", detail: "PR open, not merged" };
|
|
382
|
+
return { status: "failed", detail: "PR open, not merged", retryable: false };
|
|
360
383
|
},
|
|
361
384
|
},
|
|
362
385
|
{
|
|
@@ -370,23 +393,9 @@ export const PHASES = [
|
|
|
370
393
|
// phase table (closing the literal "remediate appears nowhere in the
|
|
371
394
|
// engine" gap) and is fully unit-tested via `pickPhase`/`detectOutcome`.
|
|
372
395
|
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
// `terminate()` immediately, before `review` is ever added to
|
|
377
|
-
// `state.committed` — and the divergence guard just above that also
|
|
378
|
-
// pauses before any non-`close` phase once the issue carries
|
|
379
|
-
// `needs-human`. So a single continuous `runIssue()` walk cannot reach
|
|
380
|
-
// this phase's `entryCondition` today. In practice `remediate.md` is
|
|
381
|
-
// (correctly, per `work-on.md` Phase 0A.1) invoked as its own separate
|
|
382
|
-
// top-level entry point (`/work-on <pr> --remediate`), not as a
|
|
383
|
-
// continuation of the original run — this phase entry documents and
|
|
384
|
-
// tests the target state shape for a future run that reconstructs
|
|
385
|
-
// `committed`/`terminalReason` from live GitHub state (e.g. a dedicated
|
|
386
|
-
// remediation-run entry point) rather than from a fresh local run-log.
|
|
387
|
-
// Making that live wiring real is out of this issue's scope — it needs
|
|
388
|
-
// changes to `review`'s `"blocked"` contract and to `bin/tests/engine.test.mjs`,
|
|
389
|
-
// both outside this issue's declared file set.
|
|
396
|
+
// A blocked review is committed with terminalReason "needs-human" before
|
|
397
|
+
// the engine selects this phase, preserving the review verdict while
|
|
398
|
+
// handing the PR to remediation in the same run.
|
|
390
399
|
id: "remediate",
|
|
391
400
|
command: "work-on/remediate",
|
|
392
401
|
entryCondition: (s) => s.committed.includes("review") && s.terminalReason === "needs-human",
|
package/bin/engine/resolve.mjs
CHANGED
|
@@ -94,7 +94,14 @@
|
|
|
94
94
|
* @returns {ClassifiedInput}
|
|
95
95
|
*/
|
|
96
96
|
export function classifyInputPattern(input) {
|
|
97
|
-
|
|
97
|
+
// Orchestration control flags authorize or tune the dispatcher; they are not
|
|
98
|
+
// part of the issue-set predicate. Strip them here so a confirmed OpenCode
|
|
99
|
+
// fast path and the full shared resolver classify the same query.
|
|
100
|
+
const trimmed = String(input ?? "")
|
|
101
|
+
.replace(/(?:^|\s)(?:--auto|--confirm|--deep-plan)(?=\s|$)/gi, " ")
|
|
102
|
+
.replace(/(?:^|\s)--max-concurrent(?:=|\s+)[1-9]\d*(?=\s|$)/gi, " ")
|
|
103
|
+
.replace(/\s+/g, " ")
|
|
104
|
+
.trim();
|
|
98
105
|
const tokens = trimmed.length > 0 ? trimmed.split(/\s+/) : [];
|
|
99
106
|
|
|
100
107
|
// literal-numbers: every token is a bare integer or a (optionally
|
|
@@ -55,6 +55,11 @@ describe("classifyInputPattern — query patterns", () => {
|
|
|
55
55
|
assert.equal(classifyInputPattern("fast-lane").kind, "query");
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
it("ignores orchestration control flags when classifying the issue predicate", () => {
|
|
59
|
+
assert.equal(classifyInputPattern("fast-lane --auto --max-concurrent 4").pattern, "fast-lane");
|
|
60
|
+
assert.equal(classifyInputPattern("1 2 --confirm").kind, "literal");
|
|
61
|
+
});
|
|
62
|
+
|
|
58
63
|
it("classifies priority:P0 / priority:P1 as a query (both label schemas, forge#2232)", () => {
|
|
59
64
|
assert.equal(classifyInputPattern("priority:P0").pattern, "priority");
|
|
60
65
|
assert.equal(classifyInputPattern("priority:P1").kind, "query");
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { join } from "node:path";
|
|
|
12
12
|
import { runIssue, DEFAULT_MAX_ATTEMPTS } from "./engine.mjs";
|
|
13
13
|
import { makeProjector } from "./engine/projector.mjs";
|
|
14
14
|
import { readLog, deriveState } from "./engine/runlog.mjs";
|
|
15
|
+
import { assertOpenCodeNativeRuntime } from "./runner.mjs";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
|
|
@@ -340,6 +341,7 @@ export function lastLocalRun(dir) {
|
|
|
340
341
|
export async function runFromCli(argv, deps = {}) {
|
|
341
342
|
const issue = parseInt(argv[0], 10);
|
|
342
343
|
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane>");
|
|
344
|
+
assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
|
|
343
345
|
const lane = flag(argv, "--lane");
|
|
344
346
|
if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
|
|
345
347
|
const repo = flag(argv, "--repo");
|
package/bin/engine.mjs
CHANGED
|
@@ -331,7 +331,9 @@ export async function runIssue(opts) {
|
|
|
331
331
|
// the same reason composes with that existing contract instead of
|
|
332
332
|
// conflating "paused pending a human" with "dead". Deliberately does
|
|
333
333
|
// NOT check `workflow:invalid`/CLOSED here — those are handled above.
|
|
334
|
-
|
|
334
|
+
// A blocked review is deliberately handed to remediation in this same
|
|
335
|
+
// run. All other needs-human states remain paused for human action.
|
|
336
|
+
if (snap.labels.includes("needs-human") && phase.id !== "remediate") {
|
|
335
337
|
const detail = `issue ${issue} carries needs-human — pausing before phase ${phase.id}`;
|
|
336
338
|
return await terminate(state, "needs-human", detail);
|
|
337
339
|
}
|
|
@@ -520,21 +522,24 @@ export async function runIssue(opts) {
|
|
|
520
522
|
}
|
|
521
523
|
}
|
|
522
524
|
|
|
525
|
+
const isRemediationHandoff = outcome.status === "blocked" &&
|
|
526
|
+
phase.id === "review" && (outcome.reason || "needs-human") === "needs-human";
|
|
523
527
|
if (outcome.status === "blocked") {
|
|
524
528
|
emitProgress({ event: "phase_exit", phase: phase.id, status: "blocked", detail: outcome.detail });
|
|
525
|
-
|
|
529
|
+
if (!isRemediationHandoff)
|
|
530
|
+
return await terminate(state, outcome.reason || "needs-human", outcome.detail);
|
|
531
|
+
} else {
|
|
532
|
+
// committed
|
|
533
|
+
// forge#2321: only report a phase_exit if a matching phase_enter was
|
|
534
|
+
// actually emitted for this phase this iteration. A reconcile-satisfied
|
|
535
|
+
// phase (short-circuited above without ever entering the `else` branch)
|
|
536
|
+
// never "started" this run — emitting phase_exit for it produced a
|
|
537
|
+
// dangling exit with no preceding enter (bin/engine-cli.mjs prints
|
|
538
|
+
// "✓ phase X committed" with no prior "→ phase X started" line).
|
|
539
|
+
// Suppressing the unmatched exit is correct here rather than fabricating
|
|
540
|
+
// a synthetic phase_enter, since the phase's runner genuinely never ran.
|
|
541
|
+
if (phaseEntered) emitProgress({ event: "phase_exit", phase: phase.id, status: "committed" });
|
|
526
542
|
}
|
|
527
|
-
|
|
528
|
-
// committed
|
|
529
|
-
// forge#2321: only report a phase_exit if a matching phase_enter was
|
|
530
|
-
// actually emitted for this phase this iteration. A reconcile-satisfied
|
|
531
|
-
// phase (short-circuited above without ever entering the `else` branch)
|
|
532
|
-
// never "started" this run — emitting phase_exit for it produced a
|
|
533
|
-
// dangling exit with no preceding enter (bin/engine-cli.mjs prints
|
|
534
|
-
// "✓ phase X committed" with no prior "→ phase X started" line).
|
|
535
|
-
// Suppressing the unmatched exit is correct here rather than fabricating
|
|
536
|
-
// a synthetic phase_enter, since the phase's runner genuinely never ran.
|
|
537
|
-
if (phaseEntered) emitProgress({ event: "phase_exit", phase: phase.id, status: "committed" });
|
|
538
543
|
// forge#2377: `outcome.usage` is populated by runPhaseWithRetry() below
|
|
539
544
|
// from the injected runner()'s (== bin/runner.mjs's runCommand()) return
|
|
540
545
|
// value — null when the backend doesn't report usage (CLI backend today)
|
|
@@ -544,7 +549,10 @@ export async function runIssue(opts) {
|
|
|
544
549
|
// owned by phase.detectOutcome() (bin/engine/phases.mjs).
|
|
545
550
|
appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {}, usage: outcome.usage ?? null });
|
|
546
551
|
state = deriveState(readLog(dir, issue));
|
|
547
|
-
|
|
552
|
+
const terminalReason = outcome.status === "blocked"
|
|
553
|
+
? (outcome.reason || "needs-human")
|
|
554
|
+
: outcome.terminalReason;
|
|
555
|
+
if (terminalReason) state.terminalReason = terminalReason;
|
|
548
556
|
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + leaseTtlMs } });
|
|
549
557
|
|
|
550
558
|
// forge#2379: the "investigate" phase reporting terminalReason "decomposed"
|
|
@@ -564,9 +572,10 @@ export async function runIssue(opts) {
|
|
|
564
572
|
// is seen), phase.id is "decompose" — not "investigate" — so this
|
|
565
573
|
// exemption does not apply and the normal terminate() path below fires,
|
|
566
574
|
// ending the run for real.
|
|
567
|
-
const isDecomposeHandoff = phase.id === "investigate" &&
|
|
568
|
-
if (
|
|
569
|
-
|
|
575
|
+
const isDecomposeHandoff = phase.id === "investigate" && terminalReason === "decomposed";
|
|
576
|
+
if (terminalReason && TERMINAL_REASONS.includes(terminalReason) &&
|
|
577
|
+
!isDecomposeHandoff && !isRemediationHandoff)
|
|
578
|
+
return await terminate(state, terminalReason, outcome.detail);
|
|
570
579
|
if (phase.isTerminalAfter && phase.isTerminalAfter(state))
|
|
571
580
|
return await terminate(state, state.terminalReason || "merged");
|
|
572
581
|
}
|
package/bin/forgedock.mjs
CHANGED
|
@@ -416,6 +416,40 @@ function getVersion() {
|
|
|
416
416
|
}
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/**
|
|
420
|
+
* Read the branch that owns the source currently being executed.
|
|
421
|
+
*
|
|
422
|
+
* This is deliberately best-effort: diagnostics should still identify the
|
|
423
|
+
* source path and version when the checkout has no readable branch ref.
|
|
424
|
+
*
|
|
425
|
+
* @param {string} [dir]
|
|
426
|
+
* @returns {string|null}
|
|
427
|
+
*/
|
|
428
|
+
function getGitBranch(dir = FORGE_HOME) {
|
|
429
|
+
try {
|
|
430
|
+
const branch = execFileSync(
|
|
431
|
+
"git",
|
|
432
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
433
|
+
{ cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
|
|
434
|
+
).trim();
|
|
435
|
+
return branch || null;
|
|
436
|
+
} catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Describe the source path the current CLI process resolves from.
|
|
443
|
+
*
|
|
444
|
+
* @param {string|null} [branch]
|
|
445
|
+
* @returns {string}
|
|
446
|
+
*/
|
|
447
|
+
function formatSourceIdentity(branch = null) {
|
|
448
|
+
const version = getVersion() || "unknown";
|
|
449
|
+
const branchLabel = branch ? `, branch ${branch}` : "";
|
|
450
|
+
return `${FORGE_HOME} (v${version}${branchLabel})`;
|
|
451
|
+
}
|
|
452
|
+
|
|
419
453
|
/**
|
|
420
454
|
* Compare two dotted version strings component-wise (numeric, not
|
|
421
455
|
* lexicographic — "1.9.0" must compare as older than "1.10.0").
|
|
@@ -1330,11 +1364,51 @@ async function uninstall() {
|
|
|
1330
1364
|
console.log("");
|
|
1331
1365
|
}
|
|
1332
1366
|
|
|
1333
|
-
// Reinstall = relink commands + re-register the hook
|
|
1334
|
-
// journey: update must not reach
|
|
1335
|
-
// curated forge.yaml (and re-runs AI
|
|
1336
|
-
// idempotent, so it's the repair path for a
|
|
1337
|
-
// symlinks
|
|
1367
|
+
// Reinstall = relink commands + re-register the hook + refresh an already
|
|
1368
|
+
// installed OpenCode adapter. Never the full journey: update must not reach
|
|
1369
|
+
// read/review, which could overwrite a curated forge.yaml (and re-runs AI
|
|
1370
|
+
// enrichment on every update). Also idempotent, so it's the repair path for a
|
|
1371
|
+
// configured repo whose symlinks, hook registration, or managed OpenCode files
|
|
1372
|
+
// got out of sync.
|
|
1373
|
+
async function refreshManagedOpenCodeAdapter() {
|
|
1374
|
+
try {
|
|
1375
|
+
const { getOpenCodeAdapterStatus, installOpenCodeAdapter } = await import("./opencode-adapter.mjs");
|
|
1376
|
+
const status = await getOpenCodeAdapterStatus({ home: HOME, env: process.env });
|
|
1377
|
+
if (!status.installed) return;
|
|
1378
|
+
|
|
1379
|
+
// npm/npx payloads must be persisted before the generated adapter points at
|
|
1380
|
+
// them; git-clone installs are already stable and persistHome returns them
|
|
1381
|
+
// unchanged.
|
|
1382
|
+
const persisted = await persistHome(ctx());
|
|
1383
|
+
const adapterForgeHome = persisted.forgeHome || FORGE_HOME;
|
|
1384
|
+
if (adapterForgeHome === FORGE_HOME && isEphemeralCachePath(FORGE_HOME)) {
|
|
1385
|
+
console.log(
|
|
1386
|
+
` ${YELLOW}OpenCode adapter refresh skipped: the current ForgeDock payload is ephemeral.${RESET}`,
|
|
1387
|
+
);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const result = await installOpenCodeAdapter({
|
|
1392
|
+
forgeHome: adapterForgeHome,
|
|
1393
|
+
home: HOME,
|
|
1394
|
+
env: process.env,
|
|
1395
|
+
includeExtras: status.manifest?.includeExtras === true,
|
|
1396
|
+
});
|
|
1397
|
+
console.log(
|
|
1398
|
+
` ${GREEN}Refreshed managed OpenCode adapter (${result.commandCount} commands, ${result.skillCount} skills).${RESET}`,
|
|
1399
|
+
);
|
|
1400
|
+
for (const warning of result.migration.warnings) {
|
|
1401
|
+
console.log(` ${YELLOW}OpenCode adapter warning: ${warning}${RESET}`);
|
|
1402
|
+
}
|
|
1403
|
+
} catch (error) {
|
|
1404
|
+
// OpenCode refresh is best-effort, matching the existing update/relink
|
|
1405
|
+
// behavior: a permissions or ownership problem must not block Claude users.
|
|
1406
|
+
console.log(
|
|
1407
|
+
` ${YELLOW}Could not refresh the managed OpenCode adapter: ${error?.message || String(error)}${RESET}`,
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1338
1412
|
async function relinkAndHint() {
|
|
1339
1413
|
const c = ctx();
|
|
1340
1414
|
const forged = await forge(c);
|
|
@@ -1343,9 +1417,10 @@ async function relinkAndHint() {
|
|
|
1343
1417
|
// version-check path), so wiring it here covers "refreshed after every
|
|
1344
1418
|
// successful update" without duplicating the call at each branch. Note:
|
|
1345
1419
|
// this is NOT reached by install's already-managed-active short-circuit
|
|
1346
|
-
// (that path
|
|
1420
|
+
// (that path is also used by managed-active install) — see the
|
|
1347
1421
|
// writeInstallReceipt() JSDoc in journey.mjs for the full picture.
|
|
1348
1422
|
await writeInstallReceipt(c, { forged });
|
|
1423
|
+
await refreshManagedOpenCodeAdapter();
|
|
1349
1424
|
if (!existsSync(join(c.cwd, "forge.yaml"))) {
|
|
1350
1425
|
const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
1351
1426
|
c.stdout.write(" " + dim("Configure this repo: npx forgedock init") + "\n");
|
|
@@ -1808,24 +1883,18 @@ async function update() {
|
|
|
1808
1883
|
const separateGlobalInstall = findSeparateGlobalInstall();
|
|
1809
1884
|
if (separateGlobalInstall) {
|
|
1810
1885
|
console.log(
|
|
1811
|
-
` ${YELLOW}Note: this
|
|
1886
|
+
` ${YELLOW}Note: this command resolves to the git clone above. Your global npm install at ${separateGlobalInstall} is NOT touched.${RESET}`,
|
|
1812
1887
|
);
|
|
1813
1888
|
console.log(
|
|
1814
1889
|
` ${YELLOW}Run 'npm update -g forgedock', or 'npx forgedock update' from outside a clone, to update it.${RESET}`,
|
|
1815
1890
|
);
|
|
1816
1891
|
}
|
|
1817
1892
|
try {
|
|
1818
|
-
const branch =
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
}).trim();
|
|
1893
|
+
const branch = getGitBranch();
|
|
1894
|
+
const branchLabel = branch || "unknown branch";
|
|
1895
|
+
console.log(` Source: ${formatSourceIdentity(branch)}`);
|
|
1822
1896
|
|
|
1823
|
-
|
|
1824
|
-
// Detached HEAD ("HEAD") is treated as non-restorable — we leave on main
|
|
1825
|
-
// after the update and warn the user.
|
|
1826
|
-
const isDetached = branch === "HEAD";
|
|
1827
|
-
const needsCheckout = branch !== "main";
|
|
1828
|
-
if (needsCheckout) {
|
|
1897
|
+
if (branch !== "main") {
|
|
1829
1898
|
// Dirty-tree guard: refuse to move HEAD in this clone if there are
|
|
1830
1899
|
// uncommitted TRACKED changes. Untracked files are intentionally
|
|
1831
1900
|
// excluded (--untracked-files=no) so a clean checkout with stray
|
|
@@ -1837,7 +1906,7 @@ async function update() {
|
|
|
1837
1906
|
).trim();
|
|
1838
1907
|
if (porcelain) {
|
|
1839
1908
|
console.log(
|
|
1840
|
-
` ${YELLOW}Working tree has uncommitted changes on branch ${
|
|
1909
|
+
` ${YELLOW}Working tree has uncommitted changes on branch ${branchLabel} — skipping git-based update.${RESET}`,
|
|
1841
1910
|
);
|
|
1842
1911
|
|
|
1843
1912
|
// forge#2460 — distinguish "developing on this checkout" from a
|
|
@@ -1882,16 +1951,13 @@ async function update() {
|
|
|
1882
1951
|
return;
|
|
1883
1952
|
}
|
|
1884
1953
|
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
);
|
|
1893
|
-
}
|
|
1894
|
-
execFileSync("git", ["checkout", "main", "--quiet"], { cwd: FORGE_HOME });
|
|
1954
|
+
console.log(
|
|
1955
|
+
` ${YELLOW}Source checkout is on ${branchLabel}, not main; skipping update to preserve this source.${RESET}`,
|
|
1956
|
+
);
|
|
1957
|
+
console.log(
|
|
1958
|
+
` Run 'npx forgedock update' from a main-branch checkout (or use 'npx forgedock@latest') to refresh ForgeDock.`,
|
|
1959
|
+
);
|
|
1960
|
+
return;
|
|
1895
1961
|
}
|
|
1896
1962
|
|
|
1897
1963
|
const before = execSync("git rev-parse HEAD", {
|
|
@@ -1914,20 +1980,6 @@ async function update() {
|
|
|
1914
1980
|
printGitCloneChangelogSummary(before, after);
|
|
1915
1981
|
}
|
|
1916
1982
|
await relinkAndHint();
|
|
1917
|
-
|
|
1918
|
-
// Restore original branch after a successful update.
|
|
1919
|
-
if (needsCheckout && !isDetached) {
|
|
1920
|
-
try {
|
|
1921
|
-
execFileSync("git", ["checkout", branch, "--quiet"], { cwd: FORGE_HOME });
|
|
1922
|
-
console.log(
|
|
1923
|
-
` Restored branch ${CYAN}${branch}${RESET}.`,
|
|
1924
|
-
);
|
|
1925
|
-
} catch {
|
|
1926
|
-
console.log(
|
|
1927
|
-
` ${YELLOW}⚠ Could not restore branch ${branch} — you are now on main.${RESET}`,
|
|
1928
|
-
);
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
1983
|
} catch (err) {
|
|
1932
1984
|
console.log(
|
|
1933
1985
|
` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
|
|
@@ -2190,7 +2242,14 @@ async function doctor(fix = false) {
|
|
|
2190
2242
|
console.log("");
|
|
2191
2243
|
|
|
2192
2244
|
const { targetDir: TARGET_DIR } = detectInstallPaths();
|
|
2193
|
-
|
|
2245
|
+
const isGitCloneInstall = existsSync(join(FORGE_HOME, ".git"));
|
|
2246
|
+
const sourceBranch = isGitCloneInstall ? getGitBranch() : null;
|
|
2247
|
+
console.log(
|
|
2248
|
+
isGitCloneInstall
|
|
2249
|
+
? ` Mode: git clone at ${CYAN}${FORGE_HOME}${RESET}`
|
|
2250
|
+
: ` Mode: global (~/.claude)`,
|
|
2251
|
+
);
|
|
2252
|
+
console.log(` Source: ${formatSourceIdentity(sourceBranch)}`);
|
|
2194
2253
|
console.log("");
|
|
2195
2254
|
|
|
2196
2255
|
let failures = 0;
|
|
@@ -2357,6 +2416,16 @@ async function doctor(fix = false) {
|
|
|
2357
2416
|
broken++;
|
|
2358
2417
|
brokenLinks.push(rel);
|
|
2359
2418
|
symlinkOk = false;
|
|
2419
|
+
} else {
|
|
2420
|
+
try {
|
|
2421
|
+
// readlink() only validates the stored target. Opening the
|
|
2422
|
+
// link also catches platform-specific traversal failures.
|
|
2423
|
+
readFileSync(tgt);
|
|
2424
|
+
} catch {
|
|
2425
|
+
broken++;
|
|
2426
|
+
brokenLinks.push(`${rel} (unreadable — link resolves but cannot be traversed)`);
|
|
2427
|
+
symlinkOk = false;
|
|
2428
|
+
}
|
|
2360
2429
|
}
|
|
2361
2430
|
} else {
|
|
2362
2431
|
// Regular file — copy-mode install (Windows without Developer Mode).
|
|
@@ -2717,7 +2786,6 @@ async function doctor(fix = false) {
|
|
|
2717
2786
|
// absence that isn't a problem should not read as one.
|
|
2718
2787
|
{
|
|
2719
2788
|
try {
|
|
2720
|
-
const isGitCloneInstall = existsSync(join(FORGE_HOME, ".git"));
|
|
2721
2789
|
if (isGitCloneInstall) {
|
|
2722
2790
|
pass("Persisted toolset home (~/.forge)", "skipped — git-clone install links directly from the clone");
|
|
2723
2791
|
} else {
|
|
@@ -3208,6 +3276,7 @@ function help() {
|
|
|
3208
3276
|
* FORGEDOCK_BACKEND Default backend ("cli"|"api"|"auto") when --backend is omitted.
|
|
3209
3277
|
* FORGEDOCK_MODEL Default model id when --model is omitted.
|
|
3210
3278
|
* FORGEDOCK_CLI_TIMEOUT_MS Wall-clock timeout (ms) for the cli backend invocation.
|
|
3279
|
+
* Overrides forge.yaml pipeline.cli_timeout_minutes.
|
|
3211
3280
|
* FORGEDOCK_SHELL Override the shell used by run_bash / the cli backend.
|
|
3212
3281
|
* Defaults to bash when found (Git Bash / WSL on
|
|
3213
3282
|
* Windows, /bin/bash on POSIX), falling back to the
|
|
@@ -3750,7 +3819,9 @@ switch (command) {
|
|
|
3750
3819
|
}
|
|
3751
3820
|
}
|
|
3752
3821
|
if (existsSync(join(c.cwd, "forge.yaml")) && resolveState(c.cwd) === "managed-active") {
|
|
3753
|
-
|
|
3822
|
+
// A configured directory may still have missing commands after uninstall.
|
|
3823
|
+
// Refresh managed files without re-running the configuration journey.
|
|
3824
|
+
await relinkAndHint();
|
|
3754
3825
|
} else {
|
|
3755
3826
|
exitCode = await runJourney(c);
|
|
3756
3827
|
// Record persistHome()'s outcome (set on c.persistHomeResult inside
|
|
@@ -3825,7 +3896,7 @@ switch (command) {
|
|
|
3825
3896
|
includeExtras: restArgs.includes("--extras"),
|
|
3826
3897
|
});
|
|
3827
3898
|
process.stdout.write(
|
|
3828
|
-
`Installed ${result.commandCount} OpenCode commands under ${result.configDir}.\n` +
|
|
3899
|
+
`Installed ${result.commandCount} OpenCode commands and ${result.skillCount} skills under ${result.configDir}.\n` +
|
|
3829
3900
|
"Restart OpenCode, then run /forge/work-on <issue>.\n",
|
|
3830
3901
|
);
|
|
3831
3902
|
if (result.migration.removedInstructionsFile || result.migration.removedConfigEntries > 0) {
|
|
@@ -3836,7 +3907,12 @@ switch (command) {
|
|
|
3836
3907
|
for (const warning of result.migration.warnings) process.stderr.write(`Warning: ${warning}\n`);
|
|
3837
3908
|
} else if (action === "status") {
|
|
3838
3909
|
const result = await getOpenCodeAdapterStatus({ home: HOME });
|
|
3839
|
-
if (
|
|
3910
|
+
if (result.legacy) {
|
|
3911
|
+
process.stdout.write(
|
|
3912
|
+
"A legacy ForgeDock OpenCode adapter is installed. Re-run: npx forgedock opencode install\n",
|
|
3913
|
+
);
|
|
3914
|
+
exitCode = 1;
|
|
3915
|
+
} else if (!result.installed) {
|
|
3840
3916
|
process.stdout.write(`OpenCode adapter is not installed under ${result.configDir}.\n`);
|
|
3841
3917
|
exitCode = 1;
|
|
3842
3918
|
} else if (!result.healthy) {
|
|
@@ -3847,7 +3923,7 @@ switch (command) {
|
|
|
3847
3923
|
exitCode = 1;
|
|
3848
3924
|
} else {
|
|
3849
3925
|
process.stdout.write(
|
|
3850
|
-
`OpenCode adapter is healthy (${result.manifest.commandCount} commands) under ${result.configDir}.\n`,
|
|
3926
|
+
`OpenCode adapter is healthy (${result.manifest.commandCount} commands, ${result.manifest.skillCount ?? 0} skills) under ${result.configDir}.\n`,
|
|
3851
3927
|
);
|
|
3852
3928
|
}
|
|
3853
3929
|
} else if (action === "uninstall") {
|