forgedock 1.1.1 → 1.1.3
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 +53 -8
- package/bin/batch-runner.mjs +339 -0
- package/bin/demo.mjs +92 -7
- package/bin/engine/phases.mjs +24 -2
- package/bin/engine/state.mjs +8 -3
- package/bin/engine-cli.mjs +177 -5
- package/bin/forgedock.mjs +409 -26
- 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 +72 -14
- package/bin/registry.mjs +139 -0
- package/bin/report.mjs +447 -0
- package/bin/runner.mjs +70 -20
- package/bin/settings-hook.mjs +235 -1
- 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 +518 -361
- 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 +58 -1
- package/commands/deploy-pr.md +485 -0
- package/commands/design-bench.md +5 -1
- package/commands/design-render-critique-loop.md +1 -1
- package/commands/diagnose.md +2 -1
- package/commands/explain.md +2 -1
- package/commands/fix-ci.md +577 -0
- package/commands/forge-stats.md +121 -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 +23 -2
- 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 -2045
- 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 +248 -10
- package/commands/recover-orphans.md +576 -0
- 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 +591 -52
- 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 +3 -1
- package/commands/work-on/build/validate.md +26 -7
- package/commands/work-on/build.md +163 -24
- package/commands/work-on/close.md +130 -3
- package/commands/work-on/decompose.md +19 -1
- package/commands/work-on/investigate.md +117 -2
- package/commands/work-on/review.md +24 -4
- package/commands/work-on-monolithic.md +7 -2
- package/commands/work-on.md +202 -4
- package/docs/BRAND.md +14 -7
- package/docs/CI.md +16 -2
- package/docs/CODEX.md +36 -1
- package/docs/CONFIG.md +5 -8
- 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 +120 -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 +386 -111
- package/docs/site/for-companies.md +95 -0
- package/docs/site/forge-annotation-protocol.md +12 -256
- package/docs/site/getting-started.md +43 -4
- package/docs/site/github-is-the-memory.md +113 -0
- package/docs/site/how-it-works.md +3 -13
- package/docs/site/index.md +9 -1
- 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/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 +3 -1
- 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 +254 -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/worktree-lifecycle.sh +3 -3
- package/templates/devdocs/agent/using-forgedock.md +14 -0
- package/docs/site/.vitepress/config.mjs +0 -85
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { dirname, join, resolve as resolvePath } from "path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
6
|
+
const _require = createRequire(import.meta.url);
|
|
7
|
+
|
|
8
|
+
/** Absolute path to the ForgeDock installation root (parent of bin/). */
|
|
9
|
+
const FORGE_HOME = resolvePath(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* bin/hooks/pre-tool-use.mjs — ForgeDock PreToolUse hook.
|
|
13
|
+
*
|
|
14
|
+
* Deterministic enforcement layer (issues #1250, #1323): intercepts tool
|
|
15
|
+
* calls before they execute and hard-blocks pipeline violations.
|
|
16
|
+
*
|
|
17
|
+
* === Hook protocol ===
|
|
18
|
+
*
|
|
19
|
+
* Claude Code sends a JSON payload to stdin and reads the exit code:
|
|
20
|
+
* exit 0 — allow the tool call
|
|
21
|
+
* exit 2 — block the tool call (error message on stderr is shown to agent)
|
|
22
|
+
*
|
|
23
|
+
* Input payload (stdin JSON):
|
|
24
|
+
* {
|
|
25
|
+
* "hook_event_name": "PreToolUse",
|
|
26
|
+
* "session_id": "...",
|
|
27
|
+
* "tool_name": "Bash",
|
|
28
|
+
* "tool_input": { "command": "gh pr create ..." }
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* === Enforced rules ===
|
|
32
|
+
*
|
|
33
|
+
* 1. PR branch target validation
|
|
34
|
+
* Intercepts `gh pr create` and validates `--base` against the
|
|
35
|
+
* pipeline's allowed targets (staging, milestone/<slug>).
|
|
36
|
+
* Hard-blocks PRs targeting main.
|
|
37
|
+
*
|
|
38
|
+
* 2. Label transition validation
|
|
39
|
+
* Intercepts `gh issue edit --add-label` and validates the transition
|
|
40
|
+
* against the workflow label state machine.
|
|
41
|
+
* Blocks invalid transitions (e.g. jumping from investigating to merged).
|
|
42
|
+
*
|
|
43
|
+
* Both rules only apply inside a ForgeDock-managed directory (a directory
|
|
44
|
+
* with a `forge.yaml` or `.forgedock` marker) — see `isForgeDockManagedCwd()`
|
|
45
|
+
* (issue #1591). The hook installs into the user's global
|
|
46
|
+
* `~/.claude/settings.json`, so without this guard every Bash call in every
|
|
47
|
+
* repo on the machine would be subject to these ForgeDock-specific rules,
|
|
48
|
+
* including unrelated repos where `main` is a legitimate PR target.
|
|
49
|
+
*
|
|
50
|
+
* === Fail-open contract ===
|
|
51
|
+
*
|
|
52
|
+
* Any uncaught error or parse failure exits 0 (allow) — this hook must
|
|
53
|
+
* NEVER prevent a legitimate tool call due to a hook bug.
|
|
54
|
+
*
|
|
55
|
+
* === Wiring ===
|
|
56
|
+
*
|
|
57
|
+
* Installed into ~/.claude/settings.json under hooks.PreToolUse by
|
|
58
|
+
* `forgedock install` (via bin/settings-hook.mjs). The install entry carries
|
|
59
|
+
* `matcher: "Bash"` so Claude Code's harness only spawns this script for
|
|
60
|
+
* Bash tool calls, not every tool call.
|
|
61
|
+
* Removed by `forgedock uninstall`.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Label state machine
|
|
66
|
+
// Valid successors for each workflow label. Only forward transitions allowed.
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Allowed label transitions: from → [to, ...]
|
|
71
|
+
* A label not in this map can be added freely (not a workflow: label).
|
|
72
|
+
*/
|
|
73
|
+
const LABEL_TRANSITIONS = {
|
|
74
|
+
"workflow:investigating": ["workflow:ready-to-build", "workflow:invalid", "workflow:decomposed"],
|
|
75
|
+
"workflow:ready-to-build": ["workflow:building"],
|
|
76
|
+
"workflow:building": ["workflow:in-review", "workflow:ready-to-build"], // retry allowed
|
|
77
|
+
"workflow:in-review": ["workflow:merged", "workflow:building"], // review → re-build allowed
|
|
78
|
+
"workflow:merged": [], // terminal — no successors
|
|
79
|
+
"workflow:invalid": [], // terminal
|
|
80
|
+
"workflow:decomposed": [], // terminal
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Labels that are never allowed as a PR --base target. */
|
|
84
|
+
const FORBIDDEN_PR_BASES = ["main", "master"];
|
|
85
|
+
|
|
86
|
+
/** Labels that are always valid (non-workflow labels are not constrained). */
|
|
87
|
+
const WORKFLOW_LABEL_PREFIX = "workflow:";
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Main — fail-open wrapper
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await main();
|
|
95
|
+
} catch {
|
|
96
|
+
// Fail open — never block a tool call due to a hook error.
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function main() {
|
|
101
|
+
const raw = await readStdin();
|
|
102
|
+
if (!raw.trim()) { process.exit(0); return; }
|
|
103
|
+
|
|
104
|
+
let payload;
|
|
105
|
+
try {
|
|
106
|
+
payload = JSON.parse(raw);
|
|
107
|
+
} catch {
|
|
108
|
+
process.exit(0); return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (payload.hook_event_name !== "PreToolUse") { process.exit(0); return; }
|
|
112
|
+
|
|
113
|
+
const toolName = payload.tool_name || "";
|
|
114
|
+
const toolInput = payload.tool_input || {};
|
|
115
|
+
|
|
116
|
+
// Only intercept Bash tool calls.
|
|
117
|
+
if (toolName !== "Bash") { process.exit(0); return; }
|
|
118
|
+
|
|
119
|
+
// Only enforce inside a ForgeDock-managed project — this hook installs
|
|
120
|
+
// globally (~/.claude/settings.json), so without this guard both rules
|
|
121
|
+
// below would fire in every unrelated repo on the machine (issue #1591).
|
|
122
|
+
if (!(await isForgeDockManagedCwd())) { process.exit(0); return; }
|
|
123
|
+
|
|
124
|
+
const command = String(toolInput.command || "");
|
|
125
|
+
|
|
126
|
+
// --- Rule 1: PR branch target validation ---
|
|
127
|
+
const prViolation = checkPrTarget(command);
|
|
128
|
+
if (prViolation) {
|
|
129
|
+
process.stderr.write(prViolation);
|
|
130
|
+
process.exit(2);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Rule 2: Label transition validation ---
|
|
135
|
+
const labelViolation = checkLabelTransition(command);
|
|
136
|
+
if (labelViolation) {
|
|
137
|
+
process.stderr.write(labelViolation);
|
|
138
|
+
process.exit(2);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Project-scope guard (issue #1591)
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Determine whether the current working directory is a ForgeDock-managed
|
|
151
|
+
* project — i.e. whether pipeline enforcement rules should apply here at
|
|
152
|
+
* all. This hook installs into the user's global `~/.claude/settings.json`,
|
|
153
|
+
* so it fires for every Bash call in every repo unless explicitly scoped.
|
|
154
|
+
*
|
|
155
|
+
* Reuses `bin/registry.mjs::resolveState`, the same primitive
|
|
156
|
+
* `bin/hooks/session-start.mjs` already uses to decide whether to inject
|
|
157
|
+
* ForgeDock context — a directory is managed iff it has a `forge.yaml` or
|
|
158
|
+
* `.forgedock` marker (and hasn't been explicitly opted out).
|
|
159
|
+
*
|
|
160
|
+
* Fail-open: if `registry.mjs` cannot be dynamically imported (broken
|
|
161
|
+
* install, missing file), falls back to a direct `forge.yaml`/`.forgedock`
|
|
162
|
+
* existence check on cwd. If even that throws, returns false (no
|
|
163
|
+
* enforcement) rather than letting an error propagate — consistent with
|
|
164
|
+
* this hook's overall fail-open contract.
|
|
165
|
+
*
|
|
166
|
+
* @returns {Promise<boolean>}
|
|
167
|
+
*/
|
|
168
|
+
async function isForgeDockManagedCwd() {
|
|
169
|
+
const cwd = process.cwd();
|
|
170
|
+
try {
|
|
171
|
+
const { resolveState } = await import(
|
|
172
|
+
pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
|
|
173
|
+
);
|
|
174
|
+
return resolveState(cwd) === "managed-active";
|
|
175
|
+
} catch {
|
|
176
|
+
try {
|
|
177
|
+
return existsSync(join(cwd, "forge.yaml")) || existsSync(join(cwd, ".forgedock"));
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Rule 1: PR branch target validation
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check whether a gh pr create command targets a forbidden base branch.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} command
|
|
192
|
+
* @returns {string|null} Error message to show, or null if allowed.
|
|
193
|
+
*/
|
|
194
|
+
function checkPrTarget(command) {
|
|
195
|
+
// Only check gh pr create commands.
|
|
196
|
+
if (!/gh\s+pr\s+create/.test(command)) return null;
|
|
197
|
+
|
|
198
|
+
const base = extractFlag(command, "--base") || extractFlag(command, "-B");
|
|
199
|
+
if (!base) return null; // no --base flag — gh will use the default
|
|
200
|
+
|
|
201
|
+
if (FORBIDDEN_PR_BASES.includes(base.toLowerCase())) {
|
|
202
|
+
return [
|
|
203
|
+
`[ForgeDock] BLOCKED: PR targets "${base}" — pipeline rule violation.`,
|
|
204
|
+
``,
|
|
205
|
+
`PRs MUST target "staging" (fast lane) or "milestone/<slug>" (feature lane).`,
|
|
206
|
+
`A PR to "${base}" is a hard pipeline violation. Fix the --base flag.`,
|
|
207
|
+
``,
|
|
208
|
+
`Allowed targets: staging | milestone/<slug>`,
|
|
209
|
+
].join("\n");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Warn if the base doesn't match expected patterns, but don't hard-block
|
|
213
|
+
// (the project may have custom branch names).
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Rule 2: Label transition validation
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Check whether a gh issue edit --add-label command represents a valid
|
|
223
|
+
* label state-machine transition.
|
|
224
|
+
*
|
|
225
|
+
* Reads the current workflow labels from GitHub via `gh issue view` and
|
|
226
|
+
* validates that the new label is a legal successor in the state machine.
|
|
227
|
+
* Falls back to allow (exit 0) on any gh CLI error so the hook is fail-open.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} command
|
|
230
|
+
* @returns {string|null} Error message to show, or null if allowed.
|
|
231
|
+
*/
|
|
232
|
+
function checkLabelTransition(command) {
|
|
233
|
+
// Only check gh issue edit commands that add labels.
|
|
234
|
+
if (!/gh\s+issue\s+edit/.test(command)) return null;
|
|
235
|
+
if (!command.includes("--add-label")) return null;
|
|
236
|
+
|
|
237
|
+
const newLabel = extractFlag(command, "--add-label");
|
|
238
|
+
if (!newLabel) return null;
|
|
239
|
+
if (!newLabel.startsWith(WORKFLOW_LABEL_PREFIX)) return null; // non-workflow label, skip
|
|
240
|
+
|
|
241
|
+
// The new label must be a known workflow label.
|
|
242
|
+
if (!Object.prototype.hasOwnProperty.call(LABEL_TRANSITIONS, newLabel) &&
|
|
243
|
+
!Object.values(LABEL_TRANSITIONS).some((arr) => arr.includes(newLabel))) {
|
|
244
|
+
return null; // unknown workflow label — don't block
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Extract the issue number from the command.
|
|
248
|
+
// Supports: `gh issue edit 123`, `gh issue edit #123`, `-R repo` or positional
|
|
249
|
+
const issueNumM = command.match(/gh\s+issue\s+edit\s+(?:#?(\d+)|(\d+))/);
|
|
250
|
+
if (!issueNumM) return null; // can't determine issue — fail-open
|
|
251
|
+
const issueNum = issueNumM[1] || issueNumM[2];
|
|
252
|
+
|
|
253
|
+
// Extract repo (-R flag) if present.
|
|
254
|
+
const repoFlag = extractFlag(command, "-R") || extractFlag(command, "--repo");
|
|
255
|
+
|
|
256
|
+
// Read current labels from GitHub synchronously.
|
|
257
|
+
let currentWorkflowLabel = null;
|
|
258
|
+
try {
|
|
259
|
+
const { execFileSync: exec } = _require("child_process");
|
|
260
|
+
const args = ["issue", "view", issueNum, "--json", "labels"];
|
|
261
|
+
if (repoFlag) { args.push("-R", repoFlag); }
|
|
262
|
+
const out = exec("gh", args, {
|
|
263
|
+
encoding: "utf-8",
|
|
264
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
265
|
+
timeout: 8000,
|
|
266
|
+
});
|
|
267
|
+
const parsed = JSON.parse(out);
|
|
268
|
+
const labels = Array.isArray(parsed.labels)
|
|
269
|
+
? parsed.labels.map((l) => (typeof l === "string" ? l : l.name))
|
|
270
|
+
: [];
|
|
271
|
+
// Find the current workflow label (the most specific one, last wins).
|
|
272
|
+
for (const lbl of labels) {
|
|
273
|
+
if (lbl.startsWith(WORKFLOW_LABEL_PREFIX)) {
|
|
274
|
+
currentWorkflowLabel = lbl;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
return null; // gh CLI error — fail-open
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!currentWorkflowLabel) {
|
|
282
|
+
// No current workflow label — allow any workflow label to be added.
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Terminal labels cannot be transitioned away from.
|
|
287
|
+
if (isTerminalLabel(currentWorkflowLabel)) {
|
|
288
|
+
return [
|
|
289
|
+
`[ForgeDock] BLOCKED: Label transition violation.`,
|
|
290
|
+
``,
|
|
291
|
+
`Issue is in terminal state "${currentWorkflowLabel}" — no further transitions allowed.`,
|
|
292
|
+
`Attempted to add: "${newLabel}"`,
|
|
293
|
+
``,
|
|
294
|
+
`Terminal states (${Object.keys(LABEL_TRANSITIONS).filter((k) => LABEL_TRANSITIONS[k].length === 0).join(", ")}) are final.`,
|
|
295
|
+
].join("\n");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Validate the transition against the state machine.
|
|
299
|
+
const allowed = LABEL_TRANSITIONS[currentWorkflowLabel] || null;
|
|
300
|
+
if (allowed === null) {
|
|
301
|
+
return null; // current state not in map — unknown, fail-open
|
|
302
|
+
}
|
|
303
|
+
if (!allowed.includes(newLabel)) {
|
|
304
|
+
return [
|
|
305
|
+
`[ForgeDock] BLOCKED: Invalid label transition.`,
|
|
306
|
+
``,
|
|
307
|
+
`Current workflow state: "${currentWorkflowLabel}"`,
|
|
308
|
+
`Attempted transition: → "${newLabel}"`,
|
|
309
|
+
`Allowed next states : ${allowed.length > 0 ? allowed.map((s) => `"${s}"`).join(", ") : "(none — terminal)"}`,
|
|
310
|
+
``,
|
|
311
|
+
`Fix: check the pipeline phase and apply the correct next workflow label.`,
|
|
312
|
+
].join("\n");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return null; // valid transition — allow
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Helpers
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @typedef {Object} CommandToken
|
|
324
|
+
* @property {string} value The token text, with surrounding quotes removed.
|
|
325
|
+
* @property {boolean} quoted True if ANY character of this token originated
|
|
326
|
+
* inside single or double quotes.
|
|
327
|
+
*/
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Split a shell command string into argv-like tokens, honoring single and
|
|
331
|
+
* double quotes so that quoted argument values (e.g. `--title "..."`) are
|
|
332
|
+
* never split apart or scanned as separate tokens.
|
|
333
|
+
*
|
|
334
|
+
* Each token carries a `quoted` flag recording whether any of its characters
|
|
335
|
+
* came from inside quotes. This flag is retained for diagnostics, but
|
|
336
|
+
* `extractFlag` does NOT use it to decide whether a token can be a flag —
|
|
337
|
+
* see the note on `extractFlag` (issue #1591) for why "was any character
|
|
338
|
+
* quoted" is the wrong discriminator.
|
|
339
|
+
*
|
|
340
|
+
* This is intentionally NOT a full POSIX shell parser — it doesn't handle
|
|
341
|
+
* escapes, `$()`, backticks, or command chaining. It only needs to be
|
|
342
|
+
* accurate enough to distinguish "a flag in argument position" from
|
|
343
|
+
* "flag-shaped text embedded inside a different argument's value", which is
|
|
344
|
+
* all `extractFlag` needs (issue #1519).
|
|
345
|
+
*
|
|
346
|
+
* @param {string} command
|
|
347
|
+
* @returns {CommandToken[]}
|
|
348
|
+
*/
|
|
349
|
+
function tokenizeCommand(command) {
|
|
350
|
+
const tokens = [];
|
|
351
|
+
let current = "";
|
|
352
|
+
let quoted = false;
|
|
353
|
+
let inSingle = false;
|
|
354
|
+
let inDouble = false;
|
|
355
|
+
|
|
356
|
+
for (let i = 0; i < command.length; i++) {
|
|
357
|
+
const ch = command[i];
|
|
358
|
+
|
|
359
|
+
if (inSingle) {
|
|
360
|
+
if (ch === "'") inSingle = false;
|
|
361
|
+
else current += ch;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (inDouble) {
|
|
366
|
+
if (ch === '"') inDouble = false;
|
|
367
|
+
else current += ch;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (ch === "'") {
|
|
372
|
+
inSingle = true;
|
|
373
|
+
quoted = true;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (ch === '"') {
|
|
378
|
+
inDouble = true;
|
|
379
|
+
quoted = true;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (/\s/.test(ch)) {
|
|
384
|
+
if (current.length > 0) {
|
|
385
|
+
tokens.push({ value: current, quoted });
|
|
386
|
+
}
|
|
387
|
+
current = "";
|
|
388
|
+
quoted = false;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
current += ch;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (current.length > 0) tokens.push({ value: current, quoted });
|
|
396
|
+
return tokens;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Extract the value of a CLI flag from a command string.
|
|
401
|
+
* Handles `--flag value`, `--flag=value`, and — for single-dash, single-letter
|
|
402
|
+
* short flags only (e.g. `-B`, `-R`) — the attached form `-Bvalue`.
|
|
403
|
+
*
|
|
404
|
+
* Tokenizes the command first (see `tokenizeCommand`) and matches the flag
|
|
405
|
+
* against actual argv tokens — NOT against substrings anywhere in the raw
|
|
406
|
+
* command string. This prevents flag-shaped text inside a quoted argument
|
|
407
|
+
* value (e.g. `--title "Fix -B main thread bug"`) from being misread as a
|
|
408
|
+
* real flag (issue #1519).
|
|
409
|
+
*
|
|
410
|
+
* The attached form is valid getopt-style syntax that real CLIs (including
|
|
411
|
+
* `gh`) accept for single-dash short flags — e.g. `gh pr create -Bmain` is
|
|
412
|
+
* equivalent to `gh pr create -B main`. Without this branch, a forbidden PR
|
|
413
|
+
* base written as `-Bmain` would produce no match and bypass the hard block
|
|
414
|
+
* (issue #1550). The attached-form check is scoped to 2-character single-dash
|
|
415
|
+
* flags so long flags (`--base`) are never affected.
|
|
416
|
+
*
|
|
417
|
+
* DECOY RULE: the equals form (`-B=value`/`--base=value`) and the attached
|
|
418
|
+
* form (`-Bvalue`) are skipped on any token that contains EMBEDDED
|
|
419
|
+
* WHITESPACE — not on any token that was merely quoted.
|
|
420
|
+
*
|
|
421
|
+
* A shell token can only contain whitespace if quoting was used to glue
|
|
422
|
+
* multiple words into a single argument (e.g. `--title "-Bstaging is
|
|
423
|
+
* fine"` — the value is a whole sentence, not a flag). That is the actual
|
|
424
|
+
* signature of the issue #1519 decoy: flag-shaped text embedded inside an
|
|
425
|
+
* unrelated multi-word `--title`/`--body` value. Skipping on embedded
|
|
426
|
+
* whitespace preserves that protection.
|
|
427
|
+
*
|
|
428
|
+
* A single-word token being quoted, on the other hand, changes nothing about
|
|
429
|
+
* the resulting argv entry — `"-Bmain"`, `'-Bmain'`, and `-Bmain` are all
|
|
430
|
+
* identical once the shell hands the argument to `gh`, as are `--base="main"`
|
|
431
|
+
* and `--base=main`. Treating "was quoted at all" as reason enough to skip
|
|
432
|
+
* (the previous behaviour) let these single-word quoted forms sail through
|
|
433
|
+
* untouched — a real, commonly-written bypass (issue #1591), not a decoy.
|
|
434
|
+
* So quoting alone must never exempt a token from these checks; only
|
|
435
|
+
* embedded whitespace does. The exact form (`token === flag`) stays
|
|
436
|
+
* quote-insensitive as before: a fully-quoted `"-B"` in flag position is
|
|
437
|
+
* unambiguously the flag, and its value is the next token regardless of
|
|
438
|
+
* quoting.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} command
|
|
441
|
+
* @param {string} flag e.g. "--base" or "-B"
|
|
442
|
+
* @returns {string|null}
|
|
443
|
+
*/
|
|
444
|
+
function extractFlag(command, flag) {
|
|
445
|
+
const tokens = tokenizeCommand(command);
|
|
446
|
+
const eqPrefix = `${flag}=`;
|
|
447
|
+
const isShortFlag = flag.length === 2 && flag[0] === "-" && flag[1] !== "-";
|
|
448
|
+
|
|
449
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
450
|
+
const { value: token } = tokens[i];
|
|
451
|
+
|
|
452
|
+
// --flag value form (value is the next token). Quote-insensitive: an exact
|
|
453
|
+
// token match is unambiguous even when the flag itself was quoted.
|
|
454
|
+
if (token === flag) {
|
|
455
|
+
return i + 1 < tokens.length ? tokens[i + 1].value : null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Prefix-based forms below must not fire on a token that required
|
|
459
|
+
// quoting to glue multiple words into one argument — that shape is the
|
|
460
|
+
// real #1519 decoy signature (e.g. a --title value that happens to
|
|
461
|
+
// start with flag-looking text). A token can only contain whitespace if
|
|
462
|
+
// it came from inside quotes, so embedded whitespace — not quoting
|
|
463
|
+
// itself — is the correct discriminator (issue #1591).
|
|
464
|
+
if (/\s/.test(token)) continue;
|
|
465
|
+
|
|
466
|
+
// --flag=value form (also covers the equals form of a short flag, e.g. -B=value)
|
|
467
|
+
if (token.startsWith(eqPrefix)) {
|
|
468
|
+
return token.slice(eqPrefix.length);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// -Bvalue attached form (single-dash short flags only, e.g. -Bmain)
|
|
472
|
+
if (isShortFlag && token.startsWith(flag) && token.length > flag.length) {
|
|
473
|
+
return token.slice(flag.length);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function isTerminalLabel(label) {
|
|
481
|
+
const successors = LABEL_TRANSITIONS[label];
|
|
482
|
+
return successors !== undefined && successors.length === 0;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
// stdin reader
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
async function readStdin() {
|
|
490
|
+
return new Promise((resolve) => {
|
|
491
|
+
let buf = "";
|
|
492
|
+
process.stdin.setEncoding("utf-8");
|
|
493
|
+
process.stdin.on("data", (chunk) => { buf += chunk; });
|
|
494
|
+
process.stdin.on("end", () => resolve(buf));
|
|
495
|
+
process.stdin.on("error", () => resolve(""));
|
|
496
|
+
setTimeout(() => resolve(buf), 1000);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bin/hooks/subagent-stop-enforce.mjs — ForgeDock SubagentStop enforcement hook.
|
|
4
|
+
*
|
|
5
|
+
* Deterministic annotation enforcement (issue #1250): called by Claude Code
|
|
6
|
+
* when a sub-agent (Skill invocation) completes. Checks the transcript for
|
|
7
|
+
* the expected FORGE: annotation and blocks silent completion if the
|
|
8
|
+
* annotation is missing.
|
|
9
|
+
*
|
|
10
|
+
* === Hook protocol ===
|
|
11
|
+
*
|
|
12
|
+
* Claude Code sends a JSON payload to stdin:
|
|
13
|
+
* {
|
|
14
|
+
* "hook_event_name": "SubagentStop",
|
|
15
|
+
* "session_id": "...",
|
|
16
|
+
* "transcript_path": "...", // path to the agent's JSONL transcript
|
|
17
|
+
* "stop_hook_active": false
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Exit codes:
|
|
21
|
+
* exit 0 — allow completion
|
|
22
|
+
* exit 2 — block completion; stdout message injected as additionalContext
|
|
23
|
+
* (requires Claude Code v2.1.163+ for additionalContext injection)
|
|
24
|
+
*
|
|
25
|
+
* === Annotation detection ===
|
|
26
|
+
*
|
|
27
|
+
* The hook scans the transcript for FORGE: annotation markers written via
|
|
28
|
+
* `gh issue comment` or `gh api` calls in the most recent pipeline phase.
|
|
29
|
+
* Each pipeline phase is expected to post a specific FORGE: marker:
|
|
30
|
+
*
|
|
31
|
+
* FORGE:INVESTIGATOR — investigate phase
|
|
32
|
+
* FORGE:CONTRACT — context/contract phase
|
|
33
|
+
* FORGE:ARCHITECT — architect phase
|
|
34
|
+
* FORGE:BUILDER — build phase (also FORGE:BUILDER:COMPLETE)
|
|
35
|
+
* FORGE:REVIEWER — review phase
|
|
36
|
+
*
|
|
37
|
+
* If the transcript shows a pipeline phase Skill was invoked but no matching
|
|
38
|
+
* FORGE: annotation appears in the tool results, completion is blocked with
|
|
39
|
+
* corrective context.
|
|
40
|
+
*
|
|
41
|
+
* === Fail-open contract ===
|
|
42
|
+
*
|
|
43
|
+
* Any uncaught error or parse failure exits 0 (allow). This hook MUST NOT
|
|
44
|
+
* block legitimate completions due to a hook bug or missing transcript.
|
|
45
|
+
* Transcript read errors, missing files, and unknown phase patterns all
|
|
46
|
+
* exit 0 silently.
|
|
47
|
+
*
|
|
48
|
+
* === Wiring ===
|
|
49
|
+
*
|
|
50
|
+
* NOT currently installed (forge#1527): `detectPhase()`'s trigger condition
|
|
51
|
+
* (a literal `FORGE:PHASE_START` marker in the transcript) is never emitted
|
|
52
|
+
* anywhere in the pipeline — no command/skill writes that string into a
|
|
53
|
+
* Claude Code transcript, so this hook always fell through to "not a
|
|
54
|
+
* pipeline phase subagent" and exited 0 with zero enforcement effect. As of
|
|
55
|
+
* forge#1527, `forgedock install`/`update` no longer installs this hook and
|
|
56
|
+
* actively removes any prior installation via `removeSubagentStopEnforceHook`.
|
|
57
|
+
*
|
|
58
|
+
* This file and its install/remove primitives (`installSubagentStopEnforceHook`
|
|
59
|
+
* / `removeSubagentStopEnforceHook` in bin/settings-hook.mjs) are kept intact
|
|
60
|
+
* for a future fix that wires a real transcript-visible trigger (e.g. the
|
|
61
|
+
* skill-header text each work-on subcommand already renders into the
|
|
62
|
+
* transcript) and corrects the `context` → `FORGE:CONTRACT` mapping bug in
|
|
63
|
+
* `PHASE_ANNOTATIONS` below (should require `FORGE:CONTEXT` instead).
|
|
64
|
+
* Identified by the string "subagent-stop-enforce.mjs" in SUBAGENT_ENFORCE_MARKER.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import { readFileSync } from "fs";
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Phase annotation map — which FORGE: marker each pipeline phase must post
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Map from phase-detection pattern to required FORGE: annotation marker.
|
|
75
|
+
* The phase-detection pattern matches the Skill name or the FORGE:PHASE_START
|
|
76
|
+
* marker that indicates which pipeline phase was active.
|
|
77
|
+
*
|
|
78
|
+
* Key: regex pattern matching the phase label in the transcript
|
|
79
|
+
* Value: FORGE: annotation string that must appear in a tool result
|
|
80
|
+
*/
|
|
81
|
+
const PHASE_ANNOTATIONS = {
|
|
82
|
+
"FORGE:PHASE_START.*investigate": "FORGE:INVESTIGATOR",
|
|
83
|
+
"FORGE:PHASE_START.*context": "FORGE:CONTRACT",
|
|
84
|
+
"FORGE:PHASE_START.*architect": "FORGE:ARCHITECT",
|
|
85
|
+
"FORGE:PHASE_START.*build": "FORGE:BUILDER",
|
|
86
|
+
"FORGE:PHASE_START.*review": "FORGE:REVIEWER",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Main — fail-open wrapper
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await main();
|
|
95
|
+
} catch {
|
|
96
|
+
// Fail open — never block a sub-agent completion due to a hook error.
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function main() {
|
|
101
|
+
const raw = await readStdin();
|
|
102
|
+
if (!raw.trim()) { process.exit(0); return; }
|
|
103
|
+
|
|
104
|
+
let payload;
|
|
105
|
+
try {
|
|
106
|
+
payload = JSON.parse(raw);
|
|
107
|
+
} catch {
|
|
108
|
+
process.exit(0); return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (payload.hook_event_name !== "SubagentStop") { process.exit(0); return; }
|
|
112
|
+
|
|
113
|
+
const transcriptPath = payload.transcript_path;
|
|
114
|
+
if (!transcriptPath) { process.exit(0); return; }
|
|
115
|
+
|
|
116
|
+
// Read the transcript file.
|
|
117
|
+
let transcriptText;
|
|
118
|
+
try {
|
|
119
|
+
transcriptText = readFileSync(transcriptPath, "utf-8");
|
|
120
|
+
} catch {
|
|
121
|
+
// Transcript unreadable — fail open.
|
|
122
|
+
process.exit(0); return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Determine which pipeline phase was active in this transcript.
|
|
126
|
+
const detectedPhase = detectPhase(transcriptText);
|
|
127
|
+
if (!detectedPhase) {
|
|
128
|
+
// Not a pipeline phase subagent — allow completion.
|
|
129
|
+
process.exit(0); return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { phaseName, requiredAnnotation } = detectedPhase;
|
|
133
|
+
|
|
134
|
+
// Check whether the required FORGE: annotation appears in the transcript.
|
|
135
|
+
if (transcriptText.includes(requiredAnnotation)) {
|
|
136
|
+
// Annotation present — allow completion.
|
|
137
|
+
process.exit(0); return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Annotation missing — block completion with corrective context.
|
|
141
|
+
const message = [
|
|
142
|
+
`[ForgeDock] BLOCKED: Pipeline phase "${phaseName}" completed without posting its FORGE: annotation.`,
|
|
143
|
+
``,
|
|
144
|
+
`Required annotation: <!-- ${requiredAnnotation} --> (or <!-- ${requiredAnnotation}:... -->)`,
|
|
145
|
+
``,
|
|
146
|
+
`Before completing this phase, post the annotation to the GitHub issue using:`,
|
|
147
|
+
` gh issue comment <issue-number> --body "<!-- ${requiredAnnotation} -->\\n..."`,
|
|
148
|
+
``,
|
|
149
|
+
`FORGE: annotations are the authoritative pipeline record. Without them, the`,
|
|
150
|
+
`next phase cannot locate the output of this phase after a context reset.`,
|
|
151
|
+
``,
|
|
152
|
+
`Post the annotation and then signal completion again.`,
|
|
153
|
+
].join("\n");
|
|
154
|
+
|
|
155
|
+
process.stdout.write(message);
|
|
156
|
+
process.exit(2);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Phase detection
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Scan the transcript for a FORGE:PHASE_START marker to identify the active
|
|
165
|
+
* pipeline phase. Returns the phase name and required annotation, or null if
|
|
166
|
+
* this is not a pipeline phase transcript.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} transcript - Full transcript text.
|
|
169
|
+
* @returns {{ phaseName: string, requiredAnnotation: string } | null}
|
|
170
|
+
*/
|
|
171
|
+
function detectPhase(transcript) {
|
|
172
|
+
for (const [pattern, annotation] of Object.entries(PHASE_ANNOTATIONS)) {
|
|
173
|
+
const re = new RegExp(pattern, "i");
|
|
174
|
+
if (re.test(transcript)) {
|
|
175
|
+
// Extract phase name from pattern (text after last dot or slash).
|
|
176
|
+
const phaseMatch = pattern.match(/\*\*?([a-z]+)\s*$/i);
|
|
177
|
+
const phaseName = phaseMatch ? phaseMatch[1] : pattern;
|
|
178
|
+
return { phaseName, requiredAnnotation: annotation };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// stdin reader
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
async function readStdin() {
|
|
189
|
+
return new Promise((resolve) => {
|
|
190
|
+
let buf = "";
|
|
191
|
+
process.stdin.setEncoding("utf-8");
|
|
192
|
+
process.stdin.on("data", (chunk) => { buf += chunk; });
|
|
193
|
+
process.stdin.on("end", () => resolve(buf));
|
|
194
|
+
process.stdin.on("error", () => resolve(""));
|
|
195
|
+
setTimeout(() => resolve(buf), 1000);
|
|
196
|
+
});
|
|
197
|
+
}
|