@garygentry/feature-forge 0.3.4 → 0.3.6

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 (164) hide show
  1. package/README.md +1 -1
  2. package/adapters/claude/.feature-forge-bundle.json +1 -1
  3. package/adapters/claude/references/forge-config-schema.json +4 -4
  4. package/adapters/claude/references/ralph-loop-contract.md +12 -8
  5. package/adapters/claude/references/shared-conventions.md +1 -1
  6. package/adapters/claude/scripts/forge-session.py +57 -32
  7. package/adapters/claude/skills/forge/references/shared-conventions.md +1 -1
  8. package/adapters/claude/skills/forge-0-epic/references/shared-conventions.md +1 -1
  9. package/adapters/claude/skills/forge-1-prd/references/shared-conventions.md +1 -1
  10. package/adapters/claude/skills/forge-2-tech/references/shared-conventions.md +1 -1
  11. package/adapters/claude/skills/forge-3-specs/references/shared-conventions.md +1 -1
  12. package/adapters/claude/skills/forge-4-backlog/references/shared-conventions.md +1 -1
  13. package/adapters/claude/skills/forge-5-loop/SKILL.md +3 -3
  14. package/adapters/claude/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
  15. package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +2 -2
  16. package/adapters/claude/skills/forge-5-loop/references/shared-conventions.md +1 -1
  17. package/adapters/claude/skills/forge-6-docs/references/shared-conventions.md +1 -1
  18. package/adapters/claude/skills/forge-fix/references/shared-conventions.md +1 -1
  19. package/adapters/claude/skills/forge-guide/references/forge-config-schema.json +4 -4
  20. package/adapters/claude/skills/forge-guide/references/ralph-loop-contract.md +12 -8
  21. package/adapters/claude/skills/forge-guide/references/shared-conventions.md +1 -1
  22. package/adapters/claude/skills/forge-verify/SKILL.md +5 -3
  23. package/adapters/claude/skills/forge-verify/references/findings-template.md +6 -6
  24. package/adapters/claude/skills/forge-verify/references/shared-conventions.md +1 -1
  25. package/adapters/codex/.feature-forge-bundle.json +1 -1
  26. package/adapters/codex/references/forge-config-schema.json +4 -4
  27. package/adapters/codex/references/ralph-loop-contract.md +12 -8
  28. package/adapters/codex/references/shared-conventions.md +1 -1
  29. package/adapters/codex/scripts/forge-session.py +57 -32
  30. package/adapters/codex/skills/forge/references/shared-conventions.md +1 -1
  31. package/adapters/codex/skills/forge-0-epic/references/shared-conventions.md +1 -1
  32. package/adapters/codex/skills/forge-1-prd/references/shared-conventions.md +1 -1
  33. package/adapters/codex/skills/forge-2-tech/references/shared-conventions.md +1 -1
  34. package/adapters/codex/skills/forge-3-specs/references/shared-conventions.md +1 -1
  35. package/adapters/codex/skills/forge-4-backlog/references/shared-conventions.md +1 -1
  36. package/adapters/codex/skills/forge-5-loop/SKILL.md +3 -3
  37. package/adapters/codex/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
  38. package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +2 -2
  39. package/adapters/codex/skills/forge-5-loop/references/shared-conventions.md +1 -1
  40. package/adapters/codex/skills/forge-6-docs/references/shared-conventions.md +1 -1
  41. package/adapters/codex/skills/forge-fix/references/shared-conventions.md +1 -1
  42. package/adapters/codex/skills/forge-guide/references/forge-config-schema.json +4 -4
  43. package/adapters/codex/skills/forge-guide/references/ralph-loop-contract.md +12 -8
  44. package/adapters/codex/skills/forge-guide/references/shared-conventions.md +1 -1
  45. package/adapters/codex/skills/forge-verify/SKILL.md +5 -3
  46. package/adapters/codex/skills/forge-verify/references/findings-template.md +6 -6
  47. package/adapters/codex/skills/forge-verify/references/shared-conventions.md +1 -1
  48. package/adapters/copilot/.feature-forge-bundle.json +1 -1
  49. package/adapters/copilot/references/forge-config-schema.json +4 -4
  50. package/adapters/copilot/references/ralph-loop-contract.md +12 -8
  51. package/adapters/copilot/references/shared-conventions.md +1 -1
  52. package/adapters/copilot/scripts/forge-session.py +57 -32
  53. package/adapters/copilot/skills/forge/references/shared-conventions.md +1 -1
  54. package/adapters/copilot/skills/forge-0-epic/references/shared-conventions.md +1 -1
  55. package/adapters/copilot/skills/forge-1-prd/references/shared-conventions.md +1 -1
  56. package/adapters/copilot/skills/forge-2-tech/references/shared-conventions.md +1 -1
  57. package/adapters/copilot/skills/forge-3-specs/references/shared-conventions.md +1 -1
  58. package/adapters/copilot/skills/forge-4-backlog/references/shared-conventions.md +1 -1
  59. package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +3 -3
  60. package/adapters/copilot/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
  61. package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +2 -2
  62. package/adapters/copilot/skills/forge-5-loop/references/shared-conventions.md +1 -1
  63. package/adapters/copilot/skills/forge-6-docs/references/shared-conventions.md +1 -1
  64. package/adapters/copilot/skills/forge-fix/references/shared-conventions.md +1 -1
  65. package/adapters/copilot/skills/forge-guide/references/forge-config-schema.json +4 -4
  66. package/adapters/copilot/skills/forge-guide/references/ralph-loop-contract.md +12 -8
  67. package/adapters/copilot/skills/forge-guide/references/shared-conventions.md +1 -1
  68. package/adapters/copilot/skills/forge-verify/forge-verify.md +5 -3
  69. package/adapters/copilot/skills/forge-verify/references/findings-template.md +6 -6
  70. package/adapters/copilot/skills/forge-verify/references/shared-conventions.md +1 -1
  71. package/adapters/cursor/.feature-forge-bundle.json +1 -1
  72. package/adapters/cursor/references/forge-config-schema.json +4 -4
  73. package/adapters/cursor/references/ralph-loop-contract.md +12 -8
  74. package/adapters/cursor/references/shared-conventions.md +1 -1
  75. package/adapters/cursor/scripts/forge-session.py +57 -32
  76. package/adapters/cursor/skills/forge/references/shared-conventions.md +1 -1
  77. package/adapters/cursor/skills/forge-0-epic/references/shared-conventions.md +1 -1
  78. package/adapters/cursor/skills/forge-1-prd/references/shared-conventions.md +1 -1
  79. package/adapters/cursor/skills/forge-2-tech/references/shared-conventions.md +1 -1
  80. package/adapters/cursor/skills/forge-3-specs/references/shared-conventions.md +1 -1
  81. package/adapters/cursor/skills/forge-4-backlog/references/shared-conventions.md +1 -1
  82. package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +3 -3
  83. package/adapters/cursor/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
  84. package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +2 -2
  85. package/adapters/cursor/skills/forge-5-loop/references/shared-conventions.md +1 -1
  86. package/adapters/cursor/skills/forge-6-docs/references/shared-conventions.md +1 -1
  87. package/adapters/cursor/skills/forge-fix/references/shared-conventions.md +1 -1
  88. package/adapters/cursor/skills/forge-guide/references/forge-config-schema.json +4 -4
  89. package/adapters/cursor/skills/forge-guide/references/ralph-loop-contract.md +12 -8
  90. package/adapters/cursor/skills/forge-guide/references/shared-conventions.md +1 -1
  91. package/adapters/cursor/skills/forge-verify/forge-verify.mdc +5 -3
  92. package/adapters/cursor/skills/forge-verify/references/findings-template.md +6 -6
  93. package/adapters/cursor/skills/forge-verify/references/shared-conventions.md +1 -1
  94. package/adapters/gemini/.feature-forge-bundle.json +1 -1
  95. package/adapters/gemini/gemini-extension.json +1 -1
  96. package/adapters/gemini/references/forge-config-schema.json +4 -4
  97. package/adapters/gemini/references/ralph-loop-contract.md +12 -8
  98. package/adapters/gemini/references/shared-conventions.md +1 -1
  99. package/adapters/gemini/scripts/forge-session.py +57 -32
  100. package/adapters/gemini/skills/forge/references/shared-conventions.md +1 -1
  101. package/adapters/gemini/skills/forge-0-epic/references/shared-conventions.md +1 -1
  102. package/adapters/gemini/skills/forge-1-prd/references/shared-conventions.md +1 -1
  103. package/adapters/gemini/skills/forge-2-tech/references/shared-conventions.md +1 -1
  104. package/adapters/gemini/skills/forge-3-specs/references/shared-conventions.md +1 -1
  105. package/adapters/gemini/skills/forge-4-backlog/references/shared-conventions.md +1 -1
  106. package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +3 -3
  107. package/adapters/gemini/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
  108. package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +2 -2
  109. package/adapters/gemini/skills/forge-5-loop/references/shared-conventions.md +1 -1
  110. package/adapters/gemini/skills/forge-6-docs/references/shared-conventions.md +1 -1
  111. package/adapters/gemini/skills/forge-fix/references/shared-conventions.md +1 -1
  112. package/adapters/gemini/skills/forge-guide/references/forge-config-schema.json +4 -4
  113. package/adapters/gemini/skills/forge-guide/references/ralph-loop-contract.md +12 -8
  114. package/adapters/gemini/skills/forge-guide/references/shared-conventions.md +1 -1
  115. package/adapters/gemini/skills/forge-verify/forge-verify.md +5 -3
  116. package/adapters/gemini/skills/forge-verify/references/findings-template.md +6 -6
  117. package/adapters/gemini/skills/forge-verify/references/shared-conventions.md +1 -1
  118. package/adapters/pi/.feature-forge-bundle.json +1 -1
  119. package/adapters/pi/extensions/forge-loop-supervisor/events.ts +90 -0
  120. package/adapters/pi/extensions/forge-loop-supervisor/index.ts +114 -0
  121. package/adapters/pi/extensions/forge-loop-supervisor/registry.ts +137 -0
  122. package/adapters/pi/extensions/forge-loop-supervisor/supervisor.ts +185 -0
  123. package/adapters/pi/extensions/forge-loop-supervisor/tailer.ts +137 -0
  124. package/adapters/pi/extensions/forge-loop-supervisor/types.ts +87 -0
  125. package/adapters/pi/extensions/forge-loop-supervisor/wiring.ts +423 -0
  126. package/adapters/pi/package.json +2 -1
  127. package/adapters/pi/references/forge-config-schema.json +4 -4
  128. package/adapters/pi/references/ralph-loop-contract.md +12 -8
  129. package/adapters/pi/references/shared-conventions.md +1 -1
  130. package/adapters/pi/scripts/forge-session.py +57 -32
  131. package/adapters/pi/skills/forge/SKILL.md +4 -1
  132. package/adapters/pi/skills/forge/references/shared-conventions.md +1 -1
  133. package/adapters/pi/skills/forge-0-epic/SKILL.md +4 -1
  134. package/adapters/pi/skills/forge-0-epic/references/shared-conventions.md +1 -1
  135. package/adapters/pi/skills/forge-1-prd/SKILL.md +4 -1
  136. package/adapters/pi/skills/forge-1-prd/references/shared-conventions.md +1 -1
  137. package/adapters/pi/skills/forge-2-tech/SKILL.md +4 -1
  138. package/adapters/pi/skills/forge-2-tech/references/shared-conventions.md +1 -1
  139. package/adapters/pi/skills/forge-3-specs/SKILL.md +4 -1
  140. package/adapters/pi/skills/forge-3-specs/references/shared-conventions.md +1 -1
  141. package/adapters/pi/skills/forge-4-backlog/SKILL.md +4 -1
  142. package/adapters/pi/skills/forge-4-backlog/references/shared-conventions.md +1 -1
  143. package/adapters/pi/skills/forge-5-loop/SKILL.md +14 -8
  144. package/adapters/pi/skills/forge-5-loop/references/ralph-loop-contract.md +12 -8
  145. package/adapters/pi/skills/forge-5-loop/references/runner-contract.md +8 -5
  146. package/adapters/pi/skills/forge-5-loop/references/shared-conventions.md +1 -1
  147. package/adapters/pi/skills/forge-6-docs/SKILL.md +4 -1
  148. package/adapters/pi/skills/forge-6-docs/references/shared-conventions.md +1 -1
  149. package/adapters/pi/skills/forge-bootstrap/SKILL.md +4 -1
  150. package/adapters/pi/skills/forge-fix/SKILL.md +4 -1
  151. package/adapters/pi/skills/forge-fix/references/shared-conventions.md +1 -1
  152. package/adapters/pi/skills/forge-guide/SKILL.md +4 -1
  153. package/adapters/pi/skills/forge-guide/references/forge-config-schema.json +4 -4
  154. package/adapters/pi/skills/forge-guide/references/ralph-loop-contract.md +12 -8
  155. package/adapters/pi/skills/forge-guide/references/shared-conventions.md +1 -1
  156. package/adapters/pi/skills/forge-init/SKILL.md +4 -1
  157. package/adapters/pi/skills/forge-verify/SKILL.md +9 -4
  158. package/adapters/pi/skills/forge-verify/references/findings-template.md +6 -6
  159. package/adapters/pi/skills/forge-verify/references/shared-conventions.md +1 -1
  160. package/dist/manifest.d.ts +1 -1
  161. package/dist/rauf.d.ts +3 -3
  162. package/dist/rauf.js +2 -2
  163. package/dist/types.d.ts +1 -1
  164. package/package.json +8 -2
