pi-crew 0.6.0 → 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.
Files changed (135) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/README.md +70 -31
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/package.json +2 -2
  6. package/src/agents/agent-config.ts +2 -1
  7. package/src/benchmark/feedback-loop.ts +4 -2
  8. package/src/config/config.ts +106 -15
  9. package/src/errors.ts +107 -0
  10. package/src/extension/async-notifier.ts +6 -2
  11. package/src/extension/crew-cleanup.ts +8 -5
  12. package/src/extension/cross-extension-rpc.ts +48 -0
  13. package/src/extension/management.ts +464 -109
  14. package/src/extension/register.ts +194 -34
  15. package/src/extension/registration/commands.ts +4 -3
  16. package/src/extension/registration/subagent-helpers.ts +2 -2
  17. package/src/extension/registration/subagent-tools.ts +3 -1
  18. package/src/extension/registration/team-tool.ts +3 -1
  19. package/src/extension/registration/viewers.ts +3 -2
  20. package/src/extension/run-export.ts +16 -1
  21. package/src/extension/run-import.ts +16 -0
  22. package/src/extension/team-tool/anchor.ts +5 -1
  23. package/src/extension/team-tool/api.ts +12 -7
  24. package/src/extension/team-tool/cancel.ts +3 -3
  25. package/src/extension/team-tool/config-patch.ts +15 -1
  26. package/src/extension/team-tool/explain.ts +1 -1
  27. package/src/extension/team-tool/handle-schedule.ts +40 -0
  28. package/src/extension/team-tool/inspect.ts +3 -3
  29. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  30. package/src/extension/team-tool/respond.ts +2 -2
  31. package/src/extension/team-tool/run.ts +60 -11
  32. package/src/extension/team-tool/status.ts +1 -1
  33. package/src/extension/team-tool.ts +175 -47
  34. package/src/hooks/registry.ts +84 -12
  35. package/src/hooks/types.ts +12 -3
  36. package/src/i18n.ts +15 -2
  37. package/src/observability/exporters/otlp-exporter.ts +73 -0
  38. package/src/plugins/plugin-define.ts +6 -0
  39. package/src/plugins/plugin-registry.ts +32 -0
  40. package/src/plugins/plugins/index.ts +3 -0
  41. package/src/plugins/plugins/nextjs.ts +19 -0
  42. package/src/plugins/plugins/vite.ts +14 -0
  43. package/src/plugins/plugins/vitest.ts +14 -0
  44. package/src/runtime/adaptive-plan.ts +24 -0
  45. package/src/runtime/agent-control.ts +6 -3
  46. package/src/runtime/async-runner.ts +86 -3
  47. package/src/runtime/background-runner.ts +529 -144
  48. package/src/runtime/chain-parser.ts +5 -1
  49. package/src/runtime/chain-runner.ts +67 -3
  50. package/src/runtime/checkpoint.ts +262 -180
  51. package/src/runtime/child-pi.ts +165 -38
  52. package/src/runtime/crash-recovery.ts +25 -14
  53. package/src/runtime/crew-agent-records.ts +6 -3
  54. package/src/runtime/cross-extension-rpc.ts +34 -8
  55. package/src/runtime/diagnostic-export.ts +4 -5
  56. package/src/runtime/dynamic-script-runner.ts +9 -6
  57. package/src/runtime/foreground-watchdog.ts +3 -3
  58. package/src/runtime/heartbeat-watcher.ts +19 -2
  59. package/src/runtime/intercom-bridge.ts +7 -0
  60. package/src/runtime/iteration-hooks.ts +1 -1
  61. package/src/runtime/live-agent-manager.ts +6 -3
  62. package/src/runtime/live-irc.ts +4 -2
  63. package/src/runtime/manifest-cache.ts +4 -0
  64. package/src/runtime/orphan-worker-registry.ts +444 -0
  65. package/src/runtime/parallel-utils.ts +2 -1
  66. package/src/runtime/parent-guard.ts +70 -16
  67. package/src/runtime/pi-args.ts +437 -14
  68. package/src/runtime/pi-spawn.ts +1 -0
  69. package/src/runtime/post-checks.ts +11 -4
  70. package/src/runtime/retry-runner.ts +9 -3
  71. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  72. package/src/runtime/run-tracker.ts +38 -10
  73. package/src/runtime/sandbox.ts +42 -21
  74. package/src/runtime/scheduler.ts +25 -2
  75. package/src/runtime/semaphore.ts +2 -1
  76. package/src/runtime/settings-store.ts +14 -2
  77. package/src/runtime/skill-effectiveness.ts +109 -64
  78. package/src/runtime/skill-instructions.ts +114 -33
  79. package/src/runtime/stale-reconciler.ts +310 -69
  80. package/src/runtime/subagent-manager.ts +265 -53
  81. package/src/runtime/subprocess-tool-registry.ts +2 -2
  82. package/src/runtime/task-health.ts +76 -0
  83. package/src/runtime/task-id.ts +20 -0
  84. package/src/runtime/task-packet.ts +13 -1
  85. package/src/runtime/task-runner/live-executor.ts +1 -1
  86. package/src/runtime/task-runner/progress.ts +1 -1
  87. package/src/runtime/task-runner/state-helpers.ts +110 -6
  88. package/src/runtime/task-runner.ts +98 -67
  89. package/src/runtime/team-runner.ts +186 -20
  90. package/src/runtime/usage-tracker.ts +4 -2
  91. package/src/runtime/verification-gates.ts +36 -9
  92. package/src/schema/team-tool-schema.ts +27 -9
  93. package/src/skills/discover-skills.ts +9 -3
  94. package/src/state/active-run-registry.ts +170 -38
  95. package/src/state/artifact-store.ts +25 -13
  96. package/src/state/atomic-write-v2.ts +86 -0
  97. package/src/state/atomic-write.ts +346 -55
  98. package/src/state/blob-store.ts +178 -10
  99. package/src/state/contracts.ts +2 -1
  100. package/src/state/crew-init.ts +161 -28
  101. package/src/state/decision-ledger.ts +172 -111
  102. package/src/state/event-log-rotation.ts +82 -52
  103. package/src/state/event-log.ts +270 -75
  104. package/src/state/health-store.ts +71 -0
  105. package/src/state/hook-instinct-bridge.ts +2 -1
  106. package/src/state/locks.ts +109 -20
  107. package/src/state/mailbox.ts +45 -7
  108. package/src/state/observation-store.ts +4 -1
  109. package/src/state/run-graph.ts +141 -130
  110. package/src/state/run-metrics.ts +24 -8
  111. package/src/state/state-store.ts +333 -44
  112. package/src/state/task-claims.ts +9 -2
  113. package/src/tools/safe-bash.ts +69 -20
  114. package/src/types/new-api-types.ts +10 -5
  115. package/src/ui/keybinding-map.ts +2 -1
  116. package/src/ui/live-run-sidebar.ts +1 -1
  117. package/src/ui/loaders.ts +4 -0
  118. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  119. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  120. package/src/ui/run-action-dispatcher.ts +4 -3
  121. package/src/ui/run-snapshot-cache.ts +1 -1
  122. package/src/ui/status-colors.ts +2 -1
  123. package/src/ui/syntax-highlight.ts +2 -1
  124. package/src/ui/tool-render.ts +13 -3
  125. package/src/utils/env-filter.ts +66 -0
  126. package/src/utils/file-coalescer.ts +9 -1
  127. package/src/utils/fs-watch.ts +4 -2
  128. package/src/utils/gh-protocol.ts +2 -1
  129. package/src/utils/paths.ts +80 -5
  130. package/src/utils/redaction.ts +6 -1
  131. package/src/utils/safe-paths.ts +287 -10
  132. package/src/worktree/cleanup.ts +117 -26
  133. package/src/worktree/worktree-manager.ts +128 -15
  134. package/test-bugs-all.mjs +10 -6
  135. 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?**