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.
Files changed (184) hide show
  1. package/README.md +255 -211
  2. package/bin/batch-runner.mjs +339 -0
  3. package/bin/cinema.mjs +320 -0
  4. package/bin/demo.mjs +92 -7
  5. package/bin/engine/phases.mjs +168 -0
  6. package/bin/engine/projector.mjs +32 -0
  7. package/bin/engine/reconcile.mjs +11 -0
  8. package/bin/engine/runlog.mjs +79 -0
  9. package/bin/engine/state.mjs +32 -0
  10. package/bin/engine-cli.mjs +222 -0
  11. package/bin/engine.mjs +138 -0
  12. package/bin/forgedock.mjs +896 -1540
  13. package/bin/hooks/interactive-engine.mjs +509 -0
  14. package/bin/hooks/pre-tool-use.mjs +498 -0
  15. package/bin/hooks/subagent-stop-enforce.mjs +197 -0
  16. package/bin/journey.mjs +841 -0
  17. package/bin/registry.mjs +139 -0
  18. package/bin/report.mjs +447 -0
  19. package/bin/runner.mjs +136 -20
  20. package/bin/settings-hook.mjs +346 -0
  21. package/bin/tui.mjs +6 -3
  22. package/commands/adopt.md +4 -1
  23. package/commands/analytics.md +4 -1
  24. package/commands/audit-agents.md +110 -27
  25. package/commands/audit.md +20 -1
  26. package/commands/autopilot.md +84 -12
  27. package/commands/changelog.md +2 -1
  28. package/commands/ci-audit.md +2 -1
  29. package/commands/cleanup.md +161 -1
  30. package/commands/compat-audit.md +2 -1
  31. package/commands/deploy-info.md +63 -4
  32. package/commands/design-bench.md +6 -1
  33. package/commands/design-render-critique-loop.md +2 -1
  34. package/commands/design.md +1 -0
  35. package/commands/diagnose.md +2 -1
  36. package/commands/explain.md +2 -1
  37. package/commands/forge-stats.md +122 -2
  38. package/commands/forgedock-init.md +1 -1
  39. package/commands/geo-audit.md +3 -0
  40. package/commands/incident-response.md +2 -1
  41. package/commands/issue.md +24 -3
  42. package/commands/milestone.md +77 -12
  43. package/commands/optimize.md +2 -1
  44. package/commands/orchestrate/config.md +67 -0
  45. package/commands/orchestrate/phase-1-resolve.md +159 -0
  46. package/commands/orchestrate/phase-2-triage.md +126 -0
  47. package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
  48. package/commands/orchestrate/phase-3-dependency.md +591 -0
  49. package/commands/orchestrate/phase-4-execution.md +962 -0
  50. package/commands/orchestrate/phase-5-cleanup.md +47 -0
  51. package/commands/orchestrate/phase-6-report.md +198 -0
  52. package/commands/orchestrate/safety.md +62 -0
  53. package/commands/orchestrate.md +25 -2027
  54. package/commands/pipeline-health/config.md +34 -0
  55. package/commands/pipeline-health/phase-1-context.md +180 -0
  56. package/commands/pipeline-health/phase-2-metrics.md +1729 -0
  57. package/commands/pipeline-health/phase-3-analyze.md +204 -0
  58. package/commands/pipeline-health/phase-4-proposals.md +117 -0
  59. package/commands/pipeline-health/phase-5-report.md +284 -0
  60. package/commands/pipeline-health/phase-6-summary.md +52 -0
  61. package/commands/pipeline-health.md +25 -2401
  62. package/commands/pipeline-resume.md +24 -1
  63. package/commands/pipeline-status.md +65 -0
  64. package/commands/qa-sweep.md +4 -1
  65. package/commands/quality-gate.md +326 -42
  66. package/commands/replay.md +2 -1
  67. package/commands/review-pr-agents/api.md +135 -0
  68. package/commands/review-pr-agents/auth.md +137 -0
  69. package/commands/review-pr-agents/billing.md +101 -0
  70. package/commands/review-pr-agents/concurrency.md +145 -0
  71. package/commands/review-pr-agents/database.md +103 -0
  72. package/commands/review-pr-agents/frontend.md +89 -0
  73. package/commands/review-pr-agents/infra.md +385 -0
  74. package/commands/review-pr-agents/protocols.md +185 -0
  75. package/commands/review-pr-agents/scraper.md +185 -0
  76. package/commands/review-pr-agents/security.md +406 -0
  77. package/commands/review-pr-agents.md +45 -1722
  78. package/commands/review-pr-staging.md +196 -40
  79. package/commands/review-pr.md +612 -56
  80. package/commands/rollback.md +2 -1
  81. package/commands/scope.md +2 -1
  82. package/commands/security-audit.md +2 -1
  83. package/commands/signal-planner.md +623 -0
  84. package/commands/sync-ecosystem.md +1 -0
  85. package/commands/test-gate.md +17 -1
  86. package/commands/validate.md +2 -1
  87. package/commands/work-on/build/architect.md +28 -0
  88. package/commands/work-on/build/context.md +2 -0
  89. package/commands/work-on/build/implement.md +19 -5
  90. package/commands/work-on/build/validate.md +62 -6
  91. package/commands/work-on/build.md +187 -30
  92. package/commands/work-on/close.md +132 -5
  93. package/commands/work-on/decompose.md +19 -1
  94. package/commands/work-on/investigate.md +117 -2
  95. package/commands/work-on/review.md +48 -4
  96. package/commands/work-on-monolithic.md +8 -2
  97. package/commands/work-on.md +247 -7
  98. package/docs/BRAND.md +16 -5
  99. package/docs/CI.md +2 -2
  100. package/docs/CODEX.md +36 -1
  101. package/docs/CONFIG.md +44 -42
  102. package/docs/FORGE-PROTOCOL.md +15 -1064
  103. package/docs/anthropic-devrel-outreach.md +2 -2
  104. package/docs/articles/command-decomposition-token-savings.md +214 -0
  105. package/docs/articles/model-release-playbook.md +131 -0
  106. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
  107. package/docs/claude-breakpoints.json +75 -5
  108. package/docs/design/abc-benchmark.md +1 -1
  109. package/docs/design/design-spec-schema.md +1 -1
  110. package/docs/design/divergent-generation.md +1 -1
  111. package/docs/design/eval-gate-runbook.md +178 -0
  112. package/docs/design/show-hn-draft.md +26 -1
  113. package/docs/design/token-cost-baseline-1298.md +59 -0
  114. package/docs/eval/README.md +117 -0
  115. package/docs/eval/corpus/seed-v1.json +6 -0
  116. package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
  117. package/docs/site/annotations-explained.md +24 -0
  118. package/docs/site/command-learning-path.md +51 -14
  119. package/docs/site/command-reference.md +339 -113
  120. package/docs/site/for-companies.md +95 -0
  121. package/docs/site/forge-annotation-protocol.md +12 -256
  122. package/docs/site/getting-started.md +58 -12
  123. package/docs/site/github-is-the-memory.md +113 -0
  124. package/docs/site/how-it-works.md +3 -13
  125. package/docs/site/index.md +10 -2
  126. package/docs/spec/eval-run-result.md +147 -0
  127. package/docs/spec/forge-protocol-v1.md +441 -18
  128. package/docs/spec/label-state-machine.md +55 -0
  129. package/docs/spec/review-protocol.md +158 -0
  130. package/docs/spec-graph-schema.md +28 -0
  131. package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
  132. package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
  133. package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
  134. package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
  135. package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
  136. package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
  137. package/examples/forgedock-demo/README.md +41 -22
  138. package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
  139. package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
  140. package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
  141. package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
  142. package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
  143. package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
  144. package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
  145. package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
  146. package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
  147. package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
  148. package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
  149. package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
  150. package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
  151. package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
  152. package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
  153. package/examples/forgedock-demo/scripts/smoke.js +18 -1
  154. package/examples/forgedock-demo/src/db.js +36 -11
  155. package/examples/forgedock-demo/src/routes/notes.js +106 -9
  156. package/examples/forgedock-demo/src/server.js +4 -1
  157. package/package.json +12 -4
  158. package/packages/protocol/LICENSE +21 -0
  159. package/packages/protocol/src/cli.js +135 -0
  160. package/packages/protocol/src/emit.js +146 -0
  161. package/packages/protocol/src/index.js +43 -0
  162. package/packages/protocol/src/parse.js +229 -0
  163. package/packages/protocol/src/types.js +171 -0
  164. package/packages/protocol/src/validate.js +122 -0
  165. package/scripts/bench-topology-cost.mjs +372 -0
  166. package/scripts/check-command-docs-drift.sh +198 -0
  167. package/scripts/check-protocol-restatements.sh +176 -0
  168. package/scripts/check-registry/README.md +72 -0
  169. package/scripts/check-registry/manifest.json +6 -0
  170. package/scripts/code-index.sh +454 -0
  171. package/scripts/conformance-check.mjs +168 -0
  172. package/scripts/doctor-pipeline-state.sh +484 -0
  173. package/scripts/eval-gate-baseline.json +17 -0
  174. package/scripts/eval-gate-scorecard.mjs +242 -0
  175. package/scripts/eval-scorecard-aggregator.mjs +252 -0
  176. package/scripts/flaky-quarantine.sh +241 -0
  177. package/scripts/forge-annotation.sh +567 -0
  178. package/scripts/issue-dedup.sh +236 -0
  179. package/scripts/validate-annotation-node.mjs +233 -0
  180. package/scripts/verify-env-vars.sh +18 -2
  181. package/scripts/verify-host-headers.sh +4 -4
  182. package/scripts/verify-route-registration.sh +2 -2
  183. package/scripts/worktree-lifecycle.sh +202 -0
  184. package/templates/devdocs/agent/using-forgedock.md +14 -0
