dev-loops 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.claude/.claude-plugin/plugin.json +9 -0
  2. package/.claude/agents/dev-loop.md +69 -0
  3. package/.claude/agents/developer.md +35 -0
  4. package/.claude/agents/docs.md +31 -0
  5. package/.claude/agents/fixer.md +51 -0
  6. package/.claude/agents/quality.md +26 -0
  7. package/.claude/agents/refiner.md +85 -0
  8. package/.claude/agents/review.md +61 -0
  9. package/.claude/hooks/_hook-io.mjs +38 -0
  10. package/.claude/hooks/hooks.json +35 -0
  11. package/.claude/hooks/post-tool-use-merge.mjs +23 -0
  12. package/.claude/hooks/pre-tool-use-bash-gate.mjs +73 -0
  13. package/.claude/hooks/pre-tool-use-write-guard.mjs +55 -0
  14. package/.claude/skills/copilot-pr-followup/SKILL.md +376 -0
  15. package/.claude/skills/dev-loop/SKILL.md +140 -0
  16. package/.claude/skills/dev-loop/templates/bootstrap-agents.md +26 -0
  17. package/.claude/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
  18. package/.claude/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
  19. package/.claude/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
  20. package/.claude/skills/dev-loop/templates/dev-mode-review.md +17 -0
  21. package/.claude/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
  22. package/.claude/skills/dev-loop/templates/merged-phase-plan.md +19 -0
  23. package/.claude/skills/dev-loop/templates/phase-doc.md +27 -0
  24. package/.claude/skills/dev-loop/templates/phase-summary.md +13 -0
  25. package/.claude/skills/dev-loop/templates/phase-variant.md +15 -0
  26. package/.claude/skills/dev-loop/templates/retrospective.md +11 -0
  27. package/.claude/skills/dev-loop/templates/review.md +32 -0
  28. package/.claude/skills/dev-loop/templates/ui-vision-review.md +55 -0
  29. package/.claude/skills/docs/acceptance-criteria-verification.md +21 -0
  30. package/.claude/skills/docs/anti-patterns.md +21 -0
  31. package/.claude/skills/docs/artifact-authority-contract.md +119 -0
  32. package/.claude/skills/docs/confirmation-rules.md +28 -0
  33. package/.claude/skills/docs/copilot-ci-status-contract.md +52 -0
  34. package/.claude/skills/docs/copilot-loop-operations.md +233 -0
  35. package/.claude/skills/docs/debt-remediation-contract.md +107 -0
  36. package/.claude/skills/docs/entrypoint-strategies.md +115 -0
  37. package/.claude/skills/docs/epic-tree-refinement-procedure.md +234 -0
  38. package/.claude/skills/docs/issue-intake-procedure.md +235 -0
  39. package/.claude/skills/docs/main-agent-contract.md +85 -0
  40. package/.claude/skills/docs/merge-preconditions.md +29 -0
  41. package/.claude/skills/docs/pr-lifecycle-contract.md +209 -0
  42. package/.claude/skills/docs/public-dev-loop-contract.md +497 -0
  43. package/.claude/skills/docs/retrospective-checkpoint-contract.md +159 -0
  44. package/.claude/skills/docs/stop-conditions.md +29 -0
  45. package/.claude/skills/docs/structural-quality.md +42 -0
  46. package/.claude/skills/docs/tracker-first-loop-state.md +281 -0
  47. package/.claude/skills/docs/validation-policy.md +27 -0
  48. package/.claude/skills/docs/workflow-handoff-contract.md +135 -0
  49. package/.claude/skills/final-approval/SKILL.md +18 -0
  50. package/.claude/skills/local-implementation/SKILL.md +636 -0
  51. package/CHANGELOG.md +51 -0
  52. package/README.md +16 -0
  53. package/agents/dev-loop.agent.md +4 -0
  54. package/cli/index.mjs +5 -5
  55. package/extension/checks.ts +18 -29
  56. package/extension/harness-types.ts +47 -0
  57. package/extension/index.ts +10 -8
  58. package/extension/pi-extension-adapter.ts +64 -0
  59. package/extension/post-merge-update.ts +69 -190
  60. package/package.json +16 -9
  61. package/scripts/README.md +6 -3
  62. package/scripts/claude/generate-claude-assets.mjs +175 -0
  63. package/scripts/claude/headless-dev-loop.mjs +89 -0
  64. package/scripts/claude/headless-info-smoke.mjs +90 -0
  65. package/scripts/github/_review-thread-mutations.mjs +3 -0
  66. package/scripts/github/reply-resolve-review-threads.mjs +3 -3
  67. package/scripts/github/upsert-checkpoint-verdict.mjs +4 -3
  68. package/scripts/loop/_pr-runner-coordination.mjs +2 -1
  69. package/scripts/loop/copilot-pr-handoff.mjs +3 -2
  70. package/scripts/loop/detect-stale-runner.mjs +5 -7
  71. package/scripts/loop/info.mjs +3 -2
  72. package/scripts/loop/inspect-run-viewer/server.mjs +2 -1
  73. package/scripts/loop/outer-loop.mjs +2 -2
  74. package/scripts/loop/pr-runner-coordination.mjs +4 -4
  75. package/skills/docs/main-agent-contract.md +19 -6