@@ -131,10 +131,10 @@ violate REQ-STATE-02; per-feature status is always derived live from each member
131
131
  `.pipeline-state.json`).
132
132
 
133
133
  Set `stages.forge-verify-epic.status` to `findings-reported` when the report lists at
134
- least one **blocking** finding (`error`/`gap`), else `passed` — for an advisory-only
135
- report pass `--findings-file`/`--findings-count` alongside `--status passed`, exactly
136
- as in feature mode (the severity floor in `skills/forge-verify/SKILL.md`) — recording
137
- `findingsFile`, `findingsCount`, and `verifiedAt`.
134
+ least one **blocking** finding (`error`/`gap`), else `passed`. Always attach the report
135
+ and its total count, including count `0` for a clean report, exactly as in feature mode
136
+ (the severity floor in `skills/forge-verify/SKILL.md`) — recording `findingsFile`,
137
+ `findingsCount`, and `verifiedAt`.
138
138
 
139
139
  **Write it with `state-verify`, never by hand.** `--stage forge-0-epic` is the sanctioned
140
140
  epic writer: it creates the file lazily, mutates only `stages.forge-verify-epic` plus the
@@ -149,8 +149,8 @@ R="$(bash -c 'for d in "${FEATURE_FORGE_ROOT:-}" "$HOME"/.claude/skills/feature-
149
149
  [ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
150
150
  python3 "$R/scripts/forge-session.py" state-verify \
151
151
  --feature "{epic}" --stage forge-0-epic \
152
- --status "{passed|findings-reported}" \
153
- --findings-file "{relative findings path}" --findings-count {n} \
152
+ --status "{outcome-specific status}" \
153
+ --findings-file "{relative findings path}" --findings-count {outcome-specific count} \
154
154
  --verified-stage-version {manifest revision} --specs-dir "{specsDir}"
155
155
  ```
156
156
 
@@ -206,7 +206,7 @@ If a `state-*` verb exits 2, surface the plain `Error:` line from stderr verbati
206
206
 
207
207
  ### `state-verify` — verification results and provenance
208
208
 
209
- `state-verify` writes exactly one `stages.forge-verify-{token}` entry — the verification result for the production stage named by `--stage` — plus the top-level `updatedAt`, and nothing else. `--stage` takes the **served production stage** (`forge-0-epic` through `forge-5-loop`; `forge-6-docs` has no verification token and is rejected). Add `--epic "{epic}"` when the feature is an epic member — required, per the member rule above. Result mode passes `--status` (`auto-verify-pending`, `passed`, `findings-reported`, `findings-applied`, or `skipped`) with whatever `--findings-file`, `--findings-count`, and `--verified-stage-version` that status requires; contradictory metadata is refused before any write. `passed` may additionally carry `--findings-file` + `--findings-count` together for an **advisory-only** report (`inconsistency`/`improvement` findings only, per forge-verify's severity floor) — the stage resolves without a fix round and the report stays attached:
209
+ `state-verify` writes exactly one `stages.forge-verify-{token}` entry — the verification result for the production stage named by `--stage` — plus the top-level `updatedAt`, and nothing else. `--stage` takes the **served production stage** (`forge-0-epic` through `forge-5-loop`; `forge-6-docs` has no verification token and is rejected). Add `--epic "{epic}"` when the feature is an epic member — required, per the member rule above. Result mode passes `--status` (`auto-verify-pending`, `passed`, `findings-reported`, `findings-applied`, or `skipped`) with whatever `--findings-file`, `--findings-count`, and `--verified-stage-version` that status requires; contradictory metadata is refused before any write. `passed` may carry `--findings-file` + `--findings-count` for a clean report (count `0`), an **advisory-only** report (`inconsistency`/`improvement` findings only), or accepted residual findings. The stage resolves without a fix round and the report stays attached, making a completed verification round directly resumable if provenance recording is interrupted. A bare `passed` remains accepted for backward compatibility:
210
210
 
211
211
  **`auto-verify-pending` is not a skill-facing status.** It is written by `stage-exit`'s scheduling boundary, which records the debt automatically when auto-verify is effective for a stage. The value is accepted on this CLI so the entry stays inspectable and repairable, not so a skill can hand-schedule verification: no skill body and no reference passes it, and none should. Every other status in the list is the recorded *result* of a verification that ran (or was explicitly skipped); this one records that one was *owed*.
212
212
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feature-forge",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "agent": "pi",
5
5
  "generatedBy": "python3 scripts/build-adapters.py"
6
6
  }
@@ -0,0 +1,90 @@
1
+ // GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/events.ts
2
+ // Regenerate with: python3 scripts/build-adapters.py
3
+ /**
4
+ * Event classification + display formatting — pure functions over one rauf event.
5
+ *
6
+ * The classification mirrors the coverage-complete filter the generic
7
+ * forge-5-loop contract arms on Claude (`Monitor` on events.ndjson): every
8
+ * terminal and exception state is surfaced (silence is never success), while
9
+ * routine milestones stay quiet. See rauf's event union in
10
+ * `packages/core/src/schemas.ts` for the authoritative field list.
11
+ */
12
+
13
+ import type { EventClass, RaufEvent } from "./types.js";
14
+
15
+ /** Events that mean "the run is over" — the supervisor wakes the session so the
16
+ * loop stage runs its post-run close-out (Steps 4–7), then stops watching. */
17
+ const TERMINAL = new Set(["loop_completed", "loop_error", "loop_cancelled"]);
18
+
19
+ /** Events a human must see immediately. `loop_paused` carries `reason` and only
20
+ * the needs-human pause is surfaced here (the runner sets the item aside and
21
+ * keeps going — it is not a full stop). Everything else in this set is an
22
+ * unconditional exception. */
23
+ const EXCEPTION = new Set([
24
+ "needs_human",
25
+ "item_blocked",
26
+ "llm_stuck_warning",
27
+ "review_failed",
28
+ ]);
29
+
30
+ /** The single routine milestone that earns a quiet progress line. */
31
+ const PROGRESS = new Set(["item_completed"]);
32
+
33
+ /** Classify one event. Unknown/interior/firehose types (token updates, tool
34
+ * activity, iteration boundaries, review start, usage-limit chatter, …) are
35
+ * `ignore` — the supervisor never surfaces them. */
36
+ export function classifyEvent(evt: RaufEvent): EventClass {
37
+ const type = evt?.type;
38
+ if (typeof type !== "string") return "ignore";
39
+ if (TERMINAL.has(type)) return "terminal";
40
+ if (EXCEPTION.has(type)) return "exception";
41
+ // `loop_paused` is an exception ONLY when it is the needs-human pause.
42
+ if (type === "loop_paused" && evt.reason === "needs_human") return "exception";
43
+ if (PROGRESS.has(type)) return "progress";
44
+ return "ignore";
45
+ }
46
+
47
+ /** A concise, deterministic one-line report for a routine `item_completed`.
48
+ * `done`/`total` are threaded in when known so the line reads `[3/10] …`. */
49
+ export function formatProgress(
50
+ evt: RaufEvent,
51
+ done?: number,
52
+ total?: number,
53
+ ): string {
54
+ const count =
55
+ typeof done === "number" && typeof total === "number" ? `[${done}/${total}] ` : "";
56
+ const title = typeof evt.title === "string" && evt.title ? evt.title : evt.itemId ?? "item";
57
+ return `${count}forge loop: completed ${title}`;
58
+ }
59
+
60
+ /** A human-facing line for an exception or terminal event, used both for the
61
+ * toast and for the wake message that triggers the session's next turn. */
62
+ export function formatSignal(evt: RaufEvent): string {
63
+ switch (evt.type) {
64
+ case "needs_human":
65
+ return `forge loop: item ${evt.itemId ?? "?"} needs a human — ${evt.reason ?? "no reason given"}`;
66
+ case "loop_paused":
67
+ return `forge loop: paused for human input on ${evt.itemId ?? "an item"}`;
68
+ case "item_blocked":
69
+ return `forge loop: item ${evt.itemId ?? "?"} blocked — ${evt.reason ?? "no reason given"}`;
70
+ case "llm_stuck_warning":
71
+ return `forge loop: item ${evt.itemId ?? "?"} looks stuck (no output for ${Math.round((evt.silentMs ?? 0) / 1000)}s)`;
72
+ case "review_failed":
73
+ return `forge loop: review failed — ${evt.reason ?? "no reason given"}`;
74
+ case "loop_error":
75
+ return `forge loop: the run errored — ${evt.reason ?? "see the runner log"}`;
76
+ case "loop_cancelled":
77
+ return "forge loop: the run was cancelled";
78
+ case "loop_completed": {
79
+ const done = evt.completedCount ?? 0;
80
+ const blocked = evt.blockedCount ?? 0;
81
+ const needsHuman = evt.needsHumanCount ?? 0;
82
+ const parts = [`${done} done`];
83
+ if (blocked) parts.push(`${blocked} blocked`);
84
+ if (needsHuman) parts.push(`${needsHuman} need a human`);
85
+ return `forge loop: run complete — ${parts.join(", ")}. Read the authoritative counts (status --json) and run the post-run close-out.`;
86
+ }
87
+ default:
88
+ return `forge loop: ${evt.type}`;
89
+ }
90
+ }
@@ -0,0 +1,114 @@
1
+ // GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/index.ts
2
+ // Regenerate with: python3 scripts/build-adapters.py
3
+ /**
4
+ * forge-loop-supervisor — a first-party Pi extension that lets forge-5-loop run
5
+ * the rauf autonomous coding loop WITHOUT blocking the Pi session, and supervises
6
+ * its native event stream.
7
+ *
8
+ * Pi has no built-in background bash, persistent monitor, or push-notification
9
+ * surface, so the generic Claude-first forge-5-loop contract (background the
10
+ * process, arm a `Monitor` on events.ndjson, `PushNotification` on exceptions)
11
+ * cannot be followed literally on Pi. This extension provides the real mechanism:
12
+ * `forge_loop_launch` starts the runner detached (it runs in rauf's server and
13
+ * outlives the session), then a rotation-aware NDJSON watcher turns each
14
+ * `item_completed` into a quiet progress line and wakes the session — via
15
+ * `pi.sendMessage(..., { triggerTurn: true })` — only on needs-human / blocked /
16
+ * stuck / review-failed / error / completion. `forge_loop_status` and
17
+ * `forge_loop_stop` round out the launch/attach/status/stop surface. Task
18
+ * identity is persisted (session entry + a file mirror beside the runner state)
19
+ * so a restarted session reattaches without duplicate reporting, and shutdown
20
+ * tears down watchers only — never the runner.
21
+ *
22
+ * The pi-facing glue is thin: all logic lives in the injectable {@link
23
+ * createExtension} factory (see wiring.ts), so the extension is unit-tested with
24
+ * a fake pi and fake deps. This file supplies the production dependencies.
25
+ */
26
+
27
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
28
+ import { spawn } from "node:child_process";
29
+ import { watch as fsWatch } from "node:fs";
30
+ import { basename, dirname } from "node:path";
31
+
32
+ import { createExtension, type Deps, type PiLike, type WatchHandle } from "./wiring.js";
33
+
34
+ /** How often the backstop poll fires (ms). fs.watch can miss events or drop on
35
+ * some platforms; a low-frequency interval guarantees the tail keeps up without
36
+ * busy-waiting. */
37
+ const BACKSTOP_MS = 2000;
38
+ /** Debounce window (ms) coalescing a burst of fs.watch events into one poll. */
39
+ const DEBOUNCE_MS = 120;
40
+
41
+ const productionDeps: Deps = {
42
+ spawnDetached(bin, args, cwd, onError) {
43
+ // detached + unref + ignored stdio: the child is fully decoupled from the
44
+ // Pi process and survives session shutdown. rauf hands the loop to its
45
+ // server and this launcher exits quickly; a non-self-detaching runner still
46
+ // stays off the session.
47
+ const child = spawn(bin, args, { cwd, detached: true, stdio: "ignore" });
48
+ // ENOENT (bad bin) and other spawn failures arrive here asynchronously —
49
+ // report them so a launch that never happened does not look successful.
50
+ child.on("error", (err) => onError?.(err instanceof Error ? err.message : String(err)));
51
+ child.unref();
52
+ },
53
+ watch(filePath, onChange): WatchHandle {
54
+ const dir = dirname(filePath);
55
+ const base = basename(filePath);
56
+ let debounce: NodeJS.Timeout | null = null;
57
+ const fire = () => {
58
+ if (debounce) return;
59
+ debounce = setTimeout(() => {
60
+ debounce = null;
61
+ onChange();
62
+ }, DEBOUNCE_MS);
63
+ };
64
+ let fsw: ReturnType<typeof fsWatch> | null = null;
65
+ try {
66
+ // Watch the DIRECTORY (not the file) so rotation-by-rename — rauf moves
67
+ // events.ndjson into archive/ and recreates it each run — keeps firing.
68
+ fsw = fsWatch(dir, (_evt, name) => {
69
+ if (!name || name === base) fire();
70
+ });
71
+ fsw.on?.("error", () => {
72
+ /* directory vanished; the backstop interval keeps polling */
73
+ });
74
+ } catch {
75
+ fsw = null;
76
+ }
77
+ const interval = setInterval(onChange, BACKSTOP_MS);
78
+ return {
79
+ close() {
80
+ try {
81
+ fsw?.close();
82
+ } catch {
83
+ /* ignore */
84
+ }
85
+ if (debounce) clearTimeout(debounce);
86
+ clearInterval(interval);
87
+ },
88
+ };
89
+ },
90
+ now: () => new Date().toISOString(),
91
+ };
92
+
93
+ export default function (pi: ExtensionAPI) {
94
+ // Adapt the real ExtensionAPI to the structural PiLike the wiring consumes.
95
+ // Each member is accessed by name off the concrete `pi`, so a method renamed
96
+ // or removed in a future pi is a COMPILE error here (not a silent runtime
97
+ // failure hidden behind a blanket cast) — while PiLike stays loose enough for
98
+ // the fake pi in tests. Argument casts bridge PiLike's minimal shapes to pi's
99
+ // stricter generic signatures; the pi-API contract this was built against
100
+ // pins the exact shapes.
101
+ const piLike: PiLike = {
102
+ registerTool: (def) => (pi.registerTool as (d: unknown) => void)(def),
103
+ on: (event, handler) =>
104
+ (pi.on as unknown as (e: string, h: (ev: unknown, ctx: unknown) => void | Promise<void>) => void)(event, handler),
105
+ sendMessage: (message, options) =>
106
+ pi.sendMessage(message as Parameters<typeof pi.sendMessage>[0], options),
107
+ appendEntry: (customType, data) => pi.appendEntry(customType, data),
108
+ exec:
109
+ typeof pi.exec === "function"
110
+ ? (command, args, opts) => pi.exec(command, args, opts)
111
+ : undefined,
112
+ };
113
+ createExtension(piLike, productionDeps);
114
+ }
@@ -0,0 +1,137 @@
1
+ // GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/registry.ts
2
+ // Regenerate with: python3 scripts/build-adapters.py
3
+ /**
4
+ * Durable task registry — a small JSON mirror of the supervised task, written
5
+ * beside the runner's own state so a brand-new Pi session (or a full pi restart,
6
+ * where the session file itself differs) can rediscover a loop it did not launch.
7
+ *
8
+ * Why a file and not only `pi.appendEntry`: appendEntry records survive session
9
+ * reload/branch within pi, but a cold restart may open a different session file.
10
+ * The mirror lives at `<stateDir>/.forge-supervisor.json`, next to rauf's own
11
+ * `state.json` / `events.ndjson`, so it is discoverable from the backlog dir
12
+ * alone. rauf's detached loop is server-managed (no single child pid to signal),
13
+ * so liveness is judged from the event stream and `rauf status --json`, never a
14
+ * tracked pid — the mirror deliberately stores no pid.
15
+ */
16
+
17
+ import {
18
+ existsSync,
19
+ mkdirSync,
20
+ readdirSync,
21
+ readFileSync,
22
+ renameSync,
23
+ rmSync,
24
+ statSync,
25
+ writeFileSync,
26
+ } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+
29
+ import type { SupervisorTask } from "./types.js";
30
+
31
+ /** The mirror filename, discoverable by scanning (see {@link discoverMirrors}). */
32
+ export const MIRROR_NAME = ".forge-supervisor.json";
33
+
34
+ /** Directory names never worth descending into during a mirror scan. */
35
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "archive", ".pi", ".claude"]);
36
+ /** How deep the scan descends from the project root — mirrors live at
37
+ * `<backlogDir>/.rauf/.forge-supervisor.json`, so a shallow bound reaches the
38
+ * common `specs/<feature>/.rauf/` layout without walking the whole tree. */
39
+ const SCAN_MAX_DEPTH = 5;
40
+
41
+ /** Path of the registry mirror for a runner state directory. */
42
+ export function mirrorPath(stateDir: string): string {
43
+ return join(stateDir, MIRROR_NAME);
44
+ }
45
+
46
+ /** Read the mirrored task for a state dir, or null if absent/unreadable/corrupt.
47
+ * A corrupt mirror is treated as "no task" rather than throwing — a half-written
48
+ * file must never wedge session startup. */
49
+ export function readMirror(stateDir: string): SupervisorTask | null {
50
+ const path = mirrorPath(stateDir);
51
+ if (!existsSync(path)) return null;
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<SupervisorTask>;
54
+ if (
55
+ parsed &&
56
+ typeof parsed.stateDir === "string" &&
57
+ typeof parsed.eventsFile === "string" &&
58
+ typeof parsed.backlogDir === "string"
59
+ ) {
60
+ return {
61
+ backlogDir: parsed.backlogDir,
62
+ stateDir: parsed.stateDir,
63
+ eventsFile: parsed.eventsFile,
64
+ launchedAt: typeof parsed.launchedAt === "string" ? parsed.launchedAt : "",
65
+ total: typeof parsed.total === "number" ? parsed.total : undefined,
66
+ eventsIno: typeof parsed.eventsIno === "number" ? parsed.eventsIno : undefined,
67
+ lastSeq: typeof parsed.lastSeq === "number" ? parsed.lastSeq : -1,
68
+ closed: parsed.closed === true,
69
+ };
70
+ }
71
+ } catch {
72
+ // fall through — corrupt mirror is "no task"
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** Persist the task mirror atomically (write-temp + rename), creating the state
78
+ * dir if the launcher raced ahead of the runner. */
79
+ export function writeMirror(task: SupervisorTask): void {
80
+ const path = mirrorPath(task.stateDir);
81
+ try {
82
+ // Stamp the CURRENT events-file inode so a later session can tell whether the
83
+ // file was rotated (a new run) while it was away — see SupervisorTask.eventsIno.
84
+ let eventsIno = task.eventsIno;
85
+ try {
86
+ eventsIno = statSync(task.eventsFile).ino;
87
+ } catch {
88
+ // events file not present yet — keep whatever the task carried (if any)
89
+ }
90
+ const record: SupervisorTask = { ...task, eventsIno };
91
+ mkdirSync(dirname(path), { recursive: true });
92
+ const tmp = `${path}.tmp-${process.pid}`;
93
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, "utf8");
94
+ renameSync(tmp, path);
95
+ } catch {
96
+ // Best-effort durability; a failed mirror write must not break the tool.
97
+ }
98
+ }
99
+
100
+ /** Remove the mirror (on an explicit stop of a task the session owns). */
101
+ export function clearMirror(stateDir: string): void {
102
+ try {
103
+ rmSync(mirrorPath(stateDir), { force: true });
104
+ } catch {
105
+ // best-effort
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Find every supervised-task mirror under `root`, so a BRAND-NEW Pi session (a
111
+ * fresh session file with no `forge-loop-task` entry of its own) can still
112
+ * rediscover and reattach to a loop a previous session launched. Bounded, cheap,
113
+ * and failure-tolerant: skips heavy directories, caps depth, and treats any
114
+ * unreadable dir or corrupt mirror as absent rather than throwing.
115
+ */
116
+ export function discoverMirrors(root: string): SupervisorTask[] {
117
+ const found: SupervisorTask[] = [];
118
+ const walk = (dir: string, depth: number): void => {
119
+ if (depth > SCAN_MAX_DEPTH) return;
120
+ let entries: import("node:fs").Dirent[];
121
+ try {
122
+ entries = readdirSync(dir, { withFileTypes: true });
123
+ } catch {
124
+ return;
125
+ }
126
+ for (const entry of entries) {
127
+ if (entry.isFile() && entry.name === MIRROR_NAME) {
128
+ const task = readMirror(dir); // mirror lives AT <stateDir>/<MIRROR_NAME>
129
+ if (task) found.push(task);
130
+ } else if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
131
+ walk(join(dir, entry.name), depth + 1);
132
+ }
133
+ }
134
+ };
135
+ walk(root, 0);
136
+ return found;
137
+ }
@@ -0,0 +1,185 @@
1
+ // GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/supervisor.ts
2
+ // Regenerate with: python3 scripts/build-adapters.py
3
+ /**
4
+ * LoopSupervisor — the host-agnostic core that turns a stream of rauf events
5
+ * into the right user-facing behavior, with exactly-once reporting across
6
+ * session restarts.
7
+ *
8
+ * Reporting rule (issue #236):
9
+ * - routine `item_completed` → one quiet deterministic line, no model turn;
10
+ * - exception (`needs_human` / block / stuck / review-failed / error /
11
+ * cancellation) → notify AND wake the session;
12
+ * - terminal (`loop_completed` / error / cancelled) → wake the session so the
13
+ * loop stage runs its post-run close-out, then stop watching.
14
+ *
15
+ * Dedup + reattach: every rauf record carries a monotonic per-run `seq`. A task
16
+ * persists the highest `seq` it has already surfaced (`lastSeq`). When a fresh
17
+ * Pi session reattaches, its tailer re-reads events.ndjson from the top; records
18
+ * with `seq <= lastSeq` are REPLAYED SILENTLY — they still rebuild the in-memory
19
+ * `done` counter, but they are never re-notified — while records past `lastSeq`
20
+ * are surfaced live. That is what lets a restart reconcile a running loop without
21
+ * duplicate reports.
22
+ *
23
+ * Duplicate watchers are prevented by keying active tasks on their stateDir: a
24
+ * second `attach` for the same stateDir returns the existing handle.
25
+ */
26
+
27
+ import { classifyEvent, formatProgress, formatSignal } from "./events.js";
28
+ import type { RaufEvent, SupervisorHost, SupervisorTask } from "./types.js";
29
+
30
+ /** A live watch handle the host drives: `poll()` on file change, `close()` on
31
+ * teardown. Reattach-safe and idempotent. */
32
+ export interface TaskHandle {
33
+ poll(): void;
34
+ close(): void;
35
+ readonly stateDir: string;
36
+ }
37
+
38
+ interface ActiveTask {
39
+ task: SupervisorTask;
40
+ /** Running count of completed items, rebuilt from replayed history. */
41
+ done: number;
42
+ /** Highest item_completed seq already counted into `done` — guards the counter
43
+ * against a re-read double-count independently of the notify cursor. */
44
+ doneCursor: number;
45
+ /** Total backlog items, when the launcher recorded it (`task.total`). */
46
+ total?: number;
47
+ closed: boolean;
48
+ }
49
+
50
+ export class LoopSupervisor {
51
+ private readonly active = new Map<string, ActiveTask>();
52
+
53
+ constructor(private readonly host: SupervisorHost) {}
54
+
55
+ /** Whether a task for this stateDir is already being supervised. */
56
+ isActive(stateDir: string): boolean {
57
+ return this.active.has(stateDir);
58
+ }
59
+
60
+ /** Snapshot of the current progress for a task (for the status tool). */
61
+ progress(stateDir: string): { done: number; total?: number; closed: boolean } | null {
62
+ const a = this.active.get(stateDir);
63
+ return a ? { done: a.done, total: a.total, closed: a.closed } : null;
64
+ }
65
+
66
+ /**
67
+ * Begin (or reattach) supervision of one task. The returned handle's `poll`
68
+ * feeds new NDJSON records through {@link handleRecord}; the host is expected
69
+ * to call it on file-change and once immediately (to replay history). A
70
+ * second attach for the same stateDir is a no-op that returns the live handle
71
+ * — the dedup guard against duplicate watchers.
72
+ *
73
+ * @param makeReader Builds a reader (typically an NdjsonTailer bound to
74
+ * `task.eventsFile`) whose `poll` dispatches each parsed record to `onRecord`.
75
+ */
76
+ attach(
77
+ task: SupervisorTask,
78
+ makeReader: (
79
+ onRecord: (rec: RaufEvent) => void,
80
+ onRotate: () => void,
81
+ ) => { poll(): void },
82
+ ): TaskHandle {
83
+ const existing = this.active.get(task.stateDir);
84
+ if (existing) return this.handleFor(task.stateDir);
85
+
86
+ const entry: ActiveTask = {
87
+ task: { ...task },
88
+ done: 0,
89
+ doneCursor: -1,
90
+ total: task.total,
91
+ closed: task.closed,
92
+ };
93
+ this.active.set(task.stateDir, entry);
94
+ const reader = makeReader(
95
+ (rec) => this.handleRecord(task.stateDir, rec),
96
+ () => this.handleRotate(task.stateDir),
97
+ );
98
+ return {
99
+ stateDir: task.stateDir,
100
+ poll: () => reader.poll(),
101
+ close: () => this.detach(task.stateDir),
102
+ };
103
+ }
104
+
105
+ /** Stop tracking a task in memory (watcher teardown / stop). Does NOT touch
106
+ * the detached runner — the loop is server-owned and outlives the session. */
107
+ detach(stateDir: string): void {
108
+ this.active.delete(stateDir);
109
+ }
110
+
111
+ /** Process one parsed rauf record for a task: dedup, classify, dispatch. */
112
+ private handleRecord(stateDir: string, rec: RaufEvent): void {
113
+ const entry = this.active.get(stateDir);
114
+ if (!entry) return;
115
+ const seq = typeof rec.seq === "number" ? rec.seq : null;
116
+ const alreadySeen = seq !== null && seq <= entry.task.lastSeq;
117
+ const cls = classifyEvent(rec);
118
+
119
+ // Count completed items for the [N/M] line. Guard against double-counting a
120
+ // replayed item: only count when its seq is past the counter's cursor. On a
121
+ // fresh attach the cursor is -1 so the whole history counts; on a re-read of
122
+ // the same run (a spurious re-poll) already-counted items are skipped. A
123
+ // genuine new run arrives via handleRotate, which resets the cursor first.
124
+ if (rec.type === "item_completed") {
125
+ if (seq === null || seq > entry.doneCursor) {
126
+ entry.done += 1;
127
+ if (seq !== null) entry.doneCursor = seq;
128
+ }
129
+ }
130
+
131
+ if (alreadySeen) return; // replayed history — rebuilt state, never re-notify
132
+
133
+ let surfaced = true;
134
+ switch (cls) {
135
+ case "progress":
136
+ this.host.notify(formatProgress(rec, entry.done, entry.total), "info");
137
+ break;
138
+ case "exception":
139
+ this.host.notify(formatSignal(rec), "warning");
140
+ this.host.wake(formatSignal(rec));
141
+ break;
142
+ case "terminal":
143
+ this.host.notify(formatSignal(rec), "info");
144
+ this.host.wake(formatSignal(rec));
145
+ entry.closed = true;
146
+ entry.task.closed = true;
147
+ break;
148
+ case "ignore":
149
+ surfaced = false;
150
+ break;
151
+ }
152
+
153
+ // Advance the cursor and persist ONLY for a surfaced event. A firehose
154
+ // (`ignore`) event must not trigger a disk write + session entry per record
155
+ // (rauf emits many per second); ignored events re-classify as `ignore` on
156
+ // any later replay, so not persisting their seq is harmless. A terminal
157
+ // event persists `closed`, letting a later session know the run is done.
158
+ if (surfaced && seq !== null && seq > entry.task.lastSeq) {
159
+ entry.task.lastSeq = seq;
160
+ this.host.persist({ ...entry.task });
161
+ }
162
+ }
163
+
164
+ /** A rotation was detected (rauf started a new run — event `seq` restarts at
165
+ * 0). Reset the per-run cursor and counters so the new run is surfaced from
166
+ * its start instead of being swallowed by the previous run's high-water seq. */
167
+ private handleRotate(stateDir: string): void {
168
+ const entry = this.active.get(stateDir);
169
+ if (!entry) return;
170
+ entry.task.lastSeq = -1;
171
+ entry.doneCursor = -1;
172
+ entry.done = 0;
173
+ entry.closed = false;
174
+ entry.task.closed = false;
175
+ this.host.persist({ ...entry.task });
176
+ }
177
+
178
+ private handleFor(stateDir: string): TaskHandle {
179
+ return {
180
+ stateDir,
181
+ poll: () => {},
182
+ close: () => this.detach(stateDir),
183
+ };
184
+ }
185
+ }