pi-crew 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +160 -0
- package/README.md +70 -33
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/package.json +2 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +2 -2
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/registration/team-tool.ts +1 -1
- package/src/extension/registration/viewers.ts +2 -2
- package/src/extension/team-tool/api.ts +3 -3
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +60 -11
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +173 -46
- package/src/hooks/registry.ts +77 -13
- package/src/hooks/types.ts +9 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/async-runner.ts +35 -7
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +9 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +164 -37
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +2 -0
- package/src/runtime/diagnostic-export.ts +1 -1
- package/src/runtime/dynamic-script-runner.ts +10 -7
- package/src/runtime/foreground-watchdog.ts +1 -1
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +1 -1
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +17 -2
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/skill-effectiveness.ts +105 -62
- package/src/runtime/skill-instructions.ts +110 -32
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +251 -57
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +92 -70
- package/src/runtime/team-runner.ts +186 -20
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +254 -70
- package/src/state/health-store.ts +71 -0
- package/src/state/locks.ts +102 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/state-store.ts +330 -43
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +2 -2
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +281 -10
- package/src/worktree/cleanup.ts +112 -24
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
|
@@ -0,0 +1,1268 @@
|
|
|
1
|
+
# Fallow Patterns Adoption - Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Adopt 5 high-value patterns from fallow (Rust static analyzer) into pi-crew: structured error codes, atomic write v2 with fsync, health score system, plugin registry, and stable task ID refinement.
|
|
6
|
+
|
|
7
|
+
**Architecture:** 5 independent subsystems — each new file/module is self-contained and backward-compatible with existing pi-crew state layer. No existing file deleted until migration complete.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript (Node.js), existing pi-crew state layer (`src/state/`), existing task-graph (`src/runtime/task-graph.ts`), existing task-id (`src/runtime/task-id.ts`)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Map
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
src/
|
|
17
|
+
errors.ts # NEW: CrewError + ErrorCode enum (E001-E006)
|
|
18
|
+
state/
|
|
19
|
+
atomic-write-v2.ts # NEW: AtomicWriter with fsync + rename (coexists with atomic-write.ts)
|
|
20
|
+
health-store.ts # NEW: RunHealth score computation + snapshot persistence
|
|
21
|
+
runtime/
|
|
22
|
+
task-health.ts # NEW: computeRunHealth(), scoreToGrade(), penalty constants
|
|
23
|
+
plugins/
|
|
24
|
+
plugin-registry.ts # NEW: Plugin interface + PluginRegistry class
|
|
25
|
+
plugin-define.ts # NEW: definePlugin() helper
|
|
26
|
+
plugins/
|
|
27
|
+
index.ts # NEW: re-exports all built-in plugins
|
|
28
|
+
nextjs.ts # NEW: NextJsPlugin
|
|
29
|
+
vitest.ts # NEW: VitestPlugin
|
|
30
|
+
vite.ts # NEW: VitePlugin
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Existing files (read-only during implementation, migrate after):
|
|
34
|
+
- `src/state/atomic-write.ts` — kept until all callers migrate
|
|
35
|
+
- `src/state/state-store.ts` — use CrewError after migration
|
|
36
|
+
- `src/runtime/task-id.ts` — extend, not replace
|
|
37
|
+
- `src/runtime/task-graph.ts` — already has cycle detection + topological sort
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Task 1: CrewError + ErrorCode System
|
|
42
|
+
|
|
43
|
+
**Files:**
|
|
44
|
+
- Create: `src/errors.ts`
|
|
45
|
+
- Test: `test/unit/errors.test.ts` (new)
|
|
46
|
+
- Modify: `src/state/state-store.ts:400-420` (error throwing sites)
|
|
47
|
+
|
|
48
|
+
- [ ] **Step 1: Write the failing test**
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// test/unit/errors.test.ts
|
|
52
|
+
import { describe, it, expect } from "vitest";
|
|
53
|
+
import { CrewError, ErrorCode, errors } from "../../src/errors.ts";
|
|
54
|
+
|
|
55
|
+
describe("CrewError", () => {
|
|
56
|
+
it("formats with error code", () => {
|
|
57
|
+
const err = new CrewError(ErrorCode.TaskNotFound, "Task 'xyz' not found");
|
|
58
|
+
expect(err.toString()).toBe("error[E003]: Task 'xyz' not found");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("formats with context", () => {
|
|
62
|
+
const err = new CrewError(ErrorCode.FileReadError, "Failed to read manifest.json")
|
|
63
|
+
.withContext("while loading run state");
|
|
64
|
+
const str = err.toString();
|
|
65
|
+
expect(str).toContain("error[E001]:");
|
|
66
|
+
expect(str).toContain("context: while loading run state");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("formats with help", () => {
|
|
70
|
+
const err = new CrewError(ErrorCode.ConfigError, "parse failure")
|
|
71
|
+
.withHelp("Try running `team init`");
|
|
72
|
+
const str = err.toString();
|
|
73
|
+
expect(str).toContain("help: Try running `team init`");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("has default help for E001-E006", () => {
|
|
77
|
+
expect(errors.fileRead("x.txt", { code: "ENOENT" } as NodeJS.ErrnoException).help).toBeDefined();
|
|
78
|
+
expect(errors.taskNotFound("t1").help).toBeDefined();
|
|
79
|
+
expect(errors.config("bad").help).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("is instanceof Error", () => {
|
|
83
|
+
expect(new CrewError(ErrorCode.FileWriteError, "x")).toBeInstanceOf(Error);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("factory methods produce correct codes", () => {
|
|
87
|
+
expect(errors.fileRead("x", {} as NodeJS.ErrnoException).code).toBe(ErrorCode.FileReadError);
|
|
88
|
+
expect(errors.taskNotFound("t1").code).toBe(ErrorCode.TaskNotFound);
|
|
89
|
+
expect(errors.invalidStatusTransition("running", "queued").code).toBe(ErrorCode.InvalidStatusTransition);
|
|
90
|
+
expect(errors.resourceNotFound("agent", "my-agent").code).toBe(ErrorCode.ResourceNotFound);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
96
|
+
|
|
97
|
+
Run: `node --experimental-strip-types --test --test-timeout=30000 test/unit/errors.test.ts`
|
|
98
|
+
Expected: FAIL with "Cannot find module '../../src/errors.ts'"
|
|
99
|
+
|
|
100
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// src/errors.ts
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Error code taxonomy for pi-crew.
|
|
107
|
+
* Maps to semantic categories matching fallow's E001-E004 pattern.
|
|
108
|
+
*/
|
|
109
|
+
export enum ErrorCode {
|
|
110
|
+
FileReadError = "E001", // Cannot read a file
|
|
111
|
+
FileWriteError = "E002", // Cannot write a file
|
|
112
|
+
TaskNotFound = "E003", // Referenced task ID does not exist
|
|
113
|
+
InvalidStatusTransition = "E004", // Run/task status cannot legally transition
|
|
114
|
+
ConfigError = "E005", // Malformed config or missing required field
|
|
115
|
+
ResourceNotFound = "E006", // Agent/team/workflow not found in discovery paths
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const DEFAULT_HELP: Record<ErrorCode, string | undefined> = {
|
|
119
|
+
[ErrorCode.FileReadError]: "Check that the file exists and that the process has read permission.",
|
|
120
|
+
[ErrorCode.FileWriteError]: "Check that the disk is not full and that the process has write permission.",
|
|
121
|
+
[ErrorCode.TaskNotFound]: "The task may have been removed or the run may be in an inconsistent state. Use `team status` to verify.",
|
|
122
|
+
[ErrorCode.InvalidStatusTransition]: "Verify the run status using `team status` before retrying.",
|
|
123
|
+
[ErrorCode.ConfigError]: "Check the configuration file for syntax errors or missing required fields.",
|
|
124
|
+
[ErrorCode.ResourceNotFound]: "Use `team list` to see available agents, teams, and workflows.",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Structured error type for pi-crew.
|
|
129
|
+
* Display format:
|
|
130
|
+
* error[E001]: Failed to read manifest.json: not found
|
|
131
|
+
* context: while loading run state
|
|
132
|
+
* help: Check that the file exists and that the process has read permission.
|
|
133
|
+
*/
|
|
134
|
+
export class CrewError extends Error {
|
|
135
|
+
readonly code: ErrorCode;
|
|
136
|
+
readonly help?: string;
|
|
137
|
+
private _context?: string;
|
|
138
|
+
|
|
139
|
+
constructor(code: ErrorCode, message: string, help?: string) {
|
|
140
|
+
super(message);
|
|
141
|
+
this.name = "CrewError";
|
|
142
|
+
this.code = code;
|
|
143
|
+
this.help = help ?? DEFAULT_HELP[code];
|
|
144
|
+
Object.defineProperty(this, "message", { enumerable: true });
|
|
145
|
+
Object.defineProperty(this, "code", { enumerable: true });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
withContext(context: string): this {
|
|
149
|
+
this._context = context;
|
|
150
|
+
return this;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
withHelp(help: string): this {
|
|
154
|
+
this.help = help;
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
toString(): string {
|
|
159
|
+
let out = `error[${this.code}]: ${this.message}`;
|
|
160
|
+
if (this._context) out += `\n context: ${this._context}`;
|
|
161
|
+
if (this.help) out += `\n help: ${this.help}`;
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const errors = {
|
|
167
|
+
fileRead(path: string, source: NodeJS.ErrnoException): CrewError {
|
|
168
|
+
return new CrewError(
|
|
169
|
+
ErrorCode.FileReadError,
|
|
170
|
+
`Failed to read ${path}: ${source.code?.toLowerCase() ?? "unknown"}`,
|
|
171
|
+
).withContext("file system read operation");
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
fileWrite(path: string, source: NodeJS.ErrnoException): CrewError {
|
|
175
|
+
return new CrewError(
|
|
176
|
+
ErrorCode.FileWriteError,
|
|
177
|
+
`Failed to write ${path}: ${source.code?.toLowerCase() ?? "unknown"}`,
|
|
178
|
+
).withContext("file system write operation");
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
taskNotFound(taskId: string, runId?: string): CrewError {
|
|
182
|
+
const msg = runId
|
|
183
|
+
? `Task '${taskId}' not found in run '${runId}'`
|
|
184
|
+
: `Task '${taskId}' not found`;
|
|
185
|
+
return new CrewError(ErrorCode.TaskNotFound, msg);
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
invalidStatusTransition(from: string, to: string): CrewError {
|
|
189
|
+
return new CrewError(
|
|
190
|
+
ErrorCode.InvalidStatusTransition,
|
|
191
|
+
`Invalid run status transition: ${from} → ${to}`,
|
|
192
|
+
);
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
config(message: string): CrewError {
|
|
196
|
+
return new CrewError(ErrorCode.ConfigError, message);
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
resourceNotFound(type: string, name: string): CrewError {
|
|
200
|
+
return new CrewError(
|
|
201
|
+
ErrorCode.ResourceNotFound,
|
|
202
|
+
`${type} '${name}' not found in any discovery path`,
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
} as const;
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
209
|
+
|
|
210
|
+
Run: `node --experimental-strip-types --test --test-timeout=30000 test/unit/errors.test.ts`
|
|
211
|
+
Expected: PASS
|
|
212
|
+
|
|
213
|
+
- [ ] **Step 5: Commit**
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
git add src/errors.ts test/unit/errors.test.ts
|
|
217
|
+
git commit -m "feat: add CrewError with E001-E006 error codes
|
|
218
|
+
|
|
219
|
+
Adds structured error type matching fallow's error model.
|
|
220
|
+
- ErrorCode enum: FileRead, FileWrite, TaskNotFound, InvalidStatusTransition, ConfigError, ResourceNotFound
|
|
221
|
+
- CrewError with builder pattern: withContext(), withHelp()
|
|
222
|
+
- Factory constructors in errors namespace
|
|
223
|
+
- Display format: error[E001]: message\\n context: ...\\n help: ..."
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Task 2: Atomic Write v2 with fsync
|
|
229
|
+
|
|
230
|
+
**Files:**
|
|
231
|
+
- Create: `src/state/atomic-write-v2.ts`
|
|
232
|
+
- Test: `test/unit/atomic-write-v2.test.ts` (new)
|
|
233
|
+
- Modify: `src/state/state-store.ts` (switch one call-site first — manifest write)
|
|
234
|
+
|
|
235
|
+
- [ ] **Step 1: Write the failing test**
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
// test/unit/atomic-write-v2.test.ts
|
|
239
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
240
|
+
import * as fs from "node:fs";
|
|
241
|
+
import * as path from "node:path";
|
|
242
|
+
import { AtomicWriter } from "../../src/state/atomic-write-v2.ts";
|
|
243
|
+
|
|
244
|
+
describe("AtomicWriter", () => {
|
|
245
|
+
const tmpDir = fs.mkdtempSync("/tmp/atomic-test-");
|
|
246
|
+
const writer = new AtomicWriter(tmpDir);
|
|
247
|
+
|
|
248
|
+
afterEach(() => {
|
|
249
|
+
// Clean up test files
|
|
250
|
+
for (const f of fs.readdirSync(tmpDir)) {
|
|
251
|
+
fs.unlinkSync(path.join(tmpDir, f));
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("writes file atomically (file exists after write)", () => {
|
|
256
|
+
const target = path.join(tmpDir, "test.json");
|
|
257
|
+
writer.writeJsonSync(target, { foo: "bar" });
|
|
258
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
259
|
+
expect(JSON.parse(fs.readFileSync(target, "utf8"))).toEqual({ foo: "bar" });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("overwrites existing file atomically", () => {
|
|
263
|
+
const target = path.join(tmpDir, "existing.json");
|
|
264
|
+
fs.writeFileSync(target, '{"old": true}', "utf8");
|
|
265
|
+
writer.writeJsonSync(target, { new: true });
|
|
266
|
+
expect(JSON.parse(fs.readFileSync(target, "utf8"))).toEqual({ new: true });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("writes .gitignore to directory on first use", () => {
|
|
270
|
+
const subDir = path.join(tmpDir, "sub");
|
|
271
|
+
fs.mkdirSync(subDir);
|
|
272
|
+
const target = path.join(subDir, "data.json");
|
|
273
|
+
writer.writeJsonSync(target, {});
|
|
274
|
+
const gitignore = path.join(subDir, ".gitignore");
|
|
275
|
+
expect(fs.existsSync(gitignore)).toBe(true);
|
|
276
|
+
expect(fs.readFileSync(gitignore, "utf8")).toBe("*\\n");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("async write works", async () => {
|
|
280
|
+
const target = path.join(tmpDir, "async.json");
|
|
281
|
+
await writer.writeJsonAsync(target, { async: true });
|
|
282
|
+
expect(JSON.parse(fs.readFileSync(target, "utf8"))).toEqual({ async: true });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("uses UUID in tmp file name", () => {
|
|
286
|
+
const target = path.join(tmpDir, "uuid-test.json");
|
|
287
|
+
// Write triggers tmp file creation — verify no collision
|
|
288
|
+
writer.writeJsonSync(target, { x: 1 });
|
|
289
|
+
// Should succeed without errors
|
|
290
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
296
|
+
|
|
297
|
+
Run: `node --experimental-strip-types --test --test-timeout=30000 test/unit/atomic-write-v2.test.ts`
|
|
298
|
+
Expected: FAIL with "Cannot find module '../../src/state/atomic-write-v2.ts'"
|
|
299
|
+
|
|
300
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
// src/state/atomic-write-v2.ts
|
|
304
|
+
|
|
305
|
+
import * as crypto from "node:crypto";
|
|
306
|
+
import * as fs from "node:fs";
|
|
307
|
+
import * as path from "node:path";
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Fallow-inspired atomic writer: write-to-.tmp → fsync → rename.
|
|
311
|
+
*
|
|
312
|
+
* Key differences from atomic-write.ts:
|
|
313
|
+
* - Uses rename() (POSIX-atomic) instead of link()+unlink()
|
|
314
|
+
* - Calls fsyncSync() on the temp file before rename
|
|
315
|
+
* - Best-effort fsync (failure does not abort)
|
|
316
|
+
* - Writes .gitignore to directory on first use
|
|
317
|
+
* - UUID-based tmp file to prevent collisions under concurrent writes
|
|
318
|
+
*/
|
|
319
|
+
export class AtomicWriter {
|
|
320
|
+
private initializedDirs = new Set<string>();
|
|
321
|
+
|
|
322
|
+
constructor(private baseDir: string) {}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Synchronously write content atomically to targetPath.
|
|
326
|
+
* 1. mkdir -p parent directory
|
|
327
|
+
* 2. Write to targetPath.{uuid}.tmp
|
|
328
|
+
* 3. fsyncSync the temp file (best-effort)
|
|
329
|
+
* 4. rename() to targetPath (POSIX-atomic on same filesystem)
|
|
330
|
+
*/
|
|
331
|
+
writeSync(targetPath: string, content: string): void {
|
|
332
|
+
this.ensureParentDir(targetPath);
|
|
333
|
+
const tmpPath = this.tmpPath(targetPath);
|
|
334
|
+
const fd = fs.openSync(tmpPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
335
|
+
try {
|
|
336
|
+
fs.writeSync(fd, content, undefined, "utf8");
|
|
337
|
+
// Best-effort fsync — failure does not abort
|
|
338
|
+
try { fs.fsyncSync(fd); } catch { /* best-effort */ }
|
|
339
|
+
} finally {
|
|
340
|
+
fs.closeSync(fd);
|
|
341
|
+
}
|
|
342
|
+
// rename() is atomic on POSIX for same-filesystem destinations
|
|
343
|
+
fs.renameSync(tmpPath, targetPath);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async writeAsync(targetPath: string, content: string): Promise<void> {
|
|
347
|
+
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
|
348
|
+
const tmpPath = this.tmpPath(targetPath);
|
|
349
|
+
const fd = await fs.promises.open(tmpPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
350
|
+
try {
|
|
351
|
+
await fd.writeFile(content, "utf8");
|
|
352
|
+
try { await fd.sync(); } catch { /* best-effort */ }
|
|
353
|
+
} finally {
|
|
354
|
+
await fd.close();
|
|
355
|
+
}
|
|
356
|
+
await fs.promises.rename(tmpPath, targetPath);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
writeJsonSync<T>(targetPath: string, value: T): void {
|
|
360
|
+
this.writeSync(targetPath, JSON.stringify(value, null, 2) + "\n");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async writeJsonAsync<T>(targetPath: string, value: T): Promise<void> {
|
|
364
|
+
await this.writeAsync(targetPath, JSON.stringify(value, null, 2) + "\n");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private tmpPath(targetPath: string): string {
|
|
368
|
+
const uuid = crypto.randomUUID();
|
|
369
|
+
return `${targetPath}.${uuid}.tmp`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private ensureParentDir(targetPath: string): void {
|
|
373
|
+
const dir = path.dirname(targetPath);
|
|
374
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
375
|
+
this.ensureGitignore(dir);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private ensureGitignore(dir: string): void {
|
|
379
|
+
if (this.initializedDirs.has(dir)) return;
|
|
380
|
+
this.initializedDirs.add(dir);
|
|
381
|
+
const gitignorePath = path.join(dir, ".gitignore");
|
|
382
|
+
try { fs.accessSync(gitignorePath); } catch {
|
|
383
|
+
try { fs.writeFileSync(gitignorePath, "*\n", "utf8"); } catch { /* best-effort */ }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
390
|
+
|
|
391
|
+
Run: `node --experimental-strip-types --test --test-timeout=30000 test/unit/atomic-write-v2.test.ts`
|
|
392
|
+
Expected: PASS
|
|
393
|
+
|
|
394
|
+
- [ ] **Step 5: Commit**
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
git add src/state/atomic-write-v2.ts test/unit/atomic-write-v2.test.ts
|
|
398
|
+
git commit -m "feat: add AtomicWriter v2 with fsync + rename pattern
|
|
399
|
+
|
|
400
|
+
Fallow-inspired atomic write:
|
|
401
|
+
- write-to-.tmp → fsyncSync() → rename() (POSIX-atomic)
|
|
402
|
+
- Best-effort fsync (failure does not abort)
|
|
403
|
+
- UUID-based tmp filename prevents concurrent write collisions
|
|
404
|
+
- .gitignore written to directory on first use
|
|
405
|
+
Coexists with atomic-write.ts during migration."
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
## Task 3: Health Score System
|
|
411
|
+
|
|
412
|
+
**Files:**
|
|
413
|
+
- Create: `src/runtime/task-health.ts`
|
|
414
|
+
- Create: `src/state/health-store.ts`
|
|
415
|
+
- Test: `test/unit/task-health.test.ts` (new)
|
|
416
|
+
|
|
417
|
+
- [ ] **Step 1: Write the failing test**
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
// test/unit/task-health.test.ts
|
|
421
|
+
import { describe, it, expect } from "vitest";
|
|
422
|
+
import { computeRunHealth, scoreToGrade } from "../../src/runtime/task-health.ts";
|
|
423
|
+
import type { TeamRunManifest } from "../../src/state/types.ts";
|
|
424
|
+
|
|
425
|
+
describe("scoreToGrade", () => {
|
|
426
|
+
it("maps 90-100 to A", () => {
|
|
427
|
+
expect(scoreToGrade(95)).toBe("A");
|
|
428
|
+
expect(scoreToGrade(90)).toBe("A");
|
|
429
|
+
});
|
|
430
|
+
it("maps 70-89 to B", () => {
|
|
431
|
+
expect(scoreToGrade(70)).toBe("B");
|
|
432
|
+
expect(scoreToGrade(89)).toBe("B");
|
|
433
|
+
});
|
|
434
|
+
it("maps 50-69 to C", () => {
|
|
435
|
+
expect(scoreToGrade(50)).toBe("C");
|
|
436
|
+
expect(scoreToGrade(69)).toBe("C");
|
|
437
|
+
});
|
|
438
|
+
it("maps 30-49 to D", () => {
|
|
439
|
+
expect(scoreToGrade(30)).toBe("D");
|
|
440
|
+
expect(scoreToGrade(49)).toBe("D");
|
|
441
|
+
});
|
|
442
|
+
it("maps 0-29 to F", () => {
|
|
443
|
+
expect(scoreToGrade(0)).toBe("F");
|
|
444
|
+
expect(scoreToGrade(29)).toBe("F");
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe("computeRunHealth", () => {
|
|
449
|
+
it("returns perfect score for all tasks completed", () => {
|
|
450
|
+
const manifest = makeManifest([
|
|
451
|
+
{ id: "t1", status: "completed" },
|
|
452
|
+
{ id: "t2", status: "completed" },
|
|
453
|
+
]);
|
|
454
|
+
const health = computeRunHealth(manifest);
|
|
455
|
+
expect(health.score).toBe(100);
|
|
456
|
+
expect(health.grade).toBe("A");
|
|
457
|
+
expect(health.penalties).toHaveLength(0);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("applies high-failure-rate penalty", () => {
|
|
461
|
+
const manifest = makeManifest([
|
|
462
|
+
{ id: "t1", status: "completed" },
|
|
463
|
+
{ id: "t2", status: "failed" },
|
|
464
|
+
{ id: "t3", status: "failed" },
|
|
465
|
+
]);
|
|
466
|
+
const health = computeRunHealth(manifest);
|
|
467
|
+
expect(health.score).toBeLessThan(100);
|
|
468
|
+
expect(health.penalties.some(p => p.reason === "high-failure-rate")).toBe(true);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("applies stalled-tasks penalty", () => {
|
|
472
|
+
const manifest = makeManifest([
|
|
473
|
+
{ id: "t1", status: "completed" },
|
|
474
|
+
{ id: "t2", status: "running", stalledSince: Date.now() - 600_000 }, // stalled 10min
|
|
475
|
+
]);
|
|
476
|
+
const health = computeRunHealth(manifest);
|
|
477
|
+
expect(health.penalties.some(p => p.reason === "stalled-tasks")).toBe(true);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("clamps score to [0, 100]", () => {
|
|
481
|
+
const manifest = makeManifest([
|
|
482
|
+
{ id: "t1", status: "failed" },
|
|
483
|
+
{ id: "t2", status: "failed" },
|
|
484
|
+
{ id: "t3", status: "failed" },
|
|
485
|
+
{ id: "t4", status: "failed" },
|
|
486
|
+
{ id: "t5", status: "failed" },
|
|
487
|
+
{ id: "t6", status: "running", stalledSince: Date.now() - 600_000 },
|
|
488
|
+
]);
|
|
489
|
+
const health = computeRunHealth(manifest);
|
|
490
|
+
expect(health.score).toBeGreaterThanOrEqual(0);
|
|
491
|
+
expect(health.score).toBeLessThanOrEqual(100);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("returns deltas from previous snapshot", () => {
|
|
495
|
+
// Test that deltas are computed (even if empty when no previous)
|
|
496
|
+
const manifest = makeManifest([{ id: "t1", status: "completed" }]);
|
|
497
|
+
const health = computeRunHealth(manifest);
|
|
498
|
+
expect(Array.isArray(health.deltas)).toBe(true);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
504
|
+
|
|
505
|
+
Run: `node --experimental-strip-types --test --test-timeout=30000 test/unit/task-health.test.ts`
|
|
506
|
+
Expected: FAIL with "Cannot find module '../../src/runtime/task-health.ts'"
|
|
507
|
+
|
|
508
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
509
|
+
|
|
510
|
+
```typescript
|
|
511
|
+
// src/runtime/task-health.ts
|
|
512
|
+
|
|
513
|
+
export type HealthGrade = "A" | "B" | "C" | "D" | "F";
|
|
514
|
+
|
|
515
|
+
export interface HealthPenalty {
|
|
516
|
+
reason: string;
|
|
517
|
+
deduction: number;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export interface HealthDelta {
|
|
521
|
+
metric: string;
|
|
522
|
+
delta: number;
|
|
523
|
+
trend: "improving" | "degrading" | "stable";
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export interface RunHealth {
|
|
527
|
+
score: number; // 0-100
|
|
528
|
+
grade: HealthGrade;
|
|
529
|
+
penalties: HealthPenalty[];
|
|
530
|
+
deltas: HealthDelta[];
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const STALLED_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
534
|
+
|
|
535
|
+
export function scoreToGrade(score: number): HealthGrade {
|
|
536
|
+
if (score >= 90) return "A";
|
|
537
|
+
if (score >= 70) return "B";
|
|
538
|
+
if (score >= 50) return "C";
|
|
539
|
+
if (score >= 30) return "D";
|
|
540
|
+
return "F";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export interface TaskSummary {
|
|
544
|
+
id: string;
|
|
545
|
+
status: string;
|
|
546
|
+
stalledSince?: number;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export interface ManifestSummary {
|
|
550
|
+
runId: string;
|
|
551
|
+
tasks: TaskSummary[];
|
|
552
|
+
createdAt: string;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Compute health score for a run manifest.
|
|
557
|
+
* Penalty-based scoring (matching fallow's vital_signs.rs approach).
|
|
558
|
+
*/
|
|
559
|
+
export function computeRunHealth(manifest: ManifestSummary): RunHealth {
|
|
560
|
+
const penalties: HealthPenalty[] = [];
|
|
561
|
+
const tasks = manifest.tasks;
|
|
562
|
+
const taskCount = tasks.length;
|
|
563
|
+
if (taskCount === 0) return { score: 100, grade: "A", penalties: [], deltas: [] };
|
|
564
|
+
|
|
565
|
+
const failedCount = tasks.filter(t => t.status === "failed").length;
|
|
566
|
+
const stalledCount = tasks.filter(t =>
|
|
567
|
+
t.stalledSince !== undefined && (Date.now() - t.stalledSince) > STALLED_THRESHOLD_MS
|
|
568
|
+
).length;
|
|
569
|
+
|
|
570
|
+
// High failure rate penalty: >20% failures
|
|
571
|
+
const failureRate = failedCount / taskCount;
|
|
572
|
+
if (failureRate > 0.2) {
|
|
573
|
+
penalties.push({ reason: "high-failure-rate", deduction: Math.round(failureRate * 50) });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Stalled tasks penalty
|
|
577
|
+
if (stalledCount > 0) {
|
|
578
|
+
penalties.push({ reason: "stalled-tasks", deduction: Math.min(15, stalledCount * 5) });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Block depth penalty (for tasks with deep dependency chains — using task count as proxy)
|
|
582
|
+
if (taskCount > 20) {
|
|
583
|
+
penalties.push({ reason: "large-task-count", deduction: Math.min(10, Math.floor((taskCount - 20) / 10)) });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const totalDeduction = penalties.reduce((sum, p) => sum + p.deduction, 0);
|
|
587
|
+
const score = Math.max(0, Math.min(100, 100 - totalDeduction));
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
score,
|
|
591
|
+
grade: scoreToGrade(score),
|
|
592
|
+
penalties,
|
|
593
|
+
deltas: [], // Filled by health-store when comparing to previous snapshot
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
599
|
+
|
|
600
|
+
Run: `node --experimental-strip-types --test --test-timeout=30000 test/unit/task-health.test.ts`
|
|
601
|
+
Expected: PASS
|
|
602
|
+
|
|
603
|
+
- [ ] **Step 5: Write health-store with snapshot persistence**
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
// src/state/health-store.ts
|
|
607
|
+
|
|
608
|
+
import * as fs from "node:fs";
|
|
609
|
+
import * as path from "node:path";
|
|
610
|
+
import type { RunHealth } from "../runtime/task-health.ts";
|
|
611
|
+
import { computeRunHealth } from "../runtime/task-health.ts";
|
|
612
|
+
import type { ManifestSummary } from "../runtime/task-health.ts";
|
|
613
|
+
|
|
614
|
+
const HEALTH_DIR = ".crew/state/health";
|
|
615
|
+
|
|
616
|
+
export interface HealthSnapshot {
|
|
617
|
+
runId: string;
|
|
618
|
+
timestamp: number;
|
|
619
|
+
gitRef?: string;
|
|
620
|
+
score: number;
|
|
621
|
+
grade: string;
|
|
622
|
+
penalties: { reason: string; deduction: number }[];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export class HealthStore {
|
|
626
|
+
constructor(private crewRoot: string) {}
|
|
627
|
+
|
|
628
|
+
private healthDir(): string {
|
|
629
|
+
return path.join(this.crewRoot, HEALTH_DIR);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Save a health snapshot for the run.
|
|
634
|
+
*/
|
|
635
|
+
saveSnapshot(manifest: ManifestSummary): HealthSnapshot {
|
|
636
|
+
const health = computeRunHealth(manifest as ManifestSummary);
|
|
637
|
+
const snapshot: HealthSnapshot = {
|
|
638
|
+
runId: manifest.runId,
|
|
639
|
+
timestamp: Date.now(),
|
|
640
|
+
score: health.score,
|
|
641
|
+
grade: health.grade,
|
|
642
|
+
penalties: health.penalties,
|
|
643
|
+
};
|
|
644
|
+
const dir = this.healthDir();
|
|
645
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
646
|
+
const file = path.join(dir, `${manifest.runId}.json`);
|
|
647
|
+
fs.writeFileSync(file, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
|
648
|
+
return snapshot;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Load the most recent health snapshot (for trend computation).
|
|
653
|
+
*/
|
|
654
|
+
loadLatestSnapshot(): HealthSnapshot | null {
|
|
655
|
+
const dir = this.healthDir();
|
|
656
|
+
if (!fs.existsSync(dir)) return null;
|
|
657
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json"));
|
|
658
|
+
if (files.length === 0) return null;
|
|
659
|
+
// Sort by timestamp descending
|
|
660
|
+
files.sort().reverse();
|
|
661
|
+
try {
|
|
662
|
+
return JSON.parse(fs.readFileSync(path.join(dir, files[0]), "utf8"));
|
|
663
|
+
} catch {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Load all snapshots (for trend analysis).
|
|
670
|
+
*/
|
|
671
|
+
loadAllSnapshots(): HealthSnapshot[] {
|
|
672
|
+
const dir = this.healthDir();
|
|
673
|
+
if (!fs.existsSync(dir)) return [];
|
|
674
|
+
return fs.readdirSync(dir)
|
|
675
|
+
.filter(f => f.endsWith(".json"))
|
|
676
|
+
.map(f => {
|
|
677
|
+
try {
|
|
678
|
+
return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
679
|
+
} catch {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
})
|
|
683
|
+
.filter(Boolean) as HealthSnapshot[];
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
- [ ] **Step 6: Commit**
|
|
689
|
+
|
|
690
|
+
```bash
|
|
691
|
+
git add src/runtime/task-health.ts src/state/health-store.ts test/unit/task-health.test.ts
|
|
692
|
+
git commit -m "feat: add health score system with penalty-based scoring
|
|
693
|
+
|
|
694
|
+
- computeRunHealth() with failure-rate, stalled-tasks, large-task-count penalties
|
|
695
|
+
- scoreToGrade() mapping 0-29=F, 30-49=D, 50-69=C, 70-89=B, 90-100=A
|
|
696
|
+
- HealthStore with snapshot persistence to .crew/state/health/
|
|
697
|
+
- loadLatestSnapshot() for trend computation
|
|
698
|
+
Coexists with existing state layer — does not modify manifest schema."
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
---
|
|
702
|
+
|
|
703
|
+
## Task 4: Plugin Registry
|
|
704
|
+
|
|
705
|
+
**Files:**
|
|
706
|
+
- Create: `src/plugins/plugin-define.ts`
|
|
707
|
+
- Create: `src/plugins/plugin-registry.ts`
|
|
708
|
+
- Create: `src/plugins/plugins/nextjs.ts`
|
|
709
|
+
- Create: `src/plugins/plugins/vitest.ts`
|
|
710
|
+
- Create: `src/plugins/plugins/vite.ts`
|
|
711
|
+
- Create: `src/plugins/plugins/index.ts`
|
|
712
|
+
- Test: `test/unit/plugin-registry.test.ts` (new)
|
|
713
|
+
|
|
714
|
+
- [ ] **Step 1: Write the failing test**
|
|
715
|
+
|
|
716
|
+
```typescript
|
|
717
|
+
// test/unit/plugin-registry.test.ts
|
|
718
|
+
import { describe, it, expect } from "vitest";
|
|
719
|
+
import { PluginRegistry } from "../../src/plugins/plugin-registry.ts";
|
|
720
|
+
import { definePlugin } from "../../src/plugins/plugin-define.ts";
|
|
721
|
+
import { NextJsPlugin } from "../../src/plugins/plugins/nextjs.ts";
|
|
722
|
+
import { VitestPlugin } from "../../src/plugins/plugins/vitest.ts";
|
|
723
|
+
|
|
724
|
+
describe("PluginRegistry", () => {
|
|
725
|
+
it("activates plugin by exact package name", () => {
|
|
726
|
+
const registry = new PluginRegistry();
|
|
727
|
+
registry.register(definePlugin({
|
|
728
|
+
name: "test-plugin",
|
|
729
|
+
enablers: ["test-pkg"],
|
|
730
|
+
}));
|
|
731
|
+
const active = registry.activePlugins(["test-pkg", "other-pkg"]);
|
|
732
|
+
expect(active.map(p => p.name)).toContain("test-plugin");
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it("activates plugin by prefix match (family)", () => {
|
|
736
|
+
const registry = new PluginRegistry();
|
|
737
|
+
registry.register(definePlugin({
|
|
738
|
+
name: "storybook-plugin",
|
|
739
|
+
enablers: ["@storybook/"],
|
|
740
|
+
}));
|
|
741
|
+
const active = registry.activePlugins(["@storybook/react", "@storybook/vue"]);
|
|
742
|
+
expect(active.map(p => p.name)).toContain("storybook-plugin");
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("does not activate plugin with no matching dep", () => {
|
|
746
|
+
const registry = new PluginRegistry();
|
|
747
|
+
registry.register(definePlugin({
|
|
748
|
+
name: "nextjs-plugin",
|
|
749
|
+
enablers: ["next"],
|
|
750
|
+
}));
|
|
751
|
+
const active = registry.activePlugins(["react", "vite"]);
|
|
752
|
+
expect(active.map(p => p.name)).not.toContain("nextjs-plugin");
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("NextJsPlugin matches 'next' dependency", () => {
|
|
756
|
+
const registry = new PluginRegistry();
|
|
757
|
+
registry.register(NextJsPlugin);
|
|
758
|
+
const active = registry.activePlugins(["next", "react"]);
|
|
759
|
+
expect(active.map(p => p.name)).toContain("nextjs");
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("VitestPlugin matches 'vitest' dependency", () => {
|
|
763
|
+
const registry = new PluginRegistry();
|
|
764
|
+
registry.register(VitestPlugin);
|
|
765
|
+
const active = registry.activePlugins(["vitest", "typescript"]);
|
|
766
|
+
expect(active.map(p => p.name)).toContain("vitest");
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
describe("definePlugin", () => {
|
|
771
|
+
it("returns the plugin spec unchanged", () => {
|
|
772
|
+
const plugin = definePlugin({ name: "my-plugin", enablers: ["my-pkg"] });
|
|
773
|
+
expect(plugin.name).toBe("my-plugin");
|
|
774
|
+
expect(plugin.enablers).toEqual(["my-pkg"]);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
780
|
+
|
|
781
|
+
Run: `node --experimental-strip-types --test --test-timeout=30000 test/unit/plugin-registry.test.ts`
|
|
782
|
+
Expected: FAIL with "Cannot find module '../../src/plugins/plugin-registry.ts'"
|
|
783
|
+
|
|
784
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
785
|
+
|
|
786
|
+
```typescript
|
|
787
|
+
// src/plugins/plugin-define.ts
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Simplifies plugin struct definition.
|
|
791
|
+
* Usage:
|
|
792
|
+
* export const MyPlugin = definePlugin({
|
|
793
|
+
* name: "my-plugin",
|
|
794
|
+
* enablers: ["my-pkg"],
|
|
795
|
+
* entryPatterns: ["src/**/*.ts"],
|
|
796
|
+
* });
|
|
797
|
+
*/
|
|
798
|
+
export function definePlugin<T extends { name: string; enablers: readonly string[] }>(spec: T): T {
|
|
799
|
+
return spec;
|
|
800
|
+
}
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
```typescript
|
|
804
|
+
// src/plugins/plugin-registry.ts
|
|
805
|
+
|
|
806
|
+
import type { Plugin } from "./plugin-define.ts";
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Unified plugin interface for pi-crew.
|
|
810
|
+
* Provides framework/tool detection via package.json dependencies.
|
|
811
|
+
*/
|
|
812
|
+
export interface Plugin {
|
|
813
|
+
readonly name: string;
|
|
814
|
+
readonly enablers: readonly string[];
|
|
815
|
+
readonly entryPatterns?: readonly string[];
|
|
816
|
+
readonly configPatterns?: readonly string[];
|
|
817
|
+
readonly toolingDependencies?: readonly string[];
|
|
818
|
+
readonly pathAliases?: readonly [string, string][];
|
|
819
|
+
readonly virtualModulePrefixes?: readonly string[];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Registry that collects plugins and determines which are active
|
|
824
|
+
* based on a package.json's dependencies.
|
|
825
|
+
*/
|
|
826
|
+
export class PluginRegistry {
|
|
827
|
+
private plugins: Plugin[] = [];
|
|
828
|
+
|
|
829
|
+
register(plugin: Plugin): void {
|
|
830
|
+
this.plugins.push(plugin);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Determine which plugins are active given a list of package dependencies.
|
|
835
|
+
* Matching rules:
|
|
836
|
+
* - Exact string match: "next" matches "next"
|
|
837
|
+
* - Prefix match (ending in "/"): "@storybook/" matches "@storybook/react"
|
|
838
|
+
*/
|
|
839
|
+
activePlugins(allDeps: string[]): Plugin[] {
|
|
840
|
+
return this.plugins.filter((p) =>
|
|
841
|
+
p.enablers.some((enabler) => {
|
|
842
|
+
if (enabler.endsWith("/")) {
|
|
843
|
+
return allDeps.some((d) => d.startsWith(enabler));
|
|
844
|
+
}
|
|
845
|
+
return allDeps.includes(enabler);
|
|
846
|
+
}),
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Get all registered plugins.
|
|
852
|
+
*/
|
|
853
|
+
allPlugins(): Plugin[] {
|
|
854
|
+
return [...this.plugins];
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
- [ ] **Step 4: Write built-in plugins**
|
|
860
|
+
|
|
861
|
+
```typescript
|
|
862
|
+
// src/plugins/plugins/nextjs.ts
|
|
863
|
+
|
|
864
|
+
import { definePlugin } from "../plugin-define.ts";
|
|
865
|
+
|
|
866
|
+
export const NextJsPlugin = definePlugin({
|
|
867
|
+
name: "nextjs",
|
|
868
|
+
enablers: ["next"],
|
|
869
|
+
entryPatterns: [
|
|
870
|
+
"src/app/**/*.{ts,tsx}",
|
|
871
|
+
"src/pages/**/*.{ts,tsx}",
|
|
872
|
+
"src/app/**/page.{ts,tsx}",
|
|
873
|
+
"src/app/**/layout.{ts,tsx}",
|
|
874
|
+
"src/app/**/route.{ts,tsx}",
|
|
875
|
+
"middleware.{ts,js}",
|
|
876
|
+
"next.config.{ts,js,mjs}",
|
|
877
|
+
],
|
|
878
|
+
configPatterns: ["next.config.{ts,js,mjs}"],
|
|
879
|
+
toolingDependencies: ["next", "@next/font", "@next/mdx"],
|
|
880
|
+
pathAliases: [["~", "src"]],
|
|
881
|
+
virtualModulePrefixes: ["next:"],
|
|
882
|
+
});
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
```typescript
|
|
886
|
+
// src/plugins/plugins/vitest.ts
|
|
887
|
+
|
|
888
|
+
import { definePlugin } from "../plugin-define.ts";
|
|
889
|
+
|
|
890
|
+
export const VitestPlugin = definePlugin({
|
|
891
|
+
name: "vitest",
|
|
892
|
+
enablers: ["vitest"],
|
|
893
|
+
entryPatterns: [
|
|
894
|
+
"**/*.test.{ts,tsx}",
|
|
895
|
+
"**/*.spec.{ts,tsx}",
|
|
896
|
+
"src/**/*.test.{ts,tsx}",
|
|
897
|
+
"src/**/*.spec.{ts,tsx}",
|
|
898
|
+
],
|
|
899
|
+
configPatterns: ["vitest.config.{ts,js,mjs}", "vite.config.ts"],
|
|
900
|
+
toolingDependencies: ["vitest"],
|
|
901
|
+
});
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
```typescript
|
|
905
|
+
// src/plugins/plugins/vite.ts
|
|
906
|
+
|
|
907
|
+
import { definePlugin } from "../plugin-define.ts";
|
|
908
|
+
|
|
909
|
+
export const VitePlugin = definePlugin({
|
|
910
|
+
name: "vite",
|
|
911
|
+
enablers: ["vite", "rolldown-vite"],
|
|
912
|
+
entryPatterns: [
|
|
913
|
+
"src/main.{ts,tsx,js,jsx}",
|
|
914
|
+
"src/index.{ts,tsx,js,jsx}",
|
|
915
|
+
"index.html",
|
|
916
|
+
],
|
|
917
|
+
configPatterns: ["vite.config.{ts,js,mts,mjs}"],
|
|
918
|
+
toolingDependencies: ["vite"],
|
|
919
|
+
virtualModulePrefixes: ["virtual:"],
|
|
920
|
+
});
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
// src/plugins/plugins/index.ts
|
|
925
|
+
|
|
926
|
+
export { NextJsPlugin } from "./nextjs.ts";
|
|
927
|
+
export { VitestPlugin } from "./vitest.ts";
|
|
928
|
+
export { VitePlugin } from "./vite.ts";
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
- [ ] **Step 5: Run test to verify it passes**
|
|
932
|
+
|
|
933
|
+
Run: `node --experimental-strip-types --test --test-timeout=30000 test/unit/plugin-registry.test.ts`
|
|
934
|
+
Expected: PASS
|
|
935
|
+
|
|
936
|
+
- [ ] **Step 6: Commit**
|
|
937
|
+
|
|
938
|
+
```bash
|
|
939
|
+
git add src/plugins/plugin-define.ts src/plugins/plugin-registry.ts src/plugins/plugins/nextjs.ts src/plugins/plugins/vitest.ts src/plugins/plugins/vite.ts src/plugins/plugins/index.ts test/unit/plugin-registry.test.ts
|
|
940
|
+
git commit -m "feat: add plugin registry system
|
|
941
|
+
|
|
942
|
+
Plugin system for pi-crew framework awareness:
|
|
943
|
+
- Plugin interface: name, enablers, entryPatterns, configPatterns, toolingDependencies, pathAliases
|
|
944
|
+
- PluginRegistry.activePlugins() with exact + prefix matching
|
|
945
|
+
- definePlugin() helper function
|
|
946
|
+
- Built-in plugins: NextJsPlugin, VitestPlugin, VitePlugin
|
|
947
|
+
Matches fallow's plugin architecture pattern."
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
---
|
|
951
|
+
|
|
952
|
+
## Task 5: Stable Task ID Refinement
|
|
953
|
+
|
|
954
|
+
**Files:**
|
|
955
|
+
- Modify: `src/runtime/task-id.ts` (extend generateTaskHashId)
|
|
956
|
+
- Test: modify `test/unit/task-id.test.ts` (add tests)
|
|
957
|
+
|
|
958
|
+
- [ ] **Step 1: Check existing tests**
|
|
959
|
+
|
|
960
|
+
Run: `node --experimental-strip-types --test --test-timeout=30000 test/unit/task-id.test.ts 2>&1 | head -20`
|
|
961
|
+
|
|
962
|
+
Expected: RUNNING (test file may not exist yet)
|
|
963
|
+
|
|
964
|
+
- [ ] **Step 2: Add generateTaskHashId tests**
|
|
965
|
+
|
|
966
|
+
```typescript
|
|
967
|
+
// Add to test/unit/task-id.test.ts (or create if doesn't exist)
|
|
968
|
+
import { describe, it, expect } from "vitest";
|
|
969
|
+
import { generateTaskHashId, hashToBase36, childId, parseHierarchicalId } from "../../src/runtime/task-id.ts";
|
|
970
|
+
|
|
971
|
+
describe("generateTaskHashId", () => {
|
|
972
|
+
it("generates deterministic ID from same parts", () => {
|
|
973
|
+
const id1 = generateTaskHashId(["fix bug", "task"]);
|
|
974
|
+
const id2 = generateTaskHashId(["fix bug", "task"]);
|
|
975
|
+
expect(id1).toBe(id2); // deterministic
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it("generates different IDs for different parts", () => {
|
|
979
|
+
const id1 = generateTaskHashId(["fix bug"]);
|
|
980
|
+
const id2 = generateTaskHashId(["fix feature"]);
|
|
981
|
+
expect(id1).not.toBe(id2);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it("starts with prefix", () => {
|
|
985
|
+
const id = generateTaskHashId(["test"], "custom");
|
|
986
|
+
expect(id.startsWith("custom-")).toBe(true);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it("generates hierarchical child ID", () => {
|
|
990
|
+
const parent = generateTaskHashId(["parent"]);
|
|
991
|
+
const child = childId(parent, 3);
|
|
992
|
+
expect(child).toBe(`${parent}.3`);
|
|
993
|
+
});
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
describe("hashToBase36", () => {
|
|
997
|
+
it("produces correct length output", () => {
|
|
998
|
+
const hash = hashToBase36("test content", 6);
|
|
999
|
+
expect(hash.length).toBe(6);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("is deterministic", () => {
|
|
1003
|
+
const h1 = hashToBase36("same input", 5);
|
|
1004
|
+
const h2 = hashToBase36("same input", 5);
|
|
1005
|
+
expect(h1).toBe(h2);
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
- [ ] **Step 3: Run test to verify it passes**
|
|
1011
|
+
|
|
1012
|
+
Run: `node --experimental-strip-types --test --test-timeout=30000 test/unit/task-id.test.ts`
|
|
1013
|
+
Expected: PASS (existing implementation should handle this)
|
|
1014
|
+
|
|
1015
|
+
- [ ] **Step 4: Extend task-id.ts — add stableIdFromContent helper**
|
|
1016
|
+
|
|
1017
|
+
Add this function to `src/runtime/task-id.ts` (after existing functions):
|
|
1018
|
+
|
|
1019
|
+
```typescript
|
|
1020
|
+
/**
|
|
1021
|
+
* Generate a stable, collision-resistant ID from arbitrary content.
|
|
1022
|
+
* Uses full SHA-256 hash (not adaptive length) for maximum stability.
|
|
1023
|
+
* Format: {prefix}-{first12charsOfBase36hash}
|
|
1024
|
+
*
|
|
1025
|
+
* Use for: run-level IDs, artifact keys, cross-run references
|
|
1026
|
+
* where determinism and uniqueness matter more than short length.
|
|
1027
|
+
*/
|
|
1028
|
+
export function stableIdFromContent(content: string, prefix = "id"): string {
|
|
1029
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
1030
|
+
// Convert first 8 bytes (16 hex chars) to base36-ish
|
|
1031
|
+
const hashChars = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
1032
|
+
let b36 = "";
|
|
1033
|
+
for (let i = 0; i < 16 && i < hash.length; i++) {
|
|
1034
|
+
b36 += hashChars[parseInt(hash[i]!, 16)] ?? "0";
|
|
1035
|
+
}
|
|
1036
|
+
return `${prefix}-${b36.slice(0, 12)}`;
|
|
1037
|
+
}
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
- [ ] **Step 5: Run test to verify it still passes**
|
|
1041
|
+
|
|
1042
|
+
Run: `node --experimental-strip-types --test --test-timeout=30000 test/unit/task-id.test.ts`
|
|
1043
|
+
Expected: PASS
|
|
1044
|
+
|
|
1045
|
+
- [ ] **Step 6: Commit**
|
|
1046
|
+
|
|
1047
|
+
```bash
|
|
1048
|
+
git add src/runtime/task-id.ts test/unit/task-id.test.ts
|
|
1049
|
+
git commit -m "feat: add stableIdFromContent for cross-run stable IDs
|
|
1050
|
+
|
|
1051
|
+
stableIdFromContent() uses full SHA-256 for maximum collision resistance.
|
|
1052
|
+
Format: prefix-{12-char-base36-hash}
|
|
1053
|
+
Use for: run IDs, artifact keys, cross-run references.
|
|
1054
|
+
Existing generateTaskHashId() unchanged (adaptive length, for task-level IDs)."
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
---
|
|
1058
|
+
|
|
1059
|
+
## Task 6: Migrate state-store.ts to CrewError
|
|
1060
|
+
|
|
1061
|
+
**Files:**
|
|
1062
|
+
- Modify: `src/state/state-store.ts:400-430` (replace plain Error throws with CrewError)
|
|
1063
|
+
|
|
1064
|
+
- [ ] **Step 1: Read current error throwing sites**
|
|
1065
|
+
|
|
1066
|
+
```bash
|
|
1067
|
+
grep -n "throw new Error" src/state/state-store.ts | head -20
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
- [ ] **Step 2: Replace invalidStatusTransition throw**
|
|
1071
|
+
|
|
1072
|
+
Find line with `throw new Error('Invalid run status transition:...')` and replace:
|
|
1073
|
+
|
|
1074
|
+
```typescript
|
|
1075
|
+
// OLD (line ~418):
|
|
1076
|
+
throw new Error(`Invalid run status transition: ${current} → ${next}`);
|
|
1077
|
+
|
|
1078
|
+
// NEW:
|
|
1079
|
+
import { errors } from "../../src/errors.ts";
|
|
1080
|
+
// ... later in function:
|
|
1081
|
+
throw errors.invalidStatusTransition(current, next);
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
- [ ] **Step 3: Replace manifest not found throw**
|
|
1085
|
+
|
|
1086
|
+
```typescript
|
|
1087
|
+
// OLD:
|
|
1088
|
+
throw new Error(`saveRunTasks: manifest not found for runId=${runId}`);
|
|
1089
|
+
|
|
1090
|
+
// NEW:
|
|
1091
|
+
throw errors.taskNotFound(runId).withContext("saveRunTasks: manifest not found");
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
- [ ] **Step 4: Replace state write failure throw**
|
|
1095
|
+
|
|
1096
|
+
```typescript
|
|
1097
|
+
// OLD:
|
|
1098
|
+
throw new Error(`Failed to write run state: manifestWritten=${manifestWritten}, tasksWritten=${tasksWritten}`);
|
|
1099
|
+
|
|
1100
|
+
// NEW:
|
|
1101
|
+
throw errors.fileWrite(statePath, err).withContext("saveManifestAndTasksAtomicSync: atomic write failed");
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
- [ ] **Step 5: Run tests**
|
|
1105
|
+
|
|
1106
|
+
Run: `node --experimental-strip-types --test --test-timeout=60000 test/unit/state-store.test.ts 2>&1 | tail -10`
|
|
1107
|
+
Expected: PASS (or existing failures unrelated to this change)
|
|
1108
|
+
|
|
1109
|
+
- [ ] **Step 6: Commit**
|
|
1110
|
+
|
|
1111
|
+
```bash
|
|
1112
|
+
git add src/state/state-store.ts
|
|
1113
|
+
git commit -m "refactor(state-store): use CrewError for structured errors
|
|
1114
|
+
|
|
1115
|
+
Migrates state-store.ts from plain Error throws to CrewError:
|
|
1116
|
+
- InvalidStatusTransition → errors.invalidStatusTransition()
|
|
1117
|
+
- Task not found → errors.taskNotFound()
|
|
1118
|
+
- File write failure → errors.fileWrite()
|
|
1119
|
+
Display format: error[E004]: Invalid run status transition: running → queued"
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
---
|
|
1123
|
+
|
|
1124
|
+
## Task 7: Integrate PluginRegistry into team-runner
|
|
1125
|
+
|
|
1126
|
+
**Files:**
|
|
1127
|
+
- Modify: `src/runtime/team-runner.ts` (add plugin context to workflow execution)
|
|
1128
|
+
- Test: `test/unit/team-runner.test.ts` (add plugin integration test)
|
|
1129
|
+
|
|
1130
|
+
- [ ] **Step 1: Read team-runner.ts first 100 lines**
|
|
1131
|
+
|
|
1132
|
+
```bash
|
|
1133
|
+
head -100 src/runtime/team-runner.ts
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
- [ ] **Step 2: Add plugin context to RunConfig**
|
|
1137
|
+
|
|
1138
|
+
In `src/config/types.ts` or wherever `RunConfig` is defined, add:
|
|
1139
|
+
|
|
1140
|
+
```typescript
|
|
1141
|
+
// Add to existing RunConfig interface:
|
|
1142
|
+
interface RunConfig {
|
|
1143
|
+
// ... existing fields ...
|
|
1144
|
+
activePlugins?: Plugin[];
|
|
1145
|
+
pluginContext?: Record<string, unknown>;
|
|
1146
|
+
}
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
- [ ] **Step 3: Wire plugin activation in team-runner**
|
|
1150
|
+
|
|
1151
|
+
Add near the top of `team-runner.ts`:
|
|
1152
|
+
|
|
1153
|
+
```typescript
|
|
1154
|
+
import { PluginRegistry } from "../plugins/plugin-registry.ts";
|
|
1155
|
+
import { NextJsPlugin, VitestPlugin, VitePlugin } from "../plugins/plugins/index.ts";
|
|
1156
|
+
|
|
1157
|
+
// Create registry with built-in plugins
|
|
1158
|
+
const builtInRegistry = new PluginRegistry();
|
|
1159
|
+
builtInRegistry.register(NextJsPlugin);
|
|
1160
|
+
builtInRegistry.register(VitestPlugin);
|
|
1161
|
+
builtInRegistry.register(VitePlugin);
|
|
1162
|
+
|
|
1163
|
+
function getActivePlugins(packageJsonDeps: string[]) {
|
|
1164
|
+
return builtInRegistry.activePlugins(packageJsonDeps);
|
|
1165
|
+
}
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
- [ ] **Step 4: Use plugin context in task routing**
|
|
1169
|
+
|
|
1170
|
+
Add to `executeTeam()` or wherever workflow starts:
|
|
1171
|
+
|
|
1172
|
+
```typescript
|
|
1173
|
+
// After loading package.json deps:
|
|
1174
|
+
const activePlugins = getActivePlugins(packageJsonDeps || []);
|
|
1175
|
+
const pluginContext = {
|
|
1176
|
+
entryPatterns: activePlugins.flatMap(p => p.entryPatterns ?? []),
|
|
1177
|
+
pathAliases: Object.fromEntries(activePlugins.flatMap(p => p.pathAliases ?? [])),
|
|
1178
|
+
};
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
- [ ] **Step 5: Commit**
|
|
1182
|
+
|
|
1183
|
+
```bash
|
|
1184
|
+
git add src/runtime/team-runner.ts src/config/types.ts
|
|
1185
|
+
git commit -m "feat(team-runner): integrate plugin registry for framework context
|
|
1186
|
+
|
|
1187
|
+
- Creates built-in PluginRegistry with NextJs, Vitest, Vite plugins
|
|
1188
|
+
- activePlugins() called with package.json dependencies
|
|
1189
|
+
- pluginContext provides entryPatterns + pathAliases to tasks
|
|
1190
|
+
Framework-aware task routing without breaking existing behavior."
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
---
|
|
1194
|
+
|
|
1195
|
+
## Task 8: Wire Health Score into team-runner
|
|
1196
|
+
|
|
1197
|
+
**Files:**
|
|
1198
|
+
- Modify: `src/runtime/team-runner.ts` (save health snapshot on run completion)
|
|
1199
|
+
- Modify: `src/state/state-store.ts` (call health store after manifest save)
|
|
1200
|
+
|
|
1201
|
+
- [ ] **Step 1: Import HealthStore into team-runner**
|
|
1202
|
+
|
|
1203
|
+
```typescript
|
|
1204
|
+
import { HealthStore } from "../state/health-store.ts";
|
|
1205
|
+
```
|
|
1206
|
+
|
|
1207
|
+
- [ ] **Step 2: After run completion, save health snapshot**
|
|
1208
|
+
|
|
1209
|
+
Find where run status transitions to "completed" or "failed" and add:
|
|
1210
|
+
|
|
1211
|
+
```typescript
|
|
1212
|
+
const healthStore = new HealthStore(stateRoot);
|
|
1213
|
+
healthStore.saveSnapshot(manifest);
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
- [ ] **Step 3: Commit**
|
|
1217
|
+
|
|
1218
|
+
```bash
|
|
1219
|
+
git add src/runtime/team-runner.ts src/state/state-store.ts
|
|
1220
|
+
git commit -m "feat(team-runner): save health snapshot on run completion
|
|
1221
|
+
|
|
1222
|
+
HealthStore.saveSnapshot() called after manifest update when run completes.
|
|
1223
|
+
Snapshots stored in .crew/state/health/{runId}.json for trend analysis.
|
|
1224
|
+
Score and grade available via `team health` command (future work)."
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
---
|
|
1228
|
+
|
|
1229
|
+
## Self-Review Checklist
|
|
1230
|
+
|
|
1231
|
+
**Spec coverage:**
|
|
1232
|
+
- [x] ErrorCode enum (E001-E006) → Task 1
|
|
1233
|
+
- [x] CrewError builder pattern (withContext, withHelp) → Task 1
|
|
1234
|
+
- [x] AtomicWriter with fsync + rename → Task 2
|
|
1235
|
+
- [x] computeRunHealth penalty-based scoring → Task 3
|
|
1236
|
+
- [x] HealthStore snapshot persistence → Task 3
|
|
1237
|
+
- [x] Plugin interface + registry → Task 4
|
|
1238
|
+
- [x] NextJs, Vitest, Vite built-in plugins → Task 4
|
|
1239
|
+
- [x] stableIdFromContent for cross-run IDs → Task 5
|
|
1240
|
+
- [x] state-store.ts migration to CrewError → Task 6
|
|
1241
|
+
- [x] Plugin registry integration → Task 7
|
|
1242
|
+
- [x] Health score integration → Task 8
|
|
1243
|
+
|
|
1244
|
+
**Placeholder scan:**
|
|
1245
|
+
- No "TBD" or "TODO" found
|
|
1246
|
+
- No "implement later" or "fill in details"
|
|
1247
|
+
- No steps that describe without showing code
|
|
1248
|
+
- All test code is actual runnable TypeScript
|
|
1249
|
+
|
|
1250
|
+
**Type consistency:**
|
|
1251
|
+
- `CrewError.code` is `ErrorCode` (not string)
|
|
1252
|
+
- `HealthStore.saveSnapshot()` accepts `ManifestSummary` (not full manifest type)
|
|
1253
|
+
- `PluginRegistry.activePlugins()` accepts `string[]` (not full package.json)
|
|
1254
|
+
- `stableIdFromContent()` uses `createHash` from `node:crypto`
|
|
1255
|
+
|
|
1256
|
+
---
|
|
1257
|
+
|
|
1258
|
+
## Execution Handoff
|
|
1259
|
+
|
|
1260
|
+
**Plan complete and saved to `docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md`.**
|
|
1261
|
+
|
|
1262
|
+
**Two execution options:**
|
|
1263
|
+
|
|
1264
|
+
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration
|
|
1265
|
+
|
|
1266
|
+
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints
|
|
1267
|
+
|
|
1268
|
+
**Which approach?**
|