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,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadRunnerCoordinationState,
|
|
3
|
+
} from "./_pr-runner-coordination.mjs";
|
|
4
|
+
export const STALE_RUNNER_ERROR = Object.freeze({
|
|
5
|
+
STALE_RUNNER: "stale_runner",
|
|
6
|
+
EXIT_SIGNAL_RECORDED: "exit_signal_recorded",
|
|
7
|
+
});
|
|
8
|
+
export const STALE_RUNNER_DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
|
|
9
|
+
function parsePositiveIntegerMs(value, fallback) {
|
|
10
|
+
const parsed = Number(value);
|
|
11
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
12
|
+
return fallback;
|
|
13
|
+
}
|
|
14
|
+
return Math.floor(parsed);
|
|
15
|
+
}
|
|
16
|
+
export function resolveStaleRunnerMaxAgeMs(options = {}, env = process.env) {
|
|
17
|
+
const explicit = options?.staleRunnerMaxAgeMs;
|
|
18
|
+
if (Number.isFinite(explicit) && explicit > 0) {
|
|
19
|
+
return Math.floor(explicit);
|
|
20
|
+
}
|
|
21
|
+
const fromEnv = parsePositiveIntegerMs(env?.PI_DEV_LOOP_STALE_RUNNER_MAX_AGE_MS, NaN);
|
|
22
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0) {
|
|
23
|
+
return fromEnv;
|
|
24
|
+
}
|
|
25
|
+
return STALE_RUNNER_DEFAULT_MAX_AGE_MS;
|
|
26
|
+
}
|
|
27
|
+
function normalizeRunIdForSignal(runId) {
|
|
28
|
+
return typeof runId === "string" && runId.trim().length > 0 ? runId.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
function isExitSignalForRun(state, runId) {
|
|
31
|
+
if (!state || !Array.isArray(state.exitSignals) || runId === null) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return state.exitSignals.some((entry) => {
|
|
35
|
+
if (!entry || typeof entry !== "object") return false;
|
|
36
|
+
return normalizeRunIdForSignal(entry.runId) === runId;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function findStaleRunnerMatch(state, { now, maxAgeMs }) {
|
|
40
|
+
if (!state || !state.activeRun) return null;
|
|
41
|
+
const active = state.activeRun;
|
|
42
|
+
if (!active.runId) return null;
|
|
43
|
+
const claimedAt = typeof active.claimedAt === "string" ? Date.parse(active.claimedAt) : NaN;
|
|
44
|
+
const updatedAt = typeof active.updatedAt === "string" ? Date.parse(active.updatedAt) : NaN;
|
|
45
|
+
const claimedCorrupt = !Number.isFinite(claimedAt);
|
|
46
|
+
const updatedCorrupt = !Number.isFinite(updatedAt);
|
|
47
|
+
if (claimedCorrupt || updatedCorrupt) {
|
|
48
|
+
return {
|
|
49
|
+
runId: active.runId,
|
|
50
|
+
claimedAt: active.claimedAt ?? "(corrupt)",
|
|
51
|
+
updatedAt: active.updatedAt ?? "(corrupt)",
|
|
52
|
+
claimedAgeMs: claimedCorrupt ? -1 : now - claimedAt,
|
|
53
|
+
updatedAgeMs: updatedCorrupt ? -1 : now - updatedAt,
|
|
54
|
+
maxAgeMs,
|
|
55
|
+
corruptedTimestamp: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const claimedAgeMs = now - claimedAt;
|
|
59
|
+
const updatedAgeMs = now - updatedAt;
|
|
60
|
+
if (claimedAgeMs > maxAgeMs && updatedAgeMs > maxAgeMs) {
|
|
61
|
+
return {
|
|
62
|
+
runId: active.runId,
|
|
63
|
+
claimedAt: active.claimedAt,
|
|
64
|
+
updatedAt: active.updatedAt,
|
|
65
|
+
claimedAgeMs,
|
|
66
|
+
updatedAgeMs,
|
|
67
|
+
maxAgeMs,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
export async function detectStaleRunner({ repo, pr, now = Date.now(), maxAgeMs, cwd = process.cwd() } = {}) {
|
|
73
|
+
if (!repo || typeof repo !== "string") {
|
|
74
|
+
throw new Error("detectStaleRunner requires a non-empty repo slug");
|
|
75
|
+
}
|
|
76
|
+
if (pr === undefined || pr === null) {
|
|
77
|
+
throw new Error("detectStaleRunner requires a PR number");
|
|
78
|
+
}
|
|
79
|
+
const effectiveMaxAgeMs = resolveStaleRunnerMaxAgeMs({ staleRunnerMaxAgeMs: maxAgeMs });
|
|
80
|
+
const loaded = await loadRunnerCoordinationState({ repo, pr, cwd });
|
|
81
|
+
if (loaded.state === null) {
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
status: "no_owner_record",
|
|
85
|
+
repo,
|
|
86
|
+
pr,
|
|
87
|
+
activeRun: null,
|
|
88
|
+
staleRunner: null,
|
|
89
|
+
exitSignal: null,
|
|
90
|
+
filePath: loaded.filePath,
|
|
91
|
+
maxAgeMs: effectiveMaxAgeMs,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const state = loaded.state;
|
|
95
|
+
const active = state.activeRun;
|
|
96
|
+
const staleMatch = findStaleRunnerMatch(state, { now, maxAgeMs: effectiveMaxAgeMs });
|
|
97
|
+
const exitSignal = isExitSignalForRun(state, active?.runId ?? null)
|
|
98
|
+
? {
|
|
99
|
+
runId: active.runId,
|
|
100
|
+
signals: (state.exitSignals || []).filter((entry) =>
|
|
101
|
+
normalizeRunIdForSignal(entry?.runId) === active.runId),
|
|
102
|
+
}
|
|
103
|
+
: null;
|
|
104
|
+
if (exitSignal !== null) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
error: STALE_RUNNER_ERROR.EXIT_SIGNAL_RECORDED,
|
|
108
|
+
status: "exit_signal_recorded",
|
|
109
|
+
repo,
|
|
110
|
+
pr,
|
|
111
|
+
activeRun: active,
|
|
112
|
+
staleRunner: null,
|
|
113
|
+
exitSignal,
|
|
114
|
+
filePath: loaded.filePath,
|
|
115
|
+
maxAgeMs: effectiveMaxAgeMs,
|
|
116
|
+
message: `Run ${active.runId} has an exit signal recorded for ${repo}#${pr}; refuse to proceed with merge.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (staleMatch !== null) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: STALE_RUNNER_ERROR.STALE_RUNNER,
|
|
123
|
+
status: "stale_runner",
|
|
124
|
+
repo,
|
|
125
|
+
pr,
|
|
126
|
+
activeRun: active,
|
|
127
|
+
staleRunner: staleMatch,
|
|
128
|
+
exitSignal: null,
|
|
129
|
+
filePath: loaded.filePath,
|
|
130
|
+
maxAgeMs: effectiveMaxAgeMs,
|
|
131
|
+
message: `Active run ${staleMatch.runId} for ${repo}#${pr} is stale (claimed ${staleMatch.claimedAgeMs}ms ago, last updated ${staleMatch.updatedAgeMs}ms ago, max age ${staleMatch.maxAgeMs}ms).`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
status: "fresh_runner",
|
|
137
|
+
repo,
|
|
138
|
+
pr,
|
|
139
|
+
activeRun: active,
|
|
140
|
+
staleRunner: null,
|
|
141
|
+
exitSignal: null,
|
|
142
|
+
filePath: loaded.filePath,
|
|
143
|
+
maxAgeMs: effectiveMaxAgeMs,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { parseRepoSlugParts } from "@dev-loops/core/github/repo-slug";
|
|
5
|
+
const STATE_FILE_LOCK_TIMEOUT_MS = 5000;
|
|
6
|
+
const STATE_FILE_LOCK_RETRY_MS = 50;
|
|
7
|
+
function normalizeRepoSlug(repo) {
|
|
8
|
+
return typeof repo === "string" ? repo.trim().toLowerCase() : "";
|
|
9
|
+
}
|
|
10
|
+
function assertSafeRepoSlug(repo) {
|
|
11
|
+
return parseRepoSlugParts(repo, {
|
|
12
|
+
errorMessage: `Invalid repo slug for steering target path: ${JSON.stringify(repo)}`,
|
|
13
|
+
lowercase: true,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function defaultStateFilePath(runId, cwd = process.cwd()) {
|
|
17
|
+
return path.join(cwd, ".pi", "steering", `${runId}.json`);
|
|
18
|
+
}
|
|
19
|
+
export function defaultStateFilePathForTarget({ repo, pr }, cwd = process.cwd()) {
|
|
20
|
+
const { owner, name } = assertSafeRepoSlug(repo);
|
|
21
|
+
return path.join(cwd, ".pi", "steering", owner, name, `pr-${pr}.json`);
|
|
22
|
+
}
|
|
23
|
+
export function validateSteeringStateTarget(steeringState, { repo, pr, runId }) {
|
|
24
|
+
if (!steeringState || typeof steeringState !== "object") {
|
|
25
|
+
return { ok: false, reason: "steering state must be an object" };
|
|
26
|
+
}
|
|
27
|
+
if (typeof runId === "string" && steeringState.runId !== runId) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
reason: `steering state runId ${JSON.stringify(steeringState.runId)} does not match expected run ${JSON.stringify(runId)}`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const target = steeringState.target;
|
|
34
|
+
const expectedRepo = normalizeRepoSlug(repo);
|
|
35
|
+
if (expectedRepo.length > 0) {
|
|
36
|
+
if (!target || typeof target !== "object") {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
reason: "steering state target metadata is missing",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const actualRepo = normalizeRepoSlug(target.repo);
|
|
43
|
+
const actualPr = typeof target.pr === "number" ? target.pr : Number(target.pr);
|
|
44
|
+
if (actualRepo !== expectedRepo || actualPr !== pr) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
reason: `steering state target ${JSON.stringify({ repo: target.repo, pr: target.pr })} does not match expected target ${JSON.stringify({ repo: expectedRepo, pr })}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, reason: null };
|
|
51
|
+
} else if (target && typeof target === "object") {
|
|
52
|
+
if (pr !== undefined) {
|
|
53
|
+
const actualPr = typeof target.pr === "number" ? target.pr : Number(target.pr);
|
|
54
|
+
if (actualPr !== pr) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
reason: `steering state target pr ${JSON.stringify(target.pr)} does not match expected pr ${JSON.stringify(pr)}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
reason: "repo identity cannot be proven for the supplied steering state in snapshot mode",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (pr !== undefined) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
reason: "steering state target metadata is missing; repo identity cannot be proven for the supplied steering state in snapshot mode",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, reason: null };
|
|
73
|
+
}
|
|
74
|
+
export async function loadStateFile(filePath) {
|
|
75
|
+
try {
|
|
76
|
+
const text = await readFile(filePath, "utf8");
|
|
77
|
+
return JSON.parse(text);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code === "ENOENT") {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`Failed to read steering state file '${filePath}': ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function sleep(ms) {
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
87
|
+
}
|
|
88
|
+
async function readLockMetadata(lockPath) {
|
|
89
|
+
try {
|
|
90
|
+
const text = await readFile(path.join(lockPath, "owner.json"), "utf8");
|
|
91
|
+
return JSON.parse(text);
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function withStateFileLock(filePath, callback) {
|
|
97
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
98
|
+
const lockPath = `${filePath}.lock`;
|
|
99
|
+
const deadline = Date.now() + STATE_FILE_LOCK_TIMEOUT_MS;
|
|
100
|
+
while (true) {
|
|
101
|
+
try {
|
|
102
|
+
await mkdir(lockPath);
|
|
103
|
+
await writeFile(
|
|
104
|
+
path.join(lockPath, "owner.json"),
|
|
105
|
+
`${JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }, null, 2)}\n`,
|
|
106
|
+
"utf8",
|
|
107
|
+
);
|
|
108
|
+
break;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code !== "EEXIST") {
|
|
111
|
+
throw new Error(`Failed to acquire steering state lock '${lockPath}': ${error.message}`);
|
|
112
|
+
}
|
|
113
|
+
if (Date.now() >= deadline) {
|
|
114
|
+
const metadata = await readLockMetadata(lockPath);
|
|
115
|
+
const ownerSuffix = metadata
|
|
116
|
+
? ` (current lock owner pid=${metadata.pid ?? "unknown"}, acquiredAt=${metadata.acquiredAt ?? "unknown"})`
|
|
117
|
+
: "";
|
|
118
|
+
throw new Error(`Timed out waiting for steering state lock '${lockPath}'${ownerSuffix}. If the owning process crashed, remove the stale lock directory and retry.`);
|
|
119
|
+
}
|
|
120
|
+
await sleep(STATE_FILE_LOCK_RETRY_MS);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return await callback();
|
|
125
|
+
} finally {
|
|
126
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export async function saveStateFile(filePath, steeringState) {
|
|
130
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
131
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
132
|
+
await writeFile(tempPath, `${JSON.stringify(steeringState, null, 2)}\n`, "utf8");
|
|
133
|
+
await rename(tempPath, filePath);
|
|
134
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI wrapper for buildDevLoopHandoffEnvelope().
|
|
4
|
+
*
|
|
5
|
+
* Subagents and shell scripts should call this instead of writing ad-hoc
|
|
6
|
+
* inline Node.js to import from the @dev-loops/core subpath. Using the
|
|
7
|
+
* bare `@dev-loops/core` specifier fails because the package has no
|
|
8
|
+
* default export — only named subpath exports.
|
|
9
|
+
*
|
|
10
|
+
* Typical usage (pipeline):
|
|
11
|
+
* dev-loops loop startup --issue 42 > resolver-output.json
|
|
12
|
+
* dev-loops loop build-envelope --input resolver-output.json
|
|
13
|
+
*
|
|
14
|
+
* Or via npx:
|
|
15
|
+
* npx dev-loops loop build-envelope --input resolver-output.json
|
|
16
|
+
*/
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
21
|
+
import { requireOptionValue } from "../_cli-primitives.mjs";
|
|
22
|
+
import { buildDevLoopHandoffEnvelope } from "@dev-loops/core/loop/handoff-envelope";
|
|
23
|
+
import { loadDevLoopConfig } from "@dev-loops/core/config";
|
|
24
|
+
import { createPiAdapter } from "@dev-loops/core/harness";
|
|
25
|
+
|
|
26
|
+
const USAGE = `Usage: build-handoff-envelope.mjs --input <path>
|
|
27
|
+
Build a deterministic handoff envelope from startup resolver output and settings.
|
|
28
|
+
Required:
|
|
29
|
+
--input <path> Path to resolver output JSON (from resolve-dev-loop-startup.mjs)
|
|
30
|
+
Optional:
|
|
31
|
+
--gate-state <json> Gate state JSON string
|
|
32
|
+
{ currentHeadSha?, ciStatus?, unresolvedThreadCount?, copilotRoundCount? }
|
|
33
|
+
--overrides <json> Overrides JSON string
|
|
34
|
+
{ mergeAuthorized?, preferLocal?, scopeConstraint?, customStopAt? }
|
|
35
|
+
--repo <owner/name> Repository slug override (falls back to bundle.repoSlug or bundle.repo)
|
|
36
|
+
Output (stdout, JSON):
|
|
37
|
+
Handoff envelope object — see workflow-handoff-contract.md for schema.
|
|
38
|
+
Error output (stderr, JSON):
|
|
39
|
+
Argument/usage errors:
|
|
40
|
+
{ "ok": false, "error": "...", "usage": "..." }
|
|
41
|
+
Runtime failures:
|
|
42
|
+
{ "ok": false, "error": "..." }
|
|
43
|
+
Exit codes:
|
|
44
|
+
0 Success
|
|
45
|
+
1 Argument error or runtime failure`.trim();
|
|
46
|
+
|
|
47
|
+
const parseError = buildParseError(USAGE);
|
|
48
|
+
|
|
49
|
+
function parseFlagJson(raw, flagName, parseErrorFn) {
|
|
50
|
+
try {
|
|
51
|
+
return parseJsonText(raw);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
54
|
+
throw parseErrorFn(`Invalid JSON for ${flagName}: ${message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseBuildHandoffEnvelopeCliArgs(argv) {
|
|
59
|
+
const args = [...argv];
|
|
60
|
+
const options = {
|
|
61
|
+
help: false,
|
|
62
|
+
inputPath: undefined,
|
|
63
|
+
gateState: undefined,
|
|
64
|
+
overrides: undefined,
|
|
65
|
+
repo: undefined,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
while (args.length > 0) {
|
|
69
|
+
const token = args.shift();
|
|
70
|
+
if (token === "--help" || token === "-h") {
|
|
71
|
+
options.help = true;
|
|
72
|
+
return options;
|
|
73
|
+
}
|
|
74
|
+
if (token === "--input") {
|
|
75
|
+
options.inputPath = requireOptionValue(args, "--input", parseError);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (token === "--gate-state") {
|
|
79
|
+
options.gateState = requireOptionValue(args, "--gate-state", parseError);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (token === "--overrides") {
|
|
83
|
+
options.overrides = requireOptionValue(args, "--overrides", parseError);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (token === "--repo") {
|
|
87
|
+
options.repo = requireOptionValue(args, "--repo", parseError);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!options.inputPath) {
|
|
94
|
+
throw parseError("--input <path> is required");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return options;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
export async function buildHandoffEnvelopeCli(
|
|
102
|
+
options,
|
|
103
|
+
{ adapter = createPiAdapter() } = {},
|
|
104
|
+
) {
|
|
105
|
+
// Resolve repo root via the adapter so envelope construction stays harness-agnostic.
|
|
106
|
+
const cwd = adapter.getCwd();
|
|
107
|
+
const repoRoot = adapter.getRepoRoot();
|
|
108
|
+
|
|
109
|
+
// Load resolver output from file
|
|
110
|
+
const inputPath = path.resolve(cwd, options.inputPath);
|
|
111
|
+
const inputText = await readFile(inputPath, "utf8");
|
|
112
|
+
const resolverOutput = parseJsonText(inputText);
|
|
113
|
+
|
|
114
|
+
// Load dev-loop settings from repo config
|
|
115
|
+
const configLoadResult = await loadDevLoopConfig({ repoRoot });
|
|
116
|
+
const hasConfigErrors = Array.isArray(configLoadResult.errors) && configLoadResult.errors.length > 0;
|
|
117
|
+
const settings = hasConfigErrors ? {} : (configLoadResult.config ?? {});
|
|
118
|
+
|
|
119
|
+
// Parse optional gate state
|
|
120
|
+
let gateState = {};
|
|
121
|
+
if (options.gateState) {
|
|
122
|
+
gateState = parseFlagJson(options.gateState, "--gate-state", parseError);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Build options for envelope builder
|
|
126
|
+
const envelopeOptions = {};
|
|
127
|
+
|
|
128
|
+
// Repo slug: explicit --repo, then resolver output bundle, then git remote
|
|
129
|
+
const bundleSlug = resolverOutput?.bundle?.repoSlug ?? resolverOutput?.bundle?.repo ?? null;
|
|
130
|
+
const repoSlug = options.repo ?? bundleSlug ?? detectRepoSlug(repoRoot);
|
|
131
|
+
if (repoSlug) {
|
|
132
|
+
envelopeOptions.repoSlug = repoSlug;
|
|
133
|
+
} else {
|
|
134
|
+
throw parseError(
|
|
135
|
+
"Repository slug could not be resolved. " +
|
|
136
|
+
"Pass --repo <owner/name>, ensure the resolver output includes a repo slug, " +
|
|
137
|
+
"or configure a git remote 'origin'.",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
envelopeOptions.repoRoot = repoRoot;
|
|
141
|
+
|
|
142
|
+
// Parse optional overrides
|
|
143
|
+
if (options.overrides) {
|
|
144
|
+
envelopeOptions.overrides = parseFlagJson(options.overrides, "--overrides", parseError);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const envelope = buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState, envelopeOptions);
|
|
148
|
+
return envelope;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function runCli(
|
|
152
|
+
argv = process.argv.slice(2),
|
|
153
|
+
{ stdout = process.stdout, stderr = process.stderr, adapter = createPiAdapter() } = {},
|
|
154
|
+
) {
|
|
155
|
+
let options;
|
|
156
|
+
try {
|
|
157
|
+
options = parseBuildHandoffEnvelopeCliArgs(argv);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
stderr.write(`${formatCliError(err)}\n`);
|
|
160
|
+
process.exitCode = 1;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (options.help) {
|
|
165
|
+
stdout.write(`${USAGE}\n`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const envelope = await buildHandoffEnvelopeCli(options, { adapter });
|
|
171
|
+
stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
const msg = formatCliError(err);
|
|
174
|
+
stderr.write(`${msg}\n`);
|
|
175
|
+
process.exitCode = 1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
180
|
+
await runCli();
|
|
181
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { defineSubcommand, isDirectCliRun } from "@dev-loops/core/cli/subcommand-runner";
|
|
5
|
+
|
|
6
|
+
const CHECKPOINT_FILE = ".pi/dev-loop-retrospective-checkpoint.json";
|
|
7
|
+
const ALLOWED_STATES = new Set(["required", "complete", "skipped", "none", "missing"]);
|
|
8
|
+
|
|
9
|
+
export function buildRetrospectiveCheckpointPayload({ state, notes = null, reason = null }, now = new Date()) {
|
|
10
|
+
const timestamp = now.toISOString();
|
|
11
|
+
if (state === "complete") return { state, completedAt: timestamp, notes };
|
|
12
|
+
if (state === "skipped") return { state, skippedAt: timestamp, reason };
|
|
13
|
+
if (state === "required") return { state, triggeredAt: timestamp };
|
|
14
|
+
if (state === "missing") return { state, triggeredAt: timestamp };
|
|
15
|
+
if (state === "none") return { state };
|
|
16
|
+
throw new Error(`Unsupported state: ${state}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { runAsScript } = defineSubcommand({
|
|
20
|
+
name: "checkpoint-contract --state <state>",
|
|
21
|
+
description: "Write .pi/dev-loop-retrospective-checkpoint.json using the retrospective contract format.",
|
|
22
|
+
options: [
|
|
23
|
+
{ flag: "--state", type: "string", required: true, choices: [...ALLOWED_STATES],
|
|
24
|
+
description: "Checkpoint state (required, complete, skipped, none, missing)" },
|
|
25
|
+
{ flag: "--notes", type: "string", description: "Required when --state is complete" },
|
|
26
|
+
{ flag: "--reason", type: "string", description: "Required when --state is skipped" },
|
|
27
|
+
],
|
|
28
|
+
async run({ state, notes, reason }, { args: _args, usage }) {
|
|
29
|
+
if (!ALLOWED_STATES.has(state)) {
|
|
30
|
+
throw Object.assign(new Error(`Invalid --state: "${state}". Allowed: ${[...ALLOWED_STATES].join(", ")}.`), { usage });
|
|
31
|
+
}
|
|
32
|
+
if (state === "complete" && !notes) {
|
|
33
|
+
throw Object.assign(new Error('state "complete" requires --notes'), { usage });
|
|
34
|
+
}
|
|
35
|
+
if (state === "skipped" && !reason) {
|
|
36
|
+
throw Object.assign(new Error('state "skipped" requires --reason'), { usage });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cwd = process.cwd();
|
|
40
|
+
const payload = buildRetrospectiveCheckpointPayload({ state, notes, reason });
|
|
41
|
+
const checkpointPath = path.join(cwd, CHECKPOINT_FILE);
|
|
42
|
+
await mkdir(path.dirname(checkpointPath), { recursive: true });
|
|
43
|
+
await writeFile(checkpointPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
44
|
+
process.stdout.write(JSON.stringify({ ok: true, path: CHECKPOINT_FILE, checkpoint: payload }) + "\n");
|
|
45
|
+
return 0;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (isDirectCliRun(import.meta.url)) { runAsScript(); }
|