package/bin/runner.mjs CHANGED
@@ -46,9 +46,9 @@ import {
46
46
  import { join, dirname, basename, relative, isAbsolute } from "path";
47
47
  import { execSync, spawnSync } from "child_process";
48
48
 
49
- const DEFAULT_MODEL = "claude-sonnet-4-5";
49
+ const DEFAULT_MODEL = "claude-sonnet-5";
50
50
  const DEFAULT_MAX_ITERATIONS = 50;
51
- const DEFAULT_MAX_TOKENS = 8192;
51
+ const DEFAULT_MAX_TOKENS = 16384;
52
52
  // Default wall-clock limit for a single run_bash command. Chosen to be
53
53
  // generous enough for CI steps (git clones, test suites) while bounding
54
54
  // the worst-case hang to 5 minutes. Override via FORGEDOCK_BASH_TIMEOUT (ms).
@@ -338,6 +338,31 @@ function resolveConfinedPath(cwd, p) {
338
338
  );
339
339
  }
340
340
 
341
+ // Residual TOCTOU assumption: we return the lexical `resolved` path rather
342
+ // than `realResolved` so that callers (readFileSync / writeFileSync) work
343
+ // correctly with not-yet-existing paths (where realResolved === resolved
344
+ // anyway, since there is nothing to dereference). The check above verified
345
+ // confinement on the realpath of the nearest *existing* ancestor, but a
346
+ // concurrent actor that mutates the filesystem between this check and the
347
+ // caller's open() call could in theory introduce a symlink that evades the
348
+ // guard — a classic time-of-check / time-of-use (TOCTOU) window.
349
+ //
350
+ // This is acceptable under the current threat model because:
351
+ // 1. Tool calls are executed in a strictly sequential, synchronous
352
+ // `for...of` loop (see the runLive tool-use loop). There is no async
353
+ // gap and no concurrent tool execution within the runner itself.
354
+ // 2. A prompt-injected model therefore cannot create this race — it would
355
+ // need to schedule two tool calls simultaneously, which the loop
356
+ // prevents.
357
+ // 3. Only an independent, external OS process running concurrently could
358
+ // exploit the window, which is outside the stated single-shot
359
+ // prompt-injection threat model.
360
+ //
361
+ // If the runner is ever refactored to execute tool calls concurrently or
362
+ // asynchronously, this assumption MUST be revisited. Mitigations to
363
+ // consider at that point include: operating on `realResolved` directly
364
+ // (eliminating the lexical/realpath split), or opening the file with an
365
+ // O_NOFOLLOW-equivalent flag so the kernel rejects a symlink at open time.
341
366
  return resolved;
