@flavor-code/superharness 1.0.0

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 (29) hide show
  1. package/HARNESS.md +56 -0
  2. package/flavor-plugin.json +25 -0
  3. package/index.js +238 -0
  4. package/package.json +23 -0
  5. package/scripts/ralph-lib.ps1 +297 -0
  6. package/scripts/ralph-lib.sh +366 -0
  7. package/skills/brainstorm/SKILL.md +179 -0
  8. package/skills/brainstorm/scripts/layout.js +76 -0
  9. package/skills/brainstorm/scripts/mindmap.html +249 -0
  10. package/skills/brainstorm/scripts/server.cjs +208 -0
  11. package/skills/brainstorm/scripts/start-server.ps1 +57 -0
  12. package/skills/brainstorm/scripts/stop-server.ps1 +17 -0
  13. package/skills/finishing-a-development-branch/SKILL.md +112 -0
  14. package/skills/go/SKILL.md +169 -0
  15. package/skills/light/SKILL.md +85 -0
  16. package/skills/requesting-code-review/SKILL.md +103 -0
  17. package/skills/requesting-code-review/code-reviewer.md +168 -0
  18. package/skills/subagent-driven-development/SKILL.md +125 -0
  19. package/skills/systematic-debugging/SKILL.md +296 -0
  20. package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
  21. package/skills/systematic-debugging/condition-based-waiting.md +115 -0
  22. package/skills/systematic-debugging/defense-in-depth.md +122 -0
  23. package/skills/systematic-debugging/find-polluter.sh +63 -0
  24. package/skills/systematic-debugging/root-cause-tracing.md +169 -0
  25. package/skills/test-driven-development/SKILL.md +371 -0
  26. package/skills/test-driven-development/testing-anti-patterns.md +299 -0
  27. package/skills/using-git-worktrees/SKILL.md +91 -0
  28. package/skills/verification-before-completion/SKILL.md +139 -0
  29. package/skills/writing-plans/SKILL.md +138 -0
