@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
@@ -0,0 +1,137 @@
1
+ // GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/tailer.ts
2
+ // Regenerate with: python3 scripts/build-adapters.py
3
+ /**
4
+ * NdjsonTailer — a rotation-aware, malformed-tolerant tail of an append-only
5
+ * NDJSON file, decoupled from any watch mechanism so it is unit-testable by
6
+ * driving {@link NdjsonTailer.poll} against a temp file.
7
+ *
8
+ * rauf is the single writer of `events.ndjson` and rotates it at the START of
9
+ * each run — the old file is moved to `<stateDir>/archive/{ts}-events.ndjson`
10
+ * and a fresh empty file takes its place (rauf `packages/core/src/events-log.ts`).
11
+ * So a live supervisor must survive the file being replaced by name mid-watch.
12
+ * Two independent signals catch a rotation: the file's inode changing, and its
13
+ * size shrinking below our read cursor. Either resets the cursor to 0 and
14
+ * re-reads from the top of the new file.
15
+ *
16
+ * Reads are incremental from a byte offset (never re-reading the whole file), a
17
+ * trailing partial line with no newline yet is buffered until its newline
18
+ * arrives, and a line that is not valid JSON is skipped rather than throwing —
19
+ * a half-flushed or corrupt record must never stall the tail.
20
+ */
21
+
22
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
23
+ import { StringDecoder } from "node:string_decoder";
24
+
25
+ import type { RaufEvent } from "./types.js";
26
+
27
+ export class NdjsonTailer {
28
+ private offset = 0;
29
+ private ino: number | null = null;
30
+ private partial = "";
31
+ // A StringDecoder holds back an incomplete trailing multibyte sequence between
32
+ // reads, so a UTF-8 character split across two polls (e.g. an accented char or
33
+ // emoji in an event's `reason`/`title`) decodes correctly instead of turning
34
+ // into replacement chars on each half.
35
+ private decoder = new StringDecoder("utf8");
36
+
37
+ /**
38
+ * @param filePath Absolute path to the NDJSON file (may not exist yet).
39
+ * @param onRecord Called once per successfully-parsed JSON line, in order.
40
+ * @param onError Optional; called with a short reason when a line is
41
+ * skipped as malformed (for observability/tests).
42
+ * @param onRotate Optional; called when a rotation is detected (a new inode,
43
+ * or the file shrank below the cursor) — the signal a NEW run
44
+ * has started, so the consumer can reset per-run state (rauf's
45
+ * event `seq` restarts at 0 each run).
46
+ * @param initialIno Optional; the inode the file had when the consumer last
47
+ * watched it (from the persisted mirror). Seeding it lets the
48
+ * FIRST poll on reattach detect a rotation that happened while
49
+ * no session was watching — the current file having a
50
+ * different inode fires `onRotate`, so a stale cursor from a
51
+ * previous run does not silently swallow the new run.
52
+ */
53
+ constructor(
54
+ private readonly filePath: string,
55
+ private readonly onRecord: (rec: RaufEvent) => void,
56
+ private readonly onError?: (reason: string) => void,
57
+ private readonly onRotate?: () => void,
58
+ initialIno?: number,
59
+ ) {
60
+ this.ino = typeof initialIno === "number" ? initialIno : null;
61
+ }
62
+
63
+ /**
64
+ * Read whatever is new since the last poll and dispatch each complete record.
65
+ * Idempotent and cheap when nothing changed. Safe to call before the file
66
+ * exists (no-op) and across a rotation (resets and re-reads the new file).
67
+ */
68
+ poll(): void {
69
+ let size: number;
70
+ let ino: number;
71
+ try {
72
+ const st = statSync(this.filePath);
73
+ size = st.size;
74
+ ino = st.ino;
75
+ } catch {
76
+ // File gone (pre-launch, or mid-rotation between unlink and recreate).
77
+ // Reset so the next existing file is read from its start.
78
+ this.offset = 0;
79
+ this.ino = null;
80
+ this.partial = "";
81
+ return;
82
+ }
83
+
84
+ // Rotation: a new inode, or the file shrank below our cursor (truncate /
85
+ // replace). Re-read from the top of whatever file is there now, reset the
86
+ // multibyte decoder, and signal the consumer so it can reset per-run state
87
+ // (rauf restarts `seq` at 0 each run — without this a stale cursor from a
88
+ // previous run would silently swallow the whole new run). This only fires
89
+ // on a genuine rotation: the first poll has ino === null, so neither branch
90
+ // trips on initial attach.
91
+ if ((this.ino !== null && ino !== this.ino) || size < this.offset) {
92
+ this.offset = 0;
93
+ this.partial = "";
94
+ this.decoder = new StringDecoder("utf8");
95
+ this.onRotate?.();
96
+ }
97
+ this.ino = ino;
98
+
99
+ if (size <= this.offset) return; // nothing new
100
+
101
+ let chunk: string;
102
+ let fd: number | null = null;
103
+ try {
104
+ fd = openSync(this.filePath, "r");
105
+ const length = size - this.offset;
106
+ const buf = Buffer.allocUnsafe(length);
107
+ const read = readSync(fd, buf, 0, length, this.offset);
108
+ // Decode through the StringDecoder so a multibyte char straddling this
109
+ // read's end is held back until its continuation bytes arrive next poll.
110
+ chunk = this.decoder.write(buf.subarray(0, read));
111
+ this.offset += read;
112
+ } catch {
113
+ return; // transient read failure; try again next poll
114
+ } finally {
115
+ if (fd !== null) closeSync(fd);
116
+ }
117
+
118
+ this.partial += chunk;
119
+ const lines = this.partial.split("\n");
120
+ // The last element is an incomplete trailing line (empty when the chunk
121
+ // ended on a newline) — hold it until its newline arrives.
122
+ this.partial = lines.pop() ?? "";
123
+
124
+ for (const line of lines) {
125
+ const trimmed = line.trim();
126
+ if (!trimmed) continue;
127
+ let rec: RaufEvent;
128
+ try {
129
+ rec = JSON.parse(trimmed) as RaufEvent;
130
+ } catch {
131
+ this.onError?.(`skipped malformed NDJSON line (${trimmed.length} chars)`);
132
+ continue;
133
+ }
134
+ if (rec && typeof rec === "object") this.onRecord(rec);
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,87 @@
1
+ // GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/types.ts
2
+ // Regenerate with: python3 scripts/build-adapters.py
3
+ /**
4
+ * Shared types for the forge-loop-supervisor Pi extension.
5
+ *
6
+ * The extension launches a rauf loop in rauf's own detached/server-managed mode
7
+ * (`rauf loop run . --backlog <dir> --detached`), which returns immediately and
8
+ * leaves the loop running inside rauf's server daemon — so the loop outlives the
9
+ * Pi session by design. The supervisor then watches rauf's native, single-writer
10
+ * event stream (`<stateDir>/events.ndjson`), turning routine milestones into
11
+ * quiet deterministic progress and waking the session only on the events that
12
+ * need a human or the loop's own close-out.
13
+ *
14
+ * These types are host-agnostic on purpose: the core (classification, the NDJSON
15
+ * tailer, the task registry) takes a {@link SupervisorHost} so it can be unit
16
+ * tested with a fake host and a temp file, exactly as the vendored
17
+ * ask-user-question extension decouples its questionnaire from the live TUI.
18
+ */
19
+
20
+ /** A rauf persisted loop event. Only the fields this extension reads are typed;
21
+ * every record also carries `timestamp`, `projectPath`, and a per-run `seq`
22
+ * (rauf `packages/core/src/schemas.ts`). Unknown/extra fields are preserved. */
23
+ export interface RaufEvent {
24
+ type: string;
25
+ seq?: number;
26
+ timestamp?: string;
27
+ itemId?: string;
28
+ title?: string;
29
+ reason?: string;
30
+ silentMs?: number;
31
+ completedCount?: number;
32
+ blockedCount?: number;
33
+ needsHumanCount?: number;
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ /** How the supervisor treats one event.
38
+ * - `progress`: a routine per-item milestone → one quiet deterministic line, NO
39
+ * model turn.
40
+ * - `exception`: something a human should see now (needs-human, block, stuck,
41
+ * review failure, error, cancellation) → notify AND wake the session.
42
+ * - `terminal`: the run finished → wake the session so the loop stage resumes
43
+ * its post-run steps.
44
+ * - `ignore`: firehose/interior events the supervisor does not surface. */
45
+ export type EventClass = "progress" | "exception" | "terminal" | "ignore";
46
+
47
+ /** Durable identity of one supervised loop, persisted so a later Pi session can
48
+ * reattach to a still-running (or already-finished) loop without relaunching. */
49
+ export interface SupervisorTask {
50
+ /** Backlog directory passed to `--backlog` (the forge {backlogDir}). */
51
+ backlogDir: string;
52
+ /** The runner state directory holding events.ndjson (default `<backlogDir>/.rauf`). */
53
+ stateDir: string;
54
+ /** Absolute path to the watched event file (`<stateDir>/events.ndjson`). */
55
+ eventsFile: string;
56
+ /** Inode of `eventsFile` when the mirror was last written. On reattach the
57
+ * tailer seeds its rotation check from this: a different inode means the file
58
+ * was rotated (a NEW run — rauf renames the old file to archive/ and recreates
59
+ * it) while no session was watching, so the per-run cursor must reset instead
60
+ * of stale-swallowing the new run. Absent on a fresh launch. */
61
+ eventsIno?: number;
62
+ /** ISO timestamp of the launch (for display + staleness reasoning). */
63
+ launchedAt: string;
64
+ /** Total backlog items at launch, when known — enables the `[N/M]` progress
65
+ * line. Absent when the launcher could not count the backlog. */
66
+ total?: number;
67
+ /** Highest event `seq` already surfaced — the dedup cursor across reattach. */
68
+ lastSeq: number;
69
+ /** Whether the supervisor has already surfaced a terminal event for this task. */
70
+ closed: boolean;
71
+ }
72
+
73
+ /** The narrow surface the pure core needs from its host (pi, or a test stub).
74
+ * Everything the supervisor does to the outside world goes through this, so the
75
+ * core carries no pi import and is unit-testable. */
76
+ export interface SupervisorHost {
77
+ /** A non-turn, user-visible line (routine progress + startup/info). */
78
+ notify(message: string, level: "info" | "warning" | "error"): void;
79
+ /** Wake the active session with a message that triggers a model turn when
80
+ * idle (pi `sendMessage(..., { triggerTurn: true })`). Used ONLY for
81
+ * exception and terminal events — never routine progress. */
82
+ wake(message: string): void;
83
+ /** Persist the task record durably (pi `appendEntry` + a file mirror). */
84
+ persist(task: SupervisorTask): void;
85
+ /** Current time as ISO-8601, injectable for deterministic tests. */
86
+ now(): string;
87
+ }
@@ -0,0 +1,423 @@
1
+ // GENERATED — DO NOT EDIT. Source: adapter-src/pi/extensions/forge-loop-supervisor/wiring.ts
2
+ // Regenerate with: python3 scripts/build-adapters.py
3
+ /**
4
+ * Pi wiring for the forge-loop-supervisor — builds the host, the three tools
5
+ * (launch / status / stop) and the session-lifecycle hooks on a given
6
+ * `ExtensionAPI`. Every side-effecting dependency (process spawn, file watch,
7
+ * clock) is injected so the whole extension is unit-testable with a fake pi and
8
+ * fake deps, mirroring how the vendored ask-user-question extension is driven
9
+ * headlessly in its own test.
10
+ *
11
+ * pi surface used (resolved against the installed pi types, stable across the
12
+ * pinned 0.81.x): `pi.registerTool`, `pi.on("session_start"|"session_shutdown")`,
13
+ * `pi.sendMessage(msg, { triggerTurn })` (wake the session — the file-trigger.ts
14
+ * pattern), `pi.appendEntry(type, data)` (durable task record), `pi.exec` (run
15
+ * `rauf status --json` / `rauf loop stop`), and `ctx.ui.notify` (quiet toasts).
16
+ * rauf's `--detached` loop is server-owned and outlives the session, so cleanup
17
+ * on shutdown tears down watchers only — never the runner.
18
+ */
19
+
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+
23
+ import { Type } from "typebox";
24
+
25
+ import { clearMirror, discoverMirrors, readMirror, writeMirror } from "./registry.js";
26
+ import { LoopSupervisor, type TaskHandle } from "./supervisor.js";
27
+ import { NdjsonTailer } from "./tailer.js";
28
+ import type { SupervisorHost, SupervisorTask } from "./types.js";
29
+
30
+ /** The custom-entry type under which task identity is persisted into the pi
31
+ * session (read back on `session_start` for reattach). */
32
+ export const TASK_ENTRY_TYPE = "forge-loop-task";
33
+ /** The custom-message type used to wake the session on exception/terminal. */
34
+ export const WAKE_MESSAGE_TYPE = "forge-loop";
35
+
36
+ /** A live poll trigger for one watched file: `close()` stops it. The real
37
+ * factory wires `fs.watch` on the containing directory (so rotation-by-rename
38
+ * is caught) plus a low-frequency backstop; a test injects a manual trigger. */
39
+ export interface WatchHandle {
40
+ close(): void;
41
+ }
42
+
43
+ /** Injectable side effects. Production values live in index.ts. */
44
+ export interface Deps {
45
+ /** Launch the runner DETACHED so it outlives the session. Returns nothing —
46
+ * the loop is owned by the runner's server, not by a tracked child. `onError`
47
+ * reports an ASYNCHRONOUS spawn failure (e.g. ENOENT for a bad bin, which
48
+ * Node surfaces on the child's 'error' event, not as a throw). */
49
+ spawnDetached(bin: string, args: string[], cwd: string, onError?: (message: string) => void): void;
50
+ /** Watch `filePath` for changes, calling `onChange` on each (real: fs.watch
51
+ * on its directory + backstop interval). */
52
+ watch(filePath: string, onChange: () => void): WatchHandle;
53
+ /** Current time as ISO-8601. */
54
+ now(): string;
55
+ }
56
+
57
+ /** Minimal shape of the pi API this wiring needs (kept structural so the fake pi
58
+ * in tests satisfies it without importing pi). */
59
+ export interface PiLike {
60
+ registerTool(def: unknown): void;
61
+ on(event: string, handler: (event: unknown, ctx: unknown) => void | Promise<void>): void;
62
+ sendMessage(
63
+ message: { customType: string; content: string; display?: boolean },
64
+ options?: { triggerTurn?: boolean },
65
+ ): void;
66
+ appendEntry(customType: string, data?: unknown): void;
67
+ exec?(
68
+ command: string,
69
+ args: string[],
70
+ options?: { cwd?: string; timeout?: number },
71
+ ): Promise<{ stdout: string; stderr: string; code: number }>;
72
+ }
73
+
74
+ interface UiLike {
75
+ notify?(message: string, level?: "info" | "warning" | "error"): void;
76
+ }
77
+ interface CtxLike {
78
+ cwd?: string;
79
+ hasUI?: boolean;
80
+ ui?: UiLike;
81
+ sessionManager?: { getEntries?(): Array<{ type?: string; customType?: string; data?: unknown }> };
82
+ }
83
+
84
+ function textResult(text: string, details: Record<string, unknown>) {
85
+ return { content: [{ type: "text" as const, text }], details };
86
+ }
87
+
88
+ /** Best-effort count of backlog items for the `[N/M]` progress line. */
89
+ function countBacklog(cwd: string, backlogDir: string): number | undefined {
90
+ for (const candidate of [join(cwd, backlogDir, "backlog.json"), join(backlogDir, "backlog.json")]) {
91
+ try {
92
+ if (!existsSync(candidate)) continue;
93
+ const parsed = JSON.parse(readFileSync(candidate, "utf8")) as { items?: unknown[] };
94
+ if (Array.isArray(parsed.items)) return parsed.items.length;
95
+ } catch {
96
+ // unreadable/unparseable backlog → no total, not an error
97
+ }
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ /** Resolve `dir` against `cwd` unless it is already absolute. */
103
+ function resolveDir(cwd: string, dir: string): string {
104
+ return dir.startsWith("/") ? dir : join(cwd, dir);
105
+ }
106
+
107
+ /**
108
+ * Register the supervisor's tools and lifecycle hooks on `pi`. Returns a small
109
+ * control object (used by tests) exposing the live supervisor and watcher map.
110
+ */
111
+ export function createExtension(pi: PiLike, deps: Deps) {
112
+ const watchers = new Map<string, { handle: TaskHandle; watch: WatchHandle; backlogDir: string }>();
113
+ let ui: UiLike | null = null;
114
+
115
+ const host: SupervisorHost = {
116
+ notify(message, level) {
117
+ try {
118
+ ui?.notify?.(message, level);
119
+ } catch {
120
+ /* headless / no dialog UI */
121
+ }
122
+ },
123
+ wake(message) {
124
+ try {
125
+ pi.sendMessage({ customType: WAKE_MESSAGE_TYPE, content: message, display: true }, { triggerTurn: true });
126
+ } catch {
127
+ /* sendMessage unavailable — nothing else to do */
128
+ }
129
+ },
130
+ persist(task) {
131
+ try {
132
+ pi.appendEntry(TASK_ENTRY_TYPE, task);
133
+ } catch {
134
+ /* appendEntry best-effort */
135
+ }
136
+ writeMirror(task);
137
+ },
138
+ now: () => deps.now(),
139
+ };
140
+
141
+ const supervisor = new LoopSupervisor(host);
142
+
143
+ /** Keep the notify target current from whatever ctx we last saw (tool call or
144
+ * session_start). ctx can go stale across a session replacement, so we always
145
+ * prefer the most recent one. */
146
+ function refreshUi(ctx: CtxLike): void {
147
+ if (ctx?.ui) ui = ctx.ui;
148
+ }
149
+
150
+ /** Begin watching a task's event file. Idempotent per stateDir (the dedup
151
+ * guard against duplicate watchers). Does an initial poll to replay history. */
152
+ function startWatch(task: SupervisorTask): void {
153
+ if (watchers.has(task.stateDir)) return;
154
+ const handle = supervisor.attach(task, (onRecord, onRotate) =>
155
+ new NdjsonTailer(task.eventsFile, onRecord, undefined, onRotate, task.eventsIno),
156
+ );
157
+ // Poll, then tear the watcher down once a terminal event has closed the task
158
+ // (its wake was already sent inside poll). This stops the fs.watch + backstop
159
+ // interval for a finished loop and frees a relaunch on the same backlog — the
160
+ // detached runner is untouched, exactly as on session end.
161
+ const pump = () => {
162
+ handle.poll();
163
+ if (supervisor.progress(task.stateDir)?.closed) stopWatch(task.stateDir);
164
+ };
165
+ const watch = deps.watch(task.eventsFile, pump);
166
+ watchers.set(task.stateDir, { handle, watch, backlogDir: task.backlogDir });
167
+ pump();
168
+ }
169
+
170
+ /** Stop watching a task (teardown / stop) WITHOUT touching the runner. */
171
+ function stopWatch(stateDir: string): void {
172
+ const w = watchers.get(stateDir);
173
+ if (!w) return;
174
+ try {
175
+ w.watch.close();
176
+ } catch {
177
+ /* ignore */
178
+ }
179
+ w.handle.close();
180
+ watchers.delete(stateDir);
181
+ }
182
+
183
+ // ---- Tools -------------------------------------------------------------
184
+
185
+ pi.registerTool({
186
+ name: "forge_loop_launch",
187
+ label: "Launch forge loop",
188
+ description:
189
+ "Launch the forge autonomous coding loop (rauf) DETACHED and supervise it. " +
190
+ "The loop runs in rauf's server and outlives this session; this tool returns " +
191
+ "immediately. It then reports each completed backlog item as a quiet line and " +
192
+ "WAKES this session on needs-human, blocked, stuck, review-failed, error, or " +
193
+ "completion. Use this for forge-5-loop's loop run on Pi instead of running the " +
194
+ "runner in the foreground.",
195
+ promptSnippet:
196
+ "Start a forge/rauf loop without blocking the session and supervise its events.ndjson.",
197
+ parameters: Type.Object({
198
+ backlogDir: Type.String({ description: "Forge backlog directory passed to --backlog (e.g. specs/auth)." }),
199
+ bin: Type.Optional(Type.String({ description: "Runner binary. Default 'rauf'." })),
200
+ stateDir: Type.Optional(
201
+ Type.String({ description: "Runner state dir holding events.ndjson. Default '<backlogDir>/.rauf'." }),
202
+ ),
203
+ iterations: Type.Optional(Type.Number({ description: "Max iterations (--iterations). Omit for the runner default." })),
204
+ review: Type.Optional(Type.Boolean({ description: "Append --review to run the loop's review pass." })),
205
+ agent: Type.Optional(Type.String({ description: "Coding-agent id passed as --agent." })),
206
+ }),
207
+ async execute(_id: string, params: unknown, _signal: unknown, _onUpdate: unknown, ctx: CtxLike) {
208
+ const p = params as {
209
+ backlogDir: string;
210
+ bin?: string;
211
+ stateDir?: string;
212
+ iterations?: number;
213
+ review?: boolean;
214
+ agent?: string;
215
+ };
216
+ refreshUi(ctx);
217
+ const cwd = ctx.cwd ?? process.cwd();
218
+ const bin = p.bin || "rauf";
219
+ const stateDir = resolveDir(cwd, p.stateDir || join(p.backlogDir, ".rauf"));
220
+ const eventsFile = join(stateDir, "events.ndjson");
221
+
222
+ if (supervisor.isActive(stateDir)) {
223
+ return textResult(
224
+ `A forge loop is already being supervised for ${stateDir}. Use forge_loop_status to check it, or forge_loop_stop first.`,
225
+ { launched: false, stateDir },
226
+ );
227
+ }
228
+
229
+ const args = ["loop", "run", ".", "--backlog", p.backlogDir, "--detached"];
230
+ if (typeof p.iterations === "number") args.push("--iterations", String(p.iterations));
231
+ if (p.review) args.push("--review");
232
+ if (p.agent) args.push("--agent", p.agent);
233
+
234
+ try {
235
+ // A synchronous throw is a definite failure. An ASYNC spawn error
236
+ // (ENOENT for a bad bin) arrives after this returns, so surface it as
237
+ // a notification when it fires rather than silently claiming success.
238
+ deps.spawnDetached(bin, args, cwd, (msg) =>
239
+ host.notify(`forge loop: failed to launch ${bin} — ${msg}. The loop did not start.`, "error"),
240
+ );
241
+ } catch (e) {
242
+ const msg = e instanceof Error ? e.message : String(e);
243
+ return textResult(`Failed to launch ${bin}: ${msg}`, { launched: false, stateDir, error: msg });
244
+ }
245
+
246
+ const task: SupervisorTask = {
247
+ backlogDir: p.backlogDir,
248
+ stateDir,
249
+ eventsFile,
250
+ launchedAt: host.now(),
251
+ total: countBacklog(cwd, p.backlogDir),
252
+ lastSeq: -1,
253
+ closed: false,
254
+ };
255
+ host.persist(task);
256
+ startWatch(task);
257
+
258
+ return textResult(
259
+ `Launched \`${bin} ${args.join(" ")}\` detached; now supervising ${eventsFile}. ` +
260
+ "I'll report each completed item and wake this session on needs-human / blocked / " +
261
+ "stuck / review-failed / error / completion. Run forge_loop_status to confirm it started. " +
262
+ "Do NOT run the loop in the foreground.",
263
+ { launched: true, stateDir, eventsFile },
264
+ );
265
+ },
266
+ });
267
+
268
+ pi.registerTool({
269
+ name: "forge_loop_status",
270
+ label: "Forge loop status",
271
+ description:
272
+ "Report the status of a supervised forge/rauf loop: how many items are done, " +
273
+ "whether it has finished, and the runner's authoritative counts (via " +
274
+ "`rauf status --json`). Use to confirm a launch or check progress on demand.",
275
+ parameters: Type.Object({
276
+ backlogDir: Type.Optional(Type.String({ description: "Backlog dir of the loop to report. Omit for the only supervised loop." })),
277
+ stateDir: Type.Optional(Type.String({ description: "State dir of the loop. Overrides backlogDir when set." })),
278
+ bin: Type.Optional(Type.String({ description: "Runner binary for the status query. Default 'rauf'." })),
279
+ }),
280
+ async execute(_id: string, params: unknown, _signal: unknown, _onUpdate: unknown, ctx: CtxLike) {
281
+ const p = params as { backlogDir?: string; stateDir?: string; bin?: string };
282
+ refreshUi(ctx);
283
+ const cwd = ctx.cwd ?? process.cwd();
284
+ const stateDir = p.stateDir
285
+ ? resolveDir(cwd, p.stateDir)
286
+ : p.backlogDir
287
+ ? resolveDir(cwd, join(p.backlogDir, ".rauf"))
288
+ : [...watchers.keys()][0];
289
+
290
+ const progress = stateDir ? supervisor.progress(stateDir) : null;
291
+ // Scope the runner query to the supervised backlog so status reflects the
292
+ // right loop, not whatever sits at the default project root.
293
+ const backlogDir = p.backlogDir ?? (stateDir ? watchers.get(stateDir)?.backlogDir : undefined);
294
+ let runnerLine = "";
295
+ if (pi.exec) {
296
+ const statusArgs = ["status", "--json"];
297
+ if (backlogDir) statusArgs.push("--backlog", backlogDir);
298
+ try {
299
+ const res = await pi.exec(p.bin || "rauf", statusArgs, { cwd, timeout: 15000 });
300
+ runnerLine = res.code === 0 ? ` Runner: ${res.stdout.trim()}` : ` Runner status exited ${res.code}.`;
301
+ } catch {
302
+ runnerLine = " (could not query the runner for authoritative status)";
303
+ }
304
+ }
305
+
306
+ if (!progress) {
307
+ return textResult(
308
+ `No forge loop is being supervised in this session${stateDir ? ` for ${stateDir}` : ""}.${runnerLine}`,
309
+ { supervised: false, stateDir, runner: runnerLine.trim() },
310
+ );
311
+ }
312
+ const totalPart = typeof progress.total === "number" ? `/${progress.total}` : "";
313
+ return textResult(
314
+ `Forge loop ${progress.closed ? "finished" : "running"}: ${progress.done}${totalPart} items completed so far.${runnerLine}`,
315
+ { supervised: true, stateDir, done: progress.done, total: progress.total, closed: progress.closed },
316
+ );
317
+ },
318
+ });
319
+
320
+ pi.registerTool({
321
+ name: "forge_loop_stop",
322
+ label: "Stop forge loop",
323
+ description:
324
+ "Stop a supervised forge/rauf loop and stop watching it. This DELIBERATELY " +
325
+ "terminates the runner (via `rauf loop stop`) — unlike ending the session, " +
326
+ "which leaves the detached loop running. Use only when the user wants the loop " +
327
+ "to actually stop.",
328
+ parameters: Type.Object({
329
+ backlogDir: Type.Optional(Type.String({ description: "Backlog dir of the loop to stop. Omit for the only supervised loop." })),
330
+ stateDir: Type.Optional(Type.String({ description: "State dir of the loop. Overrides backlogDir when set." })),
331
+ bin: Type.Optional(Type.String({ description: "Runner binary. Default 'rauf'." })),
332
+ }),
333
+ async execute(_id: string, params: unknown, _signal: unknown, _onUpdate: unknown, ctx: CtxLike) {
334
+ const p = params as { backlogDir?: string; stateDir?: string; bin?: string };
335
+ refreshUi(ctx);
336
+ const cwd = ctx.cwd ?? process.cwd();
337
+ const stateDir = p.stateDir
338
+ ? resolveDir(cwd, p.stateDir)
339
+ : p.backlogDir
340
+ ? resolveDir(cwd, join(p.backlogDir, ".rauf"))
341
+ : [...watchers.keys()][0];
342
+
343
+ // Refuse a blind stop: with nothing supervised AND no explicit target we
344
+ // must NOT run `rauf loop stop`, which would kill whatever loop happens to
345
+ // be running in this project — one this extension never launched. Require
346
+ // a supervised task or an explicit backlogDir/stateDir.
347
+ const watcherRec = stateDir ? watchers.get(stateDir) : undefined;
348
+ if (!watcherRec && !p.backlogDir && !p.stateDir) {
349
+ return textResult(
350
+ "No forge loop is being supervised in this session, and no backlog/stateDir was given — nothing to stop. Ending the session already leaves any detached loop running by design; pass a backlogDir to stop a specific loop.",
351
+ { stopped: false },
352
+ );
353
+ }
354
+
355
+ // Scope the stop to the specific backlog so it targets the intended loop,
356
+ // not the default project root.
357
+ const backlogDir = p.backlogDir ?? watcherRec?.backlogDir;
358
+ let stopLine = "";
359
+ if (pi.exec) {
360
+ const stopArgs = ["loop", "stop"];
361
+ if (backlogDir) stopArgs.push("--backlog", backlogDir);
362
+ try {
363
+ const res = await pi.exec(p.bin || "rauf", stopArgs, { cwd, timeout: 30000 });
364
+ stopLine = res.code === 0 ? "Runner stop requested." : `Runner stop exited ${res.code}: ${res.stderr.trim()}`;
365
+ } catch (e) {
366
+ stopLine = `Could not run the stop command: ${e instanceof Error ? e.message : String(e)}`;
367
+ }
368
+ }
369
+ if (stateDir) {
370
+ stopWatch(stateDir);
371
+ supervisor.detach(stateDir);
372
+ clearMirror(stateDir);
373
+ }
374
+ return textResult(`Stopped supervising${stateDir ? ` ${stateDir}` : ""}. ${stopLine}`, {
375
+ stopped: true,
376
+ stateDir,
377
+ });
378
+ },
379
+ });
380
+
381
+ // ---- Lifecycle ---------------------------------------------------------
382
+
383
+ // Reattach on every session start (startup / reload / resume / fork): rebuild
384
+ // the watcher for any loop this session (or a previous one) launched, without
385
+ // duplicate reporting — the dedup cursor (lastSeq) makes replayed history silent.
386
+ pi.on("session_start", (_event: unknown, ctx: unknown) => {
387
+ const c = ctx as CtxLike;
388
+ ui = c.ui ?? null;
389
+ const seen = new Set<string>();
390
+ const consider = (task: SupervisorTask | null) => {
391
+ if (!task || seen.has(task.stateDir) || watchers.has(task.stateDir)) return;
392
+ seen.add(task.stateDir);
393
+ // Prefer the on-disk mirror (most current lastSeq/closed/eventsIno); fall
394
+ // back to the session entry's own copy.
395
+ const fresh = readMirror(task.stateDir) ?? task;
396
+ if (fresh.closed) return; // already surfaced its terminal — nothing to resume
397
+ startWatch(fresh);
398
+ };
399
+ // (1) Session entries carry tasks across reload/resume/fork within a session
400
+ // lineage (pi.appendEntry survives those).
401
+ const entries = c.sessionManager?.getEntries?.() ?? [];
402
+ for (const entry of entries) {
403
+ if (entry?.type === "custom" && entry?.customType === TASK_ENTRY_TYPE) {
404
+ consider(entry.data as SupervisorTask);
405
+ }
406
+ }
407
+ // (2) A BRAND-NEW session file has no such entry, so also discover the disk
408
+ // mirrors under the project root — this is what lets a fresh session reattach
409
+ // to a loop a previous session launched (issue #236's "reconcile after
410
+ // session restart"). Bounded scan; `consider` dedups and skips closed tasks.
411
+ const cwd = c.cwd ?? process.cwd();
412
+ for (const task of discoverMirrors(cwd)) consider(task);
413
+ });
414
+
415
+ // Cleanup on shutdown: close every watcher, but LEAVE the detached runner
416
+ // running (it is server-owned and meant to outlive the session) and leave the
417
+ // mirror in place so the next session reattaches.
418
+ pi.on("session_shutdown", () => {
419
+ for (const stateDir of [...watchers.keys()]) stopWatch(stateDir);
420
+ });
421
+
422
+ return { supervisor, watchers, startWatch, stopWatch, host };
423
+ }
@@ -10,7 +10,8 @@
10
10
  "./skills"
11
11
  ],
12
12
  "extensions": [
13
- "./extensions/ask-user-question/index.ts"
13
+ "./extensions/ask-user-question/index.ts",
14
+ "./extensions/forge-loop-supervisor/index.ts"
14
15
  ]
15
16
  },
16
17
  "pi-subagents": {