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.
- package/.claude/.claude-plugin/plugin.json +9 -0
- package/.claude/agents/dev-loop.md +69 -0
- package/.claude/agents/developer.md +35 -0
- package/.claude/agents/docs.md +31 -0
- package/.claude/agents/fixer.md +51 -0
- package/.claude/agents/quality.md +26 -0
- package/.claude/agents/refiner.md +85 -0
- package/.claude/agents/review.md +61 -0
- package/.claude/hooks/_hook-io.mjs +38 -0
- package/.claude/hooks/hooks.json +35 -0
- package/.claude/hooks/post-tool-use-merge.mjs +23 -0
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +73 -0
- package/.claude/hooks/pre-tool-use-write-guard.mjs +55 -0
- package/.claude/skills/copilot-pr-followup/SKILL.md +376 -0
- package/.claude/skills/dev-loop/SKILL.md +140 -0
- package/.claude/skills/dev-loop/templates/bootstrap-agents.md +26 -0
- package/.claude/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
- package/.claude/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
- package/.claude/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
- package/.claude/skills/dev-loop/templates/dev-mode-review.md +17 -0
- package/.claude/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
- package/.claude/skills/dev-loop/templates/merged-phase-plan.md +19 -0
- package/.claude/skills/dev-loop/templates/phase-doc.md +27 -0
- package/.claude/skills/dev-loop/templates/phase-summary.md +13 -0
- package/.claude/skills/dev-loop/templates/phase-variant.md +15 -0
- package/.claude/skills/dev-loop/templates/retrospective.md +11 -0
- package/.claude/skills/dev-loop/templates/review.md +32 -0
- package/.claude/skills/dev-loop/templates/ui-vision-review.md +55 -0
- package/.claude/skills/docs/acceptance-criteria-verification.md +21 -0
- package/.claude/skills/docs/anti-patterns.md +21 -0
- package/.claude/skills/docs/artifact-authority-contract.md +119 -0
- package/.claude/skills/docs/confirmation-rules.md +28 -0
- package/.claude/skills/docs/copilot-ci-status-contract.md +52 -0
- package/.claude/skills/docs/copilot-loop-operations.md +233 -0
- package/.claude/skills/docs/debt-remediation-contract.md +107 -0
- package/.claude/skills/docs/entrypoint-strategies.md +115 -0
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +234 -0
- package/.claude/skills/docs/issue-intake-procedure.md +235 -0
- package/.claude/skills/docs/main-agent-contract.md +85 -0
- package/.claude/skills/docs/merge-preconditions.md +29 -0
- package/.claude/skills/docs/pr-lifecycle-contract.md +209 -0
- package/.claude/skills/docs/public-dev-loop-contract.md +497 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +159 -0
- package/.claude/skills/docs/stop-conditions.md +29 -0
- package/.claude/skills/docs/structural-quality.md +42 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +281 -0
- package/.claude/skills/docs/validation-policy.md +27 -0
- package/.claude/skills/docs/workflow-handoff-contract.md +135 -0
- package/.claude/skills/final-approval/SKILL.md +18 -0
- package/.claude/skills/local-implementation/SKILL.md +636 -0
- package/CHANGELOG.md +51 -0
- package/README.md +16 -0
- package/agents/dev-loop.agent.md +4 -0
- package/cli/index.mjs +5 -5
- package/extension/checks.ts +18 -29
- package/extension/harness-types.ts +47 -0
- package/extension/index.ts +10 -8
- package/extension/pi-extension-adapter.ts +64 -0
- package/extension/post-merge-update.ts +69 -190
- package/package.json +16 -9
- package/scripts/README.md +6 -3
- package/scripts/claude/generate-claude-assets.mjs +175 -0
- package/scripts/claude/headless-dev-loop.mjs +89 -0
- package/scripts/claude/headless-info-smoke.mjs +90 -0
- package/scripts/github/_review-thread-mutations.mjs +3 -0
- package/scripts/github/reply-resolve-review-threads.mjs +3 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +4 -3
- package/scripts/loop/_pr-runner-coordination.mjs +2 -1
- package/scripts/loop/copilot-pr-handoff.mjs +3 -2
- package/scripts/loop/detect-stale-runner.mjs +5 -7
- package/scripts/loop/info.mjs +3 -2
- package/scripts/loop/inspect-run-viewer/server.mjs +2 -1
- package/scripts/loop/outer-loop.mjs +2 -2
- package/scripts/loop/pr-runner-coordination.mjs +4 -4
- package/skills/docs/main-agent-contract.md +19 -6
|
@@ -1,14 +1,39 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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(
|
|
84
|
-
const rootResult = await
|
|
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
|
|
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(
|
|
112
|
-
return
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
?? (
|
|
185
|
+
?? (exec ? ((cwd: string) => defaultResolveRepoContext(exec, cwd)) : null);
|
|
316
186
|
const runCommand = options.runCommand
|
|
317
|
-
?? (
|
|
187
|
+
?? (exec ? ((args: RunCommandArgs) => defaultRunCommand(exec, args)) : null);
|
|
318
188
|
|
|
319
189
|
if (!resolveRepoContext || !runCommand) {
|
|
320
|
-
throw new Error('createPostMergeUpdateHook requires an
|
|
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(
|
|
345
|
-
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
375
|
+
notify(ctx, `Post-merge update completed: ${POST_MERGE_UPDATE_COMMAND}`, 'info');
|
|
497
376
|
} else {
|
|
498
377
|
notify(
|
|
499
|
-
ctx
|
|
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
|
|
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": "
|
|
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.
|
|
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.
|
|
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 `
|
|
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": "
|
|
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
|
+
}
|