dev-loops 0.1.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/.pi/dev-loop/defaults.yaml +477 -0
- package/AGENTS.md +25 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/agents/dev-loop.agent.md +82 -0
- package/agents/developer.agent.md +37 -0
- package/agents/docs.agent.md +33 -0
- package/agents/fixer.agent.md +53 -0
- package/agents/quality.agent.md +28 -0
- package/agents/refiner.agent.md +87 -0
- package/agents/review.agent.md +64 -0
- package/cli/index.mjs +424 -0
- package/extension/README.md +233 -0
- package/extension/checks.ts +94 -0
- package/extension/index.ts +131 -0
- package/extension/post-merge-update.ts +512 -0
- package/extension/presentation.ts +107 -0
- package/lib/dev-loops-core.mjs +284 -0
- package/package.json +103 -0
- package/scripts/README.md +1007 -0
- package/scripts/_cli-primitives.mjs +10 -0
- package/scripts/_core-helpers.mjs +30 -0
- package/scripts/docs/validate-links.mjs +567 -0
- package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
- package/scripts/github/_review-thread-mutations.mjs +214 -0
- package/scripts/github/capture-review-threads.mjs +180 -0
- package/scripts/github/create-draft-pr.mjs +108 -0
- package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
- package/scripts/github/detect-linked-issue-pr.mjs +331 -0
- package/scripts/github/manage-sub-issues.mjs +394 -0
- package/scripts/github/probe-copilot-review.mjs +323 -0
- package/scripts/github/ready-for-review.mjs +93 -0
- package/scripts/github/reconcile-draft-gate.mjs +328 -0
- package/scripts/github/reply-resolve-review-thread.mjs +42 -0
- package/scripts/github/reply-resolve-review-threads.mjs +329 -0
- package/scripts/github/request-copilot-review.mjs +551 -0
- package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
- package/scripts/github/stage-reviewer-draft.mjs +191 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
- package/scripts/github/verify-fresh-review-context.mjs +125 -0
- package/scripts/github/write-gate-findings-log.mjs +212 -0
- package/scripts/loop/_checkpoint-io.mjs +55 -0
- package/scripts/loop/_checkpoint-paths.mjs +28 -0
- package/scripts/loop/_handoff-contract.mjs +230 -0
- package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
- package/scripts/loop/_loop-evidence.mjs +32 -0
- package/scripts/loop/_pr-runner-coordination.mjs +611 -0
- package/scripts/loop/_stale-runner-detection.mjs +145 -0
- package/scripts/loop/_steering-state-file.mjs +134 -0
- package/scripts/loop/build-handoff-envelope.mjs +181 -0
- package/scripts/loop/checkpoint-contract.mjs +49 -0
- package/scripts/loop/conductor-monitor.mjs +1850 -0
- package/scripts/loop/conductor.mjs +214 -0
- package/scripts/loop/copilot-pr-handoff.mjs +493 -0
- package/scripts/loop/debt-remediate.mjs +304 -0
- package/scripts/loop/detect-change-scope.mjs +102 -0
- package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
- package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
- package/scripts/loop/detect-internal-only-pr.mjs +270 -0
- package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
- package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
- package/scripts/loop/detect-stale-runner.mjs +250 -0
- package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
- package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
- package/scripts/loop/info.mjs +267 -0
- package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
- package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
- package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
- package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
- package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
- package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
- package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
- package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
- package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
- package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
- package/scripts/loop/inspect-run-viewer.mjs +82 -0
- package/scripts/loop/inspect-run.mjs +382 -0
- package/scripts/loop/outer-loop.mjs +419 -0
- package/scripts/loop/pr-runner-coordination.mjs +143 -0
- package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
- package/scripts/loop/pre-flight-gate.mjs +236 -0
- package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
- package/scripts/loop/pre-push-main-guard.mjs +103 -0
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
- package/scripts/loop/print-gates.mjs +42 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
- package/scripts/loop/run-conductor-cycle.mjs +322 -0
- package/scripts/loop/run-queue.mjs +124 -0
- package/scripts/loop/run-refinement-audit.mjs +513 -0
- package/scripts/loop/run-watch-cycle.mjs +358 -0
- package/scripts/loop/steer-loop.mjs +841 -0
- package/scripts/loop/ui-designer-review-contract.mjs +76 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
- package/scripts/projects/add-queue-item.mjs +528 -0
- package/scripts/projects/ensure-queue-board.mjs +837 -0
- package/scripts/projects/list-queue-items.mjs +489 -0
- package/scripts/projects/move-queue-item.mjs +549 -0
- package/scripts/projects/reorder-queue-item.mjs +518 -0
- package/scripts/refine/_refine-helpers.mjs +258 -0
- package/scripts/refine/prose-linkage-detector.mjs +92 -0
- package/scripts/refine/refinement-completeness-checker.mjs +88 -0
- package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
- package/scripts/refine/tree-integrity-validator.mjs +211 -0
- package/scripts/refine/verify.mjs +178 -0
- package/scripts/repo-wiki-local.mjs +156 -0
- package/scripts/repo-wiki.mjs +119 -0
- package/skills/copilot-pr-followup/SKILL.md +380 -0
- package/skills/dev-loop/SKILL.md +141 -0
- package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
- package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
- package/skills/dev-loop/scripts/init-phase.mjs +71 -0
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
- package/skills/dev-loop/scripts/phase-files.mjs +29 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
- package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
- package/skills/dev-loop/scripts/render-template.mjs +82 -0
- package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
- package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
- package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
- package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
- package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
- package/skills/dev-loop/templates/dev-mode-review.md +17 -0
- package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
- package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
- package/skills/dev-loop/templates/phase-doc.md +27 -0
- package/skills/dev-loop/templates/phase-summary.md +13 -0
- package/skills/dev-loop/templates/phase-variant.md +15 -0
- package/skills/dev-loop/templates/retrospective.md +11 -0
- package/skills/dev-loop/templates/review.md +32 -0
- package/skills/dev-loop/templates/ui-vision-review.md +55 -0
- package/skills/docs/acceptance-criteria-verification.md +21 -0
- package/skills/docs/anti-patterns.md +21 -0
- package/skills/docs/artifact-authority-contract.md +119 -0
- package/skills/docs/confirmation-rules.md +28 -0
- package/skills/docs/copilot-ci-status-contract.md +52 -0
- package/skills/docs/copilot-loop-operations.md +233 -0
- package/skills/docs/debt-remediation-contract.md +107 -0
- package/skills/docs/entrypoint-strategies.md +115 -0
- package/skills/docs/epic-tree-refinement-procedure.md +234 -0
- package/skills/docs/issue-intake-procedure.md +235 -0
- package/skills/docs/main-agent-contract.md +72 -0
- package/skills/docs/merge-preconditions.md +29 -0
- package/skills/docs/pr-lifecycle-contract.md +209 -0
- package/skills/docs/public-dev-loop-contract.md +497 -0
- package/skills/docs/retrospective-checkpoint-contract.md +159 -0
- package/skills/docs/stop-conditions.md +29 -0
- package/skills/docs/structural-quality.md +42 -0
- package/skills/docs/tracker-first-loop-state.md +281 -0
- package/skills/docs/validation-policy.md +27 -0
- package/skills/docs/workflow-handoff-contract.md +135 -0
- package/skills/final-approval/SKILL.md +19 -0
- package/skills/local-implementation/SKILL.md +640 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentEndEvent,
|
|
3
|
+
ExecResult,
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
ToolResultEvent,
|
|
7
|
+
UserBashEvent,
|
|
8
|
+
UserBashEventResult,
|
|
9
|
+
} from '@mariozechner/pi-coding-agent';
|
|
10
|
+
|
|
11
|
+
export const TARGET_REPO_SLUG = 'mfittko/dev-loops';
|
|
12
|
+
export const POST_MERGE_UPDATE_COMMAND = 'pi update git:github.com/mfittko/dev-loops';
|
|
13
|
+
export const PRE_PR_READY_GATE_SCRIPT = 'node scripts/loop/pre-pr-ready-gate.mjs';
|
|
14
|
+
|
|
15
|
+
const MERGE_COMMAND_TIMEOUT_MS = 15 * 60 * 1000;
|
|
16
|
+
const POST_MERGE_UPDATE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
17
|
+
const REPO_RESOLUTION_TIMEOUT_MS = 5_000;
|
|
18
|
+
const PR_READY_GATE_TIMEOUT_MS = 30_000;
|
|
19
|
+
|
|
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
|
+
type RepoContext = {
|
|
24
|
+
repoRoot: string | null;
|
|
25
|
+
repoSlug: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type RunCommandArgs = {
|
|
29
|
+
command: string;
|
|
30
|
+
cwd?: string;
|
|
31
|
+
timeout?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type RunCommandResult = ExecResult;
|
|
35
|
+
|
|
36
|
+
type PostMergeUpdateHookState = {
|
|
37
|
+
pendingPostMergeUpdate: boolean;
|
|
38
|
+
updateInFlight: boolean;
|
|
39
|
+
pendingRepoRoot: string | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type CreatePostMergeUpdateHookOptions = {
|
|
43
|
+
resolveRepoContext?: (cwd: string) => Promise<RepoContext>;
|
|
44
|
+
runCommand?: (args: RunCommandArgs) => Promise<RunCommandResult>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function trimToNull(value: string | null | undefined): string | null {
|
|
48
|
+
const trimmed = `${value ?? ''}`.trim();
|
|
49
|
+
return trimmed ? trimmed : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildShellOutput(result: Pick<RunCommandResult, 'stdout' | 'stderr'>): string {
|
|
53
|
+
const stdout = `${result.stdout ?? ''}`.trimEnd();
|
|
54
|
+
const stderr = `${result.stderr ?? ''}`.trimEnd();
|
|
55
|
+
if (stdout && stderr) {
|
|
56
|
+
return `${stdout}\n${stderr}`;
|
|
57
|
+
}
|
|
58
|
+
return stdout || stderr;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildFailureSummary(result: Pick<RunCommandResult, 'stdout' | 'stderr' | 'code' | 'killed'>): string {
|
|
62
|
+
return trimToNull(result.stderr)
|
|
63
|
+
?? trimToNull(result.stdout)
|
|
64
|
+
?? (result.killed
|
|
65
|
+
? 'command was killed before completing'
|
|
66
|
+
: (typeof result.code === 'number' ? `exit code ${result.code}` : 'exit code unavailable'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getBashCommandFromToolResult(event: ToolResultEvent): string | null {
|
|
70
|
+
if (event.toolName !== 'bash') {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const command = event.input?.command;
|
|
74
|
+
return typeof command === 'string' ? command : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function notify(ctx: ExtensionContext, message: string, level: 'info' | 'warning' | 'error' = 'info'): void {
|
|
78
|
+
if (ctx.hasUI) {
|
|
79
|
+
ctx.ui.notify(message, level);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function defaultResolveRepoContext(pi: ExtensionAPI, cwd: string): Promise<RepoContext> {
|
|
84
|
+
const rootResult = await pi.exec('bash', ['-lc', 'git rev-parse --show-toplevel'], {
|
|
85
|
+
cwd,
|
|
86
|
+
timeout: REPO_RESOLUTION_TIMEOUT_MS,
|
|
87
|
+
});
|
|
88
|
+
if (rootResult.code !== 0) {
|
|
89
|
+
return { repoRoot: null, repoSlug: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const repoRoot = trimToNull(rootResult.stdout);
|
|
93
|
+
if (!repoRoot) {
|
|
94
|
+
return { repoRoot: null, repoSlug: null };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const remoteResult = await pi.exec('bash', ['-lc', 'git config --get remote.origin.url'], {
|
|
98
|
+
cwd: repoRoot,
|
|
99
|
+
timeout: REPO_RESOLUTION_TIMEOUT_MS,
|
|
100
|
+
});
|
|
101
|
+
if (remoteResult.code !== 0) {
|
|
102
|
+
return { repoRoot, repoSlug: null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
repoRoot,
|
|
107
|
+
repoSlug: normalizeGitHubRepoSlug(remoteResult.stdout),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function defaultRunCommand(pi: ExtensionAPI, args: RunCommandArgs): Promise<RunCommandResult> {
|
|
112
|
+
return pi.exec('bash', ['-lc', args.command], {
|
|
113
|
+
cwd: args.cwd,
|
|
114
|
+
timeout: args.timeout,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function markPendingUpdate(state: PostMergeUpdateHookState, command: string, repoContext: RepoContext): void {
|
|
119
|
+
if (!repoContext.repoRoot || repoContext.repoSlug !== TARGET_REPO_SLUG) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (state.pendingPostMergeUpdate) {
|
|
124
|
+
state.pendingRepoRoot ??= repoContext.repoRoot;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
state.pendingPostMergeUpdate = true;
|
|
129
|
+
state.pendingRepoRoot = repoContext.repoRoot;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function resolveRepoContextSafe(
|
|
133
|
+
resolveRepoContext: (cwd: string) => Promise<RepoContext>,
|
|
134
|
+
cwd: string,
|
|
135
|
+
): Promise<RepoContext | null> {
|
|
136
|
+
try {
|
|
137
|
+
return await resolveRepoContext(cwd);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function queueIfEligible(
|
|
144
|
+
state: PostMergeUpdateHookState,
|
|
145
|
+
resolveRepoContext: (cwd: string) => Promise<RepoContext>,
|
|
146
|
+
command: string,
|
|
147
|
+
cwd: string,
|
|
148
|
+
): Promise<boolean> {
|
|
149
|
+
if (!isMergeCapableCommand(command)) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const repoContext = await resolveRepoContextSafe(resolveRepoContext, cwd);
|
|
154
|
+
if (!repoContext?.repoRoot || repoContext.repoSlug !== TARGET_REPO_SLUG) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
markPendingUpdate(state, command, repoContext);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
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
|
+
|
|
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);
|
|
313
|
+
|
|
314
|
+
const resolveRepoContext = options.resolveRepoContext
|
|
315
|
+
?? (pi ? ((cwd: string) => defaultResolveRepoContext(pi, cwd)) : null);
|
|
316
|
+
const runCommand = options.runCommand
|
|
317
|
+
?? (pi ? ((args: RunCommandArgs) => defaultRunCommand(pi, args)) : null);
|
|
318
|
+
|
|
319
|
+
if (!resolveRepoContext || !runCommand) {
|
|
320
|
+
throw new Error('createPostMergeUpdateHook requires an ExtensionAPI or explicit resolveRepoContext/runCommand overrides.');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const state: PostMergeUpdateHookState = {
|
|
324
|
+
pendingPostMergeUpdate: false,
|
|
325
|
+
updateInFlight: false,
|
|
326
|
+
pendingRepoRoot: null,
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
function reset(): void {
|
|
330
|
+
state.pendingPostMergeUpdate = false;
|
|
331
|
+
state.updateInFlight = false;
|
|
332
|
+
state.pendingRepoRoot = null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
getState(): PostMergeUpdateHookState {
|
|
337
|
+
return { ...state };
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
onSessionStart(): void {
|
|
341
|
+
reset();
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
async onToolResult(event: Pick<ToolResultEvent, 'toolName' | 'input' | 'isError'>, ctx: Pick<ExtensionContext, 'cwd'>): Promise<void> {
|
|
345
|
+
const command = getBashCommandFromToolResult(event as ToolResultEvent);
|
|
346
|
+
if (!command || event.isError) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
await queueIfEligible(state, resolveRepoContext, command, ctx.cwd);
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
async onUserBash(event: Pick<UserBashEvent, 'command' | 'cwd'>, _ctx?: ExtensionContext): Promise<UserBashEventResult | undefined> {
|
|
353
|
+
// Intercept gh pr ready before any other checks
|
|
354
|
+
if (isGhPrReadyCommand(event.command)) {
|
|
355
|
+
// Check if the command explicitly targets a different repo via -R/--repo
|
|
356
|
+
const explicitRepo = extractRepoFlagFromGhPrReady(event.command);
|
|
357
|
+
if (explicitRepo && explicitRepo.toLowerCase() !== TARGET_REPO_SLUG.toLowerCase()) {
|
|
358
|
+
// Explicitly targeting a different repo — pass through
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const repoContext = await resolveRepoContextSafe(resolveRepoContext, event.cwd);
|
|
363
|
+
if (!repoContext?.repoRoot || repoContext.repoSlug !== TARGET_REPO_SLUG) {
|
|
364
|
+
// Not our target repo — pass through to default handling
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const prNumber = extractPrNumberFromGhPrReady(event.command);
|
|
369
|
+
if (prNumber === null) {
|
|
370
|
+
return {
|
|
371
|
+
result: {
|
|
372
|
+
output: 'gh pr ready blocked: could not determine PR number from command. Include the PR number explicitly.',
|
|
373
|
+
exitCode: 1,
|
|
374
|
+
cancelled: false,
|
|
375
|
+
truncated: false,
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Run draft-gate evidence check
|
|
381
|
+
const gateCommand = `${PRE_PR_READY_GATE_SCRIPT} --repo ${repoContext.repoSlug} --pr ${prNumber}`;
|
|
382
|
+
try {
|
|
383
|
+
const gateResult = await runCommand({
|
|
384
|
+
command: gateCommand,
|
|
385
|
+
cwd: repoContext.repoRoot,
|
|
386
|
+
timeout: PR_READY_GATE_TIMEOUT_MS,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
if (gateResult.code !== 0) {
|
|
390
|
+
const stderr = `${gateResult.stderr ?? ''}`.trim();
|
|
391
|
+
let message = `gh pr ready blocked: no visible clean draft_gate checkpoint verdict comment found for PR #${prNumber}.`;
|
|
392
|
+
try {
|
|
393
|
+
const parsed = JSON.parse(stderr);
|
|
394
|
+
if (parsed.error) {
|
|
395
|
+
message = `gh pr ready blocked: ${parsed.error}`;
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
if (stderr) {
|
|
399
|
+
message = `gh pr ready blocked:\n${stderr}`;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
result: {
|
|
404
|
+
output: message,
|
|
405
|
+
exitCode: 1,
|
|
406
|
+
cancelled: false,
|
|
407
|
+
truncated: false,
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Gate passed — run the actual gh pr ready command
|
|
413
|
+
const readyResult = await runCommand({
|
|
414
|
+
command: event.command,
|
|
415
|
+
cwd: event.cwd,
|
|
416
|
+
timeout: MERGE_COMMAND_TIMEOUT_MS,
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
result: {
|
|
421
|
+
output: buildShellOutput(readyResult),
|
|
422
|
+
exitCode: readyResult.killed ? undefined : readyResult.code,
|
|
423
|
+
cancelled: Boolean(readyResult.killed),
|
|
424
|
+
truncated: false,
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
} catch {
|
|
428
|
+
return {
|
|
429
|
+
result: {
|
|
430
|
+
output: 'gh pr ready blocked: draft-gate evidence check failed (could not run guard script).',
|
|
431
|
+
exitCode: 1,
|
|
432
|
+
cancelled: false,
|
|
433
|
+
truncated: false,
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (!isMergeCapableCommand(event.command)) {
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const repoContext = await resolveRepoContextSafe(resolveRepoContext, event.cwd);
|
|
444
|
+
if (!repoContext?.repoRoot || repoContext.repoSlug !== TARGET_REPO_SLUG) {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
const result = await runCommand({
|
|
450
|
+
command: event.command,
|
|
451
|
+
cwd: event.cwd,
|
|
452
|
+
timeout: MERGE_COMMAND_TIMEOUT_MS,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
if (result.code === 0 && !result.killed) {
|
|
456
|
+
markPendingUpdate(state, event.command, repoContext);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
result: {
|
|
461
|
+
output: buildShellOutput(result),
|
|
462
|
+
exitCode: result.killed ? undefined : result.code,
|
|
463
|
+
cancelled: Boolean(result.killed),
|
|
464
|
+
truncated: false,
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
} catch (error) {
|
|
468
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
469
|
+
return {
|
|
470
|
+
result: {
|
|
471
|
+
output: detail,
|
|
472
|
+
exitCode: 1,
|
|
473
|
+
cancelled: false,
|
|
474
|
+
truncated: false,
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
async onAgentEnd(_event: AgentEndEvent, ctx: Pick<ExtensionContext, 'cwd' | 'hasUI' | 'ui'>): Promise<void> {
|
|
481
|
+
if (!state.pendingPostMergeUpdate || state.updateInFlight) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
state.updateInFlight = true;
|
|
486
|
+
notify(ctx as ExtensionContext, `Post-merge update running: ${POST_MERGE_UPDATE_COMMAND}`, 'info');
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
const result = await runCommand({
|
|
490
|
+
command: POST_MERGE_UPDATE_COMMAND,
|
|
491
|
+
cwd: state.pendingRepoRoot ?? ctx.cwd,
|
|
492
|
+
timeout: POST_MERGE_UPDATE_TIMEOUT_MS,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
if (result.code === 0 && !result.killed) {
|
|
496
|
+
notify(ctx as ExtensionContext, `Post-merge update completed: ${POST_MERGE_UPDATE_COMMAND}`, 'info');
|
|
497
|
+
} else {
|
|
498
|
+
notify(
|
|
499
|
+
ctx as ExtensionContext,
|
|
500
|
+
`Post-merge update failed (warning only): ${buildFailureSummary(result)}`,
|
|
501
|
+
'warning',
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
} catch (error) {
|
|
505
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
506
|
+
notify(ctx as ExtensionContext, `Post-merge update failed (warning only): ${detail}`, 'warning');
|
|
507
|
+
} finally {
|
|
508
|
+
reset();
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { DevLoopCheck, DevLoopCheckId } from './checks.ts';
|
|
2
|
+
import { describeReadiness, DEV_LOOP_CHECK_IDS, summarizeChecks, renderCheckLines } from '../lib/dev-loops-core.mjs';
|
|
3
|
+
|
|
4
|
+
export type DevLoopsAction = 'doctor' | 'help' | 'status' | 'hide';
|
|
5
|
+
export type InspectAction = 'open' | 'resume' | 'status' | 'stop' | 'restart';
|
|
6
|
+
|
|
7
|
+
const SETUP_GUIDANCE: Record<(typeof DEV_LOOP_CHECK_IDS)[number], string> = {
|
|
8
|
+
'gh-installed': 'Install GitHub CLI to enable remote GitHub/Copilot workflows.',
|
|
9
|
+
'gh-auth': 'Run `gh auth login` so remote GitHub/Copilot workflows can use your GitHub session.',
|
|
10
|
+
'subagent-command': 'Install or enable subagent support so the `subagent` command is available.',
|
|
11
|
+
'git-repo': 'Open Pi inside a git repository checkout before using the shared loops.',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function readinessLabel(ready: boolean): string {
|
|
15
|
+
return ready ? 'ready' : 'needs setup';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function checkMap(checks: DevLoopCheck[]): Map<DevLoopCheckId, DevLoopCheck> {
|
|
19
|
+
return new Map(checks.map((check) => [check.id, check]));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function orderedSetupSteps(checks: DevLoopCheck[]): string[] {
|
|
23
|
+
const byId = checkMap(checks);
|
|
24
|
+
const uniqueSteps = [...new Set(DEV_LOOP_CHECK_IDS.filter((id) => byId.get(id)?.ok === false).map((id) => SETUP_GUIDANCE[id]))];
|
|
25
|
+
const steps = uniqueSteps.map((step, index) => `${index + 1}. ${step}`);
|
|
26
|
+
|
|
27
|
+
if (steps.length > 0) {
|
|
28
|
+
return steps;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return [
|
|
32
|
+
'1. Use `/skill:dev-loop` to start or continue a dev loop — the single public entry; routing handles the rest.',
|
|
33
|
+
'2. Run `/dev-loops status` whenever you want a concise readiness snapshot.',
|
|
34
|
+
'3. Use `pi install git:github.com/mfittko/dev-loops` to install the package, or `pi update git:github.com/mfittko/dev-loops` to refresh it.',
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildHelpLines(): string[] {
|
|
39
|
+
return [
|
|
40
|
+
'dev-loops help',
|
|
41
|
+
'Workflow entry:',
|
|
42
|
+
'- /skill:dev-loop — single public entrypoint; routing handles the rest',
|
|
43
|
+
'Commands:',
|
|
44
|
+
'- /dev-loops status',
|
|
45
|
+
'- /dev-loops doctor',
|
|
46
|
+
'- /dev-loops hide',
|
|
47
|
+
'- /dev-loops inspect open [--repo <owner/name>]',
|
|
48
|
+
'- /dev-loops inspect resume [--repo <owner/name>]',
|
|
49
|
+
'- /dev-loops inspect status [--repo <owner/name>]',
|
|
50
|
+
'- /dev-loops inspect stop [--repo <owner/name>]',
|
|
51
|
+
'- /dev-loops inspect restart [--repo <owner/name>]',
|
|
52
|
+
'Use `pi install git:github.com/mfittko/dev-loops` to install skills and agents; packaged agents sync into `~/.agents/` on session start.',
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildWidgetLines(action: Extract<DevLoopsAction, 'doctor' | 'status'>, checks: DevLoopCheck[]): string[] {
|
|
57
|
+
const summary = summarizeChecks(checks);
|
|
58
|
+
const readiness = describeReadiness(checks);
|
|
59
|
+
const lines = [
|
|
60
|
+
`dev-loops ${action}: ${summary.ok}/${summary.total} checks passed`,
|
|
61
|
+
`Local loop readiness: ${readinessLabel(readiness.localReady)}`,
|
|
62
|
+
`Remote GitHub/Copilot readiness: ${readinessLabel(readiness.remoteReady)}`,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
if (action === 'status') {
|
|
66
|
+
return [
|
|
67
|
+
...lines,
|
|
68
|
+
'Suggested next steps:',
|
|
69
|
+
...orderedSetupSteps(checks),
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [
|
|
74
|
+
...lines,
|
|
75
|
+
...renderCheckLines(checks),
|
|
76
|
+
'Skills load via `pi install git:github.com/mfittko/dev-loops`; packaged agents sync into `~/.agents/` on session start.',
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function buildInspectLines(action: InspectAction, result: { state: string; url?: string | null; detail?: string | null; warning?: string | null; repo?: string | null }): string[] {
|
|
81
|
+
const lines = [
|
|
82
|
+
`inspect ${action}`,
|
|
83
|
+
`State: ${result.state}`,
|
|
84
|
+
];
|
|
85
|
+
if (result.repo) {
|
|
86
|
+
lines.push(`Repo: ${result.repo}`);
|
|
87
|
+
}
|
|
88
|
+
if (result.url) {
|
|
89
|
+
lines.push(`URL: ${result.url}`);
|
|
90
|
+
}
|
|
91
|
+
if (result.detail) {
|
|
92
|
+
lines.push(`Detail: ${result.detail}`);
|
|
93
|
+
}
|
|
94
|
+
if (result.warning) {
|
|
95
|
+
lines.push(`Warning: ${result.warning}`);
|
|
96
|
+
}
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildNotificationMessage(action: Extract<DevLoopsAction, 'doctor' | 'status'>, checks: DevLoopCheck[]): string {
|
|
101
|
+
const summary = summarizeChecks(checks);
|
|
102
|
+
return `dev-loops ${action}: ${summary.ok}/${summary.total} checks passed`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function buildInspectNotification(action: InspectAction, state: string): string {
|
|
106
|
+
return `inspect ${action}: ${state}`;
|
|
107
|
+
}
|