342
367
  }
343
368
 
@@ -539,31 +564,73 @@ export function getToolHandlers(cwd) {
539
564
  // available. `shell: shell || true` preserves execSync's implicit
540
565
  // "always run via a shell" behavior for compound commands (&&, pipes,
541
566
  // heredocs) when resolveBashShell() returns undefined.
567
+ // maxBuffer defaults to 50 MB. FORGEDOCK_MAX_BUFFER_BYTES overrides it so
568
+ // tests can trigger ENOBUFS with a small value without generating 50 MB of
569
+ // output. Non-positive or non-finite values fall back to the default.
570
+ const rawMaxBuffer = parseInt(process.env.FORGEDOCK_MAX_BUFFER_BYTES, 10);
571
+ const maxBuffer =
572
+ Number.isFinite(rawMaxBuffer) && rawMaxBuffer > 0
573
+ ? rawMaxBuffer
574
+ : 50 * 1024 * 1024;
542
575
  const result = spawnSync(command, {
543
576
  cwd,
544
577
  encoding: "utf-8",
545
- maxBuffer: 50 * 1024 * 1024,
578
+ maxBuffer,
546
579
  timeout: timeoutMs,
547
580
  env: childEnv,
548
581
  shell: shell || true,
549
582
  });
550
583
  const stdout = result.stdout ? String(result.stdout) : "";
551
584
  const stderr = result.stderr ? String(result.stderr) : "";
552
- // Detect timeout: result.error.code === "ETIMEDOUT" and/or
553
- // result.signal === "SIGTERM" are the primary indicators (set by
554
- // Node.js when the timeout fires and the process is killed), but on
555
- // Windows signal reporting may not be reliable. Fall back to elapsed
556
- // wall time — if we spent at least as long as the timeout, the timer
557
- // must have fired, since a voluntarily-exiting process would have
558
- // returned before then. Gate all of this on `result.status === null`
559
- // so a legitimately-completed process near the timeout boundary is
560
- // never misreported as timed out.
585
+ // Check ENOBUFS BEFORE the timedOut fallback. When a command both exceeds
586
+ // maxBuffer AND runs past timeoutMs, both result.error.code === "ENOBUFS"
587
+ // and elapsedMs >= timeoutMs are simultaneously true. The elapsedMs
588
+ // fallback (check 2 below) is a heuristic catch-all that must not
589
+ // override a specific, authoritative error code. ENOBUFS is always the
590
+ // correct diagnosis when present — surface it first. (issue #1364)
591
+ if (result.error?.code === "ENOBUFS") {
592
+ // The process ran but produced more output than maxBuffer allows.
593
+ // spawnSync populates result.stdout/stderr up to the limit before
594
+ // setting result.error — surface what was captured so the agent has
595
+ // actionable context rather than a misleading "failed to start" error.
596
+ const partial = (stdout + stderr).trim();
597
+ const bufDisplay =
598
+ maxBuffer < 1024
599
+ ? `${maxBuffer} bytes`
600
+ : maxBuffer < 1024 * 1024
601
+ ? `${Math.round(maxBuffer / 1024)}KB`
602
+ : `${Math.round(maxBuffer / (1024 * 1024))}MB`;
603
+ throw new Error(
604
+ `Command output exceeded ${bufDisplay} buffer limit (output truncated).` +
605
+ (partial ? `\nPartial output:\n${partial}` : ""),
606
+ );
607
+ }
608
+ // Detect timeout. Two reliable indicators:
609
+ // 1. result.error.code === "ETIMEDOUT" — Node sets this on the error
610
+ // object specifically when spawnSync's own `timeout` option fires
611
+ // and it kills the child. This is the authoritative Node-initiated
612
+ // kill signal.
613
+ // 2. elapsedMs >= timeoutMs — elapsed-wall-time fallback for platforms
614
+ // (primarily Windows) where the ETIMEDOUT error code is unreliable.
615
+ // If we spent at least as long as the timeout, the timer must have
616
+ // fired, since a voluntarily-exiting process would have returned
617
+ // before then.
618
+ //
619
+ // NOTE: `result.signal === "SIGTERM"` is intentionally NOT included.
620
+ // spawnSync sets result.signal for ANY signal that terminated the child —
621
+ // including SIGTERM from external sources (supervisors, orchestrators,
622
+ // `timeout(1)` wrappers, or the child self-signaling) that are entirely
623
+ // unrelated to this runner's timeout. Including it caused any externally-
624
+ // sent SIGTERM to be misreported as a ForgeDock timeout (issue #1240).
625
+ //
626
+ // Gate all of this on `result.status === null` so a legitimately-
627
+ // completed process near the timeout boundary is never misreported.
628
+ // ENOBUFS is excluded above — it is checked first so a command that both
629
+ // overflows the buffer AND exceeds the timeout gets the correct message.
561
630
  const elapsedMs = Date.now() - startMs;
562
631
  const timedOut =
563
632
  result.status === null &&
564
- (result.error?.code === "ETIMEDOUT" ||
565
- result.signal === "SIGTERM" ||
566
- elapsedMs >= timeoutMs);
633
+ (result.error?.code === "ETIMEDOUT" || elapsedMs >= timeoutMs);
567
634
  if (timedOut) {
568
635
  const timeoutSecs = Math.round(timeoutMs / 1000);
569
636
  const partial = (stdout + stderr).trim();
@@ -622,20 +689,36 @@ export function renderDryRun(ctx) {
622
689
 
623
690
  /**
624
691
  * Render the pipeline summary card emitted on completion.
625
- * @param {{command: string, args: string[], iterations: number, stopReason: string}} ctx
692
+ * @param {{command: string, args: string[], iterations: number, stopReason: string, usage?: object|null}} ctx
626
693
  * @returns {string}
627
694
  */
628
695
  export function renderSummaryCard(ctx) {
629
- const { command, args, iterations, stopReason } = ctx;
696
+ const { command, args, iterations, stopReason, usage = null } = ctx;
630
697
  const argStr = Array.isArray(args) ? args.join(" ") : String(args ?? "");
631
- return [
698
+ const lines = [
632
699
  ``,
633
700
  `┌─ ForgeDock pipeline summary ────────────────────────────`,
634
701
  `│ command: /${command} ${argStr}`.trimEnd(),
635
702
  `│ iterations: ${iterations}`,
636
703
  `│ stop: ${stopReason}`,
704
+ ];
705
+ if (usage) {
706
+ lines.push(
707
+ `│ tokens: ${usage.input_tokens} in / ${usage.output_tokens} out`,
708
+ `│ cache: ${usage.cache_read_input_tokens} read / ${usage.cache_creation_input_tokens} write`,
709
+ );
710
+ } else {
711
+ lines.push(`│ tokens: N/A`);
712
+ }
713
+ lines.push(
637
714
  `└─────────────────────────────────────────────────────────`,
638
- ].join("\n");
715
+ );
716
+ if (usage) {
717
+ lines.push(
718
+ `FORGE:USAGE_JSON:${JSON.stringify(usage)}`,
719
+ );
720
+ }
721
+ return lines.join("\n");
639
722
  }
640
723
 
641
724
  // ---------------------------------------------------------------------------
@@ -701,9 +784,31 @@ export async function runCommand(opts = {}) {
701
784
  }
702
785
 
703
786
  const client = new Anthropic({ apiKey });
787
+ // NOTE on /proc/$PPID/environ (Linux): deleting from process.env calls
788
+ // unsetenv() at the C level, which does NOT update the kernel's
789
+ // /proc/<pid>/environ snapshot (that is fixed at execve() time). A
790
+ // run_bash payload can still read /proc/$PPID/environ and recover the
791
+ // key if it was set in the shell environment before the node process
792
+ // started. The only effective JavaScript-layer mitigation is the
793
+ // child-env scrub in run_bash: `childEnv = {...process.env}; delete
794
+ // childEnv.ANTHROPIC_API_KEY` — this prevents the child from inheriting
795
+ // the key in its own process.env, which is sufficient for
796
+ // `process.env.ANTHROPIC_API_KEY` access. The /proc/$PPID/environ vector
797
+ // is a known Linux limitation at the OS level; closing it would require
798
+ // a native module (e.g. prctl PR_SET_DUMPABLE) or process isolation.
799
+ // See: issue #1370 for tracking this known limitation.
704
800
  const handlers = getToolHandlers(cwd);
705
801
  const messages = [{ role: "user", content: userMessage }];
706
802
 
803
+ // Accumulate token usage across all messages.create() calls in this run.
804
+ // Field names match the Anthropic SDK's response.usage object exactly.
805
+ const usage = {
806
+ input_tokens: 0,
807
+ output_tokens: 0,
808
+ cache_creation_input_tokens: 0,
809
+ cache_read_input_tokens: 0,
810
+ };
811
+
707
812
  let iterations = 0;
708
813
  while (iterations < maxIterations) {
709
814
  iterations++;
@@ -716,6 +821,14 @@ export async function runCommand(opts = {}) {
716
821
  messages,
717
822
  });
718
823
 
824
+ // Accumulate usage — guard each field for null/undefined (SDK omits
825
+ // cache fields when prompt caching is not active).
826
+ const ru = response.usage ?? {};
827
+ usage.input_tokens += ru.input_tokens ?? 0;
828
+ usage.output_tokens += ru.output_tokens ?? 0;
829
+ usage.cache_creation_input_tokens += ru.cache_creation_input_tokens ?? 0;
830
+ usage.cache_read_input_tokens += ru.cache_read_input_tokens ?? 0;
831
+
719
832
  messages.push({ role: "assistant", content: response.content });
720
833
 
721
834
  for (const block of response.content) {
@@ -735,6 +848,7 @@ export async function runCommand(opts = {}) {
735
848
  args,
736
849
  iterations,
737
850
  stopReason: response.stop_reason,
851
+ usage,
738
852
  }),
739
853
  );
740
854
  return {
@@ -742,6 +856,7 @@ export async function runCommand(opts = {}) {
742
856
  command: spec.name,
743
857
  iterations,
744
858
  stopReason: response.stop_reason,
859
+ usage,
745
860
  };
746
861
  }
747
862
 
@@ -774,7 +889,8 @@ export async function runCommand(opts = {}) {
774
889
  args,
775
890
  iterations,
776
891
  stopReason: "max_iterations",
892
+ usage,
777
893
  }),
778
894
  );
