forgedock 1.0.22 → 1.1.2
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 +255 -211
- package/bin/batch-runner.mjs +339 -0
- package/bin/cinema.mjs +320 -0
- package/bin/demo.mjs +92 -7
- package/bin/engine/phases.mjs +168 -0
- package/bin/engine/projector.mjs +32 -0
- package/bin/engine/reconcile.mjs +11 -0
- package/bin/engine/runlog.mjs +79 -0
- package/bin/engine/state.mjs +32 -0
- package/bin/engine-cli.mjs +222 -0
- package/bin/engine.mjs +138 -0
- package/bin/forgedock.mjs +896 -1540
- package/bin/hooks/interactive-engine.mjs +509 -0
- package/bin/hooks/pre-tool-use.mjs +498 -0
- package/bin/hooks/subagent-stop-enforce.mjs +197 -0
- package/bin/journey.mjs +841 -0
- package/bin/registry.mjs +139 -0
- package/bin/report.mjs +447 -0
- package/bin/runner.mjs +136 -20
- package/bin/settings-hook.mjs +346 -0
- package/bin/tui.mjs +6 -3
- package/commands/adopt.md +4 -1
- package/commands/analytics.md +4 -1
- package/commands/audit-agents.md +110 -27
- package/commands/audit.md +20 -1
- package/commands/autopilot.md +84 -12
- package/commands/changelog.md +2 -1
- package/commands/ci-audit.md +2 -1
- package/commands/cleanup.md +161 -1
- package/commands/compat-audit.md +2 -1
- package/commands/deploy-info.md +63 -4
- package/commands/design-bench.md +6 -1
- package/commands/design-render-critique-loop.md +2 -1
- package/commands/design.md +1 -0
- package/commands/diagnose.md +2 -1
- package/commands/explain.md +2 -1
- package/commands/forge-stats.md +122 -2
- package/commands/forgedock-init.md +1 -1
- package/commands/geo-audit.md +3 -0
- package/commands/incident-response.md +2 -1
- package/commands/issue.md +24 -3
- package/commands/milestone.md +77 -12
- package/commands/optimize.md +2 -1
- package/commands/orchestrate/config.md +67 -0
- package/commands/orchestrate/phase-1-resolve.md +159 -0
- package/commands/orchestrate/phase-2-triage.md +126 -0
- package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
- package/commands/orchestrate/phase-3-dependency.md +591 -0
- package/commands/orchestrate/phase-4-execution.md +962 -0
- package/commands/orchestrate/phase-5-cleanup.md +47 -0
- package/commands/orchestrate/phase-6-report.md +198 -0
- package/commands/orchestrate/safety.md +62 -0
- package/commands/orchestrate.md +25 -2027
- package/commands/pipeline-health/config.md +34 -0
- package/commands/pipeline-health/phase-1-context.md +180 -0
- package/commands/pipeline-health/phase-2-metrics.md +1729 -0
- package/commands/pipeline-health/phase-3-analyze.md +204 -0
- package/commands/pipeline-health/phase-4-proposals.md +117 -0
- package/commands/pipeline-health/phase-5-report.md +284 -0
- package/commands/pipeline-health/phase-6-summary.md +52 -0
- package/commands/pipeline-health.md +25 -2401
- package/commands/pipeline-resume.md +24 -1
- package/commands/pipeline-status.md +65 -0
- package/commands/qa-sweep.md +4 -1
- package/commands/quality-gate.md +326 -42
- package/commands/replay.md +2 -1
- package/commands/review-pr-agents/api.md +135 -0
- package/commands/review-pr-agents/auth.md +137 -0
- package/commands/review-pr-agents/billing.md +101 -0
- package/commands/review-pr-agents/concurrency.md +145 -0
- package/commands/review-pr-agents/database.md +103 -0
- package/commands/review-pr-agents/frontend.md +89 -0
- package/commands/review-pr-agents/infra.md +385 -0
- package/commands/review-pr-agents/protocols.md +185 -0
- package/commands/review-pr-agents/scraper.md +185 -0
- package/commands/review-pr-agents/security.md +406 -0
- package/commands/review-pr-agents.md +45 -1722
- package/commands/review-pr-staging.md +196 -40
- package/commands/review-pr.md +612 -56
- package/commands/rollback.md +2 -1
- package/commands/scope.md +2 -1
- package/commands/security-audit.md +2 -1
- package/commands/signal-planner.md +623 -0
- package/commands/sync-ecosystem.md +1 -0
- package/commands/test-gate.md +17 -1
- package/commands/validate.md +2 -1
- package/commands/work-on/build/architect.md +28 -0
- package/commands/work-on/build/context.md +2 -0
- package/commands/work-on/build/implement.md +19 -5
- package/commands/work-on/build/validate.md +62 -6
- package/commands/work-on/build.md +187 -30
- package/commands/work-on/close.md +132 -5
- package/commands/work-on/decompose.md +19 -1
- package/commands/work-on/investigate.md +117 -2
- package/commands/work-on/review.md +48 -4
- package/commands/work-on-monolithic.md +8 -2
- package/commands/work-on.md +247 -7
- package/docs/BRAND.md +16 -5
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +36 -1
- package/docs/CONFIG.md +44 -42
- package/docs/FORGE-PROTOCOL.md +15 -1064
- package/docs/anthropic-devrel-outreach.md +2 -2
- package/docs/articles/command-decomposition-token-savings.md +214 -0
- package/docs/articles/model-release-playbook.md +131 -0
- package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
- package/docs/claude-breakpoints.json +75 -5
- package/docs/design/abc-benchmark.md +1 -1
- package/docs/design/design-spec-schema.md +1 -1
- package/docs/design/divergent-generation.md +1 -1
- package/docs/design/eval-gate-runbook.md +178 -0
- package/docs/design/show-hn-draft.md +26 -1
- package/docs/design/token-cost-baseline-1298.md +59 -0
- package/docs/eval/README.md +117 -0
- package/docs/eval/corpus/seed-v1.json +6 -0
- package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
- package/docs/site/annotations-explained.md +24 -0
- package/docs/site/command-learning-path.md +51 -14
- package/docs/site/command-reference.md +339 -113
- package/docs/site/for-companies.md +95 -0
- package/docs/site/forge-annotation-protocol.md +12 -256
- package/docs/site/getting-started.md +58 -12
- package/docs/site/github-is-the-memory.md +113 -0
- package/docs/site/how-it-works.md +3 -13
- package/docs/site/index.md +10 -2
- package/docs/spec/eval-run-result.md +147 -0
- package/docs/spec/forge-protocol-v1.md +441 -18
- package/docs/spec/label-state-machine.md +55 -0
- package/docs/spec/review-protocol.md +158 -0
- package/docs/spec-graph-schema.md +28 -0
- package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
- package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
- package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
- package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
- package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
- package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
- package/examples/forgedock-demo/README.md +41 -22
- package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
- package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
- package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
- package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
- package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
- package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
- package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
- package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
- package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
- package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
- package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
- package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
- package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
- package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
- package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
- package/examples/forgedock-demo/scripts/smoke.js +18 -1
- package/examples/forgedock-demo/src/db.js +36 -11
- package/examples/forgedock-demo/src/routes/notes.js +106 -9
- package/examples/forgedock-demo/src/server.js +4 -1
- package/package.json +12 -4
- package/packages/protocol/LICENSE +21 -0
- package/packages/protocol/src/cli.js +135 -0
- package/packages/protocol/src/emit.js +146 -0
- package/packages/protocol/src/index.js +43 -0
- package/packages/protocol/src/parse.js +229 -0
- package/packages/protocol/src/types.js +171 -0
- package/packages/protocol/src/validate.js +122 -0
- package/scripts/bench-topology-cost.mjs +372 -0
- package/scripts/check-command-docs-drift.sh +198 -0
- package/scripts/check-protocol-restatements.sh +176 -0
- package/scripts/check-registry/README.md +72 -0
- package/scripts/check-registry/manifest.json +6 -0
- package/scripts/code-index.sh +454 -0
- package/scripts/conformance-check.mjs +168 -0
- package/scripts/doctor-pipeline-state.sh +484 -0
- package/scripts/eval-gate-baseline.json +17 -0
- package/scripts/eval-gate-scorecard.mjs +242 -0
- package/scripts/eval-scorecard-aggregator.mjs +252 -0
- package/scripts/flaky-quarantine.sh +241 -0
- package/scripts/forge-annotation.sh +567 -0
- package/scripts/issue-dedup.sh +236 -0
- package/scripts/validate-annotation-node.mjs +233 -0
- package/scripts/verify-env-vars.sh +18 -2
- package/scripts/verify-host-headers.sh +4 -4
- package/scripts/verify-route-registration.sh +2 -2
- package/scripts/worktree-lifecycle.sh +202 -0
- package/templates/devdocs/agent/using-forgedock.md +14 -0
package/bin/journey.mjs
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bin/journey.mjs — The five-act onboarding journey for ForgeDock.
|
|
3
|
+
*
|
|
4
|
+
* Acts (added across Tasks 4–8):
|
|
5
|
+
* preflight() → forge() → read() → review() → celebrate()
|
|
6
|
+
*
|
|
7
|
+
* This file owns forge.yaml generation (single source of truth, driven from
|
|
8
|
+
* detection values) and orchestrates the existing modules:
|
|
9
|
+
* init-detect.mjs, init-enrich-api.mjs, tui.annotatedReviewScreen, registry.mjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
|
|
13
|
+
import { join, basename } from "path";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// forge.yaml generation (Task 4)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "\\r").replace(/\n/g, "\\n");
|
|
20
|
+
|
|
21
|
+
/** Append the TODO flag for a low-confidence field. */
|
|
22
|
+
function todo(key, low) {
|
|
23
|
+
return low.includes(key) ? ` # TODO(forgedock:${key}) — verify this value` : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Write forge.yaml from reviewed values.
|
|
28
|
+
* @param {{owner:string,repo:string,name:string,description:string,root:string,
|
|
29
|
+
* worktreeBase:string,defaultBranch:string,stagingBranch:string}} v
|
|
30
|
+
* Exactly the object annotatedReviewScreen resolves with.
|
|
31
|
+
* @param {string[]} lowConfidenceKeys - keys to flag with # TODO comments.
|
|
32
|
+
* @param {string} outputPath
|
|
33
|
+
* @returns {{ todoCount: number }}
|
|
34
|
+
*/
|
|
35
|
+
export function writeForgeYaml(v, lowConfidenceKeys, outputPath) {
|
|
36
|
+
const low = lowConfidenceKeys;
|
|
37
|
+
const content = `# forge.yaml — ForgeDock Configuration
|
|
38
|
+
#
|
|
39
|
+
# Auto-generated by: npx forgedock
|
|
40
|
+
# Fields marked with TODO comments below were guessed — verify them.
|
|
41
|
+
#
|
|
42
|
+
# Required sections: project, paths, branches
|
|
43
|
+
# Optional sections: repos, project_board, services, review, verification
|
|
44
|
+
#
|
|
45
|
+
# See docs/CONFIG.md for full reference.
|
|
46
|
+
|
|
47
|
+
# =============================================================================
|
|
48
|
+
# PROJECT (REQUIRED)
|
|
49
|
+
# =============================================================================
|
|
50
|
+
|
|
51
|
+
project:
|
|
52
|
+
name: "${esc(v.name)}"${todo("name", low)}
|
|
53
|
+
owner: "${esc(v.owner)}"${todo("owner", low)}
|
|
54
|
+
repo: "${esc(v.repo)}"${todo("repo", low)}
|
|
55
|
+
description: "${esc(v.description)}"${todo("description", low)}
|
|
56
|
+
|
|
57
|
+
# =============================================================================
|
|
58
|
+
# PATHS (REQUIRED)
|
|
59
|
+
# =============================================================================
|
|
60
|
+
|
|
61
|
+
paths:
|
|
62
|
+
root: "${esc(v.root)}"${todo("root", low)}
|
|
63
|
+
worktree_base: "${esc(v.worktreeBase)}"${todo("worktreeBase", low)}
|
|
64
|
+
|
|
65
|
+
# =============================================================================
|
|
66
|
+
# BRANCHES (REQUIRED)
|
|
67
|
+
# =============================================================================
|
|
68
|
+
|
|
69
|
+
branches:
|
|
70
|
+
default: "${esc(v.defaultBranch)}"${todo("defaultBranch", low)}
|
|
71
|
+
staging: "${esc(v.stagingBranch)}"${todo("stagingBranch", low)}
|
|
72
|
+
feature_pattern: "milestone/{slug}"
|
|
73
|
+
|
|
74
|
+
# =============================================================================
|
|
75
|
+
# REPOS (OPTIONAL) — multi-repo configuration. Remove the # to enable.
|
|
76
|
+
# =============================================================================
|
|
77
|
+
|
|
78
|
+
# repos:
|
|
79
|
+
# default:
|
|
80
|
+
# repo: "${esc(v.owner)}/${esc(v.repo)}"
|
|
81
|
+
# staging_branch: "${esc(v.stagingBranch)}"
|
|
82
|
+
# satellites:
|
|
83
|
+
# - prefix: "mcp"
|
|
84
|
+
# repo: "${esc(v.owner)}/your-satellite-repo"
|
|
85
|
+
# staging_branch: "main"
|
|
86
|
+
|
|
87
|
+
# =============================================================================
|
|
88
|
+
# PROJECT BOARD (OPTIONAL) — GitHub Projects v2 integration.
|
|
89
|
+
# To find IDs: gh project list --owner ${esc(v.owner)}
|
|
90
|
+
# =============================================================================
|
|
91
|
+
|
|
92
|
+
# project_board:
|
|
93
|
+
# owner: "${esc(v.owner)}"
|
|
94
|
+
# project_number: 1
|
|
95
|
+
# project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
|
|
96
|
+
# field_ids:
|
|
97
|
+
# status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
98
|
+
|
|
99
|
+
# =============================================================================
|
|
100
|
+
# REVIEW (OPTIONAL) — context injected into review agent prompts.
|
|
101
|
+
# =============================================================================
|
|
102
|
+
|
|
103
|
+
# review:
|
|
104
|
+
# tech_stack: "Node.js, TypeScript, PostgreSQL"
|
|
105
|
+
# context: |
|
|
106
|
+
# Describe your repo structure and any unusual conventions here.
|
|
107
|
+
|
|
108
|
+
# =============================================================================
|
|
109
|
+
# VERIFICATION (OPTIONAL) — health checks for quality gate / validate.
|
|
110
|
+
# =============================================================================
|
|
111
|
+
|
|
112
|
+
# verification:
|
|
113
|
+
# health_endpoint: "https://api.example.com/health"
|
|
114
|
+
# health_patterns:
|
|
115
|
+
# - '"status": "ok"'
|
|
116
|
+
`;
|
|
117
|
+
// Atomic write: write to a temp file first, then rename into place.
|
|
118
|
+
// If the write fails (e.g. ENOSPC), the original file is untouched and
|
|
119
|
+
// any partial .tmp is cleaned up — no corrupt or missing forge.yaml.
|
|
120
|
+
const tmpPath = outputPath + ".tmp";
|
|
121
|
+
try {
|
|
122
|
+
writeFileSync(tmpPath, content, "utf-8");
|
|
123
|
+
renameSync(tmpPath, outputPath);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
const todoCount = (content.match(/# TODO\(forgedock:/g) || []).length;
|
|
129
|
+
return { todoCount };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Maps manual-flow value keys to their location in a ConfigDraft. */
|
|
133
|
+
const DRAFT_KEY_PATHS = {
|
|
134
|
+
owner: ["project", "owner"],
|
|
135
|
+
repo: ["project", "repo"],
|
|
136
|
+
name: ["project", "name"],
|
|
137
|
+
root: ["paths", "root"],
|
|
138
|
+
worktreeBase: ["paths", "worktreeBase"],
|
|
139
|
+
defaultBranch: ["branches", "default"],
|
|
140
|
+
stagingBranch: ["branches", "staging"],
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Compute which fields accepted in the `--manual` init flow should be flagged
|
|
145
|
+
* low-confidence (and so get a `# TODO(forgedock:<field>)` comment).
|
|
146
|
+
*
|
|
147
|
+
* A field counts as low-confidence when detection scored it "low" AND the
|
|
148
|
+
* user accepted the detected value unchanged (didn't type over the default).
|
|
149
|
+
* `description` is special-cased: it has no draft entry, so it counts as low
|
|
150
|
+
* when detection found nothing (empty) and the user also left it empty.
|
|
151
|
+
*
|
|
152
|
+
* Pure — no I/O, safe to unit test directly.
|
|
153
|
+
*
|
|
154
|
+
* @param {import('./init-detect.mjs').ConfigDraft} draft
|
|
155
|
+
* @param {{ value: string, source: string }} description
|
|
156
|
+
* @param {{owner:string,repo:string,name:string,description:string,root:string,
|
|
157
|
+
* worktreeBase:string,defaultBranch:string,stagingBranch:string}} values
|
|
158
|
+
* The values accepted from the manual prompts.
|
|
159
|
+
* @returns {string[]}
|
|
160
|
+
*/
|
|
161
|
+
export function manualLowConfidenceKeys(draft, description, values) {
|
|
162
|
+
const keys = [];
|
|
163
|
+
for (const [key, path] of Object.entries(DRAFT_KEY_PATHS)) {
|
|
164
|
+
const field = path.reduce(
|
|
165
|
+
(node, k) => (node && typeof node === "object" ? node[k] : undefined),
|
|
166
|
+
draft,
|
|
167
|
+
);
|
|
168
|
+
if (field && field.confidence === "low" && values[key] === field.value) {
|
|
169
|
+
keys.push(key);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!description.value && !values.description) {
|
|
173
|
+
keys.push("description");
|
|
174
|
+
}
|
|
175
|
+
return keys;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Back up an existing file to <name>.bak (timestamped if .bak exists).
|
|
180
|
+
* @returns {{ backupName: string } | null} null when the file didn't exist.
|
|
181
|
+
*/
|
|
182
|
+
export function backupExisting(outputPath) {
|
|
183
|
+
if (!existsSync(outputPath)) return null;
|
|
184
|
+
const baseBak = outputPath + ".bak";
|
|
185
|
+
const backupPath = existsSync(baseBak)
|
|
186
|
+
? `${baseBak}.${new Date().toISOString().replace(/[:.]/g, "-")}`
|
|
187
|
+
: baseBak;
|
|
188
|
+
renameSync(outputPath, backupPath);
|
|
189
|
+
return { backupName: basename(backupPath) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Description detection (moved from forgedock.mjs init(), Task 4)
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
function firstParagraph(content) {
|
|
197
|
+
const lines = content.split("\n");
|
|
198
|
+
let started = false;
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
if (!started && line.match(/^#/)) continue;
|
|
202
|
+
if (!started && line.trim() === "") continue;
|
|
203
|
+
if (!started && line.match(/^[!<\[`|]/)) continue;
|
|
204
|
+
if (!started && line.match(/^---/)) continue;
|
|
205
|
+
if (!started) started = true;
|
|
206
|
+
if (line.trim() === "") break;
|
|
207
|
+
out.push(line.trim());
|
|
208
|
+
}
|
|
209
|
+
if (out.length === 0) return "";
|
|
210
|
+
return out
|
|
211
|
+
.join(" ")
|
|
212
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
213
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
214
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
215
|
+
.slice(0, 200)
|
|
216
|
+
.trim();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Detect a project description from README.md, falling back to CLAUDE.md.
|
|
221
|
+
* @returns {{ value: string, source: 'README.md'|'CLAUDE.md'|'' }}
|
|
222
|
+
*/
|
|
223
|
+
export function detectDescription(cwd) {
|
|
224
|
+
for (const file of ["README.md", "CLAUDE.md"]) {
|
|
225
|
+
try {
|
|
226
|
+
const p = join(cwd, file);
|
|
227
|
+
if (!existsSync(p)) continue;
|
|
228
|
+
const value = firstParagraph(readFileSync(p, "utf-8").slice(0, 2048));
|
|
229
|
+
if (value) return { value, source: file };
|
|
230
|
+
} catch {
|
|
231
|
+
// best-effort only
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { value: "", source: "" };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Journey context (Task 5)
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
import { execFileSync } from "child_process";
|
|
242
|
+
import os from "os";
|
|
243
|
+
import {
|
|
244
|
+
renderMark, ember, shimmer, revealRows, moltenBar, fixCard,
|
|
245
|
+
colorMode, motionEnabled, CHROME_STOPS, HERO_MARK, COMPACT_MARK, sleep,
|
|
246
|
+
} from "./cinema.mjs";
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Build the shared journey context. Every act takes this as its first arg.
|
|
250
|
+
* All process-touching values are injectable for tests.
|
|
251
|
+
*/
|
|
252
|
+
export function makeCtx(overrides = {}) {
|
|
253
|
+
const env = overrides.env ?? process.env;
|
|
254
|
+
const stdout = overrides.stdout ?? process.stdout;
|
|
255
|
+
const argv = overrides.argv ?? process.argv.slice(2);
|
|
256
|
+
const nodeVersion = overrides.nodeVersion ?? process.versions.node;
|
|
257
|
+
const enrichFn = overrides.enrichFn ?? enrich;
|
|
258
|
+
return {
|
|
259
|
+
cwd: process.cwd(),
|
|
260
|
+
home: env.HOME || env.USERPROFILE || os.homedir(),
|
|
261
|
+
forgeHome: "",
|
|
262
|
+
argv,
|
|
263
|
+
env,
|
|
264
|
+
stdout,
|
|
265
|
+
mode: colorMode(env, stdout),
|
|
266
|
+
motion: motionEnabled(argv, env, stdout),
|
|
267
|
+
nodeVersion,
|
|
268
|
+
linkStrategy: "symlink",
|
|
269
|
+
enrichFn,
|
|
270
|
+
exec: (cmd, args) =>
|
|
271
|
+
execFileSync(cmd, args, {
|
|
272
|
+
encoding: "utf-8",
|
|
273
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
274
|
+
timeout: 10000,
|
|
275
|
+
}).trim(),
|
|
276
|
+
startedAt: Date.now(),
|
|
277
|
+
...overrides,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Act I — Ignition: hero mark + preflight checks (Task 5)
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
const dimLine = (ctx, s) => (ctx.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Render the hero banner and run preflight checks. Failures render fix cards
|
|
289
|
+
* and the journey continues — advisory, never fatal.
|
|
290
|
+
* @returns {Promise<{ checks: Array<{name, ok, detail, fix?}>, ghReady: boolean }>}
|
|
291
|
+
*/
|
|
292
|
+
export async function preflight(ctx) {
|
|
293
|
+
const { stdout: w } = ctx;
|
|
294
|
+
w.write("\n");
|
|
295
|
+
await shimmer(HERO_MARK, CHROME_STOPS, { mode: ctx.mode, motion: ctx.motion, writer: w });
|
|
296
|
+
w.write("\n " + ember("F O R G E D O C K", ctx.mode) + "\n");
|
|
297
|
+
w.write(" " + dimLine(ctx, "──── lighting the forge ────────────────────") + "\n\n");
|
|
298
|
+
|
|
299
|
+
const rows = [
|
|
300
|
+
{
|
|
301
|
+
label: "Node",
|
|
302
|
+
run: async () => {
|
|
303
|
+
const major = Number(ctx.nodeVersion.split(".")[0]);
|
|
304
|
+
return major >= 18
|
|
305
|
+
? { ok: true, detail: `v${ctx.nodeVersion}` }
|
|
306
|
+
: { ok: false, detail: `v${ctx.nodeVersion} — need ≥18`, fix: ["Upgrade Node: https://nodejs.org/"] };
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
label: "git",
|
|
311
|
+
run: async () => {
|
|
312
|
+
try {
|
|
313
|
+
const v = ctx.exec("git", ["--version"]);
|
|
314
|
+
return { ok: true, detail: v.replace(/^git version\s*/, "") };
|
|
315
|
+
} catch {
|
|
316
|
+
return { ok: false, detail: "not found", fix: ["Install git: https://git-scm.com/downloads"] };
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
label: "Claude Code",
|
|
322
|
+
run: async () => {
|
|
323
|
+
const claudeDir = join(ctx.home, ".claude");
|
|
324
|
+
return existsSync(claudeDir)
|
|
325
|
+
? { ok: true, detail: "~/.claude found" }
|
|
326
|
+
: { ok: false, detail: "~/.claude not found", fix: ["Install Claude Code: https://claude.com/claude-code"] };
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
label: "GitHub CLI",
|
|
331
|
+
run: async () => {
|
|
332
|
+
try {
|
|
333
|
+
ctx.exec("gh", ["--version"]);
|
|
334
|
+
} catch {
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
detail: "not found",
|
|
338
|
+
fix: ["Install gh: https://cli.github.com/", "Windows: winget install GitHub.cli"],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
ctx.exec("gh", ["auth", "status"]);
|
|
343
|
+
return { ok: true, detail: "authenticated" };
|
|
344
|
+
} catch {
|
|
345
|
+
return { ok: false, detail: "not authenticated", fix: ["Run: gh auth login"] };
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
const results = await revealRows(rows, { mode: ctx.mode, motion: ctx.motion, writer: w });
|
|
352
|
+
const named = rows.map((r, i) => ({ name: r.label, ...results[i] }));
|
|
353
|
+
return { checks: named, ghReady: named[3].ok };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// Act II — Forging: command symlinks + SessionStart hook (Task 6)
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
import { mkdir, symlink, readlink, lstat, readdir, rename, copyFile, readFile, writeFile, unlink } from "fs/promises";
|
|
361
|
+
import { relative, dirname as pathDirname } from "path";
|
|
362
|
+
import {
|
|
363
|
+
installSessionStartHook,
|
|
364
|
+
installSubagentStopHook,
|
|
365
|
+
installPreToolUseHook,
|
|
366
|
+
removeSubagentStopEnforceHook,
|
|
367
|
+
} from "./settings-hook.mjs";
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Parse the `install:` tier from a command spec's YAML frontmatter block.
|
|
371
|
+
*
|
|
372
|
+
* Reads the first 512 bytes of the file synchronously (large enough to reach
|
|
373
|
+
* any realistic frontmatter block without loading the full spec into memory).
|
|
374
|
+
* Strips a leading UTF-8 BOM if present — BOM-prefixed files would cause the
|
|
375
|
+
* frontmatter regex to miss the leading `---` delimiter.
|
|
376
|
+
*
|
|
377
|
+
* Tier values:
|
|
378
|
+
* 'core' — install for all users (default when key is absent or on any error)
|
|
379
|
+
* 'extras' — opt-in, not installed by default; use `npx forgedock install --extras` (#1257)
|
|
380
|
+
* 'internal' — ForgeDock development only; never installed to user machines
|
|
381
|
+
*
|
|
382
|
+
* Fail-open: any read error, malformed frontmatter, or unrecognised value falls
|
|
383
|
+
* back to `'core'` so the command is installed rather than silently excluded.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} filePath - Absolute path to the .md command spec.
|
|
386
|
+
* @returns {'core' | 'extras' | 'internal'}
|
|
387
|
+
*/
|
|
388
|
+
export function parseInstallTier(filePath) {
|
|
389
|
+
try {
|
|
390
|
+
// Read the file as UTF-8. readFileSync is already imported from "fs" at the
|
|
391
|
+
// top of this module — no require() needed in this ESM file. For large specs
|
|
392
|
+
// only the first 512 bytes are relevant (frontmatter fits comfortably within
|
|
393
|
+
// that budget), but readFileSync is simpler and safe for the file sizes here.
|
|
394
|
+
let content = readFileSync(filePath, "utf-8");
|
|
395
|
+
// Strip BOM — a BOM-prefixed file would shift the leading `---` off the
|
|
396
|
+
// start of the string and cause the frontmatter regex to miss it entirely.
|
|
397
|
+
// (Ref: review-finding #657 — parseFrontmatter silently falls back on BOM files)
|
|
398
|
+
content = content.replace(/^\uFEFF/, "");
|
|
399
|
+
|
|
400
|
+
// Match YAML frontmatter block: must start at position 0.
|
|
401
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
402
|
+
if (!match) return "core";
|
|
403
|
+
|
|
404
|
+
const raw = match[1];
|
|
405
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
406
|
+
const colonIdx = line.indexOf(":");
|
|
407
|
+
if (colonIdx === -1) continue;
|
|
408
|
+
const key = line.slice(0, colonIdx).trim();
|
|
409
|
+
if (key !== "install") continue;
|
|
410
|
+
const val = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, "");
|
|
411
|
+
if (val === "internal" || val === "extras" || val === "core") return val;
|
|
412
|
+
return "core"; // unrecognised value → default
|
|
413
|
+
}
|
|
414
|
+
return "core"; // key absent
|
|
415
|
+
} catch {
|
|
416
|
+
return "core"; // any read/parse error → fail-open
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Recursively find installable .md files, sorted (moved from forgedock.mjs).
|
|
421
|
+
*
|
|
422
|
+
* Files with `install: internal` in their YAML frontmatter are excluded — they
|
|
423
|
+
* are ForgeDock-development-only specs that must never reach user machines.
|
|
424
|
+
* Files with `install: core` (or no `install:` key) are included. Files with
|
|
425
|
+
* `install: extras` are excluded from the default install surface; they are
|
|
426
|
+
* available via `npx forgedock install --extras` (implemented in #1257).
|
|
427
|
+
*
|
|
428
|
+
* @param {string} dir - Directory to search recursively.
|
|
429
|
+
* @param {{ includeExtras?: boolean }} [opts] - Options.
|
|
430
|
+
* includeExtras: when true, also include `install: extras` specs (opt-in tier).
|
|
431
|
+
*/
|
|
432
|
+
export async function findMarkdownFiles(dir, opts = {}) {
|
|
433
|
+
const { includeExtras = false } = opts;
|
|
434
|
+
const results = [];
|
|
435
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
436
|
+
for (const entry of entries) {
|
|
437
|
+
const full = join(dir, entry.name);
|
|
438
|
+
if (entry.isDirectory()) {
|
|
439
|
+
results.push(...(await findMarkdownFiles(full, opts)));
|
|
440
|
+
} else if (entry.name.endsWith(".md")) {
|
|
441
|
+
const tier = parseInstallTier(full);
|
|
442
|
+
if (tier === "core" || (includeExtras && tier === "extras")) {
|
|
443
|
+
results.push(full);
|
|
444
|
+
}
|
|
445
|
+
// 'internal' is always excluded from the install surface
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return results.sort();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Load the copied-commands ownership manifest (missing/corrupt → empty). */
|
|
452
|
+
async function loadCopiedManifest(manifestPath) {
|
|
453
|
+
try {
|
|
454
|
+
const parsed = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
455
|
+
if (parsed && typeof parsed === "object" && parsed.files && typeof parsed.files === "object") {
|
|
456
|
+
return { version: 1, files: parsed.files };
|
|
457
|
+
}
|
|
458
|
+
} catch {
|
|
459
|
+
// missing or corrupt → start fresh
|
|
460
|
+
}
|
|
461
|
+
return { version: 1, files: {} };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Save the manifest atomically (mkdir recursive + .tmp+rename). */
|
|
465
|
+
async function saveCopiedManifest(manifestPath, manifest) {
|
|
466
|
+
await mkdir(pathDirname(manifestPath), { recursive: true });
|
|
467
|
+
await writeFile(manifestPath + ".tmp", JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
468
|
+
await rename(manifestPath + ".tmp", manifestPath);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const isLinkPermissionError = (err) => err.code === "EPERM" || err.code === "EACCES";
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Act II: link commands into ~/.claude/commands with a molten progress line,
|
|
475
|
+
* then register the SessionStart hook.
|
|
476
|
+
* Symlink semantics preserved verbatim from the original install():
|
|
477
|
+
* regular files are skipped with a warning; changed links updated atomically.
|
|
478
|
+
* On Windows without Developer Mode (symlink → EPERM/EACCES) each command is
|
|
479
|
+
* copied instead, and recorded in a manifest so re-runs can tell ForgeDock's
|
|
480
|
+
* own copies apart from user-owned files. ctx.linkStrategy ("symlink" default)
|
|
481
|
+
* can be set to "copy" to skip all symlink attempts (deterministic tests).
|
|
482
|
+
*/
|
|
483
|
+
export async function forge(ctx) {
|
|
484
|
+
const { stdout: w } = ctx;
|
|
485
|
+
const commandsDir = join(ctx.forgeHome, "commands");
|
|
486
|
+
const targetDir = join(ctx.home, ".claude", "commands");
|
|
487
|
+
const manifestPath = join(ctx.home, ".claude", "forgedock", "copied-commands.json");
|
|
488
|
+
const wantSymlink = ctx.linkStrategy !== "copy";
|
|
489
|
+
|
|
490
|
+
w.write("\n " + ember("Forging commands", ctx.mode) + " " + dimLine(ctx, `into ${targetDir}`) + "\n\n");
|
|
491
|
+
await mkdir(targetDir, { recursive: true });
|
|
492
|
+
|
|
493
|
+
const manifest = await loadCopiedManifest(manifestPath);
|
|
494
|
+
let manifestChanged = false;
|
|
495
|
+
|
|
496
|
+
const files = await findMarkdownFiles(commandsDir, { includeExtras: !!ctx.includeExtras });
|
|
497
|
+
let installed = 0, updated = 0, skipped = 0, copied = 0;
|
|
498
|
+
const barWidth = 24;
|
|
499
|
+
let barShown = false;
|
|
500
|
+
|
|
501
|
+
const recordCopy = (rel) => {
|
|
502
|
+
if (!manifest.files[rel]) {
|
|
503
|
+
manifest.files[rel] = true;
|
|
504
|
+
manifestChanged = true;
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
for (let i = 0; i < files.length; i++) {
|
|
509
|
+
const file = files[i];
|
|
510
|
+
const rel = relative(commandsDir, file);
|
|
511
|
+
const target = join(targetDir, rel);
|
|
512
|
+
await mkdir(pathDirname(target), { recursive: true });
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
const stats = await lstat(target);
|
|
516
|
+
if (stats.isSymbolicLink()) {
|
|
517
|
+
const current = await readlink(target);
|
|
518
|
+
if (current === file) {
|
|
519
|
+
skipped++;
|
|
520
|
+
} else {
|
|
521
|
+
let relinked = false;
|
|
522
|
+
if (wantSymlink) {
|
|
523
|
+
try {
|
|
524
|
+
await symlink(file, target + ".tmp");
|
|
525
|
+
try {
|
|
526
|
+
await rename(target + ".tmp", target);
|
|
527
|
+
} catch (renameErr) {
|
|
528
|
+
await unlink(target + ".tmp").catch(() => {});
|
|
529
|
+
throw renameErr;
|
|
530
|
+
}
|
|
531
|
+
updated++;
|
|
532
|
+
relinked = true;
|
|
533
|
+
} catch (linkErr) {
|
|
534
|
+
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (!relinked) {
|
|
538
|
+
// Can't (or shouldn't) re-link — replace the managed link with a copy.
|
|
539
|
+
// unlink first: copyFile onto a symlink writes THROUGH the link.
|
|
540
|
+
await unlink(target);
|
|
541
|
+
await copyFile(file, target);
|
|
542
|
+
updated++; // replaces an existing managed entry
|
|
543
|
+
recordCopy(rel);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
} else if (manifest.files[rel]) {
|
|
547
|
+
// A regular file we copied on a previous run — ours to manage.
|
|
548
|
+
// First try upgrading it to a symlink (Developer Mode enabled since).
|
|
549
|
+
let upgraded = false;
|
|
550
|
+
if (wantSymlink) {
|
|
551
|
+
try {
|
|
552
|
+
await symlink(file, target + ".tmp");
|
|
553
|
+
try {
|
|
554
|
+
await rename(target + ".tmp", target); // rename replaces existing files on Windows
|
|
555
|
+
} catch (renameErr) {
|
|
556
|
+
await unlink(target + ".tmp").catch(() => {});
|
|
557
|
+
throw renameErr;
|
|
558
|
+
}
|
|
559
|
+
updated++;
|
|
560
|
+
delete manifest.files[rel];
|
|
561
|
+
manifestChanged = true;
|
|
562
|
+
upgraded = true;
|
|
563
|
+
} catch (linkErr) {
|
|
564
|
+
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (!upgraded) {
|
|
568
|
+
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
569
|
+
if (src.equals(dst)) {
|
|
570
|
+
skipped++;
|
|
571
|
+
} else {
|
|
572
|
+
await copyFile(file, target);
|
|
573
|
+
updated++;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
} else {
|
|
577
|
+
if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
|
|
578
|
+
w.write(` WARNING: ${rel} is a regular file — skipping (remove it manually to let ForgeDock manage it)\n`);
|
|
579
|
+
skipped++;
|
|
580
|
+
}
|
|
581
|
+
} catch (err) {
|
|
582
|
+
if (err.code !== "ENOENT") throw err;
|
|
583
|
+
let linked = false;
|
|
584
|
+
if (wantSymlink) {
|
|
585
|
+
try {
|
|
586
|
+
await symlink(file, target);
|
|
587
|
+
installed++;
|
|
588
|
+
linked = true;
|
|
589
|
+
if (manifest.files[rel]) {
|
|
590
|
+
// Symlinks work now and the old copy is gone — drop the stale record.
|
|
591
|
+
delete manifest.files[rel];
|
|
592
|
+
manifestChanged = true;
|
|
593
|
+
}
|
|
594
|
+
} catch (linkErr) {
|
|
595
|
+
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (!linked) {
|
|
599
|
+
// Windows without Developer Mode/admin — fall back to a copy.
|
|
600
|
+
await copyFile(file, target);
|
|
601
|
+
copied++;
|
|
602
|
+
recordCopy(rel);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (ctx.motion) {
|
|
607
|
+
if (barShown) w.write("\x1b[1A\x1b[2K");
|
|
608
|
+
w.write(` ${moltenBar(i + 1, files.length, { width: barWidth, mode: ctx.mode })} ${i + 1}/${files.length} ${dimLine(ctx, "/" + rel.replace(/\.md$/, ""))}\n`);
|
|
609
|
+
barShown = true;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (barShown) w.write("\x1b[1A\x1b[2K");
|
|
613
|
+
|
|
614
|
+
const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
|
|
615
|
+
const settingsPath = join(ctx.home, ".claude", "settings.json");
|
|
616
|
+
const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
|
|
617
|
+
|
|
618
|
+
// Install enforcement hooks (#1250): PreToolUse (branch/label validation)
|
|
619
|
+
// and SubagentStop interactive engine adapter. Both are idempotent and
|
|
620
|
+
// always installed (fail-open if settings.json is malformed, same
|
|
621
|
+
// contract as SessionStart hook).
|
|
622
|
+
const preToolUseScript = join(ctx.forgeHome, "bin", "hooks", "pre-tool-use.mjs");
|
|
623
|
+
const subagentStopScript = join(ctx.forgeHome, "bin", "hooks", "interactive-engine.mjs");
|
|
624
|
+
const { status: preToolUseStatus } = installPreToolUseHook(settingsPath, preToolUseScript);
|
|
625
|
+
installSubagentStopHook(settingsPath, subagentStopScript);
|
|
626
|
+
|
|
627
|
+
// SubagentStop annotation-verifier hook (#1250) is NOT installed: its
|
|
628
|
+
// trigger condition (a `FORGE:PHASE_START` marker in the transcript) is
|
|
629
|
+
// never emitted anywhere in the pipeline, so it always exits 0 with zero
|
|
630
|
+
// enforcement effect while still spawning a process + reading the
|
|
631
|
+
// transcript on every SubagentStop (forge#1527). Actively clean up any
|
|
632
|
+
// prior installation instead of installing it.
|
|
633
|
+
const { status: subagentStopEnforceStatus } = removeSubagentStopEnforceHook(settingsPath);
|
|
634
|
+
|
|
635
|
+
// Housekeeping — must never abort the receipt or the hook.
|
|
636
|
+
let manifestSaveFailed = false;
|
|
637
|
+
if (manifestChanged) {
|
|
638
|
+
try {
|
|
639
|
+
await saveCopiedManifest(manifestPath, manifest);
|
|
640
|
+
} catch {
|
|
641
|
+
manifestSaveFailed = true;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
|
|
646
|
+
const headlineVerb = copied > 0 ? "installed" : "linked";
|
|
647
|
+
const headlineDetail = copied > 0
|
|
648
|
+
? `(new ${installed}, copied ${copied}, updated ${updated}, unchanged ${skipped})`
|
|
649
|
+
: `(new ${installed}, updated ${updated}, unchanged ${skipped})`;
|
|
650
|
+
w.write(` ${glyph(true)} ${files.length} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
|
|
651
|
+
if (copied > 0) {
|
|
652
|
+
w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
|
|
653
|
+
}
|
|
654
|
+
if (manifestSaveFailed) {
|
|
655
|
+
w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
|
|
656
|
+
}
|
|
657
|
+
if (hookStatus === "skipped-malformed") {
|
|
658
|
+
w.write(` ${glyph(false)} SessionStart hook NOT registered — ${settingsPath} is not valid JSON\n`);
|
|
659
|
+
w.write(fixCard([`Fix the JSON in ${settingsPath}, then re-run: npx forgedock install`], ctx.mode) + "\n");
|
|
660
|
+
} else {
|
|
661
|
+
w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Report enforcement hook status.
|
|
665
|
+
if (preToolUseStatus !== null) {
|
|
666
|
+
w.write(` ${glyph(true)} PreToolUse enforcement hook ${preToolUseStatus === "already" ? "active" : "registered"} ${dimLine(ctx, "(branch/label enforcement)")}\n`);
|
|
667
|
+
} else {
|
|
668
|
+
w.write(" " + dimLine(ctx, "PreToolUse hook skipped — requires Claude Code v2.1.163+") + "\n");
|
|
669
|
+
}
|
|
670
|
+
// "skipped-malformed" and "absent" are intentionally silent here:
|
|
671
|
+
// - skipped-malformed: installSessionStartHook() above reads the same
|
|
672
|
+
// settingsPath and already prints the malformed-JSON fix-card, so a
|
|
673
|
+
// second message would just repeat the same signal.
|
|
674
|
+
// - absent: the expected steady state (nothing to remove) — this
|
|
675
|
+
// function only reports state *changes*, not no-ops.
|
|
676
|
+
if (subagentStopEnforceStatus === "removed") {
|
|
677
|
+
w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return { installed, updated, skipped, copied, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
// Act III — Reading your repository (Task 8)
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
import { detectConfig } from "./init-detect.mjs";
|
|
688
|
+
import { enrich } from "./init-enrich-api.mjs";
|
|
689
|
+
import { annotatedReviewScreen, box } from "./tui.mjs";
|
|
690
|
+
|
|
691
|
+
const badgeOf = (field) => field.confidence;
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Act III: detect the repo, show each field as a reveal row with its
|
|
695
|
+
* confidence badge, optionally AI-enrich. Detection runs first (it is fast);
|
|
696
|
+
* rows are display pacing, not fake latency.
|
|
697
|
+
*/
|
|
698
|
+
export async function read(ctx) {
|
|
699
|
+
const { stdout: w } = ctx;
|
|
700
|
+
w.write("\n " + ember("Reading your repository", ctx.mode) + "\n\n");
|
|
701
|
+
|
|
702
|
+
let draft = await detectConfig(ctx.cwd);
|
|
703
|
+
const description = detectDescription(ctx.cwd);
|
|
704
|
+
|
|
705
|
+
const rows = [
|
|
706
|
+
{ label: "owner/repo", run: async () => ({ ok: true, detail: `${draft.project.owner.value}/${draft.project.repo.value}`, badge: badgeOf(draft.project.owner) }) },
|
|
707
|
+
{ label: "default branch", run: async () => ({ ok: true, detail: draft.branches.default.value, badge: badgeOf(draft.branches.default) }) },
|
|
708
|
+
{ label: "staging branch", run: async () => ({ ok: true, detail: draft.branches.staging.value, badge: badgeOf(draft.branches.staging) }) },
|
|
709
|
+
{ label: "project name", run: async () => ({ ok: true, detail: draft.project.name.value, badge: badgeOf(draft.project.name) }) },
|
|
710
|
+
{
|
|
711
|
+
label: "description",
|
|
712
|
+
run: async () => description.value
|
|
713
|
+
? { ok: true, detail: `"${description.value.slice(0, 40)}${description.value.length > 40 ? "…" : ""}"`, badge: "medium" }
|
|
714
|
+
: { ok: true, detail: "none found", badge: "low" },
|
|
715
|
+
},
|
|
716
|
+
];
|
|
717
|
+
await revealRows(rows, { mode: ctx.mode, motion: ctx.motion, writer: w });
|
|
718
|
+
|
|
719
|
+
if (ctx.env.ANTHROPIC_API_KEY) {
|
|
720
|
+
try {
|
|
721
|
+
w.write(" " + dimLine(ctx, "✦ enriching with AI…") + "\n");
|
|
722
|
+
const enriched = await ctx.enrichFn(draft);
|
|
723
|
+
if (enriched && typeof enriched === "object") draft = enriched;
|
|
724
|
+
} catch {
|
|
725
|
+
w.write(" " + dimLine(ctx, "✦ AI enrichment unavailable — continuing with detection only") + "\n");
|
|
726
|
+
}
|
|
727
|
+
} else {
|
|
728
|
+
w.write(" " + dimLine(ctx, "✦ no ANTHROPIC_API_KEY — skipping AI enrichment") + "\n");
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return { draft, description };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
// Act IV — The Review (Task 8)
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Act IV: the single interaction. Non-TTY + existing config aborts to protect
|
|
740
|
+
* the file; non-TTY + fresh config writes detection values with TODO flags
|
|
741
|
+
* (annotatedReviewScreen's non-TTY path returns them directly).
|
|
742
|
+
*/
|
|
743
|
+
export async function review(ctx, draft, description) {
|
|
744
|
+
const { stdout: w } = ctx;
|
|
745
|
+
const outputPath = join(ctx.cwd, "forge.yaml");
|
|
746
|
+
const hasExisting = existsSync(outputPath);
|
|
747
|
+
|
|
748
|
+
if (hasExisting && process.stdin.isTTY !== true) {
|
|
749
|
+
w.write("\n forge.yaml already exists — non-interactive run, aborting to protect it.\n");
|
|
750
|
+
w.write(" " + dimLine(ctx, "Run interactively (or delete forge.yaml) to regenerate.") + "\n");
|
|
751
|
+
return { written: false, todoCount: 0, backupName: null, aborted: true };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const extraFields = description.value
|
|
755
|
+
? { description: { value: description.value, confidence: "medium", source: description.source, why: `First paragraph of ${description.source}` } }
|
|
756
|
+
: {};
|
|
757
|
+
|
|
758
|
+
const accepted = await annotatedReviewScreen(draft, {
|
|
759
|
+
hasExistingConfig: hasExisting,
|
|
760
|
+
showSources: ctx.argv.includes("--verbose"),
|
|
761
|
+
extraFields,
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const backup = backupExisting(outputPath);
|
|
765
|
+
if (backup) w.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
|
|
766
|
+
|
|
767
|
+
const { todoCount } = writeForgeYaml(accepted, accepted.lowConfidenceKeys, outputPath);
|
|
768
|
+
return { written: true, todoCount, backupName: backup ? backup.backupName : null, aborted: false };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ---------------------------------------------------------------------------
|
|
772
|
+
// Act V — Forged (Task 8)
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Act V: quench flash on the compact mark, receipt with real elapsed time,
|
|
777
|
+
* next-steps box.
|
|
778
|
+
*/
|
|
779
|
+
export function celebrate(ctx, summary) {
|
|
780
|
+
const { stdout: w } = ctx;
|
|
781
|
+
const elapsed = Math.round((Date.now() - ctx.startedAt) / 1000);
|
|
782
|
+
const mark = renderMark("compact", ctx.mode);
|
|
783
|
+
|
|
784
|
+
w.write("\n");
|
|
785
|
+
w.write(mark[0] + "\n");
|
|
786
|
+
w.write(mark[1] + " " + ember("Forged.", ctx.mode) + " " + dimLine(ctx, `install → config in ${elapsed}s`) + "\n");
|
|
787
|
+
w.write(mark[2] + "\n");
|
|
788
|
+
w.write(mark[3] + "\n\n");
|
|
789
|
+
|
|
790
|
+
const glyph = ctx.mode === "none" ? "✔" : "\x1b[38;2;255;179;71m✔\x1b[0m";
|
|
791
|
+
if (summary.written) {
|
|
792
|
+
const todoNote = summary.todoCount > 0 ? `${summary.todoCount} field${summary.todoCount === 1 ? "" : "s"} flagged # TODO` : "all fields detected";
|
|
793
|
+
w.write(` ${glyph} forge.yaml written ${dimLine(ctx, todoNote)}\n`);
|
|
794
|
+
}
|
|
795
|
+
if (summary.total !== undefined) {
|
|
796
|
+
const hookNote = summary.hookStatus === "skipped-malformed" ? "hook NOT active — see fix above" : "Claude Code knows this repo";
|
|
797
|
+
w.write(` ${glyph} ${summary.total} commands · hook ${summary.hookStatus === "skipped-malformed" ? "skipped" : "active"} ${dimLine(ctx, hookNote)}\n`);
|
|
798
|
+
}
|
|
799
|
+
w.write("\n");
|
|
800
|
+
if (summary.isMinimal) {
|
|
801
|
+
w.write(" " + dimLine(ctx, "see docs/CONFIG.md for optional sections") + "\n");
|
|
802
|
+
}
|
|
803
|
+
w.write(
|
|
804
|
+
box(
|
|
805
|
+
[
|
|
806
|
+
` 1. run npx forgedock labels setup — bootstrap GitHub labels`,
|
|
807
|
+
` 2. run npx forgedock doctor — verify the install is green`,
|
|
808
|
+
` 3. open claude in this repo`,
|
|
809
|
+
` 4. run /issue <title> then /work-on <number>`,
|
|
810
|
+
],
|
|
811
|
+
{ title: "what's next" },
|
|
812
|
+
),
|
|
813
|
+
);
|
|
814
|
+
w.write(" " + dimLine(ctx, "docs: github.com/RapierCraftStudios/ForgeDock · ⭐ a star is the whole marketing budget") + "\n\n");
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
// The full journey (Task 8)
|
|
819
|
+
// ---------------------------------------------------------------------------
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Acts I→V. Returns the process exit code.
|
|
823
|
+
* SIGINT: restore cursor, summarize partial state, exit 130.
|
|
824
|
+
*/
|
|
825
|
+
export async function runJourney(ctx) {
|
|
826
|
+
const onSigint = () => {
|
|
827
|
+
ctx.stdout.write("\x1b[0m\x1b[?25h\n Interrupted. Partial state: commands may be installed; config not written.\n Finish anytime with: npx forgedock init\n");
|
|
828
|
+
process.exit(130);
|
|
829
|
+
};
|
|
830
|
+
process.on("SIGINT", onSigint);
|
|
831
|
+
try {
|
|
832
|
+
await preflight(ctx);
|
|
833
|
+
const forged = await forge(ctx);
|
|
834
|
+
const { draft, description } = await read(ctx);
|
|
835
|
+
const reviewed = await review(ctx, draft, description);
|
|
836
|
+
celebrate(ctx, { ...reviewed, total: forged.total, hookStatus: forged.hookStatus });
|
|
837
|
+
return reviewed.aborted ? 1 : 0;
|
|
838
|
+
} finally {
|
|
839
|
+
process.removeListener("SIGINT", onSigint);
|
|
840
|
+
}
|
|
841
|
+
}
|