@@ -1,14 +1,39 @@
1
- import type {
2
- AgentEndEvent,
3
- ExecResult,
4
- ExtensionAPI,
5
- ExtensionContext,
6
- ToolResultEvent,
7
- UserBashEvent,
8
- UserBashEventResult,
9
- } from '@earendil-works/pi-coding-agent';
10
-
11
- export const TARGET_REPO_SLUG = 'mfittko/dev-loops';
1
+ import type { ExtensionHarnessAdapter, HarnessContext, HarnessExecResult } from './harness-types.ts';
2
+ import {
3
+ TARGET_REPO_SLUG,
4
+ trimToNull,
5
+ normalizeGitHubRepoSlug,
6
+ isMergeCapableCommand,
7
+ isGhPrReadyCommand,
8
+ extractPrNumberFromGhPrReady,
9
+ extractRepoFlagFromGhPrReady,
10
+ } from '@dev-loops/core/loop/bash-command-classify';
11
+
12
+ // The bash-command classifiers now live in `@dev-loops/core/loop/bash-command-classify` so the
13
+ // Pi extension and the Claude Code Bash hook share one source of truth. Re-export them here so
14
+ // existing importers (and tests) that reference them from this module keep resolving.
15
+ export {
16
+ TARGET_REPO_SLUG,
17
+ normalizeGitHubRepoSlug,
18
+ isMergeCapableCommand,
19
+ isGhPrReadyCommand,
20
+ extractPrNumberFromGhPrReady,
21
+ extractRepoFlagFromGhPrReady,
22
+ };
23
+
24
+ // Neutral event shapes the post-merge hook reads. The harness adapter forwards the
25
+ // harness-native event object through unchanged; these capture only the fields used here.
26
+ type ToolResultLike = { toolName?: string; input?: { command?: unknown } | null; isError?: boolean };
27
+ type UserBashLike = { command: string; cwd: string };
28
+ type UserBashResultLike = {
29
+ result: {
30
+ output: string;
31
+ exitCode: number | undefined;
32
+ cancelled: boolean;
33
+ truncated: boolean;
34
+ };
35
+ };
36
+
12
37
  export const POST_MERGE_UPDATE_COMMAND = 'pi update git:github.com/mfittko/dev-loops';
13
38
  export const PRE_PR_READY_GATE_SCRIPT = 'node scripts/loop/pre-pr-ready-gate.mjs';
14
39
 
@@ -17,9 +42,6 @@ const POST_MERGE_UPDATE_TIMEOUT_MS = 10 * 60 * 1000;
17
42
  const REPO_RESOLUTION_TIMEOUT_MS = 5_000;
18
43
  const PR_READY_GATE_TIMEOUT_MS = 30_000;
19
44
 