779
- return { status: "max-iterations", command: spec.name, iterations };
895
+ return { status: "max-iterations", command: spec.name, iterations, usage };
780
896
  }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * bin/settings-hook.mjs — Idempotent SessionStart hook registration in
3
+ * ~/.claude/settings.json.
4
+ *
5
+ * Contract (spec: "Hook wiring"):
6
+ * - Read-modify-write; unrelated keys and hooks preserved verbatim
7
+ * - Our entry identified by HOOK_MARKER in its command string
8
+ * - Malformed JSON → skip and report, NEVER overwrite the file
9
+ * - Absolute hook script path baked in at install time
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "fs";
13
+ import { dirname } from "path";
14
+
15
+ /** Substring identifying ForgeDock's own SessionStart hook entry. */
16
+ export const HOOK_MARKER = "session-start.mjs";
17
+
18
+ /** Substring identifying ForgeDock's own SubagentStop hook entry (interactive engine adapter). */
19
+ export const SUBAGENT_STOP_MARKER = "interactive-engine.mjs";
20
+
21
+ /** Substring identifying ForgeDock's SubagentStop enforcement hook (annotation verification). */
22
+ export const SUBAGENT_STOP_ENFORCE_MARKER = "subagent-stop-enforce.mjs";
23
+
24
+ /** Substring identifying ForgeDock's own PreToolUse hook entry. */
25
+ export const PRE_TOOL_USE_MARKER = "pre-tool-use.mjs";
26
+
27
+ function isOurs(entry) {
28
+ return JSON.stringify(entry).includes(HOOK_MARKER);
29
+ }
30
+
31
+ function isOursSubagentStop(entry) {
32
+ return JSON.stringify(entry).includes(SUBAGENT_STOP_MARKER);
33
+ }
34
+
35
+ function isOursSubagentStopEnforce(entry) {
36
+ return JSON.stringify(entry).includes(SUBAGENT_STOP_ENFORCE_MARKER);
37
+ }
38
+
39
+ function isOursPreToolUse(entry) {
40
+ return JSON.stringify(entry).includes(PRE_TOOL_USE_MARKER);
41
+ }
42
+
43
+ function readSettings(settingsPath) {
44
+ if (!existsSync(settingsPath)) return { settings: {}, fresh: true };
45
+ try {
46
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
47
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
48
+ return { settings: parsed, fresh: false };
49
+ } catch {
50
+ return null; // malformed — caller must not write
51
+ }
52
+ }
53
+
54
+ function writeSettings(settingsPath, settings) {
55
+ mkdirSync(dirname(settingsPath), { recursive: true });
56
+ const tmp = settingsPath + ".tmp";
57
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", "utf-8");
58
+ renameSync(tmp, settingsPath);
59
+ }
60
+
61
+ /**
62
+ * Merge ForgeDock's SessionStart hook into settings.json.
63
+ * @param {string} settingsPath - Absolute path to ~/.claude/settings.json
64
+ * @param {string} hookScriptPath - Absolute path to bin/hooks/session-start.mjs
65
+ * @returns {{ status: 'installed'|'already'|'skipped-malformed' }}
66
+ */
67
+ export function installSessionStartHook(settingsPath, hookScriptPath) {
68
+ const read = readSettings(settingsPath);
69
+ if (read === null) return { status: "skipped-malformed" };
70
+ const { settings } = read;
71
+
72
+ // Validate hooks shape: must be a plain object (not array, string, etc.)
73
+ if (settings.hooks !== undefined && settings.hooks !== null) {
74
+ if (Array.isArray(settings.hooks) || typeof settings.hooks !== "object") {
75
+ return { status: "skipped-malformed" };
76
+ }
77
+ // Validate SessionStart if present: must be an array
78
+ if (settings.hooks.SessionStart !== undefined && !Array.isArray(settings.hooks.SessionStart)) {
79
+ return { status: "skipped-malformed" };
80
+ }
81
+ } else {
82
+ // hooks missing or null → initialize to fresh object
83
+ settings.hooks = {};
84
+ }
85
+
86
+ const list = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : [];
87
+ if (list.some(isOurs)) return { status: "already" };
88
+
89
+ list.push({
90
+ hooks: [
91
+ {
92
+ type: "command",
93
+ command: `node "${hookScriptPath}"`,
94
+ },
95
+ ],
96
+ });
97
+ settings.hooks.SessionStart = list;
98
+ writeSettings(settingsPath, settings);
99
+ return { status: "installed" };
100
+ }
101
+
102
+ /**
103
+ * Remove ForgeDock's SessionStart hook entry; leaves everything else alone.
104
+ * @returns {{ status: 'removed'|'absent'|'skipped-malformed' }}
105
+ */
106
+ export function removeSessionStartHook(settingsPath) {
107
+ const read = readSettings(settingsPath);
108
+ if (read === null) return { status: "skipped-malformed" };
109
+ const { settings, fresh } = read;
110
+ if (fresh) return { status: "absent" };
111
+
112
+ // Validate hooks shape: must be a plain object (not array, string, etc.)
113
+ if (settings.hooks !== undefined && settings.hooks !== null) {
114
+ if (Array.isArray(settings.hooks) || typeof settings.hooks !== "object") {
115
+ return { status: "skipped-malformed" };
116
+ }
117
+ }
118
+
119
+ // Validate SessionStart shape if present: must be an array
120
+ if (settings.hooks && settings.hooks.SessionStart !== undefined && !Array.isArray(settings.hooks.SessionStart)) {
121
+ return { status: "skipped-malformed" };
122
+ }
123
+
124
+ const list = settings.hooks && Array.isArray(settings.hooks.SessionStart)
125
+ ? settings.hooks.SessionStart
126
+ : [];
127
+ const kept = list.filter((e) => !isOurs(e));
128
+ if (kept.length === list.length) return { status: "absent" };
129
+
130
+ settings.hooks.SessionStart = kept;
131
+ writeSettings(settingsPath, settings);
132
+ return { status: "removed" };
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // SubagentStop hook — interactive engine adapter (#1323)
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Merge ForgeDock's SubagentStop hook into settings.json.
141
+ * @param {string} settingsPath - Absolute path to ~/.claude/settings.json
142
+ * @param {string} hookScriptPath - Absolute path to bin/hooks/interactive-engine.mjs
143
+ * @returns {{ status: 'installed'|'already'|'skipped-malformed' }}
144
+ */
145
+ export function installSubagentStopHook(settingsPath, hookScriptPath) {
146
+ const read = readSettings(settingsPath);
147
+ if (read === null) return { status: "skipped-malformed" };
148
+ const { settings } = read;
149
+
150
+ if (settings.hooks !== undefined && settings.hooks !== null) {
151
+ if (Array.isArray(settings.hooks) || typeof settings.hooks !== "object") {
152
+ return { status: "skipped-malformed" };
153
+ }
154
+ if (settings.hooks.SubagentStop !== undefined && !Array.isArray(settings.hooks.SubagentStop)) {
155
+ return { status: "skipped-malformed" };
156
+ }
157
+ } else {
158
+ settings.hooks = {};
159
+ }
160
+
161
+ const list = Array.isArray(settings.hooks.SubagentStop) ? settings.hooks.SubagentStop : [];
162
+ if (list.some(isOursSubagentStop)) return { status: "already" };
163
+
164
+ list.push({
165
+ hooks: [{ type: "command", command: `node "${hookScriptPath}"` }],
166
+ });
167
+ settings.hooks.SubagentStop = list;
168
+ writeSettings(settingsPath, settings);
169
+ return { status: "installed" };
170
+ }
171
+
172
+ /**
173
+ * Remove ForgeDock's SubagentStop hook entry; leaves everything else alone.
174
+ * @returns {{ status: 'removed'|'absent'|'skipped-malformed' }}
175
+ */
176
+ export function removeSubagentStopHook(settingsPath) {
177
+ const read = readSettings(settingsPath);
178
+ if (read === null) return { status: "skipped-malformed" };
179
+ const { settings, fresh } = read;
180
+ if (fresh) return { status: "absent" };
181
+
182
+ if (settings.hooks !== undefined && settings.hooks !== null) {
183
+ if (Array.isArray(settings.hooks) || typeof settings.hooks !== "object") {
184
+ return { status: "skipped-malformed" };
185
+ }
186
+ }
187
+ if (settings.hooks && settings.hooks.SubagentStop !== undefined && !Array.isArray(settings.hooks.SubagentStop)) {
188
+ return { status: "skipped-malformed" };
189
+ }
190
+
191
+ const list = settings.hooks && Array.isArray(settings.hooks.SubagentStop)
192
+ ? settings.hooks.SubagentStop : [];
193
+ const kept = list.filter((e) => !isOursSubagentStop(e));
194
+ if (kept.length === list.length) return { status: "absent" };
195
+
196
+ settings.hooks.SubagentStop = kept;
197
+ writeSettings(settingsPath, settings);
198
+ return { status: "removed" };
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // PreToolUse hook — enforcement (#1250, #1323)
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /**
206
+ * Merge ForgeDock's PreToolUse hook into settings.json.
207
+ *
208
+ * The installed entry carries `matcher: "Bash"` so Claude Code's harness
209
+ * only invokes this hook for Bash tool calls — without it, Node spawns for
210
+ * every tool call (Read/Edit/Grep/Glob/...) just to exit 0 immediately
211
+ * (issue #1591).
212
+ *
213
+ * @param {string} settingsPath - Absolute path to ~/.claude/settings.json
214
+ * @param {string} hookScriptPath - Absolute path to bin/hooks/pre-tool-use.mjs
215
+ * @returns {{ status: 'installed'|'already'|'skipped-malformed' }}
216
+ */
217
+ export function installPreToolUseHook(settingsPath, hookScriptPath) {
218
+ const read = readSettings(settingsPath);
219
+ if (read === null) return { status: "skipped-malformed" };
220
+ const { settings } = read;
221
+
222
+ if (settings.hooks !== undefined && settings.hooks !== null) {
223
+ if (Array.isArray(settings.hooks) || typeof settings.hooks !== "object") {
224
+ return { status: "skipped-malformed" };
225
+ }
226
+ if (settings.hooks.PreToolUse !== undefined && !Array.isArray(settings.hooks.PreToolUse)) {
227
+ return { status: "skipped-malformed" };
228
+ }
229
+ } else {
230
+ settings.hooks = {};
231
+ }
232
+
233
+ const list = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
234
+ if (list.some(isOursPreToolUse)) return { status: "already" };
235
+
236
+ list.push({
237
+ matcher: "Bash",
238
+ hooks: [{ type: "command", command: `node "${hookScriptPath}"` }],
239
+ });
240
+ settings.hooks.PreToolUse = list;
241
+ writeSettings(settingsPath, settings);
242
+ return { status: "installed" };
243
+ }
244
+
245
+ /**
246
+ * Remove ForgeDock's PreToolUse hook entry; leaves everything else alone.
247
+ * @returns {{ status: 'removed'|'absent'|'skipped-malformed' }}
248
+ */
249
+ export function removePreToolUseHook(settingsPath) {
250
+ const read = readSettings(settingsPath);
251
+ if (read === null) return { status: "skipped-malformed" };
252
+ const { settings, fresh } = read;
253
+ if (fresh) return { status: "absent" };
254
+
255
+ if (settings.hooks !== undefined && settings.hooks !== null) {
256
+ if (Array.isArray(settings.hooks) || typeof settings.hooks !== "object") {
257
+ return { status: "skipped-malformed" };
258
+ }
259
+ }
260
+ if (settings.hooks && settings.hooks.PreToolUse !== undefined && !Array.isArray(settings.hooks.PreToolUse)) {
261
+ return { status: "skipped-malformed" };
262
+ }
263
+
264
+ const list = settings.hooks && Array.isArray(settings.hooks.PreToolUse)
265
+ ? settings.hooks.PreToolUse : [];
266
+ const kept = list.filter((e) => !isOursPreToolUse(e));
267
+ if (kept.length === list.length) return { status: "absent" };
268
+
269
+ settings.hooks.PreToolUse = kept;
270
+ writeSettings(settingsPath, settings);
271
+ return { status: "removed" };
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // SubagentStop enforcement hook — annotation verification (#1250)
276
+ // ---------------------------------------------------------------------------
277
+
278
+ /**
279
+ * Merge ForgeDock's SubagentStop enforcement hook into settings.json.
280
+ *
281
+ * This hook (subagent-stop-enforce.mjs) is separate from the interactive
282
+ * engine adapter (interactive-engine.mjs): it verifies that each pipeline
283
+ * phase posted its FORGE: annotation before completion, and blocks (exit 2)
284
+ * if the annotation is missing.
285
+ *
286
+ * Version-gated: requires Claude Code v2.1.163+ (additionalContext injection).
287
+ *
288
+ * @param {string} settingsPath - Absolute path to ~/.claude/settings.json
289
+ * @param {string} hookScriptPath - Absolute path to bin/hooks/subagent-stop-enforce.mjs
290
+ * @returns {{ status: 'installed'|'already'|'skipped-malformed' }}
291
+ */
292
+ export function installSubagentStopEnforceHook(settingsPath, hookScriptPath) {
293
+ const read = readSettings(settingsPath);
294
+ if (read === null) return { status: "skipped-malformed" };
295
+ const { settings } = read;
296
+
297
+ if (settings.hooks !== undefined && settings.hooks !== null) {
298
+ if (Array.isArray(settings.hooks) || typeof settings.hooks !== "object") {
299
+ return { status: "skipped-malformed" };
300
+ }
301
+ if (settings.hooks.SubagentStop !== undefined && !Array.isArray(settings.hooks.SubagentStop)) {
302
+ return { status: "skipped-malformed" };
303
+ }
304
+ } else {
305
+ settings.hooks = {};
306
+ }
307
+
308
+ const list = Array.isArray(settings.hooks.SubagentStop) ? settings.hooks.SubagentStop : [];
309
+ if (list.some(isOursSubagentStopEnforce)) return { status: "already" };
310
+
311
+ list.push({
312
+ hooks: [{ type: "command", command: `node "${hookScriptPath}"` }],
313
+ });
314
+ settings.hooks.SubagentStop = list;
315
+ writeSettings(settingsPath, settings);
316
+ return { status: "installed" };
317
+ }
318
+
319
+ /**
320
+ * Remove ForgeDock's SubagentStop enforcement hook entry; leaves everything else alone.
321
+ * @returns {{ status: 'removed'|'absent'|'skipped-malformed' }}
322
+ */
323
+ export function removeSubagentStopEnforceHook(settingsPath) {
324
+ const read = readSettings(settingsPath);
325
+ if (read === null) return { status: "skipped-malformed" };
326
+ const { settings, fresh } = read;
327
+ if (fresh) return { status: "absent" };
328
+
329
+ if (settings.hooks !== undefined && settings.hooks !== null) {
330
+ if (Array.isArray(settings.hooks) || typeof settings.hooks !== "object") {
331
+ return { status: "skipped-malformed" };
332
+ }
333
+ }
334
+ if (settings.hooks && settings.hooks.SubagentStop !== undefined && !Array.isArray(settings.hooks.SubagentStop)) {
335
+ return { status: "skipped-malformed" };
336
+ }
337
+
338
+ const list = settings.hooks && Array.isArray(settings.hooks.SubagentStop)
339
+ ? settings.hooks.SubagentStop : [];
340
+ const kept = list.filter((e) => !isOursSubagentStopEnforce(e));
341
+ if (kept.length === list.length) return { status: "absent" };
342
+
343
+ settings.hooks.SubagentStop = kept;
344
+ writeSettings(settingsPath, settings);
345
+ return { status: "removed" };
346
+ }
package/bin/tui.mjs CHANGED
@@ -837,7 +837,7 @@ const REVIEW_FIELDS = [
837
837
  */
838
838
  export async function annotatedReviewScreen(
839
839
  draft,
840
- { hasExistingConfig = false, existingContent = "", showSources = false } = {},
840
+ { hasExistingConfig = false, existingContent = "", showSources = false, extraFields = {} } = {},
841
841
  ) {
842
842
  // Helper — pull a field from the draft by path, or return a low-confidence placeholder.
843
843
  function getField(draftPath) {
@@ -862,7 +862,10 @@ export async function annotatedReviewScreen(
862
862
  const whys = {};
863
863
 
864
864
  for (const fd of REVIEW_FIELDS) {
865
- const field = getField(fd.draftPath);
865
+ const field =
866
+ fd.draftPath === null && extraFields[fd.key]
867
+ ? extraFields[fd.key]
868
+ : getField(fd.draftPath);
866
869
  values[fd.key] = field.value;
867
870
  confidences[fd.key] = field.confidence;
868
871
  sources[fd.key] = field.source;
@@ -987,7 +990,7 @@ export async function annotatedReviewScreen(
987
990
  }
988
991
 
989
992
  process.stdout.write(
990
- ` ${dim("Press")} ${bold("Enter")} ${dim("to accept all values, or enter a field number to edit it.")}\n\n`,
993
+ ` ${dim("Press")} ${bold("Enter")} ${dim("to forge, or a number to edit a field")}\n\n`,
991
994
  );
992
995
  }
993
996
 
package/commands/adopt.md CHANGED
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Bootstrap an existing repo for ForgeDock — triage open issues, apply labels, suggest a starter milestone, and identify first /work-on candidates
3
3
  argument-hint: [--dry-run | --limit <N> | --milestone | --no-milestone]
4
+ install: extras
4
5
  ---
5
6
 
6
7
  # /adopt — Repo Bootstrap & Backlog Triage
@@ -11,9 +12,11 @@ You onboard an existing repo into the ForgeDock pipeline. Unlike `/forgedock-ini
11
12
 
12
13
  Run `/adopt` once after `npx forgedock init` + `/forgedock-init` to make a legacy backlog pipeline-ready without touching the issues themselves.
13
14
 
14
- **Agent model policy**: Default `model: "sonnet"`. Fallback: `model: "opus"` if Sonnet is rate-limited.
15
+ **Agent model policy**: `model: "sonnet"` (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154.
15
16
  **NEVER use plan mode (EnterPlanMode).**
16
17
 
18
+ <!-- FORGE:SPEC_LOADED — adopt.md loaded and active. Agent is bound by this spec. -->
19
+
17
20
  ---
18
21
 
19
22
  ## Argument Parsing
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  description: Pull production analytics from GSC, Bing Webmaster, Clarity, Umami, Cloudflare, Stripe, and GA4 — generate insights and create actionable GitHub issues. Trigger when user says things like "check analytics", "look at prod analytics", "make issues from analytics", "what's happening on the site", "audit the site", "check revenue", etc.
3
+ install: extras
3
4
  ---
4
5
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
5
6
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
@@ -12,7 +13,9 @@ You are the analytics orchestrator. Pull data from ALL available analytics platf
12
13
 
13
14
  **NEVER use plan mode (EnterPlanMode)** — it breaks execution context.
14
15
 
15
- **Agent model policy**: Default `model: "sonnet"`. If Sonnet is rate-limited, fall back to `model: "opus"`. User can override with `--model <name>`. Pass the resolved model in every `Agent` tool call. Each agent prompt includes specific queries, exact API endpoints, and the credentials file path so the model can execute without guessing.
16
+ **Agent model policy**: `model: "sonnet"` (standard tier). Fallback: `model: "opus"` if rate-limited. User can override with `--model <name>`. Pass model explicitly in every `Agent` tool call. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154.
17
+
18
+ <!-- FORGE:SPEC_LOADED — analytics.md loaded and active. Agent is bound by this spec. -->
16
19
 
17
20
  ---
18
21