forgedock 1.0.6 → 1.0.8
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/forgedock.mjs +139 -35
- package/commands/autopilot.md +20 -6
- package/commands/cleanup.md +27 -12
- package/commands/deploy-info.md +36 -17
- package/commands/incident-response.md +14 -9
- package/commands/issue.md +8 -6
- package/commands/milestone.md +97 -48
- package/commands/orchestrate.md +108 -64
- package/commands/pipeline-health.md +8 -1
- package/commands/qa-sweep.md +14 -3
- package/commands/review-pr-agents.md +1 -1
- package/commands/review-pr-staging.md +80 -20
- package/commands/review-pr.md +232 -87
- package/commands/rollback.md +25 -6
- package/commands/work-on/build/validate.md +32 -13
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on-monolithic.md +27 -6
- package/commands/work-on.md +66 -22
- package/package.json +1 -1
- package/scripts/verify-route-registration.sh +65 -19
- package/scripts/verify-sops-chain.sh +22 -2
package/bin/forgedock.mjs
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import os from "os";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
basename,
|
|
7
|
+
dirname,
|
|
8
|
+
isAbsolute,
|
|
9
|
+
join,
|
|
10
|
+
relative,
|
|
11
|
+
resolve,
|
|
12
|
+
sep,
|
|
13
|
+
} from "path";
|
|
6
14
|
import {
|
|
7
15
|
mkdir,
|
|
16
|
+
rm,
|
|
8
17
|
symlink,
|
|
9
18
|
copyFile,
|
|
10
19
|
readlink,
|
|
@@ -2211,7 +2220,9 @@ async function _buildForgeDockBlock() {
|
|
|
2211
2220
|
if (!description) continue;
|
|
2212
2221
|
|
|
2213
2222
|
// Derive display name: relative path from COMMANDS_DIR, remove .md extension
|
|
2214
|
-
const rel = relative(COMMANDS_DIR, file)
|
|
2223
|
+
const rel = relative(COMMANDS_DIR, file)
|
|
2224
|
+
.replace(/\\/g, "/")
|
|
2225
|
+
.replace(/\.md$/, "");
|
|
2215
2226
|
commands.push({ name: rel, description });
|
|
2216
2227
|
}
|
|
2217
2228
|
|
|
@@ -2274,28 +2285,51 @@ function _injectManagedBlock(filePath, blockContent, createIfMissing) {
|
|
|
2274
2285
|
|
|
2275
2286
|
let existing = readFileSync(filePath, "utf-8");
|
|
2276
2287
|
|
|
2277
|
-
if (
|
|
2278
|
-
|
|
2279
|
-
|
|
2288
|
+
if (
|
|
2289
|
+
existing.includes(CLAUDE_BLOCK_BEGIN) &&
|
|
2290
|
+
existing.includes(CLAUDE_BLOCK_END)
|
|
2291
|
+
) {
|
|
2292
|
+
// Both markers present — replace existing block in place.
|
|
2293
|
+
// Guard: if markers are reversed (END before BEGIN) the regex finds no
|
|
2294
|
+
// forward-spanning match; fall through to the repair path instead of
|
|
2295
|
+
// writing the file unchanged and returning a false 'updated'. <!-- Added: forge#291 -->
|
|
2296
|
+
const escaped_begin = CLAUDE_BLOCK_BEGIN.replace(
|
|
2297
|
+
/[.*+?^${}()|[\]\\]/g,
|
|
2298
|
+
"\\$&",
|
|
2299
|
+
);
|
|
2280
2300
|
const escaped_end = CLAUDE_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2281
2301
|
const blockRegex = new RegExp(
|
|
2282
2302
|
`${escaped_begin}[\\s\\S]*?${escaped_end}`,
|
|
2283
2303
|
"g",
|
|
2284
2304
|
);
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2305
|
+
const replaced = existing.replace(blockRegex, managed);
|
|
2306
|
+
if (replaced !== existing) {
|
|
2307
|
+
writeFileSync(filePath, replaced, "utf-8");
|
|
2308
|
+
return "updated";
|
|
2309
|
+
}
|
|
2310
|
+
// Regex found no match (e.g. reversed markers) — strip both orphaned
|
|
2311
|
+
// markers below and fall through to the append path to repair the file.
|
|
2312
|
+
existing = existing
|
|
2313
|
+
.replace(CLAUDE_BLOCK_BEGIN, "")
|
|
2314
|
+
.replace(CLAUDE_BLOCK_END, "")
|
|
2315
|
+
.trimEnd();
|
|
2316
|
+
} else if (existing.includes(CLAUDE_BLOCK_BEGIN)) {
|
|
2291
2317
|
// BEGIN present but END missing — file is truncated or malformed.
|
|
2292
2318
|
// Strip the orphaned marker and everything after it, then fall through
|
|
2293
2319
|
// to the append path below so the complete block is written correctly.
|
|
2294
|
-
existing = existing
|
|
2320
|
+
existing = existing
|
|
2321
|
+
.slice(0, existing.indexOf(CLAUDE_BLOCK_BEGIN))
|
|
2322
|
+
.trimEnd();
|
|
2323
|
+
} else if (existing.includes(CLAUDE_BLOCK_END)) {
|
|
2324
|
+
// END present but BEGIN missing — orphaned END marker left by a previous
|
|
2325
|
+
// failed write. Strip it and fall through to append the complete block.
|
|
2326
|
+
// <!-- Added: forge#291 -->
|
|
2327
|
+
existing = existing.replace(CLAUDE_BLOCK_END, "").trimEnd();
|
|
2295
2328
|
}
|
|
2296
2329
|
|
|
2297
2330
|
// Append block to existing content, separated by a blank line
|
|
2298
|
-
const separator =
|
|
2331
|
+
const separator =
|
|
2332
|
+
existing.length === 0 || existing.endsWith("\n") ? "\n" : "\n\n";
|
|
2299
2333
|
writeFileSync(filePath, `${existing}${separator}${managed}\n`, "utf-8");
|
|
2300
2334
|
return "updated";
|
|
2301
2335
|
}
|
|
@@ -2324,9 +2358,13 @@ async function injectClaudeMd(cwd) {
|
|
|
2324
2358
|
|
|
2325
2359
|
const claudeResult = _injectManagedBlock(claudePath, blockContent, true);
|
|
2326
2360
|
if (claudeResult === "created") {
|
|
2327
|
-
console.log(
|
|
2361
|
+
console.log(
|
|
2362
|
+
` ${GREEN}✔${RESET} CLAUDE.md created with ForgeDock integration block`,
|
|
2363
|
+
);
|
|
2328
2364
|
} else {
|
|
2329
|
-
console.log(
|
|
2365
|
+
console.log(
|
|
2366
|
+
` ${GREEN}✔${RESET} CLAUDE.md updated — ForgeDock block refreshed`,
|
|
2367
|
+
);
|
|
2330
2368
|
}
|
|
2331
2369
|
|
|
2332
2370
|
if (existsSync(agentsPath)) {
|
|
@@ -3421,9 +3459,7 @@ async function labelsSetup(repo) {
|
|
|
3421
3459
|
const manifestPath = join(__dirname, "labels.json");
|
|
3422
3460
|
|
|
3423
3461
|
if (!existsSync(manifestPath)) {
|
|
3424
|
-
console.log(
|
|
3425
|
-
`${RED}Label manifest not found: ${manifestPath}${RESET}`,
|
|
3426
|
-
);
|
|
3462
|
+
console.log(`${RED}Label manifest not found: ${manifestPath}${RESET}`);
|
|
3427
3463
|
process.exit(1);
|
|
3428
3464
|
}
|
|
3429
3465
|
|
|
@@ -3543,21 +3579,54 @@ async function docsInit() {
|
|
|
3543
3579
|
|
|
3544
3580
|
// Security: assert targetDir is confined to the project directory.
|
|
3545
3581
|
// Prevents path traversal via ../.. sequences, absolute paths, and symlinks in devdocs.path.
|
|
3546
|
-
//
|
|
3547
|
-
//
|
|
3548
|
-
//
|
|
3582
|
+
//
|
|
3583
|
+
// Two hardening steps beyond the initial check:
|
|
3584
|
+
//
|
|
3585
|
+
// 1. realpath(cwd) — on Windows, process.cwd() may return a directory-junction path while
|
|
3586
|
+
// realpath() returns the canonical target. Comparing a canonical resolvedTarget against
|
|
3587
|
+
// a non-canonical cwd would false-reject valid paths (BUG-3).
|
|
3588
|
+
//
|
|
3589
|
+
// 2. Walk-up-to-existing-ancestor resolution on ENOENT — when the target path does not yet
|
|
3590
|
+
// exist, falling back to lexical resolve() fails to follow intermediate symlinks. A
|
|
3591
|
+
// symlink planted at "sub/" pointing outside the project would not be dereferenced, so
|
|
3592
|
+
// the lexical result would appear inside the project and the containment check would
|
|
3593
|
+
// pass, allowing mkdir() to write through the symlink (SEC-1). Calling realpath() only
|
|
3594
|
+
// one level up ("parent-first") still fails when the parent also does not exist (e.g.,
|
|
3595
|
+
// devdocs.path = "sub/mid/leaf" where "sub" is a symlink but "sub/mid" is absent) —
|
|
3596
|
+
// realpath("sub/mid") follows sub then ENOENT-s on mid, and the lexical fallback does
|
|
3597
|
+
// not dereference sub (SEC-2). Instead, walk up the directory hierarchy until finding
|
|
3598
|
+
// an ancestor that exists, realpath() it to dereference any symlinks at that level, then
|
|
3599
|
+
// reattach all collected suffix segments.
|
|
3600
|
+
const realCwd = await realpath(cwd).catch(() => resolve(cwd));
|
|
3549
3601
|
let resolvedTarget;
|
|
3550
3602
|
try {
|
|
3551
3603
|
resolvedTarget = await realpath(targetDir);
|
|
3552
3604
|
} catch {
|
|
3605
|
+
// targetDir does not exist — walk up the hierarchy to the nearest existing ancestor,
|
|
3606
|
+
// dereference it via realpath() to follow any intermediate symlinks, then reattach the
|
|
3607
|
+
// collected suffix segments. Initialise resolvedTarget to the lexical path as a
|
|
3608
|
+
// last-resort sentinel (overwritten by the loop on the first successful realpath call).
|
|
3609
|
+
const trailSegments = [basename(targetDir)];
|
|
3610
|
+
let ancestor = dirname(targetDir);
|
|
3553
3611
|
resolvedTarget = resolve(targetDir);
|
|
3612
|
+
while (true) {
|
|
3613
|
+
const parent = dirname(ancestor);
|
|
3614
|
+
if (parent === ancestor) break; // reached filesystem root — keep lexical sentinel
|
|
3615
|
+
try {
|
|
3616
|
+
resolvedTarget = join(await realpath(ancestor), ...trailSegments);
|
|
3617
|
+
break;
|
|
3618
|
+
} catch {
|
|
3619
|
+
trailSegments.unshift(basename(ancestor));
|
|
3620
|
+
ancestor = parent;
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3554
3623
|
}
|
|
3555
|
-
if (resolvedTarget !==
|
|
3624
|
+
if (resolvedTarget !== realCwd && !resolvedTarget.startsWith(realCwd + sep)) {
|
|
3556
3625
|
console.log(
|
|
3557
3626
|
`${RED}Error: devdocs.path must be inside the project directory.${RESET}`,
|
|
3558
3627
|
);
|
|
3559
3628
|
console.log(` Resolved: ${resolvedTarget}`);
|
|
3560
|
-
console.log(` Project: ${
|
|
3629
|
+
console.log(` Project: ${realCwd}`);
|
|
3561
3630
|
console.log(
|
|
3562
3631
|
` Fix: set ${CYAN}devdocs.path${RESET} to a relative path inside your project (e.g. "devdocs" or "docs/knowledge").`,
|
|
3563
3632
|
);
|
|
@@ -3568,12 +3637,8 @@ async function docsInit() {
|
|
|
3568
3637
|
const templatesDir = join(FORGE_HOME, "templates", "devdocs");
|
|
3569
3638
|
|
|
3570
3639
|
if (!existsSync(templatesDir)) {
|
|
3571
|
-
console.log(
|
|
3572
|
-
|
|
3573
|
-
);
|
|
3574
|
-
console.log(
|
|
3575
|
-
` This usually means ForgeDock is not installed correctly.`,
|
|
3576
|
-
);
|
|
3640
|
+
console.log(`${RED}DevDocs templates not found: ${templatesDir}${RESET}`);
|
|
3641
|
+
console.log(` This usually means ForgeDock is not installed correctly.`);
|
|
3577
3642
|
console.log(
|
|
3578
3643
|
` Try: ${CYAN}npm install -g forgedock${RESET} or ${CYAN}npx forgedock@latest docs init${RESET}`,
|
|
3579
3644
|
);
|
|
@@ -3589,14 +3654,51 @@ async function docsInit() {
|
|
|
3589
3654
|
console.log(` Files: ${templateFiles.length} seed files`);
|
|
3590
3655
|
console.log("");
|
|
3591
3656
|
|
|
3592
|
-
|
|
3657
|
+
// Use resolvedTarget (canonical path) rather than the raw targetDir string. The
|
|
3658
|
+
// containment check above already resolved symlinks and validated the path; using the
|
|
3659
|
+
// canonical form here closes the TOCTOU window to the narrowest possible interval
|
|
3660
|
+
// and ensures mkdir does not follow a symlink that was NOT present when we checked.
|
|
3661
|
+
//
|
|
3662
|
+
// TOCTOU residual: a narrow window remains between the realpath check and this mkdir
|
|
3663
|
+
// on POSIX systems — a symlink planted at an intermediate path component during that
|
|
3664
|
+
// window could cause mkdir to create directories outside the project boundary.
|
|
3665
|
+
// Full elimination requires O_NOFOLLOW semantics (kernel-level, not available from
|
|
3666
|
+
// Node.js user-space). We mitigate with a post-create re-validation below (SEC-5).
|
|
3667
|
+
await mkdir(resolvedTarget, { recursive: true });
|
|
3668
|
+
|
|
3669
|
+
// SEC-5: Post-create re-validation — detect symlink planted during the TOCTOU window.
|
|
3670
|
+
// After mkdir succeeds, call realpath() on the now-existing directory to get its
|
|
3671
|
+
// canonical on-disk location. If that location lies outside the project root a
|
|
3672
|
+
// symlink was planted between the containment check and the mkdir call. In that case
|
|
3673
|
+
// we remove the directory tree that was created (wherever it landed) and abort.
|
|
3674
|
+
try {
|
|
3675
|
+
const postMkdirReal = await realpath(resolvedTarget);
|
|
3676
|
+
if (postMkdirReal !== realCwd && !postMkdirReal.startsWith(realCwd + sep)) {
|
|
3677
|
+
await rm(postMkdirReal, { recursive: true, force: true }).catch(() => {});
|
|
3678
|
+
console.log(
|
|
3679
|
+
`${RED}Error: devdocs directory escaped the project boundary after creation (TOCTOU).${RESET}`,
|
|
3680
|
+
);
|
|
3681
|
+
console.log(` Created at: ${postMkdirReal}`);
|
|
3682
|
+
console.log(` Project: ${realCwd}`);
|
|
3683
|
+
console.log(
|
|
3684
|
+
` A symlink was likely planted at an intermediate path component between the`,
|
|
3685
|
+
);
|
|
3686
|
+
console.log(
|
|
3687
|
+
` containment check and the mkdir call. The created directory has been removed.`,
|
|
3688
|
+
);
|
|
3689
|
+
process.exit(1);
|
|
3690
|
+
}
|
|
3691
|
+
} catch {
|
|
3692
|
+
// realpath() failure after a successful mkdir is unexpected; proceed — the directory
|
|
3693
|
+
// was created by us and the normal containment check already passed.
|
|
3694
|
+
}
|
|
3593
3695
|
|
|
3594
3696
|
let copied = 0;
|
|
3595
3697
|
let skipped = 0;
|
|
3596
3698
|
|
|
3597
3699
|
for (const srcFile of templateFiles) {
|
|
3598
3700
|
const rel = relative(templatesDir, srcFile);
|
|
3599
|
-
const destFile = join(
|
|
3701
|
+
const destFile = join(resolvedTarget, rel);
|
|
3600
3702
|
const destDir = dirname(destFile);
|
|
3601
3703
|
|
|
3602
3704
|
await mkdir(destDir, { recursive: true });
|
|
@@ -4083,7 +4185,11 @@ if (!command) {
|
|
|
4083
4185
|
break;
|
|
4084
4186
|
case "labels": {
|
|
4085
4187
|
const subcommand = args[1];
|
|
4086
|
-
if (
|
|
4188
|
+
if (
|
|
4189
|
+
!subcommand ||
|
|
4190
|
+
subcommand === "setup" ||
|
|
4191
|
+
subcommand.startsWith("--")
|
|
4192
|
+
) {
|
|
4087
4193
|
const subArgs = args.slice(1);
|
|
4088
4194
|
const repo = resolveLabelsRepo(subArgs);
|
|
4089
4195
|
if (!repo) {
|
|
@@ -4112,9 +4218,7 @@ if (!command) {
|
|
|
4112
4218
|
await docsInit();
|
|
4113
4219
|
} else {
|
|
4114
4220
|
console.log(`${RED}Unknown docs subcommand: ${docsSubcommand}${RESET}`);
|
|
4115
|
-
console.log(
|
|
4116
|
-
`Usage: ${CYAN}npx forgedock docs init${RESET}`,
|
|
4117
|
-
);
|
|
4221
|
+
console.log(`Usage: ${CYAN}npx forgedock docs init${RESET}`);
|
|
4118
4222
|
process.exit(1);
|
|
4119
4223
|
}
|
|
4120
4224
|
break;
|
package/commands/autopilot.md
CHANGED
|
@@ -11,6 +11,13 @@ argument-hint: [--fix | --recon-only | --fix --limit 5 | --dry-run]
|
|
|
11
11
|
- `{CREDENTIALS_FILE}` ← `paths.credentials.file` (optional) — path to credentials YAML for analytics APIs
|
|
12
12
|
- `{SERVER_SSH}` ← `services.server_ssh` (optional) — SSH target for production server health checks (e.g., `ubuntu@1.2.3.4`)
|
|
13
13
|
- `{EMEMO_PATH}` ← `services.ememo_path` (optional) — path on production server to open eMemo files
|
|
14
|
+
- `{BILLING_ENABLED}` ← `billing.enabled` (optional, default `false`) — set to `true` to enable Stripe data collection in the Analytics Snapshot agent
|
|
15
|
+
|
|
16
|
+
Resolve `{BILLING_ENABLED}` from `forge.yaml` before executing any phase:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
BILLING_ENABLED=$(yq '.billing.enabled // false' "$(git rev-parse --show-toplevel)/forge.yaml" 2>/dev/null || echo 'false')
|
|
20
|
+
```
|
|
14
21
|
|
|
15
22
|
You are an autonomous improvement engine for this project. Your job is to **find what's wrong, create trackable issues, and optionally fix the highest-impact ones** — all in a single cycle. Every cycle leaves the platform measurably better than before.
|
|
16
23
|
|
|
@@ -51,9 +58,15 @@ DRY_RUN = true if --dry-run present
|
|
|
51
58
|
```bash
|
|
52
59
|
echo "=== Autopilot Cycle: $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="
|
|
53
60
|
|
|
61
|
+
# Portable ISO-8601 date arithmetic using python3 (works on Windows, macOS, Linux).
|
|
62
|
+
# Replaces GNU-only `date -u -d 'N days ago'` which fails on Windows and macOS.
|
|
63
|
+
DATE_3D_AGO=$(python3 -c "from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc) - timedelta(days=3)).strftime('%Y-%m-%dT%H:%M:%SZ'))")
|
|
64
|
+
DATE_7D_AGO=$(python3 -c "from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc) - timedelta(days=7)).strftime('%Y-%m-%dT%H:%M:%SZ'))")
|
|
65
|
+
DATE_1D_AGO=$(python3 -c "from datetime import datetime, timedelta, timezone; print((datetime.now(timezone.utc) - timedelta(days=1)).strftime('%Y-%m-%dT%H:%M:%SZ'))")
|
|
66
|
+
|
|
54
67
|
# Recent closed issues (last 3 days) — what was fixed recently?
|
|
55
68
|
gh issue list --state closed --json number,title,labels,closedAt \
|
|
56
|
-
--jq '[.[] | select(.closedAt > "'$
|
|
69
|
+
--jq '[.[] | select(.closedAt > "'"$DATE_3D_AGO"'")] | length' 2>/dev/null || echo "0"
|
|
57
70
|
|
|
58
71
|
# Open issue count by priority
|
|
59
72
|
gh issue list --state open --label "priority:P0" --json number --jq 'length'
|
|
@@ -64,12 +77,12 @@ gh issue list --state open --label "priority:P2" --json number --jq 'length'
|
|
|
64
77
|
gh issue list --state open --limit 200 --json number,title,labels,createdAt \
|
|
65
78
|
--jq '[.[] | select(
|
|
66
79
|
(.labels | map(.name) | any(startswith("workflow:")) | not) and
|
|
67
|
-
(.createdAt < "'$
|
|
80
|
+
(.createdAt < "'"$DATE_7D_AGO"'")
|
|
68
81
|
)] | length'
|
|
69
82
|
|
|
70
83
|
# Failed CI runs in last 24h
|
|
71
84
|
gh run list --limit 30 --json conclusion,createdAt \
|
|
72
|
-
--jq '[.[] | select(.conclusion == "failure" and .createdAt > "'$
|
|
85
|
+
--jq '[.[] | select(.conclusion == "failure" and .createdAt > "'"$DATE_1D_AGO"'")] | length'
|
|
73
86
|
```
|
|
74
87
|
|
|
75
88
|
Store these as `BASELINE` metrics for the cycle report.
|
|
@@ -140,8 +153,9 @@ Analyze GitHub issue backlog:
|
|
|
140
153
|
Quick analytics pulse — just the key metrics, not a full audit:
|
|
141
154
|
1. Read credentials from {CREDENTIALS_FILE} (set via paths.credentials.file in forge.yaml)
|
|
142
155
|
2. GSC: mcp__gsc__search_analytics for last 7 days — total clicks, impressions, avg position
|
|
143
|
-
3. Stripe: mcp__stripe__retrieve_balance — current balance
|
|
144
|
-
|
|
156
|
+
3. Stripe (only if {BILLING_ENABLED} is true): mcp__stripe__retrieve_balance — current balance.
|
|
157
|
+
Skip this step entirely if {BILLING_ENABLED} is false — do NOT call any Stripe MCP tools.
|
|
158
|
+
4. Return: clicks trend (up/down), revenue (or "N/A — billing.enabled: false" if skipped), notable changes
|
|
145
159
|
```
|
|
146
160
|
|
|
147
161
|
### 1B: Collect results
|
|
@@ -291,7 +305,7 @@ Print a structured report:
|
|
|
291
305
|
|
|
292
306
|
### Analytics Pulse
|
|
293
307
|
- Clicks (7d): {N} ({trend})
|
|
294
|
-
- Revenue: ${N}
|
|
308
|
+
- Revenue: ${N} (or "N/A — billing.enabled: false" if Stripe step was skipped)
|
|
295
309
|
|
|
296
310
|
### Actions Taken
|
|
297
311
|
- Issues created: {count} ({list with numbers})
|
package/commands/cleanup.md
CHANGED
|
@@ -25,11 +25,16 @@ GH_FLAG="-R $GH_REPO"
|
|
|
25
25
|
REPO_PATH=$(yq '.paths.root' "$CONFIG_FILE")
|
|
26
26
|
STAGING_BRANCH=$(yq '.branches.staging' "$CONFIG_FILE")
|
|
27
27
|
PROJECT_BOARD_OWNER=$(yq '.project_board.owner // .project.owner' "$CONFIG_FILE")
|
|
28
|
-
PROJECT_NUMBER=$(yq '.project_board.project_number //
|
|
29
|
-
PROJECT_ID=$(yq '.project_board.project_id' "$CONFIG_FILE")
|
|
28
|
+
PROJECT_NUMBER=$(yq '.project_board.project_number // empty' "$CONFIG_FILE" 2>/dev/null || echo "")
|
|
29
|
+
PROJECT_ID=$(yq '.project_board.project_id // empty' "$CONFIG_FILE" 2>/dev/null || echo "")
|
|
30
|
+
# Project board field and option IDs — empty string when project_board section is absent
|
|
31
|
+
STATUS_FIELD_ID=$(yq '.project_board.field_ids.status // ""' "$CONFIG_FILE" 2>/dev/null || echo "")
|
|
32
|
+
WORKFLOW_FIELD_ID=$(yq '.project_board.field_ids.workflow // ""' "$CONFIG_FILE" 2>/dev/null || echo "")
|
|
33
|
+
STATUS_DONE_OPTION_ID=$(yq '.project_board.option_ids.status.done // ""' "$CONFIG_FILE" 2>/dev/null || echo "")
|
|
34
|
+
WORKFLOW_MERGED_OPTION_ID=$(yq '.project_board.option_ids.workflow.merged // ""' "$CONFIG_FILE" 2>/dev/null || echo "")
|
|
30
35
|
```
|
|
31
36
|
|
|
32
|
-
All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{STAGING_BRANCH}`, `{PROJECT_BOARD_OWNER}`, `{PROJECT_NUMBER}`, and `{
|
|
37
|
+
All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{STAGING_BRANCH}`, `{PROJECT_BOARD_OWNER}`, `{PROJECT_NUMBER}`, `{PROJECT_ID}`, `{STATUS_FIELD_ID}`, `{WORKFLOW_FIELD_ID}`, `{STATUS_DONE_OPTION_ID}`, and `{WORKFLOW_MERGED_OPTION_ID}` references below are populated from `forge.yaml`.
|
|
33
38
|
|
|
34
39
|
---
|
|
35
40
|
|
|
@@ -222,19 +227,29 @@ fi
|
|
|
222
227
|
|
|
223
228
|
## Phase 5: Sync Project Board (if `board` or `all`)
|
|
224
229
|
|
|
225
|
-
|
|
230
|
+
**Skip if `project_board` is not configured** — check resolved vars before proceeding:
|
|
226
231
|
|
|
227
232
|
```bash
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
if [ -z "$PROJECT_BOARD_OWNER" ] || [ -z "$PROJECT_ID" ] || [ -z "$PROJECT_NUMBER" ]; then
|
|
234
|
+
echo "INFO: project_board not configured in forge.yaml — skipping board sync"
|
|
235
|
+
else
|
|
236
|
+
# Board sync: mark closed issues as Done/Merged on the project board
|
|
230
237
|
|
|
231
|
-
|
|
238
|
+
# List all board items
|
|
239
|
+
ITEMS=$(gh project item-list "$PROJECT_NUMBER" --owner "$PROJECT_BOARD_OWNER" --format json --limit 200)
|
|
232
240
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
gh project item-edit --project-id
|
|
237
|
-
|
|
241
|
+
# For each item where content.state == "CLOSED" but status != "Done" or workflow is not terminal:
|
|
242
|
+
echo "$ITEMS" | jq -r '.items[] | select(.content.state == "CLOSED") | .id' | while read -r ITEM_ID; do
|
|
243
|
+
if [ -n "$STATUS_FIELD_ID" ] && [ -n "$STATUS_DONE_OPTION_ID" ]; then
|
|
244
|
+
gh project item-edit --project-id "$PROJECT_ID" --id "$ITEM_ID" \
|
|
245
|
+
--field-id "$STATUS_FIELD_ID" --single-select-option-id "$STATUS_DONE_OPTION_ID" 2>/dev/null || true # Status=Done
|
|
246
|
+
fi
|
|
247
|
+
if [ -n "$WORKFLOW_FIELD_ID" ] && [ -n "$WORKFLOW_MERGED_OPTION_ID" ]; then
|
|
248
|
+
gh project item-edit --project-id "$PROJECT_ID" --id "$ITEM_ID" \
|
|
249
|
+
--field-id "$WORKFLOW_FIELD_ID" --single-select-option-id "$WORKFLOW_MERGED_OPTION_ID" 2>/dev/null || true # Workflow=Merged
|
|
250
|
+
fi
|
|
251
|
+
done
|
|
252
|
+
fi
|
|
238
253
|
```
|
|
239
254
|
|
|
240
255
|
---
|
package/commands/deploy-info.md
CHANGED
|
@@ -10,6 +10,8 @@ argument-hint: [staging | milestone/{slug} | compare {branch}]
|
|
|
10
10
|
**Config variables used by this command** (set in `forge.yaml`):
|
|
11
11
|
- `{REPO_PATH}` ← `paths.root` — project repository root
|
|
12
12
|
- `{HEALTH_ENDPOINT}` ← `services.health_endpoint` (optional) — URL used in post-deploy health check
|
|
13
|
+
- `{DEPLOY_WORKFLOW}` ← `deploy.workflow` (optional) — GitHub Actions workflow filename to trigger a deploy (e.g., `hotfix-deploy.yml`). When absent or empty, workflow-trigger steps are omitted.
|
|
14
|
+
- `{DEPLOY_SECRETS_BACKEND}` ← `deploy.secrets_backend` (optional) — secrets delivery method (`sops`, `aws-sm`, `vault`, `ci-env`, `none`). Controls whether secret-chain checks run.
|
|
13
15
|
|
|
14
16
|
You are the pipeline's deploy awareness layer. Before the user merges staging → main (triggering CI/CD deployment), this command shows exactly what's going out: which PRs, which issues, what changed, and what risks exist.
|
|
15
17
|
|
|
@@ -153,13 +155,20 @@ done
|
|
|
153
155
|
# Full file diff summary
|
|
154
156
|
git diff --stat origin/$TARGET..origin/$SOURCE
|
|
155
157
|
|
|
158
|
+
# Read layout paths from forge.yaml, fall back to AlterLab defaults
|
|
159
|
+
LAYOUT_API=$(yq '.review.layout.api // "services/api"' forge.yaml 2>/dev/null || echo "services/api")
|
|
160
|
+
LAYOUT_WORKER=$(yq '.review.layout.worker // "services/worker"' forge.yaml 2>/dev/null || echo "services/worker")
|
|
161
|
+
LAYOUT_PAGES=$(yq '.review.layout.pages // "web/src/app"' forge.yaml 2>/dev/null || echo "web/src/app")
|
|
162
|
+
# Derive web root from pages root (strip trailing /src/app or similar suffix if present)
|
|
163
|
+
LAYOUT_WEB=$(echo "$LAYOUT_PAGES" | sed 's|/src/app$||; s|/src$||')
|
|
164
|
+
|
|
156
165
|
# Categorize by service
|
|
157
166
|
echo "=== By Service ==="
|
|
158
167
|
git diff --name-only origin/$TARGET..origin/$SOURCE | sort | while read f; do
|
|
159
168
|
case "$f" in
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
169
|
+
${LAYOUT_API}/*) echo "API: $f" ;;
|
|
170
|
+
${LAYOUT_WORKER}/*) echo "WORKER: $f" ;;
|
|
171
|
+
${LAYOUT_WEB}/*) echo "WEB: $f" ;;
|
|
163
172
|
shared/*) echo "SHARED: $f" ;;
|
|
164
173
|
infra/*) echo "INFRA: $f" ;;
|
|
165
174
|
*) echo "OTHER: $f" ;;
|
|
@@ -170,8 +179,11 @@ done | sort
|
|
|
170
179
|
### Step 2E: Migration check
|
|
171
180
|
|
|
172
181
|
```bash
|
|
182
|
+
# Read migrations path from forge.yaml, fall back to infra/migrations
|
|
183
|
+
LAYOUT_MIGRATIONS=$(yq '.review.layout.migrations // "infra/migrations"' forge.yaml 2>/dev/null || echo "infra/migrations")
|
|
184
|
+
|
|
173
185
|
# Are there new migrations?
|
|
174
|
-
MIGRATIONS=$(git diff --name-only origin/$TARGET..origin/$SOURCE | grep "
|
|
186
|
+
MIGRATIONS=$(git diff --name-only origin/$TARGET..origin/$SOURCE | grep "^${LAYOUT_MIGRATIONS}/" | sort)
|
|
175
187
|
if [ -n "$MIGRATIONS" ]; then
|
|
176
188
|
echo "⚠️ DATABASE MIGRATIONS INCLUDED:"
|
|
177
189
|
echo "$MIGRATIONS"
|
|
@@ -195,7 +207,7 @@ Analyze the deploy payload and flag risks:
|
|
|
195
207
|
|--------|-----------|--------|
|
|
196
208
|
| Database migrations present | MEDIUM | Verify migrations are reversible |
|
|
197
209
|
| `shared/` changes | LOW | Restart-only deploy possible (no rebuild) |
|
|
198
|
-
| `.env.example` changes | HIGH | Verify
|
|
210
|
+
| `.env.example` changes | HIGH | Verify secrets backend has new vars (see `deploy.secrets_backend`) |
|
|
199
211
|
| `docker-compose.prod.yml` changes | HIGH | Review infrastructure changes carefully |
|
|
200
212
|
| `infra/traefik/` changes | HIGH | Routing changes — test before full deploy |
|
|
201
213
|
| 10+ PRs in one deploy | MEDIUM | Consider deploying in smaller batches |
|
|
@@ -205,17 +217,22 @@ Analyze the deploy payload and flag risks:
|
|
|
205
217
|
### Automated Checks
|
|
206
218
|
|
|
207
219
|
```bash
|
|
208
|
-
# Check for new env vars that might not be in
|
|
220
|
+
# Check for new env vars that might not be in the configured secrets backend
|
|
209
221
|
NEW_ENV=$(git diff origin/$TARGET..origin/$SOURCE -- '.env.example' | grep "^+" | grep -v "^+++" | grep -v "^#")
|
|
210
222
|
if [ -n "$NEW_ENV" ]; then
|
|
211
|
-
echo "⚠️ NEW ENVIRONMENT VARIABLES — verify they exist in
|
|
223
|
+
echo "⚠️ NEW ENVIRONMENT VARIABLES — verify they exist in your secrets backend ({DEPLOY_SECRETS_BACKEND}):"
|
|
212
224
|
echo "$NEW_ENV"
|
|
213
|
-
# Cross-check with
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
225
|
+
# Cross-check with SOPS chain (only when deploy.secrets_backend == "sops")
|
|
226
|
+
# If your project uses a different backend, skip this block and verify manually.
|
|
227
|
+
if [ "{DEPLOY_SECRETS_BACKEND}" = "sops" ]; then
|
|
228
|
+
for var in $(echo "$NEW_ENV" | grep -oP '^\+\K[A-Z_]+'); do
|
|
229
|
+
if ! grep -q "$var" scripts/decrypt-secrets.sh 2>/dev/null; then
|
|
230
|
+
echo " ❌ $var NOT in decrypt-secrets.sh ENV_MAPPING"
|
|
231
|
+
fi
|
|
232
|
+
done
|
|
233
|
+
else
|
|
234
|
+
echo " ℹ️ SOPS chain check skipped — deploy.secrets_backend is '{DEPLOY_SECRETS_BACKEND}'. Verify new vars in your configured backend."
|
|
235
|
+
fi
|
|
219
236
|
fi
|
|
220
237
|
```
|
|
221
238
|
|
|
@@ -232,7 +249,7 @@ Generate a checklist based on what's in the deploy:
|
|
|
232
249
|
- [ ] All PRs in this deploy have been reviewed
|
|
233
250
|
- [ ] No open `needs-human` issues blocking deploy
|
|
234
251
|
- [ ] {If migrations} Database backup verified
|
|
235
|
-
- [ ] {If new env vars}
|
|
252
|
+
- [ ] {If new env vars} Secrets backend ({DEPLOY_SECRETS_BACKEND}) updated with new vars; verify via your configured secrets chain
|
|
236
253
|
- [ ] {If traefik changes} Routing tested locally
|
|
237
254
|
|
|
238
255
|
### Deploy Command
|
|
@@ -240,8 +257,9 @@ Generate a checklist based on what's in the deploy:
|
|
|
240
257
|
# Standard deploy (merge staging → main via GitHub web UI)
|
|
241
258
|
# CI/CD auto-triggers on push to main
|
|
242
259
|
|
|
243
|
-
# Or manual trigger for specific services:
|
|
244
|
-
gh workflow run
|
|
260
|
+
# Or manual trigger for specific services (requires deploy.workflow set in forge.yaml):
|
|
261
|
+
gh workflow run {deploy.workflow} --ref main -f {deploy.workflow_inputs.services}={affected_services} -f {deploy.workflow_inputs.reason}="Deploy: {summary}"
|
|
262
|
+
# If deploy.workflow is not configured, deploy via the GitHub web UI or your CI/CD provider.
|
|
245
263
|
```
|
|
246
264
|
|
|
247
265
|
### Post-Deploy
|
|
@@ -292,5 +310,6 @@ gh workflow run hotfix-deploy.yml --ref main -f services={affected_services} -f
|
|
|
292
310
|
---
|
|
293
311
|
|
|
294
312
|
**Ready to deploy?** Merge staging → main via GitHub web UI, or run:
|
|
295
|
-
`gh workflow run
|
|
313
|
+
`gh workflow run {deploy.workflow} --ref staging -f {deploy.workflow_inputs.services}={services} -f {deploy.workflow_inputs.reason}="Batch deploy"`
|
|
314
|
+
*(requires `deploy.workflow` set in `forge.yaml`; omit this line if not configured)*
|
|
296
315
|
```
|
|
@@ -9,6 +9,7 @@ argument-hint: [issue number | "active" | "postmortem {issue}"]
|
|
|
9
9
|
|
|
10
10
|
**Config variables used by this command** (set in `forge.yaml`):
|
|
11
11
|
- `{REPO_PATH}` ← `paths.root` — project repository root
|
|
12
|
+
- `{DEPLOY_WORKFLOW}` ← `deploy.workflow` (optional) — GitHub Actions workflow filename for hotfix deploys (e.g., `hotfix-deploy.yml`). When absent or empty, workflow-trigger and workflow-monitor steps are omitted.
|
|
12
13
|
|
|
13
14
|
You are the pipeline's incident response coordinator. When production goes down or a critical bug surfaces, this command orchestrates the response: validates the hotfix before deploy, reconstructs the incident timeline, and produces a post-incident analysis.
|
|
14
15
|
|
|
@@ -55,8 +56,9 @@ Parse the issue to understand:
|
|
|
55
56
|
gh pr list --state merged --base main --json number,title,mergedAt \
|
|
56
57
|
--jq '[.[] | select(.mergedAt > (now - 86400 | todate))] | .[] | "\(.number) | \(.title) | \(.mergedAt)"'
|
|
57
58
|
|
|
58
|
-
# Check recent hotfix deploys
|
|
59
|
-
|
|
59
|
+
# Check recent hotfix deploys (requires deploy.workflow set in forge.yaml)
|
|
60
|
+
# Skip this check if deploy.workflow is not configured.
|
|
61
|
+
gh run list --workflow={deploy.workflow} --limit 5 --json databaseId,status,conclusion,createdAt,displayTitle
|
|
60
62
|
```
|
|
61
63
|
|
|
62
64
|
#### Step 1C: Post incident acknowledgment
|
|
@@ -119,9 +121,10 @@ Your job:
|
|
|
119
121
|
- Does it remove functionality other code depends on?
|
|
120
122
|
- Are there syntax errors?
|
|
121
123
|
- Does it change DB schema without a migration?
|
|
122
|
-
4. Run compile checks:
|
|
123
|
-
- Python: python -m py_compile on changed .py files
|
|
124
|
-
-
|
|
124
|
+
4. Run compile checks (read forge.yaml → verification.commands for tool commands):
|
|
125
|
+
- Python: always run python -m py_compile on changed .py files (no config needed)
|
|
126
|
+
- Python format/lint: run verification.commands.python.format and .lint if configured; log "SKIPPED — not configured in verification.commands" if absent
|
|
127
|
+
- TypeScript: run verification.commands.typescript.typecheck if configured; log "SKIPPED — not configured in verification.commands" if absent
|
|
125
128
|
5. Check if the fix is scoped tightly (minimal changes for the incident)
|
|
126
129
|
|
|
127
130
|
Output:
|
|
@@ -143,10 +146,11 @@ gh issue comment {NUMBER} --body "$(cat <<'EOF'
|
|
|
143
146
|
**Confidence**: {level}
|
|
144
147
|
|
|
145
148
|
{If safe}:
|
|
146
|
-
**Ready for deploy.** Run:
|
|
149
|
+
**Ready for deploy.** Run (requires `deploy.workflow` set in `forge.yaml`):
|
|
147
150
|
```
|
|
148
|
-
gh workflow run
|
|
151
|
+
gh workflow run {deploy.workflow} --ref {FIX_BRANCH} -f {deploy.workflow_inputs.services}={affected_services} -f {deploy.workflow_inputs.reason}="P0: {TITLE}"
|
|
149
152
|
```
|
|
153
|
+
*(If `deploy.workflow` is not configured, merge the fix branch via the GitHub web UI or your CI/CD provider.)*
|
|
150
154
|
|
|
151
155
|
{If not safe}:
|
|
152
156
|
**Concerns found:**
|
|
@@ -164,8 +168,9 @@ EOF
|
|
|
164
168
|
#### Step 3A: Watch the deploy workflow
|
|
165
169
|
|
|
166
170
|
```bash
|
|
167
|
-
# Find the most recent
|
|
168
|
-
|
|
171
|
+
# Find the most recent deploy run (requires deploy.workflow set in forge.yaml)
|
|
172
|
+
# Skip if deploy.workflow is not configured.
|
|
173
|
+
RUN_ID=$(gh run list --workflow={deploy.workflow} --limit 1 --json databaseId --jq '.[0].databaseId')
|
|
169
174
|
|
|
170
175
|
# Check status
|
|
171
176
|
gh run view $RUN_ID --json status,conclusion,jobs
|
package/commands/issue.md
CHANGED
|
@@ -28,7 +28,7 @@ The user's input (`$ARGUMENTS`) can be:
|
|
|
28
28
|
|-----------|---------|
|
|
29
29
|
| Free-text description | "the billing page crashes when credits hit zero" |
|
|
30
30
|
| URL to error/log | "https://sentry.io/..." or a pasted stack trace |
|
|
31
|
-
| Reference to code | "the
|
|
31
|
+
| Reference to code | "the background worker leaks memory in task_processor.py" |
|
|
32
32
|
| Feature request | "add a dark mode toggle to the dashboard settings page" |
|
|
33
33
|
| Multi-repo prefix | "mcp: add list_schemas tool" or "n8n: fix credential refresh" |
|
|
34
34
|
| Audit/investigation | "investigate why deploy times doubled this week" |
|
|
@@ -161,8 +161,8 @@ Format: `{prefix}: {concise description}`
|
|
|
161
161
|
Rules:
|
|
162
162
|
- **Max 80 characters**
|
|
163
163
|
- Use conventional commit prefixes: `fix:`, `feat:`, `refactor:`, `investigate:`, `docs:`
|
|
164
|
-
- Be specific — "fix: billing page crash" is bad, "fix:
|
|
165
|
-
- Include the component/domain if it helps: `fix(
|
|
164
|
+
- Be specific — "fix: billing page crash" is bad, "fix: payment_processor.charge() raises ZeroDivisionError when amount == 0" is good
|
|
165
|
+
- Include the component/domain if it helps: `fix(worker): queue leak in task_processor heartbeat loop`
|
|
166
166
|
- Never use vague words: "improve", "update", "change", "handle" without specifics
|
|
167
167
|
|
|
168
168
|
### 3B: Compose the body
|
|
@@ -258,6 +258,8 @@ Files/areas to examine:
|
|
|
258
258
|
|
|
259
259
|
### 3D: Pipeline Issue Template (machine-callable reference)
|
|
260
260
|
|
|
261
|
+
> **Canonical Standard**: This template is the single source of truth for all automated issue creation in the ForgeDock pipeline. `milestone.md`, `orchestrate.md`, and `work-on/decompose.md` MUST use this template structure when creating issues programmatically. A shared convention is only useful if all create-paths enforce it — divergent inline templates in each command file cause acceptance-criteria drift and coverage gaps. See `work-on/decompose.md` Phase D0 for the investigation-gate pattern that enforces this standard at decomposition time. <!-- Added: forge#293 -->
|
|
262
|
+
|
|
261
263
|
When pipeline agents create issues programmatically (not via user input), use this canonical template. All automated `gh issue create` calls across Forge commands MUST include these mandatory sections. Domain-specific sections are additive — preserve them, but wrap them with this structure.
|
|
262
264
|
|
|
263
265
|
```markdown
|
|
@@ -488,10 +490,10 @@ Create all {N} issues? (yes / adjust #N / cancel)
|
|
|
488
490
|
| Bad Issue | Why It Fails | Good Version |
|
|
489
491
|
|-----------|-------------|-------------|
|
|
490
492
|
| "Fix docker mount" | Which compose file? Which mount? | "fix: `.gemini` volume mount in docker-compose.prod.yml missing rw flag" |
|
|
491
|
-
| "Update billing" | What about billing? What's broken? | "fix:
|
|
492
|
-
| "Investigate performance" | Investigate what? Where? | "investigate: API p95 latency doubled since last deploy — trace hot paths in
|
|
493
|
+
| "Update billing" | What about billing? What's broken? | "fix: payment_processor.charge() raises ZeroDivisionError when amount == 0" |
|
|
494
|
+
| "Investigate performance" | Investigate what? Where? | "investigate: API p95 latency doubled since last deploy — trace hot paths in job processing routers" |
|
|
493
495
|
| No affected files listed | Investigator guesses wrong files | List every file with what needs to change |
|
|
494
|
-
| "Handle edge case" | Which edge case? In what function? | "fix:
|
|
496
|
+
| "Handle edge case" | Which edge case? In what function? | "fix: task_processor.process_job() silently drops jobs when queue.length > MAX_BATCH" |
|
|
495
497
|
| Missing override files | Fix is incomplete, deploy fails | Include docker-compose.prod.yml, .env.example, etc. |
|
|
496
498
|
| P0 for a typo fix | Wastes priority signaling | Use P3 for cosmetic issues |
|
|
497
499
|
|