20
- // Flags known to take a value argument for gh pr ready (not boolean flags)
21
- const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
22
-
23
45
  type RepoContext = {
24
46
  repoRoot: string | null;
25
47
  repoSlug: string | null;
@@ -31,7 +53,7 @@ type RunCommandArgs = {
31
53
  timeout?: number;
32
54
  };
33
55
 
34
- type RunCommandResult = ExecResult;
56
+ type RunCommandResult = HarnessExecResult;
35
57
 
36
58
  type PostMergeUpdateHookState = {
37
59
  pendingPostMergeUpdate: boolean;
@@ -40,15 +62,11 @@ type PostMergeUpdateHookState = {
40
62
  };
41
63
 
42
64
  type CreatePostMergeUpdateHookOptions = {
65
+ exec?: ExtensionHarnessAdapter['exec'];
43
66
  resolveRepoContext?: (cwd: string) => Promise<RepoContext>;
44
67
  runCommand?: (args: RunCommandArgs) => Promise<RunCommandResult>;
45
68
  };
46
69
 
47
- function trimToNull(value: string | null | undefined): string | null {
48
- const trimmed = `${value ?? ''}`.trim();
49
- return trimmed ? trimmed : null;
50
- }
51
-
52
70
  function buildShellOutput(result: Pick<RunCommandResult, 'stdout' | 'stderr'>): string {
53
71
  const stdout = `${result.stdout ?? ''}`.trimEnd();
54
72
  const stderr = `${result.stderr ?? ''}`.trimEnd();
@@ -66,22 +84,22 @@ function buildFailureSummary(result: Pick<RunCommandResult, 'stdout' | 'stderr'
66
84
  : (typeof result.code === 'number' ? `exit code ${result.code}` : 'exit code unavailable'));
67
85
  }
68
86
 
69
- function getBashCommandFromToolResult(event: ToolResultEvent): string | null {
70
- if (event.toolName !== 'bash') {
87
+ function getBashCommandFromToolResult(event: ToolResultLike | null | undefined): string | null {
88
+ if (!event || event.toolName !== 'bash') {
71
89
  return null;
72
90
  }
73
91
  const command = event.input?.command;
74
92
  return typeof command === 'string' ? command : null;
75
93
  }
76
94
 
77
- function notify(ctx: ExtensionContext, message: string, level: 'info' | 'warning' | 'error' = 'info'): void {
95
+ function notify(ctx: Pick<HarnessContext, 'hasUI' | 'ui'>, message: string, level: 'info' | 'warning' | 'error' = 'info'): void {
78
96
  if (ctx.hasUI) {
79
97
  ctx.ui.notify(message, level);
80
98
  }
81
99
  }
82
100
 
83
- async function defaultResolveRepoContext(pi: ExtensionAPI, cwd: string): Promise<RepoContext> {
84
- const rootResult = await pi.exec('bash', ['-lc', 'git rev-parse --show-toplevel'], {
101
+ async function defaultResolveRepoContext(exec: ExtensionHarnessAdapter['exec'], cwd: string): Promise<RepoContext> {
102
+ const rootResult = await exec('git rev-parse --show-toplevel', {
85
103
  cwd,
86
104
  timeout: REPO_RESOLUTION_TIMEOUT_MS,
87
105
  });
@@ -94,7 +112,7 @@ async function defaultResolveRepoContext(pi: ExtensionAPI, cwd: string): Promise
94
112
  return { repoRoot: null, repoSlug: null };
95
113
  }
96
114
 
97
- const remoteResult = await pi.exec('bash', ['-lc', 'git config --get remote.origin.url'], {
115
+ const remoteResult = await exec('git config --get remote.origin.url', {
98
116
  cwd: repoRoot,
99
117
  timeout: REPO_RESOLUTION_TIMEOUT_MS,
100
118
  });
@@ -104,12 +122,12 @@ async function defaultResolveRepoContext(pi: ExtensionAPI, cwd: string): Promise
104
122
 
105
123
  return {
106
124
  repoRoot,
107
- repoSlug: normalizeGitHubRepoSlug(remoteResult.stdout),
125
+ repoSlug: normalizeGitHubRepoSlug(remoteResult.stdout ?? ''),
108
126
  };
109
127
  }
110
128
 
111
- async function defaultRunCommand(pi: ExtensionAPI, args: RunCommandArgs): Promise<RunCommandResult> {
112
- return pi.exec('bash', ['-lc', args.command], {
129
+ async function defaultRunCommand(exec: ExtensionHarnessAdapter['exec'], args: RunCommandArgs): Promise<RunCommandResult> {
130
+ return exec(args.command, {
113
131
  cwd: args.cwd,
114
132
  timeout: args.timeout,
115
133
  });
@@ -159,165 +177,17 @@ async function queueIfEligible(
159
177
  return true;
160
178
  }
161
179
 
162
- export function normalizeGitHubRepoSlug(remoteUrl: string): string | null {
163
- const normalized = trimToNull(remoteUrl);
164
- if (!normalized) {
165
- return null;
166
- }
167
-
168
- const patterns = [
169
- /^git@github\.com:([^\s]+?)(?:\.git)?$/i,
170
- /^https:\/\/github\.com\/([^\s]+?)(?:\.git)?$/i,
171
- /^ssh:\/\/git@github\.com\/([^\s]+?)(?:\.git)?$/i,
172
- /^git:github\.com\/([^\s]+?)(?:\.git)?$/i,
173
- ];
174
-
175
- for (const pattern of patterns) {
176
- const match = normalized.match(pattern);
177
- if (!match) {
178
- continue;
179
- }
180
- return trimToNull(match[1])?.toLowerCase() ?? null;
181
- }
182
-
183
- return null;
184
- }
185
-
186
- function isGhPrMergeCommand(segment: string): boolean {
187
- if (!/^gh\s+pr\s+merge(?:\s|$)/i.test(segment)) {
188
- return false;
189
- }
190
-
191
- const remainder = segment.replace(/^gh\s+pr\s+merge(?:\s|$)/i, '').trim();
192
- if (!remainder) {
193
- return true;
194
- }
195
-
196
- const firstArg = remainder.match(/^(\S+)/)?.[1]?.toLowerCase() ?? '';
197
- return !['--help', '-h'].includes(firstArg);
198
- }
199
-
200
- function isGitMergeCompletionCommand(segment: string): boolean {
201
- if (!/^git\s+merge(?:\s|$)/i.test(segment)) {
202
- return false;
203
- }
204
-
205
- const remainder = segment.replace(/^git\s+merge(?:\s|$)/i, '').trim();
206
- if (!remainder) {
207
- return true;
208
- }
209
-
210
- const firstArg = remainder.match(/^(\S+)/)?.[1]?.toLowerCase() ?? '';
211
- return !['--abort', '--continue', '--quit', '--help', '-h'].includes(firstArg);
212
- }
213
-
214
- export function isMergeCapableCommand(command: string): boolean {
215
- const normalized = command.trim();
216
- if (!normalized) {
217
- return false;
218
- }
219
-
220
- return normalized
221
- .split(/\s*(?:&&|\|\||;|\|)\s*/)
222
- .some((segment) => isGhPrMergeCommand(segment) || isGitMergeCompletionCommand(segment));
223
- }
224
-
225
- function firstShellSegment(command: string): string {
226
- return command.trim().split(/\s*(?:&&|\|\||;|\|)\s*/)[0]?.trim() ?? '';
227
- }
228
-
229
- export function isGhPrReadyCommand(command: string): boolean {
230
- const segment = firstShellSegment(command);
231
- if (!segment || !/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
232
- return false;
233
- }
234
180
 
235
- const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, '').trim();
236
- if (!remainder) {
237
- return true;
238
- }
239
- // Block interception if --help or -h appears anywhere in the arguments
240
- const args = remainder.split(/\s+/).map(a => a.toLowerCase());
241
- return !args.includes('--help') && !args.includes('-h');
242
- }
243
-
244
- export function extractPrNumberFromGhPrReady(command: string): number | null {
245
- const segment = firstShellSegment(command);
246
- if (!/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
247
- return null;
248
- }
249
- const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, '').trim();
250
- if (!remainder) {
251
- return null;
252
- }
253
- // Skip flags (--flag or --flag=value)
254
- const tokens = remainder.split(/\s+/);
255
- for (let i = 0; i < tokens.length; i++) {
256
- const token = tokens[i];
257
- if (token.startsWith('-')) {
258
- // Only skip the next token for flags known to take a value argument
259
- const flagName = token.replace(/=.*$/, '').toLowerCase();
260
- if (!token.includes('=') && FLAGS_THAT_TAKE_VALUE.has(flagName)) {
261
- i++; // skip next token (the flag value)
262
- }
263
- continue;
264
- }
265
- if (/^\d+$/.test(token)) {
266
- const num = Number(token);
267
- if (num > 0) {
268
- return num;
269
- }
270
- }
271
- // Non-numeric non-flag token — not a PR number
272
- return null;
273
- }
274
- return null;
275
- }
276
-
277
- export function extractRepoFlagFromGhPrReady(command: string): string | null {
278
- const segment = firstShellSegment(command);
279
- if (!/^gh\s+pr\s+ready(?:\s|$)/i.test(segment)) {
280
- return null;
281
- }
282
- const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, '').trim();
283
- if (!remainder) {
284
- return null;
285
- }
286
- const tokens = remainder.split(/\s+/);
287
- for (let i = 0; i < tokens.length; i++) {
288
- const token = tokens[i];
289
- const lower = token.toLowerCase();
290
- if (lower === '-r' || lower === '--repo') {
291
- // Next token is the repo slug value
292
- if (i + 1 < tokens.length && !tokens[i + 1].startsWith('-')) {
293
- return tokens[i + 1];
294
- }
295
- }
296
- // Handle --repo=value and -R=value
297
- const repoEqMatch = token.match(/^(?:--repo|-R)=(.+)$/i);
298
- if (repoEqMatch) {
299
- return repoEqMatch[1];
300
- }
301
-
302
- }
303
- return null;
304
- }
305
-
306
- export function createPostMergeUpdateHook(
307
- piOrOptions: ExtensionAPI | CreatePostMergeUpdateHookOptions,
308
- maybeOptions: CreatePostMergeUpdateHookOptions = {},
309
- ) {
310
- const hasPiExec = typeof (piOrOptions as ExtensionAPI)?.exec === 'function';
311
- const pi = hasPiExec ? piOrOptions as ExtensionAPI : null;
312
- const options = hasPiExec ? maybeOptions : (piOrOptions as CreatePostMergeUpdateHookOptions);
181
+ export function createPostMergeUpdateHook(options: CreatePostMergeUpdateHookOptions = {}) {
182
+ const exec = options.exec ?? null;
313
183
 
314
184
  const resolveRepoContext = options.resolveRepoContext
315
- ?? (pi ? ((cwd: string) => defaultResolveRepoContext(pi, cwd)) : null);
185
+ ?? (exec ? ((cwd: string) => defaultResolveRepoContext(exec, cwd)) : null);
316
186
  const runCommand = options.runCommand
317
- ?? (pi ? ((args: RunCommandArgs) => defaultRunCommand(pi, args)) : null);
187
+ ?? (exec ? ((args: RunCommandArgs) => defaultRunCommand(exec, args)) : null);
318
188
 
319
189
  if (!resolveRepoContext || !runCommand) {
320
- throw new Error('createPostMergeUpdateHook requires an ExtensionAPI or explicit resolveRepoContext/runCommand overrides.');
190
+ throw new Error('createPostMergeUpdateHook requires an `exec` function or explicit resolveRepoContext/runCommand overrides.');
321
191
  }
322
192
 
323
193
  const state: PostMergeUpdateHookState = {
@@ -341,15 +211,24 @@ export function createPostMergeUpdateHook(
341
211
  reset();
342
212
  },
343
213
 
344
- async onToolResult(event: Pick<ToolResultEvent, 'toolName' | 'input' | 'isError'>, ctx: Pick<ExtensionContext, 'cwd'>): Promise<void> {
345
- const command = getBashCommandFromToolResult(event as ToolResultEvent);
214
+ async onToolResult(rawEvent: unknown, ctx: Pick<HarnessContext, 'cwd'>): Promise<void> {
215
+ // Harness events arrive as `unknown` across the neutral seam; narrow here, at the
216
+ // single place that knows which fields this hook reads.
217
+ const event = rawEvent as ToolResultLike;
218
+ const command = getBashCommandFromToolResult(event);
346
219
  if (!command || event.isError) {
347
220
  return;
348
221
  }
349
222
  await queueIfEligible(state, resolveRepoContext, command, ctx.cwd);
350
223
  },
351
224
 
352
- async onUserBash(event: Pick<UserBashEvent, 'command' | 'cwd'>, _ctx?: ExtensionContext): Promise<UserBashEventResult | undefined> {
225
+ async onUserBash(rawEvent: unknown, _ctx?: HarnessContext): Promise<UserBashResultLike | undefined> {
226
+ const event = rawEvent as UserBashLike;
227
+ // The seam forwards events as `unknown`; a malformed/foreign-harness event must pass
228
+ // through untouched rather than crash the handler (downstream calls .trim() on command).
229
+ if (!event || typeof event.command !== 'string') {
230
+ return undefined;
231
+ }
353
232
  // Intercept gh pr ready before any other checks
354
233
  if (isGhPrReadyCommand(event.command)) {
355
234
  // Check if the command explicitly targets a different repo via -R/--repo
@@ -477,13 +356,13 @@ export function createPostMergeUpdateHook(
477
356
  }
478
357
  },
479
358
 
480
- async onAgentEnd(_event: AgentEndEvent, ctx: Pick<ExtensionContext, 'cwd' | 'hasUI' | 'ui'>): Promise<void> {
359
+ async onAgentEnd(_event: unknown, ctx: Pick<HarnessContext, 'cwd' | 'hasUI' | 'ui'>): Promise<void> {
481
360
  if (!state.pendingPostMergeUpdate || state.updateInFlight) {
482
361
  return;
483
362
  }
484
363
 
485
364
  state.updateInFlight = true;
486
- notify(ctx as ExtensionContext, `Post-merge update running: ${POST_MERGE_UPDATE_COMMAND}`, 'info');
365
+ notify(ctx, `Post-merge update running: ${POST_MERGE_UPDATE_COMMAND}`, 'info');
487
366
 
488
367
  try {
489
368
  const result = await runCommand({
@@ -493,17 +372,17 @@ export function createPostMergeUpdateHook(
493
372
  });
494
373
 
495
374
  if (result.code === 0 && !result.killed) {
496
- notify(ctx as ExtensionContext, `Post-merge update completed: ${POST_MERGE_UPDATE_COMMAND}`, 'info');
375
+ notify(ctx, `Post-merge update completed: ${POST_MERGE_UPDATE_COMMAND}`, 'info');
497
376
  } else {
498
377
  notify(
499
- ctx as ExtensionContext,
378
+ ctx,
500
379
  `Post-merge update failed (warning only): ${buildFailureSummary(result)}`,
501
380
  'warning',
502
381
  );
503
382
  }
504
383
  } catch (error) {
505
384
  const detail = error instanceof Error ? error.message : String(error);
506
- notify(ctx as ExtensionContext, `Post-merge update failed (warning only): ${detail}`, 'warning');
385
+ notify(ctx, `Post-merge update failed (warning only): ${detail}`, 'warning');
507
386
  } finally {
508
387
  reset();
509
388
  }
package/package.json CHANGED
@@ -2,15 +2,17 @@
2
2
  "name": "dev-loops",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "description": "Shared Pi workflow infrastructure for reusable local and remote development loops",
5
+ "description": "Agent-harness-agnostic dev-loop infrastructure (Pi and Claude Code) for reusable local and remote development loops",
6
6
  "keywords": [
7
- "pi-package",
8
- "pi",
9
- "pi-coding-agent",
10
- "workflow",
11
7
  "dev-loop",
8
+ "workflow",
12
9
  "github",
13
- "copilot"
10
+ "copilot",
11
+ "claude-code",
12
+ "claude",
13
+ "pi",
14
+ "pi-coding-agent",
15
+ "pi-package"
14
16
  ],
15
17
  "engines": {
16
18
  "node": ">=20"
@@ -22,11 +24,12 @@
22
24
  "verify": "npm test && npm run test:dev-loop",
23
25
  "test": "npm run test:assets && npm run test:extension && npm run test:scripts && npm run test:core && npm run test:docs",
24
26
  "test:assets": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/dev-loop-init-phase-smoke.test.mjs test/contracts/*.test.mjs test/workflow-handoff-contract.test.mjs",
25
- "test:extension": "node --import tsx --test --test-reporter ./test/failure-summary-reporter.mjs test/extension-checks.test.mjs test/extension-post-merge-update.test.mjs test/extension-command-contract.test.mjs test/extension-package-contract.test.mjs test/dev-loops-core.test.mjs test/dev-loops-cli.test.mjs",
27
+ "test:extension": "node --import tsx --test --test-reporter ./test/failure-summary-reporter.mjs test/extension-checks.test.mjs test/extension-post-merge-update.test.mjs test/extension-command-contract.test.mjs test/extension-package-contract.test.mjs test/extension-pi-adapter.test.mjs test/extension-claude-adapter.test.mjs test/dev-loops-core.test.mjs test/dev-loops-cli.test.mjs",
26
28
  "test:scripts": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/github/*.test.mjs test/loop/*.test.mjs test/docs/*.test.mjs test/projects/*.test.mjs",
27
29
  "test:core": "node --test --test-reporter ./test/failure-summary-reporter.mjs packages/core/test/*.test.mjs",
28
30
  "test:dev-loop": "node --test --test-reporter ./test/failure-summary-reporter.mjs skills/dev-loop/scripts/dev-mode-context.test.mjs skills/dev-loop/scripts/render-template.test.mjs skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs",
29
31
  "test:playwright:viewer": "playwright test -c playwright.inspect-run-viewer.config.mjs",
32
+ "smoke:headless": "node scripts/claude/headless-info-smoke.mjs",
30
33
  "test:docs": "node scripts/docs/validate-links.mjs && node scripts/docs/validate-no-duplicate-rules.mjs",
31
34
  "repo-wiki": "node scripts/repo-wiki.mjs",
32
35
  "repo-wiki:bootstrap": "node scripts/repo-wiki.mjs scan --repo . && node scripts/repo-wiki.mjs plan --repo . && node scripts/repo-wiki.mjs compile --repo .",
@@ -72,7 +75,7 @@
72
75
  "dependencies": {
73
76
  "mermaid": "11.15.0",
74
77
  "zod": "^4.4.3",
75
- "@dev-loops/core": "^0.1.3"
78
+ "@dev-loops/core": "^0.2.0"
76
79
  },
77
80
  "repository": {
78
81
  "type": "git",
@@ -82,7 +85,7 @@
82
85
  "url": "https://github.com/mfittko/dev-loops/issues"
83
86
  },
84
87
  "homepage": "https://github.com/mfittko/dev-loops#readme",
85
- "version": "0.1.3",
88
+ "version": "0.2.0",
86
89
  "files": [
87
90
  "cli/",
88
91
  "lib/",
@@ -90,6 +93,10 @@
90
93
  "extension/",
91
94
  "skills/",
92
95
  "agents/",
96
+ ".claude/.claude-plugin/",
97
+ ".claude/agents/",
98
+ ".claude/skills/",
99
+ ".claude/hooks/",
93
100
  "README.md",
94
101
  "CHANGELOG.md",
95
102
  "LICENSE",
package/scripts/README.md CHANGED
@@ -45,7 +45,7 @@ Failure behavior:
45
45
 
46
46
  ### `scripts/github/capture-review-threads.mjs`
47
47
 
48
- Capture and normalize PR review-thread JSON.
48
+ Capture and normalize PR review-thread JSON from all reviewers.
49
49
 
50
50
  Supported inputs:
51
51
  - `--input <path>`
@@ -55,6 +55,9 @@ Supported inputs:
55
55
  Optional:
56
56
  - `--output <path>` writes the same success JSON emitted on stdout
57
57
 
58
+ Contract:
59
+ - live capture loads review threads from all reviewers; no author filter is applied
60
+
58
61
  Success output shape:
59
62
  - `{ "ok": true, "source": { ... }, "summary": { ... }, "threads": [...], "comments": [...] }`
60
63
  - normalized `comments[]` preserve both the GraphQL comment node id (`id`) and the REST-safe numeric review-comment id (`databaseId`) when available
@@ -280,7 +283,7 @@ Required:
280
283
  - `--pr <number>`
281
284
 
282
285
  Optional:
283
- - `--author <login>` (default `Copilot`)
286
+ - `--author <login>` (default `all`)
284
287
  - exactly one message source: `--message <text>` or stdin
285
288
  - `--resolve`
286
289
 
@@ -294,7 +297,7 @@ Contract:
294
297
  - zero-match runs are deterministic no-ops with success JSON
295
298
 
296
299
  Success output shape:
297
- - `{ "ok": true, "repo": "owner/name", "pr": 17, "author": "Copilot", "resolve": true|false, "matchedThreadCount": 2, "repliedThreadCount": 2, "resolvedThreadCount": 2, "skippedThreadCount": 1, "results": [{ "threadId": "...", "commentId": 123, "replyId": 456, "replyUrl": "...", "resolved": true }] }`
300
+ - `{ "ok": true, "repo": "owner/name", "pr": 17, "author": "all", "resolve": true|false, "matchedThreadCount": 2, "repliedThreadCount": 2, "resolvedThreadCount": 2, "skippedThreadCount": 1, "results": [{ "threadId": "...", "commentId": 123, "replyId": 456, "replyUrl": "...", "resolved": true }] }`
298
301
 
299
302
  Failure behavior:
300
303
  - malformed arguments, empty/conflicting message input, malformed thread snapshots, unexpected `gh` failures, reply failures, resolve failures, and failed post-resolve verification emit `{ "ok": false, "error": "..." }` on stderr and exit non-zero
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate the Claude Code asset tree (.claude/agents, .claude/skills) from the canonical
4
+ * Pi sources (agents/*.agent.md, skills/<name>/SKILL.md). The sources remain the single
5
+ * source of truth; the generated tree is committed and kept in sync by `--check` (CI/test).
6
+ *
7
+ * Usage:
8
+ * node scripts/claude/generate-claude-assets.mjs Write the .claude tree.
9
+ * node scripts/claude/generate-claude-assets.mjs --check Exit non-zero if the committed
10
+ * tree drifts from the sources.
11
+ * --repo-root <path> Override the repo root (default: cwd).
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { transformAgent, transformSkill } from "@dev-loops/core/claude/asset-generation";
18
+
19
+ /**
20
+ * Collect the generated assets as { target, content } pairs (target is repo-relative).
21
+ * @param {{ repoRoot?: string }} [options]
22
+ * @returns {{ target: string, content: string }[]}
23
+ */
24
+ export function collectGeneratedAssets({ repoRoot = process.cwd() } = {}) {
25
+ const assets = [];
26
+
27
+ const agentsDir = path.join(repoRoot, "agents");
28
+ if (fs.existsSync(agentsDir)) {
29
+ for (const entry of fs.readdirSync(agentsDir).sort()) {
30
+ if (!entry.endsWith(".agent.md")) continue;
31
+ const source = `agents/${entry}`;
32
+ const raw = fs.readFileSync(path.join(repoRoot, source), "utf8");
33
+ const base = entry.slice(0, -".agent.md".length);
34
+ assets.push({ target: `.claude/agents/${base}.md`, content: transformAgent({ source, raw }) });
35
+ }
36
+ }
37
+
38
+ const skillsDir = path.join(repoRoot, "skills");
39
+ if (fs.existsSync(skillsDir)) {
40
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
41
+ if (!entry.isDirectory()) continue;
42
+ const source = `skills/${entry.name}/SKILL.md`;
43
+ const abs = path.join(repoRoot, source);
44
+ if (!fs.existsSync(abs)) continue; // e.g. skills/docs/ holds shared docs, not a SKILL.md
45
+ const raw = fs.readFileSync(abs, "utf8");
46
+ assets.push({ target: `.claude/skills/${entry.name}/SKILL.md`, content: transformSkill({ source, raw }) });
47
+ }
48
+ }
49
+
50
+ // Bundle the shared markdown the generated skills reference via relative links so they resolve
51
+ // inside the .claude/ tree (#816). The skills live at .claude/skills/<name>/, so:
52
+ // `../docs/<contract>.md` → .claude/skills/docs/<contract>.md (skills/docs/*.md)
53
+ // `../dev-loop/templates/<t>.md` → .claude/skills/dev-loop/templates/<t>.md (skills/dev-loop/templates/*.md)
54
+ // Copied verbatim; the no-drift test keeps them in sync with source. (Repo-root `../../` refs —
55
+ // PLAN.md/AGENTS.md/docs/phases — point at the *consumer project's* files and are out of scope.)
56
+ for (const [srcRel, targetRel] of [
57
+ ["skills/docs", ".claude/skills/docs"],
58
+ ["skills/dev-loop/templates", ".claude/skills/dev-loop/templates"],
59
+ ]) {
60
+ assets.push(...collectBundle(repoRoot, srcRel, targetRel));
61
+ }
62
+
63
+ return assets;
64
+ }
65
+
66
+ /** Recursively collect `*.md` files under a source dir as verbatim {target, content} bundle assets. */
67
+ function collectBundle(repoRoot, srcRel, targetRel) {
68
+ const out = [];
69
+ const absDir = path.join(repoRoot, srcRel);
70
+ if (!fs.existsSync(absDir)) return out;
71
+ for (const entry of fs.readdirSync(absDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
72
+ if (entry.isDirectory()) {
73
+ out.push(...collectBundle(repoRoot, `${srcRel}/${entry.name}`, `${targetRel}/${entry.name}`));
74
+ } else if (entry.name.endsWith(".md")) {
75
+ out.push({
76
+ target: `${targetRel}/${entry.name}`,
77
+ content: fs.readFileSync(path.join(absDir, entry.name), "utf8"),
78
+ });
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /** Write the generated assets to disk. Returns the list of written targets. */
85
+ export function writeAssets(assets, { repoRoot = process.cwd() } = {}) {
86
+ for (const { target, content } of assets) {
87
+ const abs = path.join(repoRoot, target);
88
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
89
+ fs.writeFileSync(abs, content, "utf8");
90
+ }
91
+ return assets.map((a) => a.target);
92
+ }
93
+
94
+ /** Recursively list committed files under a `.claude/` subtree (repo-relative, sorted). */
95
+ function listFilesRecursive(repoRoot, rel) {
96
+ const out = [];
97
+ const abs = path.join(repoRoot, rel);
98
+ if (!fs.existsSync(abs)) return out;
99
+ for (const entry of fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
100
+ if (entry.isDirectory()) out.push(...listFilesRecursive(repoRoot, `${rel}/${entry.name}`));
101
+ else out.push(`${rel}/${entry.name}`);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * List committed generated-asset files currently on disk (repo-relative, posix separators).
108
+ * Recurses the whole `.claude/agents` + `.claude/skills` trees so stale orphans — including
109
+ * bundled docs/templates whose source was removed — are detected, not just SKILL.md files.
110
+ */
111
+ function listExistingAssetFiles(repoRoot) {
112
+ return [
113
+ ...listFilesRecursive(repoRoot, ".claude/agents"),
114
+ ...listFilesRecursive(repoRoot, ".claude/skills"),
115
+ ];
116
+ }
117
+
118
+ /**
119
+ * Compare generated assets against the committed tree. Returns drifted targets, covering both
120
+ * missing/out-of-date generated files AND orphaned committed files no longer produced by a
121
+ * source (e.g. after a source agent/skill is renamed or removed).
122
+ */
123
+ export function checkAssets(assets, { repoRoot = process.cwd() } = {}) {
124
+ const drifted = [];
125
+ const expected = new Set(assets.map((a) => a.target));
126
+ for (const { target, content } of assets) {
127
+ const abs = path.join(repoRoot, target);
128
+ const current = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : null;
129
+ if (current !== content) {
130
+ drifted.push({ target, reason: current === null ? "missing" : "out-of-date" });
131
+ }
132
+ }
133
+ for (const existing of listExistingAssetFiles(repoRoot)) {
134
+ if (!expected.has(existing)) {
135
+ drifted.push({ target: existing, reason: "orphaned" });
136
+ }
137
+ }
138
+ return drifted;
139
+ }
140
+
141
+ function main(argv) {
142
+ const check = argv.includes("--check");
143
+ const rootIdx = argv.indexOf("--repo-root");
144
+ let repoRoot = process.cwd();
145
+ if (rootIdx !== -1) {
146
+ const value = argv[rootIdx + 1];
147
+ if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
148
+ process.stderr.write(JSON.stringify({ ok: false, error: "--repo-root requires a path value" }) + "\n");
149
+ process.exit(1);
150
+ }
151
+ repoRoot = value;
152
+ }
153
+
154
+ const assets = collectGeneratedAssets({ repoRoot });
155
+
156
+ if (check) {
157
+ const drifted = checkAssets(assets, { repoRoot });
158
+ if (drifted.length > 0) {
159
+ process.stderr.write(
160
+ JSON.stringify({ ok: false, error: "generated .claude assets are out of date", drifted }, null, 2) + "\n",
161
+ );
162
+ process.stderr.write("Run `node scripts/claude/generate-claude-assets.mjs` and commit the result.\n");
163
+ process.exit(1);
164
+ }
165
+ process.stdout.write(JSON.stringify({ ok: true, checked: assets.length }) + "\n");
166
+ return;
167
+ }
168
+
169
+ const written = writeAssets(assets, { repoRoot });
170
+ process.stdout.write(JSON.stringify({ ok: true, written }, null, 2) + "\n");
171
+ }
172
+
173
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
174
+ main(process.argv.slice(2));
175
+ }