package/HARNESS.md ADDED
@@ -0,0 +1,56 @@
1
+ # Superharness Bootstrap
2
+
3
+ You have superharness: a project-level engineering discipline harness. It is loaded
4
+ as a plugin of your host — from the local marketplace at `.claude/superharness`
5
+ under Claude Code, or as a native plugin under `.flavor/plugins/superharness`
6
+ under flavor-code — and this document is injected at session start by its
7
+ SessionStart hook. The state root follows the host: `.claude/superharness/`
8
+ under Claude Code, `.flavor/superharness/` under flavor-code.
9
+
10
+ ## The Rule
11
+
12
+ **Before responding to ANY engineering task — including "simple" ones — check whether a
13
+ superharness skill applies. If there is even a 1% chance a skill applies, invoke it with
14
+ the Skill tool before doing anything else.**
15
+
16
+ ## Available Skills
17
+
18
+ | Skill | Invoke when |
19
+ |-------|-------------|
20
+ | `superharness:go` | The user gives a task goal to complete end-to-end (also triggered by `/superharness:go <goal>`) |
21
+ | `superharness:brainstorm` | ONLY when the user explicitly runs `/superharness:brainstorm <topic>` — never self-invoke. Requirements/design dialogue with a live browser mind map |
22
+ | `superharness:light` | Small, focused tasks that need discipline without the full go machinery — quick fixes, small features, config/docs tweaks, prototypes. Lighter go: TDD with explicit exemptions, real-output verification, root-cause debugging; no worktree, no plan file, no ralph tracking |
23
+ | `superharness:writing-plans` | A multi-step task needs an implementation plan, before touching code |
24
+ | `superharness:using-git-worktrees` | Starting feature work that needs an isolated workspace, before implementation (go Phase 0.5) |
25
+ | `superharness:finishing-a-development-branch` | Worktree/branch work is done, verified, and reviewed — merge it back, remove the worktree, delete the branch (go Phase 5) |
26
+ | `superharness:subagent-driven-development` | Executing a multi-task plan with independent tasks in the current session (go Phase 2) |
27
+ | `superharness:test-driven-development` | Implementing ANY feature or bugfix, before writing implementation code |
28
+ | `superharness:systematic-debugging` | Any bug, test failure, or unexpected behavior, before proposing fixes |
29
+ | `superharness:requesting-code-review` | A task or major feature is complete, before moving on or merging |
30
+ | `superharness:verification-before-completion` | About to claim work is complete, fixed, or passing |
31
+
32
+ ## Non-Negotiable Constraints
33
+
34
+ 1. **TDD always.** Write the failing test first, watch it fail (RED), write minimal code,
35
+ watch it pass (GREEN), refactor, commit. Implementation code written before its test
36
+ gets deleted and redone. See `superharness:test-driven-development`.
37
+ 2. **Evidence before claims.** Never say "done", "fixed", or "passing" without running the
38
+ verification command in this session and seeing the output. See
39
+ `superharness:verification-before-completion`.
40
+ 3. **Root cause over quick fix.** When something breaks, follow the 4-phase process in
41
+ `superharness:systematic-debugging`. No "let me just try this" patches.
42
+ 4. **Plan before multi-step work.** Tasks with 3+ steps get a written plan
43
+ (`superharness:writing-plans`) saved to `<state-root>/superharness/plans/` in the
44
+ project, with bite-sized 2–5 minute TDD steps.
45
+ 5. **Review before declaring victory.** Significant changes get a code review pass
46
+ (`superharness:requesting-code-review`); Critical issues block progress.
47
+
48
+ ## Red Flags — STOP if you catch yourself thinking:
49
+
50
+ | Thought | Reality |
51
+ |---------|---------|
52
+ | "This is too simple for TDD" | Simple things break too. Test first. |
53
+ | "I'll write tests after" | That's not TDD. RED comes first. |
54
+ | "It should work now" | Run it. Show the output. Then say it works. |
55
+ | "Let me just try changing this" | That's guessing, not debugging. Find the root cause. |
56
+ | "The plan is in my head" | Write it down. Plans in heads evaporate. |
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "superharness",
3
+ "version": "1.0.0",
4
+ "apiVersion": "1",
5
+ "main": "./index.js",
6
+ "permissions": [],
7
+ "contributes": {
8
+ "commands": [],
9
+ "tools": [],
10
+ "hooks": [
11
+ { "name": "SessionStart" },
12
+ { "name": "UserPromptSubmit" },
13
+ { "name": "Stop" },
14
+ { "name": "SessionEnd" },
15
+ { "name": "BeforePlan" },
16
+ { "name": "AfterPlan" },
17
+ { "name": "SubagentStart" },
18
+ { "name": "SubagentStop" }
19
+ ],
20
+ "skillRoots": [
21
+ { "name": "superharness", "path": "./skills" }
22
+ ],
23
+ "modelAdapters": []
24
+ }
25
+ }
package/index.js ADDED
@@ -0,0 +1,238 @@
1
+ // superharness flavor-code plugin
2
+ // Registers the superharness skill root so that go, brainstorm, tdd, etc.
3
+ // are discovered under the /superharness namespace, and registers three
4
+ // lifecycle hooks mirroring the Claude Code hooks:
5
+ // SessionStart inject HARNESS.md (+ STACK.md when present) as additionalContext
6
+ // UserPromptSubmit auto-bootstrap ralph tracking on `/superharness:go <goal>`
7
+ // Stop append a 'round' heartbeat to trace.jsonl while a go task runs
8
+ // All hooks are best-effort and always return { decision: "allow" }.
9
+
10
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const PLUGIN_ROOT = dirname(fileURLToPath(import.meta.url));
15
+ const ALLOW = Object.freeze({ decision: "allow" });
16
+
17
+ // The workspace path is cached from SessionStart (payload.workspace is a string);
18
+ // hooks fall back to the process working directory when it is unavailable.
19
+ let workspaceRoot;
20
+
21
+ function projectRoot(event) {
22
+ const workspace = event?.payload?.workspace;
23
+ if (typeof workspace === "string" && workspace.length > 0) return workspace;
24
+ if (typeof workspaceRoot === "string" && workspaceRoot.length > 0) return workspaceRoot;
25
+ return process.cwd();
26
+ }
27
+
28
+ function ralphDir(root) {
29
+ // flavor-code host: runtime state lives under the .flavor state root
30
+ // (the Claude Code counterpart uses .claude/superharness/ralph).
31
+ return join(root, ".flavor", "superharness", "ralph");
32
+ }
33
+
34
+ function isoNow() {
35
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "+00:00");
36
+ }
37
+
38
+ function atomicWrite(path, text) {
39
+ mkdirSync(dirname(path), { recursive: true });
40
+ const tmp = `${path}.tmp`;
41
+ writeFileSync(tmp, text, "utf8");
42
+ renameSync(tmp, path);
43
+ }
44
+
45
+ function readJson(path) {
46
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; }
47
+ }
48
+
49
+ function readText(path) {
50
+ try {
51
+ const text = readFileSync(path, "utf8");
52
+ return text.length > 0 ? text : undefined;
53
+ } catch { return undefined; }
54
+ }
55
+
56
+ function currentTaskPath(root) {
57
+ return join(ralphDir(root), ".current-task");
58
+ }
59
+
60
+ function getCurrentTask(root) {
61
+ const raw = readText(currentTaskPath(root));
62
+ const line = raw === undefined ? "" : raw.trim();
63
+ return line.length > 0 ? line : undefined;
64
+ }
65
+
66
+ function appendTrace(root, phase, traceEvent, detail) {
67
+ const line = JSON.stringify({ ts: isoNow(), phase, event: traceEvent, detail: detail ?? "" });
68
+ const path = join(ralphDir(root), "trace.jsonl");
69
+ mkdirSync(dirname(path), { recursive: true });
70
+ const existing = readText(path) ?? "";
71
+ writeFileSync(path, `${existing}${line}\n`, "utf8");
72
+ }
73
+
74
+ // Parse a go invocation at the start of the prompt. flavor-code invokes skills
75
+ // as `/go <goal>`; the Claude-style `superharness:go <goal>` is also accepted.
76
+ // Returns { goal, slug } or undefined.
77
+ function goInvocation(prompt) {
78
+ if (typeof prompt !== "string") return undefined;
79
+ const match = /^\s*(?:\/go|superharness:go)(?:[^A-Za-z0-9_]|$)/.exec(prompt);
80
+ if (match === null) return undefined;
81
+ const goal = prompt.replace(/^\s*(?:\/go|superharness:go)[ \t]*/, "").trim();
82
+ const now = new Date();
83
+ const date = now.toISOString().slice(0, 10);
84
+ const tokens = (goal.toLowerCase().match(/[a-z0-9]+/g) ?? []).slice(0, 6);
85
+ const kebab = tokens.length > 0
86
+ ? tokens.join("-")
87
+ : `task-${String(now.getUTCHours()).padStart(2, "0")}${String(now.getUTCMinutes()).padStart(2, "0")}${String(now.getUTCSeconds()).padStart(2, "0")}`;
88
+ return { goal, slug: `${date}-${kebab}` };
89
+ }
90
+
91
+ // Bootstrap a fresh go task: point .current-task, seed an empty task.json
92
+ // (planning/plan), open the trace ledger with task:started, reset retry state.
93
+ function startTask(root, taskId, goal) {
94
+ atomicWrite(currentTaskPath(root), taskId.trim());
95
+ atomicWrite(join(ralphDir(root), "task.json"), JSON.stringify({
96
+ status: "planning",
97
+ phase: "plan",
98
+ sprint: { current: 0, total: 0 },
99
+ tasks: [],
100
+ updated_at: isoNow(),
101
+ }));
102
+ appendTrace(root, "plan", "task:started", goal);
103
+ atomicWrite(join(ralphDir(root), ".ralph-state.json"), JSON.stringify({
104
+ retries: 0, max: 5, updated_at: isoNow(),
105
+ }));
106
+ }
107
+
108
+ function onSessionStart(event) {
109
+ const workspace = event?.payload?.workspace;
110
+ if (typeof workspace === "string" && workspace.length > 0) workspaceRoot = workspace;
111
+ const harness = readText(join(PLUGIN_ROOT, "HARNESS.md"));
112
+ if (harness === undefined) return ALLOW;
113
+ let context = `<EXTREMELY_IMPORTANT>\nYou have superharness. Follow it for all engineering work in this project.\n\n${harness}\n</EXTREMELY_IMPORTANT>`;
114
+ const stack = readText(join(PLUGIN_ROOT, "STACK.md"));
115
+ if (stack !== undefined) {
116
+ context += `\n\n<EXTREMELY_IMPORTANT>\nThis project targets a specific tech stack. Follow this guidance.\n\n${stack}\n</EXTREMELY_IMPORTANT>`;
117
+ }
118
+ return { decision: "allow", additionalContext: context };
119
+ }
120
+
121
+ function onUserPromptSubmit(event) {
122
+ const root = projectRoot(event);
123
+ const prompt = typeof event?.payload?.prompt === "string" ? event.payload.prompt : "";
124
+
125
+ // 1. Auto-trigger on a go invocation (start/repoint a task automatically).
126
+ const invocation = goInvocation(prompt);
127
+ if (invocation !== undefined && invocation.slug !== getCurrentTask(root)) {
128
+ startTask(root, invocation.slug, invocation.goal);
129
+ }
130
+
131
+ // 2. Stash the pending round so the Stop hook can record a heartbeat.
132
+ atomicWrite(join(ralphDir(root), ".pending-prompt.json"), JSON.stringify({ ts: isoNow(), query: prompt }));
133
+ return ALLOW;
134
+ }
135
+
136
+ function onStop(event) {
137
+ const root = projectRoot(event);
138
+ const pendingPath = join(ralphDir(root), ".pending-prompt.json");
139
+ const current = getCurrentTask(root);
140
+ if (current === undefined) {
141
+ // Not tracking a go task — drop any stray pending prompt and bail.
142
+ try { rmSync(pendingPath, { force: true }); } catch { /* ignore */ }
143
+ return ALLOW;
144
+ }
145
+ const pending = readJson(pendingPath);
146
+ const query = typeof pending?.query === "string" ? pending.query : "";
147
+ const tasks = readJson(join(ralphDir(root), "task.json"));
148
+ const phase = typeof tasks?.phase === "string" && tasks.phase.length > 0 ? tasks.phase : "go";
149
+ appendTrace(root, phase, "round", query);
150
+ try { rmSync(pendingPath, { force: true }); } catch { /* ignore */ }
151
+ return ALLOW;
152
+ }
153
+
154
+ // Called when the session ends (host fires SessionEnd). Records a final
155
+ // trace event and clears any remaining ralph state so the next session
156
+ // starts with a clean slate.
157
+ function onSessionEnd(event) {
158
+ const root = projectRoot(event);
159
+ const current = getCurrentTask(root);
160
+ if (current === undefined) return ALLOW;
161
+ const tasks = readJson(join(ralphDir(root), "task.json"));
162
+ const phase = typeof tasks?.phase === "string" && tasks.phase.length > 0 ? tasks.phase : "go";
163
+ appendTrace(root, phase, "session:end", "session ended");
164
+ // Clear the active-task pointer so the Stop hook no longer records heartbeats.
165
+ try { rmSync(currentTaskPath(root), { force: true }); } catch { /* ignore */ }
166
+ return ALLOW;
167
+ }
168
+
169
+ // Called before the planning phase begins. Injects a lightweight context
170
+ // flagging that plan mode is about to start so the model can prepare.
171
+ function onBeforePlan(event) {
172
+ const root = projectRoot(event);
173
+ const current = getCurrentTask(root);
174
+ if (current === undefined) return ALLOW;
175
+ appendTrace(root, "plan", "plan:before", "planning phase starting");
176
+ return ALLOW;
177
+ }
178
+
179
+ // Called after the planning phase ends. Records the plan result and
180
+ // advances the ralph task status so the next phase is clear.
181
+ function onAfterPlan(event) {
182
+ const root = projectRoot(event);
183
+ const current = getCurrentTask(root);
184
+ if (current === undefined) return ALLOW;
185
+ const tasks = readJson(join(ralphDir(root), "task.json"));
186
+ const phase = typeof tasks?.phase === "string" && tasks.phase.length > 0 ? tasks.phase : "go";
187
+ appendTrace(root, phase, "plan:after", "planning phase complete");
188
+ return ALLOW;
189
+ }
190
+
191
+ // Called when a subagent is about to start. Records the subagent
192
+ // launch so the trace has a clear subagent lifecycle boundary.
193
+ function onSubagentStart(event) {
194
+ const root = projectRoot(event);
195
+ const current = getCurrentTask(root);
196
+ if (current === undefined) return ALLOW;
197
+ const tasks = readJson(join(ralphDir(root), "task.json"));
198
+ const phase = typeof tasks?.phase === "string" && tasks.phase.length > 0 ? tasks.phase : "go";
199
+ appendTrace(root, phase, "subagent:start", "subagent starting");
200
+ return ALLOW;
201
+ }
202
+
203
+ // Called when a subagent finishes. Records the subagent completion
204
+ // and any errors so trace.jsonl has a complete subagent picture.
205
+ function onSubagentStop(event) {
206
+ const root = projectRoot(event);
207
+ const current = getCurrentTask(root);
208
+ if (current === undefined) return ALLOW;
209
+ const tasks = readJson(join(ralphDir(root), "task.json"));
210
+ const phase = typeof tasks?.phase === "string" && tasks.phase.length > 0 ? tasks.phase : "go";
211
+ const outcome = typeof event?.payload?.outcome === "string" ? event.payload.outcome : "completed";
212
+ appendTrace(root, phase, "subagent:stop", `subagent ${outcome}`);
213
+ return ALLOW;
214
+ }
215
+
216
+ export function activate(context) {
217
+ context.registerSkillRoot("superharness", "./skills");
218
+
219
+ const guard = (fn) => (event, signal) => {
220
+ try { return fn(event, signal); } catch { return ALLOW; }
221
+ };
222
+ const options = { failurePolicy: "allow" };
223
+
224
+ const disposers = [
225
+ context.registerHook("SessionStart", guard(onSessionStart), options),
226
+ context.registerHook("UserPromptSubmit", guard(onUserPromptSubmit), options),
227
+ context.registerHook("Stop", guard(onStop), options),
228
+ context.registerHook("SessionEnd", guard(onSessionEnd), options),
229
+ context.registerHook("BeforePlan", guard(onBeforePlan), options),
230
+ context.registerHook("AfterPlan", guard(onAfterPlan), options),
231
+ context.registerHook("SubagentStart", guard(onSubagentStart), options),
232
+ context.registerHook("SubagentStop", guard(onSubagentStop), options),
233
+ ];
234
+ return () => { for (const dispose of disposers.reverse()) dispose(); };
235
+ }
236
+
237
+ // Exported for tests.
238
+ export const __test = { goInvocation, onSessionStart, onUserPromptSubmit, onStop, onSessionEnd, onBeforePlan, onAfterPlan, onSubagentStart, onSubagentStop, ralphDir };
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@flavor-code/superharness",
3
+ "version": "1.0.0",
4
+ "description": "为 flavor-code 注入规划、调试、测试与交付工作流的工程增强插件。",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "files": [
8
+ "**/*"
9
+ ],
10
+ "scripts": {},
11
+ "keywords": [
12
+ "flavor-code",
13
+ "flavor-plugin",
14
+ "workflow",
15
+ "skills",
16
+ "hooks"
17
+ ],
18
+ "author": "Flavor Code",
19
+ "repository": "C:\\Users\\wangzh\\Desktop\\idea\\superharness",
20
+ "flavorPlugin": {
21
+ "apiVersion": "1"
22
+ }
23
+ }
@@ -0,0 +1,297 @@
1
+ # Ralph state mechanism — zero-dependency PowerShell state library.
2
+ #
3
+ # Manages the four runtime files of a resumable autonomous-task loop, all under
4
+ # <project>/<state-root>/superharness/ralph/ where <state-root> follows the host:
5
+ # .claude (Claude Code install: .claude/superharness marketplace)
6
+ # .flavor (flavor-code install: .flavor/plugins/superharness)
7
+ # .current-task one-line pointer to the active task (switch = rewrite the line)
8
+ # task.json task-list snapshot {status,phase,sprint,tasks[],updated_at}
9
+ # trace.jsonl append-only ledger, one {ts,phase,event,detail} JSON per line
10
+ # .ralph-state.json retry counter {retries,max,updated_at}, capped at 5
11
+ #
12
+ # Dot-source this file to use the functions. The trace hooks (hooks/stop.ps1,
13
+ # hooks/user-prompt-submit.ps1) dot-source it for go task tracking. Conventions:
14
+ # UTF-8 without BOM, atomic temp-then-move for JSON snapshots, ISO-8601 timestamps.
15
+
16
+ # ---------------------------------------------------------------- paths & helpers
17
+
18
+ function Get-RalphStateRoot {
19
+ # Host detection: this library ships inside the host install, so its own path
20
+ # names the state root. $env:SUPERHARNESS_STATE_ROOT ('.claude' | '.flavor')
21
+ # forces a choice for unusual layouts; Claude Code is the historical default.
22
+ $forced = $env:SUPERHARNESS_STATE_ROOT
23
+ if ($forced -eq '.flavor' -or $forced -eq 'flavor') { return '.flavor' }
24
+ if ($forced -eq '.claude' -or $forced -eq 'claude') { return '.claude' }
25
+ if ($PSScriptRoot -match '[/\\]\.flavor[/\\]plugins[/\\]superharness[/\\]scripts') { return '.flavor' }
26
+ return '.claude'
27
+ }
28
+
29
+ function Get-RalphDir {
30
+ param([Parameter(Mandatory)][string]$Root)
31
+ $stateRoot = Get-RalphStateRoot # '.claude' or '.flavor'
32
+ Join-Path $Root (Join-Path $stateRoot 'superharness\ralph')
33
+ }
34
+
35
+ function Get-RalphIso { (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz') }
36
+
37
+ function Get-RalphGoInvocation {
38
+ # Parse a UserPromptSubmit prompt. If it is a `/superharness:go <goal>` invocation
39
+ # (leading slash optional, must be at the start of the prompt), return
40
+ # { Goal; Slug='YYYY-MM-DD-<kebab|task-HHmmss>' }; otherwise return $null. Pure.
41
+ param(
42
+ [Parameter(Mandatory)][AllowEmptyString()][string]$Prompt,
43
+ [datetime]$Now = (Get-Date)
44
+ )
45
+ if ($null -eq $Prompt) { return $null }
46
+ $m = [regex]::Match($Prompt, '^\s*/?superharness:go\b[ \t]*(?<goal>[\s\S]*)$')
47
+ if (-not $m.Success) { return $null }
48
+ $goal = $m.Groups['goal'].Value.Trim()
49
+ $date = $Now.ToString('yyyy-MM-dd')
50
+ $tokens = [regex]::Matches($goal.ToLower(), '[a-z0-9]+') | ForEach-Object { $_.Value }
51
+ if ($tokens.Count -gt 0) {
52
+ $kebab = (@($tokens) | Select-Object -First 6) -join '-'
53
+ } else {
54
+ $kebab = 'task-' + $Now.ToString('HHmmss')
55
+ }
56
+ [PSCustomObject]@{ Goal = $goal; Slug = "$date-$kebab" }
57
+ }
58
+
59
+ function New-RalphDir {
60
+ param([string]$Root)
61
+ $dir = Get-RalphDir $Root
62
+ if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force $dir | Out-Null }
63
+ return $dir
64
+ }
65
+
66
+ function Read-RalphJson {
67
+ param([string]$Path)
68
+ if (-not (Test-Path $Path)) { return $null }
69
+ try { return Get-Content $Path -Raw | ConvertFrom-Json } catch { return $null }
70
+ }
71
+
72
+ function Write-RalphText {
73
+ # Atomic write: temp file then move-replace. UTF-8 without BOM.
74
+ param([string]$Path, [string]$Text)
75
+ $dir = Split-Path -Parent $Path
76
+ if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force $dir | Out-Null }
77
+ $enc = New-Object System.Text.UTF8Encoding($false)
78
+ $tmp = "$Path.tmp"
79
+ [IO.File]::WriteAllText($tmp, $Text, $enc)
80
+ Move-Item -Path $tmp -Destination $Path -Force
81
+ }
82
+
83
+ function Write-RalphJson {
84
+ param([string]$Path, $Object)
85
+ Write-RalphText $Path (ConvertTo-Json -InputObject $Object -Depth 12 -Compress)
86
+ }
87
+
88
+ # ---------------------------------------------------------------- .current-task
89
+
90
+ function Get-RalphCurrentTaskPath { param([string]$Root) Join-Path (Get-RalphDir $Root) '.current-task' }
91
+
92
+ function Set-RalphCurrentTask {
93
+ # The pointer is a single line; switching a task rewrites only this line.
94
+ param([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$TaskId)
95
+ Write-RalphText (Get-RalphCurrentTaskPath $Root) ($TaskId.Trim())
96
+ }
97
+
98
+ function Get-RalphCurrentTask {
99
+ param([Parameter(Mandatory)][string]$Root)
100
+ $p = Get-RalphCurrentTaskPath $Root
101
+ if (-not (Test-Path $p)) { return $null }
102
+ $raw = (Get-Content $p -Raw)
103
+ if ($null -eq $raw) { return $null }
104
+ $line = $raw.Trim()
105
+ if ($line -eq '') { return $null }
106
+ return $line
107
+ }
108
+
109
+ # ---------------------------------------------------------------- task.json
110
+
111
+ $script:RalphStatuses = @('pending', 'in_progress', 'done')
112
+
113
+ function Get-RalphTaskPath { param([string]$Root) Join-Path (Get-RalphDir $Root) 'task.json' }
114
+
115
+ function Initialize-RalphTasks {
116
+ # Write the task-list snapshot. Each task defaults to status 'pending'.
117
+ param(
118
+ [Parameter(Mandatory)][string]$Root,
119
+ [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Tasks,
120
+ [string]$Status = 'planning',
121
+ [string]$Phase = 'implement',
122
+ [int]$SprintCurrent = 0,
123
+ [int]$SprintTotal = 0
124
+ )
125
+ $norm = @()
126
+ foreach ($t in $Tasks) {
127
+ $st = if ($t.status) { [string]$t.status } else { 'pending' }
128
+ if ($script:RalphStatuses -notcontains $st) { throw "Invalid task status '$st' (allowed: $($script:RalphStatuses -join ', '))" }
129
+ $norm += [ordered]@{ id = $t.id; name = [string]$t.name; status = $st }
130
+ }
131
+ $snapshot = [ordered]@{
132
+ status = $Status
133
+ phase = $Phase
134
+ sprint = [ordered]@{ current = $SprintCurrent; total = $SprintTotal }
135
+ tasks = $norm
136
+ updated_at = (Get-RalphIso)
137
+ }
138
+ Write-RalphJson (Get-RalphTaskPath $Root) $snapshot
139
+ }
140
+
141
+ function Get-RalphTasks {
142
+ param([Parameter(Mandatory)][string]$Root)
143
+ Read-RalphJson (Get-RalphTaskPath $Root)
144
+ }
145
+
146
+ function Get-RalphNextTask {
147
+ # The first task whose status is not 'done' — the resume entry point.
148
+ param([Parameter(Mandatory)][string]$Root)
149
+ $snap = Get-RalphTasks $Root
150
+ if (-not $snap) { return $null }
151
+ foreach ($t in @($snap.tasks)) {
152
+ if ($t.status -ne 'done') { return $t }
153
+ }
154
+ return $null
155
+ }
156
+
157
+ function Set-RalphTaskStatus {
158
+ # Idempotently set one task's status and refresh updated_at. Order preserved.
159
+ param(
160
+ [Parameter(Mandatory)][string]$Root,
161
+ [Parameter(Mandatory)]$Id,
162
+ [Parameter(Mandatory)][string]$Status
163
+ )
164
+ if ($script:RalphStatuses -notcontains $Status) { throw "Invalid task status '$Status' (allowed: $($script:RalphStatuses -join ', '))" }
165
+ $snap = Get-RalphTasks $Root
166
+ if (-not $snap) { throw "No task.json under $(Get-RalphDir $Root)" }
167
+ $tasks = @()
168
+ foreach ($t in @($snap.tasks)) {
169
+ $st = if ("$($t.id)" -eq "$Id") { $Status } else { [string]$t.status }
170
+ $tasks += [ordered]@{ id = $t.id; name = [string]$t.name; status = $st }
171
+ }
172
+ $snapshot = [ordered]@{
173
+ status = [string]$snap.status
174
+ phase = [string]$snap.phase
175
+ sprint = [ordered]@{ current = $snap.sprint.current; total = $snap.sprint.total }
176
+ tasks = $tasks
177
+ updated_at = (Get-RalphIso)
178
+ }
179
+ Write-RalphJson (Get-RalphTaskPath $Root) $snapshot
180
+ }
181
+
182
+ # ---------------------------------------------------------------- trace.jsonl
183
+
184
+ function Get-RalphTracePath { param([string]$Root) Join-Path (Get-RalphDir $Root) 'trace.jsonl' }
185
+
186
+ function Add-RalphTrace {
187
+ # Append a single minified {ts,phase,event,detail} line. Never rewrites earlier
188
+ # lines — the worst a crash can corrupt is the final line.
189
+ param(
190
+ [Parameter(Mandatory)][string]$Root,
191
+ [Parameter(Mandatory)][string]$Phase,
192
+ [Parameter(Mandatory)][string]$Event,
193
+ [string]$Detail = ''
194
+ )
195
+ New-RalphDir $Root | Out-Null
196
+ $line = ConvertTo-Json -InputObject ([ordered]@{
197
+ ts = (Get-RalphIso); phase = $Phase; event = $Event; detail = $Detail
198
+ }) -Depth 12 -Compress
199
+ $enc = New-Object System.Text.UTF8Encoding($false)
200
+ [IO.File]::AppendAllText((Get-RalphTracePath $Root), ($line + "`n"), $enc)
201
+ }
202
+
203
+ function Get-RalphTraceTail {
204
+ # Return the last N events (parsed), oldest-first. Empty array if no ledger.
205
+ param([Parameter(Mandatory)][string]$Root, [int]$Count = 1)
206
+ $p = Get-RalphTracePath $Root
207
+ if (-not (Test-Path $p)) { return @() }
208
+ $lines = @(Get-Content $p -Tail $Count | Where-Object { $_.Trim() -ne '' })
209
+ $out = @()
210
+ foreach ($l in $lines) { try { $out += ($l | ConvertFrom-Json) } catch {} }
211
+ return $out
212
+ }
213
+
214
+ # ---------------------------------------------------------------- .ralph-state.json (retry counter)
215
+
216
+ function Get-RalphRetryPath { param([string]$Root) Join-Path (Get-RalphDir $Root) '.ralph-state.json' }
217
+
218
+ function Get-RalphRetryState {
219
+ # Defaults to {retries:0, max:5} when the file is absent or malformed.
220
+ param([Parameter(Mandatory)][string]$Root)
221
+ $s = Read-RalphJson (Get-RalphRetryPath $Root)
222
+ $retries = 0; $max = 5; $upd = $null
223
+ if ($s) {
224
+ if ($null -ne $s.retries) { $retries = [int]$s.retries }
225
+ if ($null -ne $s.max) { $max = [int]$s.max }
226
+ $upd = $s.updated_at
227
+ }
228
+ [PSCustomObject]@{ retries = $retries; max = $max; updated_at = $upd }
229
+ }
230
+
231
+ function Set-RalphRetryState {
232
+ param([Parameter(Mandatory)][string]$Root, [int]$Retries, [int]$Max)
233
+ $obj = [ordered]@{ retries = $Retries; max = $Max; updated_at = (Get-RalphIso) }
234
+ Write-RalphJson (Get-RalphRetryPath $Root) $obj
235
+ [PSCustomObject]@{ retries = $Retries; max = $Max; updated_at = $obj.updated_at }
236
+ }
237
+
238
+ function Add-RalphRetry {
239
+ # Increment the retry counter, clamped at max. Returns the new state.
240
+ param([Parameter(Mandatory)][string]$Root)
241
+ $st = Get-RalphRetryState $Root
242
+ $n = $st.retries + 1
243
+ if ($n -gt $st.max) { $n = $st.max }
244
+ Set-RalphRetryState -Root $Root -Retries $n -Max $st.max
245
+ }
246
+
247
+ function Test-RalphRetryExhausted {
248
+ param([Parameter(Mandatory)][string]$Root)
249
+ $st = Get-RalphRetryState $Root
250
+ return ($st.retries -ge $st.max)
251
+ }
252
+
253
+ function Reset-RalphRetry {
254
+ param([Parameter(Mandatory)][string]$Root)
255
+ $st = Get-RalphRetryState $Root
256
+ Set-RalphRetryState -Root $Root -Retries 0 -Max $st.max
257
+ }
258
+
259
+ # ---------------------------------------------------------------- task bootstrap
260
+
261
+ function Start-RalphTask {
262
+ # Auto-bootstrap a fresh go task: point .current-task, seed an empty task.json
263
+ # (planning/plan — the agent enriches the task list later), open the trace ledger
264
+ # with a task:started event, and reset the retry counter. Idempotent-ish: calling
265
+ # again repoints to a new TaskId and appends another task:started line.
266
+ param(
267
+ [Parameter(Mandatory)][string]$Root,
268
+ [Parameter(Mandatory)][string]$TaskId,
269
+ [string]$Goal = ''
270
+ )
271
+ Set-RalphCurrentTask -Root $Root -TaskId $TaskId
272
+ Initialize-RalphTasks -Root $Root -Tasks @() -Status 'planning' -Phase 'plan'
273
+ Add-RalphTrace -Root $Root -Phase 'plan' -Event 'task:started' -Detail $Goal
274
+ Reset-RalphRetry -Root $Root | Out-Null
275
+ }
276
+
277
+ # ---------------------------------------------------------------- cold-start recovery
278
+
279
+ function Get-RalphResumeContext {
280
+ # Assemble the deterministic file-based facts a freshly-started agent needs to
281
+ # resume: the active pointer, the task snapshot, the first not-done task, the
282
+ # last ledger event, and the retry state. The agent then reconciles these
283
+ # against `git diff` (code wins) and fixes task.json via Set-RalphTaskStatus.
284
+ param([Parameter(Mandatory)][string]$Root)
285
+ $snap = Get-RalphTasks $Root
286
+ $next = Get-RalphNextTask $Root
287
+ $tail = @(Get-RalphTraceTail -Root $Root -Count 1)
288
+ $last = if ($tail.Count -gt 0) { $tail[0] } else { $null }
289
+ [PSCustomObject]@{
290
+ current_task = (Get-RalphCurrentTask $Root)
291
+ tasks = $snap
292
+ next_task = $next
293
+ last_trace = $last
294
+ all_done = [bool]($snap -and ($null -eq $next))
295
+ retry = (Get-RalphRetryState $Root)
296
+ }
297
+ }