dev-loops 0.6.0 → 0.7.2
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 +1 -1
- package/.claude/agents/dev-loop.md +8 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +2 -2
- package/.claude/agents/review.md +4 -3
- package/.claude/commands/{auto.md → loop-auto.md} +1 -1
- package/.claude/commands/loop-continue.md +15 -0
- package/.claude/commands/loop-enqueue.md +22 -0
- package/.claude/commands/loop-grill.md +17 -0
- package/.claude/commands/{info.md → loop-info.md} +2 -2
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
- package/.claude/commands/{start.md → loop-start.md} +1 -1
- package/.claude/commands/{status.md → loop-status.md} +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
- package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
- package/.claude/skills/dev-loop/SKILL.md +16 -12
- package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
- package/.claude/skills/docs/anti-patterns.md +5 -3
- package/.claude/skills/docs/artifact-authority-contract.md +30 -11
- package/.claude/skills/docs/confirmation-rules.md +1 -0
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +19 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +26 -19
- package/.claude/skills/docs/local-planning-flow.md +1 -1
- package/.claude/skills/docs/local-planning-worked-example.md +1 -1
- package/.claude/skills/docs/merge-preconditions.md +30 -12
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
- package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +62 -145
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +101 -0
- package/README.md +9 -9
- package/agents/dev-loop.agent.md +11 -10
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +2 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +3 -3
- package/agents/review.agent.md +5 -4
- package/cli/index.mjs +35 -42
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +6 -0
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +7 -19
- package/package.json +12 -12
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +2 -9
- package/scripts/github/create-label.mjs +133 -0
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
- package/scripts/github/detect-linked-issue-pr.mjs +22 -6
- package/scripts/github/edit-pr.mjs +259 -0
- package/scripts/github/fetch-ci-logs.mjs +2 -9
- package/scripts/github/list-issues.mjs +2 -9
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/manage-sub-issues.mjs +46 -10
- package/scripts/github/offer-human-handoff.mjs +8 -5
- package/scripts/github/post-gate-findings.mjs +20 -5
- package/scripts/github/probe-ci-status.mjs +8 -2
- package/scripts/github/probe-copilot-review.mjs +21 -14
- package/scripts/github/ready-for-review.mjs +26 -8
- package/scripts/github/reconcile-draft-gate.mjs +7 -3
- package/scripts/github/reply-resolve-review-thread.mjs +16 -5
- package/scripts/github/reply-resolve-review-threads.mjs +69 -7
- package/scripts/github/request-copilot-review.mjs +98 -26
- package/scripts/github/resolve-handoff-candidates.mjs +7 -3
- package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
- package/scripts/github/stage-reviewer-draft.mjs +10 -2
- package/scripts/github/tick-verified-checkboxes.mjs +202 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
- package/scripts/github/verify-briefing-prefixes.mjs +191 -0
- package/scripts/github/verify-fresh-review-context.mjs +226 -32
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +458 -65
- package/scripts/github/write-gate-findings-log.mjs +128 -2
- package/scripts/lib/jq-output.mjs +18 -0
- package/scripts/loop/_post-convergence-change.mjs +211 -0
- package/scripts/loop/_pr-runner-coordination.mjs +70 -0
- package/scripts/loop/_repo-root-resolver.mjs +47 -0
- package/scripts/loop/build-handoff-envelope.mjs +14 -4
- package/scripts/loop/check-retro-tooling.mjs +14 -3
- package/scripts/loop/checkpoint-contract.mjs +7 -4
- package/scripts/loop/cleanup-worktree.mjs +12 -3
- package/scripts/loop/conductor-monitor.mjs +12 -18
- package/scripts/loop/copilot-pr-handoff.mjs +162 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +38 -9
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
- package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
- package/scripts/loop/detect-internal-only-pr.mjs +8 -2
- package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
- package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
- package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
- package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
- package/scripts/loop/ensure-worktree.mjs +8 -3
- package/scripts/loop/info.mjs +12 -4
- package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
- package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
- package/scripts/loop/inspect-run-viewer.mjs +67 -4
- package/scripts/loop/inspect-run.mjs +8 -2
- package/scripts/loop/outer-loop.mjs +8 -4
- package/scripts/loop/pr-runner-coordination.mjs +2 -9
- package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
- package/scripts/loop/pre-flight-gate.mjs +9 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
- package/scripts/loop/provision-worktree.mjs +74 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
- package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
- package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
- package/scripts/loop/run-conductor-cycle.mjs +8 -2
- package/scripts/loop/run-queue.mjs +18 -9
- package/scripts/loop/run-refinement-audit.mjs +8 -9
- package/scripts/loop/run-watch-cycle.mjs +2 -9
- package/scripts/loop/sanctioned-commands.mjs +106 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +223 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
- package/scripts/pages/build-site.mjs +59 -8
- package/scripts/pages/build-state-atlas.mjs +443 -0
- package/scripts/projects/_resolve-project.mjs +1 -0
- package/scripts/projects/add-queue-item.mjs +60 -54
- package/scripts/projects/archive-done-items.mjs +49 -84
- package/scripts/projects/ensure-queue-board.mjs +10 -36
- package/scripts/projects/list-queue-items.mjs +59 -382
- package/scripts/projects/move-queue-item.mjs +21 -449
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +43 -68
- package/scripts/projects/resolve-active-board-item.mjs +108 -46
- package/scripts/projects/sync-item-status.mjs +15 -10
- package/scripts/refine/_refine-helpers.mjs +21 -5
- package/scripts/refine/exit-spike.mjs +18 -8
- package/scripts/refine/promote-plan.mjs +22 -16
- package/scripts/refine/prose-linkage-detector.mjs +1 -1
- package/scripts/refine/refine-plan-file.mjs +13 -4
- package/scripts/refine/refinement-completeness-checker.mjs +1 -1
- package/scripts/refine/scaffold-spike-file.mjs +4 -8
- package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
- package/scripts/refine/tree-integrity-validator.mjs +1 -1
- package/scripts/refine/validate-plan-file.mjs +1 -1
- package/scripts/refine/validate-spike-file.mjs +1 -1
- package/scripts/refine/verify.mjs +11 -6
- package/scripts/release/assert-core-dependency-version.mjs +123 -0
- package/scripts/release/extract-changelog-section.mjs +16 -2
- package/skills/copilot-pr-followup/SKILL.md +60 -34
- package/skills/dev-loop/SKILL.md +11 -7
- package/skills/docs/acceptance-criteria-verification.md +8 -3
- package/skills/docs/anti-patterns.md +5 -3
- package/skills/docs/artifact-authority-contract.md +30 -11
- package/skills/docs/confirmation-rules.md +1 -0
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +19 -15
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +26 -19
- package/skills/docs/local-planning-flow.md +1 -1
- package/skills/docs/local-planning-worked-example.md +1 -1
- package/skills/docs/merge-preconditions.md +30 -12
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/pr-lifecycle-contract.md +44 -35
- package/skills/docs/public-dev-loop-contract.md +60 -44
- package/skills/docs/required-rules.json +149 -0
- package/skills/docs/retrospective-checkpoint-contract.md +106 -79
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/skills/docs/validation-policy.md +4 -5
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +62 -145
- package/skills/loop-grill/SKILL.md +165 -0
- package/.claude/commands/continue.md +0 -15
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
- package/scripts/loop/conductor.mjs +0 -233
- package/scripts/loop/detect-stale-runner.mjs +0 -265
- package/scripts/loop/pre-push-main-guard.mjs +0 -117
|
@@ -4,6 +4,9 @@ import path from "node:path";
|
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
|
|
6
6
|
import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
7
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
8
|
+
import { checkFanoutAngleCoverage, provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
|
|
9
|
+
import { loadDevLoopConfig, resolveGateAngleContract, resolveRejectForeignAngles } from "@dev-loops/core/config";
|
|
7
10
|
const USAGE = `Usage: write-gate-findings-log.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --verdict <clean|findings_present|blocked> --findings <json> [--tmp-root <path>]
|
|
8
11
|
Write a durable <gate>-<headSha>.json log under deterministic tmp/ paths.
|
|
9
12
|
Required:
|
|
@@ -14,7 +17,11 @@ Required:
|
|
|
14
17
|
--verdict <clean|findings_present|blocked>
|
|
15
18
|
--findings <json> JSON array of finding objects with severity, disposition, angle, and summary
|
|
16
19
|
Optional:
|
|
20
|
+
--provenance <json> Fan-out provenance object: { distinctReviewers: <int>, perAngle: [{ angle, reviewer?, dispatchId?, model? }] }
|
|
21
|
+
distinctReviewers must be <= the distinct reviewers recorded in perAngle (perAngle non-empty when distinctReviewers > 0)
|
|
17
22
|
--tmp-root <path> Root tmp directory (default: tmp/)
|
|
23
|
+
|
|
24
|
+
${JQ_OUTPUT_USAGE}
|
|
18
25
|
`.trim();
|
|
19
26
|
function parseError(message) {
|
|
20
27
|
return Object.assign(new Error(message), { usage: USAGE });
|
|
@@ -24,6 +31,7 @@ function normalizeGate(value) {
|
|
|
24
31
|
const normalized = String(value).trim().toLowerCase();
|
|
25
32
|
return gates.has(normalized) ? normalized : null;
|
|
26
33
|
}
|
|
34
|
+
const GATE_CONFIG_KEY = { draft_gate: "draft", pre_approval_gate: "preApproval" };
|
|
27
35
|
function normalizeVerdict(value) {
|
|
28
36
|
const verdicts = new Set(["clean", "findings_present", "blocked"]);
|
|
29
37
|
const normalized = String(value).trim().toLowerCase();
|
|
@@ -89,6 +97,95 @@ function parseFindingsJson(raw) {
|
|
|
89
97
|
return entry;
|
|
90
98
|
});
|
|
91
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Validate + normalize the fan-out provenance object. Records how many distinct
|
|
102
|
+
* reviewer agents were dispatched (distinctReviewers) and per-angle dispatch
|
|
103
|
+
* provenance (perAngle). Rejects MALFORMED or self-INCONSISTENT provenance (bad
|
|
104
|
+
* shape, or a distinctReviewers claim not backed by recorded dispatch entries).
|
|
105
|
+
* This raises the bar; it does NOT make provenance un-forgeable — a determined
|
|
106
|
+
* single agent can still write an internally-consistent blob. Un-forgeable
|
|
107
|
+
* recording is the Pi-harness bridge (subagent tool at child depth). Returns the
|
|
108
|
+
* normalized object.
|
|
109
|
+
*/
|
|
110
|
+
export function parseProvenanceJson(raw) {
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(raw);
|
|
114
|
+
} catch {
|
|
115
|
+
throw parseError("--provenance must be valid JSON");
|
|
116
|
+
}
|
|
117
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
118
|
+
throw parseError("--provenance must be a JSON object");
|
|
119
|
+
}
|
|
120
|
+
if (!Number.isInteger(parsed.distinctReviewers) || parsed.distinctReviewers < 0) {
|
|
121
|
+
throw parseError("--provenance.distinctReviewers must be a non-negative integer");
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(parsed.perAngle)) {
|
|
124
|
+
throw parseError("--provenance.perAngle must be an array");
|
|
125
|
+
}
|
|
126
|
+
const perAngle = parsed.perAngle.map((a, i) => {
|
|
127
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) {
|
|
128
|
+
throw parseError(`--provenance.perAngle[${i}] must be an object`);
|
|
129
|
+
}
|
|
130
|
+
if (typeof a.angle !== "string" || a.angle.trim().length === 0) {
|
|
131
|
+
throw parseError(`--provenance.perAngle[${i}].angle is required`);
|
|
132
|
+
}
|
|
133
|
+
const entry = { angle: a.angle.trim() };
|
|
134
|
+
for (const key of ["reviewer", "dispatchId", "model"]) {
|
|
135
|
+
if (key in a) {
|
|
136
|
+
if (typeof a[key] !== "string" || a[key].trim().length === 0) {
|
|
137
|
+
throw parseError(`--provenance.perAngle[${i}].${key} must be a non-empty string`);
|
|
138
|
+
}
|
|
139
|
+
entry[key] = a[key].trim();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return entry;
|
|
143
|
+
});
|
|
144
|
+
const normalized = { distinctReviewers: parsed.distinctReviewers, perAngle };
|
|
145
|
+
// Internal-consistency gate: a distinctReviewers claim must be backed by that
|
|
146
|
+
// many distinct recorded reviewer identities (closes the {n, perAngle:[]} loophole).
|
|
147
|
+
const consistencyError = provenanceConsistencyError(normalized);
|
|
148
|
+
if (consistencyError) {
|
|
149
|
+
throw parseError(`--${consistencyError}`);
|
|
150
|
+
}
|
|
151
|
+
return normalized;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Validate recorded provenance.perAngle against the gate's configured angle
|
|
155
|
+
* contract (mandatoryAngles + pool, resolved from .devloops/defaults). A
|
|
156
|
+
* missing mandatory angle always fails the write. A foreign (out-of-pool)
|
|
157
|
+
* angle fails the write unless `gates.rejectForeignAngles: false`, in which
|
|
158
|
+
* case it is returned as a warning instead. Throws a `parseError`-shaped Error
|
|
159
|
+
* (matching this module's other validation failures) on a fail-closed
|
|
160
|
+
* rejection.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ perAngle: Array<{ angle: string }> }} provenance
|
|
163
|
+
* @param {"draft_gate"|"pre_approval_gate"} gate
|
|
164
|
+
* @param {{ repoRoot?: string }} [options]
|
|
165
|
+
* @returns {Promise<{ warning: string|null }>}
|
|
166
|
+
*/
|
|
167
|
+
export async function checkProvenanceAngleCoverage(provenance, gate, { repoRoot = process.cwd() } = {}) {
|
|
168
|
+
const { config } = await loadDevLoopConfig({ repoRoot });
|
|
169
|
+
const gateKey = GATE_CONFIG_KEY[gate];
|
|
170
|
+
const { mandatoryAngles, pool } = resolveGateAngleContract(config, gateKey);
|
|
171
|
+
const { missingMandatory, foreignAngles } = checkFanoutAngleCoverage(provenance.perAngle, {
|
|
172
|
+
mandatoryAngles,
|
|
173
|
+
pool,
|
|
174
|
+
});
|
|
175
|
+
if (missingMandatory.length > 0) {
|
|
176
|
+
throw parseError(
|
|
177
|
+
`--provenance.perAngle is missing mandatory angle(s) for ${gate}: ${missingMandatory.join(", ")} (configured in gates.${gateKey}.mandatoryAngles)`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (foreignAngles.length > 0) {
|
|
181
|
+
const message = `--provenance.perAngle names angle(s) outside the configured pool for ${gate}: ${foreignAngles.join(", ")}`;
|
|
182
|
+
if (resolveRejectForeignAngles(config)) {
|
|
183
|
+
throw parseError(`${message} (add them to gates.${gateKey}.angles, or set gates.rejectForeignAngles: false to warn instead of fail)`);
|
|
184
|
+
}
|
|
185
|
+
return { warning: `${message} (gates.rejectForeignAngles is false; recorded as a warning)` };
|
|
186
|
+
}
|
|
187
|
+
return { warning: null };
|
|
188
|
+
}
|
|
92
189
|
export function parseWriteGateFindingsLogCliArgs(argv) {
|
|
93
190
|
const { tokens } = parseArgs({
|
|
94
191
|
args: [...argv],
|
|
@@ -100,7 +197,9 @@ export function parseWriteGateFindingsLogCliArgs(argv) {
|
|
|
100
197
|
"head-sha": { type: "string" },
|
|
101
198
|
verdict: { type: "string" },
|
|
102
199
|
findings: { type: "string" },
|
|
200
|
+
provenance: { type: "string" },
|
|
103
201
|
"tmp-root": { type: "string" },
|
|
202
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
104
203
|
},
|
|
105
204
|
allowPositionals: true,
|
|
106
205
|
strict: false,
|
|
@@ -155,10 +254,15 @@ export function parseWriteGateFindingsLogCliArgs(argv) {
|
|
|
155
254
|
options.findings = requireTokenValue(token, parseError);
|
|
156
255
|
continue;
|
|
157
256
|
}
|
|
257
|
+
if (token.name === "provenance") {
|
|
258
|
+
options.provenance = requireTokenValue(token, parseError);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
158
261
|
if (token.name === "tmp-root") {
|
|
159
262
|
options.tmpRoot = requireTokenValue(token, parseError).trim();
|
|
160
263
|
continue;
|
|
161
264
|
}
|
|
265
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
162
266
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
163
267
|
}
|
|
164
268
|
const missing = ["repo", "pr", "gate", "headSha", "verdict", "findings"]
|
|
@@ -183,6 +287,13 @@ export function buildLogPath({ repo, pr, gate, headSha, tmpRoot }) {
|
|
|
183
287
|
}
|
|
184
288
|
export async function writeGateFindingsLog(options, { repoRoot = process.cwd() } = {}) {
|
|
185
289
|
const findings = parseFindingsJson(options.findings);
|
|
290
|
+
const provenance = options.provenance === undefined ? undefined : parseProvenanceJson(options.provenance);
|
|
291
|
+
// Angle-coverage enforcement (fail-closed on missing mandatory angles / foreign
|
|
292
|
+
// angles) only applies when provenance is actually recorded — provenance
|
|
293
|
+
// remains optional and additive (inline_single_agent writes never carry it).
|
|
294
|
+
const angleCoverage = provenance !== undefined
|
|
295
|
+
? await checkProvenanceAngleCoverage(provenance, options.gate, { repoRoot })
|
|
296
|
+
: { warning: null };
|
|
186
297
|
const logPath = buildLogPath({
|
|
187
298
|
repo: options.repo,
|
|
188
299
|
pr: options.pr,
|
|
@@ -200,9 +311,18 @@ export async function writeGateFindingsLog(options, { repoRoot = process.cwd() }
|
|
|
200
311
|
loggedAt: new Date().toISOString(),
|
|
201
312
|
findings,
|
|
202
313
|
};
|
|
314
|
+
// Provenance is optional and additive: when absent the ledger writes exactly
|
|
315
|
+
// as before (no provenance key), preserving byte-identical output for the
|
|
316
|
+
// default / Claude-Code path. When present it records fan-out provenance for
|
|
317
|
+
// gates.requireFanoutProvenance enforcement.
|
|
318
|
+
if (provenance !== undefined) {
|
|
319
|
+
log.provenance = provenance;
|
|
320
|
+
}
|
|
203
321
|
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
204
322
|
await writeFile(fullPath, JSON.stringify(log, null, 2) + "\n", "utf8");
|
|
205
|
-
return
|
|
323
|
+
return angleCoverage.warning
|
|
324
|
+
? { ok: true, path: logPath, log, warning: angleCoverage.warning }
|
|
325
|
+
: { ok: true, path: logPath, log };
|
|
206
326
|
}
|
|
207
327
|
async function main() {
|
|
208
328
|
let options;
|
|
@@ -219,7 +339,13 @@ async function main() {
|
|
|
219
339
|
}
|
|
220
340
|
try {
|
|
221
341
|
const result = await writeGateFindingsLog(options);
|
|
222
|
-
|
|
342
|
+
// rejectForeignAngles: false is WARNING mode, not silence — surface the
|
|
343
|
+
// angle-coverage warning on stderr too (the JSON result carries it as
|
|
344
|
+
// `warning`). Suppressed under --silent.
|
|
345
|
+
if (result.warning && !options.silent) {
|
|
346
|
+
process.stderr.write(`WARNING: ${result.warning}\n`);
|
|
347
|
+
}
|
|
348
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
|
|
223
349
|
} catch (error) {
|
|
224
350
|
process.stderr.write(JSON.stringify({
|
|
225
351
|
ok: false,
|
|
@@ -256,6 +256,24 @@ export const JQ_OUTPUT_USAGE = `Output filtering:
|
|
|
256
256
|
--silent, -s Suppress stdout; map result to exit code only
|
|
257
257
|
(0 = pass/truthy, 1 = fail/falsy). Composes with --jq as a predicate.`;
|
|
258
258
|
|
|
259
|
+
// Shared token-matcher for scripts that hand-roll a `parseArgs({ tokens: true })`
|
|
260
|
+
// loop (rather than reading `values.jq`/`values.silent` off a strict parse).
|
|
261
|
+
// Call once per `option` token: `if (matchJqOutputToken(token, options)) continue;`.
|
|
262
|
+
// `requireValue` extracts the `--jq <value>` argument; pass the script's own
|
|
263
|
+
// value-extraction function (e.g. `(t) => requireTokenValue(t, parseError)`) to
|
|
264
|
+
// preserve its existing error semantics. Defaults to the raw token value.
|
|
265
|
+
export function matchJqOutputToken(token, options, requireValue = (t) => t.value) {
|
|
266
|
+
if (token.name === "jq") {
|
|
267
|
+
options.jq = requireValue(token);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
if (token.name === "silent") {
|
|
271
|
+
options.silent = true;
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
259
277
|
// Apply --jq / --silent to a result object and emit. Returns the exit code the
|
|
260
278
|
// CLI should use (0 success / truthy, 1 falsy or non-ok, 2 invalid filter).
|
|
261
279
|
// Without jq/silent the result is printed verbatim as JSON (unchanged shape).
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Single source of truth (#1103, #1126) for "did a significant product/test-logic
|
|
2
|
+
// change land since the last Copilot review at the round cap". Both
|
|
3
|
+
// detect-pr-gate-coordination-state.mjs and copilot-pr-handoff.mjs consume this
|
|
4
|
+
// so they agree on the round-cap escape hatch: at the cap, a significant
|
|
5
|
+
// post-convergence change reopens a Copilot cycle (rerequest) instead of the
|
|
6
|
+
// clean fallback; a doc/comment-only change stays at the clean fallback.
|
|
7
|
+
//
|
|
8
|
+
// It lives in scripts/loop/ (not packages/core) because significance is derived
|
|
9
|
+
// from a `gh api .../compare` diff — gh I/O that does not belong in core.
|
|
10
|
+
import { extractReviewCommitSha, isCopilotLogin, parseJsonText } from "../_core-helpers.mjs";
|
|
11
|
+
import { runChild } from "../_cli-primitives.mjs";
|
|
12
|
+
|
|
13
|
+
export function getLatestSubmittedCopilotReviewHeadSha(reviews) {
|
|
14
|
+
const copilotSubmitted = (Array.isArray(reviews) ? reviews : [])
|
|
15
|
+
.filter((review) => {
|
|
16
|
+
const login = review?.author?.login;
|
|
17
|
+
const state = String(review?.state ?? "").toUpperCase();
|
|
18
|
+
return isCopilotLogin(login) && state !== "PENDING";
|
|
19
|
+
})
|
|
20
|
+
.map((review, index) => {
|
|
21
|
+
const submittedAt = review?.submittedAt ?? review?.submitted_at;
|
|
22
|
+
const submittedAtMs = typeof submittedAt === "string" ? Date.parse(submittedAt) : Number.NaN;
|
|
23
|
+
return { review, submittedAtMs, index };
|
|
24
|
+
})
|
|
25
|
+
.sort((left, right) => {
|
|
26
|
+
const leftValid = !Number.isNaN(left.submittedAtMs);
|
|
27
|
+
const rightValid = !Number.isNaN(right.submittedAtMs);
|
|
28
|
+
if (leftValid && rightValid) {
|
|
29
|
+
return right.submittedAtMs - left.submittedAtMs;
|
|
30
|
+
}
|
|
31
|
+
if (leftValid !== rightValid) {
|
|
32
|
+
return leftValid ? -1 : 1;
|
|
33
|
+
}
|
|
34
|
+
return right.index - left.index;
|
|
35
|
+
});
|
|
36
|
+
const latest = copilotSubmitted[0]?.review;
|
|
37
|
+
const sha = extractReviewCommitSha(latest);
|
|
38
|
+
return typeof sha === "string" && sha.trim().length > 0 ? sha.trim() : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isTrivialDocumentationOnlyPath(filePath) {
|
|
42
|
+
if (typeof filePath !== "string") return true;
|
|
43
|
+
const normalized = filePath.trim().toLowerCase();
|
|
44
|
+
if (normalized.length === 0) return true;
|
|
45
|
+
if (normalized.startsWith("docs/")) return true;
|
|
46
|
+
return normalized.endsWith(".md")
|
|
47
|
+
|| normalized.endsWith(".mdx")
|
|
48
|
+
|| normalized.endsWith(".txt")
|
|
49
|
+
|| normalized.endsWith(".rst")
|
|
50
|
+
|| normalized.endsWith(".adoc");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Extensions whose comment syntax we can classify with confidence (`//`, `/* */`).
|
|
54
|
+
const JS_TS_EXTENSIONS = [".mjs", ".cjs", ".js", ".jsx", ".ts", ".tsx", ".mts", ".cts"];
|
|
55
|
+
|
|
56
|
+
// Classifies one line's content given the current block-comment state.
|
|
57
|
+
// Returns { code, state }: code=true when the line carries anything that is not
|
|
58
|
+
// comment/blank; state = inBlockComment AFTER the line.
|
|
59
|
+
// Known ceilings (both err toward significance or genuinely-trivial cases only):
|
|
60
|
+
// - a line STARTING with `//` or `/*` inside a string literal is misread as a
|
|
61
|
+
// comment — such a line still has to literally begin with the token, which
|
|
62
|
+
// real code lines almost never do;
|
|
63
|
+
// - a `/*` opener sitting AFTER code on the same line (`foo(); /* start`) is
|
|
64
|
+
// not tracked (we return code=true immediately) — follow-up `*` body lines
|
|
65
|
+
// then read as code → significant, the safe direction.
|
|
66
|
+
function classifyLine(content, inBlock) {
|
|
67
|
+
let rest = content;
|
|
68
|
+
let state = inBlock;
|
|
69
|
+
while (true) {
|
|
70
|
+
if (state) {
|
|
71
|
+
const close = rest.indexOf("*/");
|
|
72
|
+
if (close === -1) return { code: false, state: true };
|
|
73
|
+
rest = rest.slice(close + 2);
|
|
74
|
+
state = false;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const trimmed = rest.trim();
|
|
78
|
+
if (trimmed.length === 0) return { code: false, state };
|
|
79
|
+
if (trimmed.startsWith("//")) return { code: false, state };
|
|
80
|
+
if (trimmed.startsWith("/*")) {
|
|
81
|
+
rest = trimmed.slice(2);
|
|
82
|
+
state = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// Anything else is code — including a bare `*`-leading line with NO open
|
|
86
|
+
// block observed: generator signatures (`*items() {`) and operator
|
|
87
|
+
// continuations (`* b`) start with `*` and are real code.
|
|
88
|
+
return { code: true, state };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Content-level significance filter (#1137). A JS/TS file change is "comment-only"
|
|
93
|
+
// when EVERY added/removed line in its patch is blank OR a comment line. Such
|
|
94
|
+
// changes (JSDoc/inline-comment tweaks) must NOT reopen a Copilot round past the
|
|
95
|
+
// cap. Comment rules:
|
|
96
|
+
// - a line starting with `//` is a comment;
|
|
97
|
+
// - `/*` opens block-comment state, `*/` closes it; state is tracked across
|
|
98
|
+
// ALL patch lines (changed AND context) and RESET at every `@@` hunk header
|
|
99
|
+
// — a block opened before the hunk is unobservable, so a `*`-leading line
|
|
100
|
+
// with no observed opener in the same hunk is treated as CODE (conservative:
|
|
101
|
+
// bare `*` counts as comment ONLY inside an observed open `/* ... */` block).
|
|
102
|
+
// Conservative by construction — every ambiguous case returns false (= NOT
|
|
103
|
+
// classifiable as comment-only; the file remains subject to the existing
|
|
104
|
+
// size/count thresholds, it is NOT auto-escalated to significant):
|
|
105
|
+
// - patch missing (binary / oversized to diff) → false
|
|
106
|
+
// - extension we cannot classify (non-JS/TS code) → false
|
|
107
|
+
// - a changed line mixing code + comment → false
|
|
108
|
+
// - a patch with no parseable added/removed lines → false
|
|
109
|
+
// Input contract: GitHub compare `files[].patch` starts at `@@` and never
|
|
110
|
+
// contains `---`/`+++` file headers, so `+`/`-` prefixes are always content
|
|
111
|
+
// (`+++counter;` is the real added line `++counter;`, not a header).
|
|
112
|
+
// Note: line-scanner heuristic, upgrade to a real tokenizer only if string-literal misreads ever bite.
|
|
113
|
+
export function isCommentOnlyFileChange(file) {
|
|
114
|
+
const filename = typeof file?.filename === "string" ? file.filename : "";
|
|
115
|
+
const lower = filename.toLowerCase();
|
|
116
|
+
if (!JS_TS_EXTENSIONS.some((ext) => lower.endsWith(ext))) {
|
|
117
|
+
// Not a classifiable code file (or a doc path handled elsewhere) → treat as code.
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
const patch = file?.patch;
|
|
121
|
+
if (typeof patch !== "string" || patch.length === 0) {
|
|
122
|
+
// No patch (binary / too large) → cannot prove trivial → treat as code.
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
let sawChangedLine = false;
|
|
126
|
+
let inBlock = false;
|
|
127
|
+
for (const line of patch.split("\n")) {
|
|
128
|
+
if (line.startsWith("@@")) {
|
|
129
|
+
inBlock = false; // hunk boundary: the gap is unobserved → reset state
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (line.startsWith("\\")) continue; // ""
|
|
133
|
+
const marker = line[0];
|
|
134
|
+
const content = line.slice(1);
|
|
135
|
+
if (marker === "+" || marker === "-") {
|
|
136
|
+
sawChangedLine = true;
|
|
137
|
+
const { code, state } = classifyLine(content, inBlock);
|
|
138
|
+
if (code) return false;
|
|
139
|
+
inBlock = state;
|
|
140
|
+
} else {
|
|
141
|
+
// Context line: never classified as changed content, but it advances
|
|
142
|
+
// block-comment state so an opener/closer visible in the hunk is honored.
|
|
143
|
+
inBlock = classifyLine(content, inBlock).state;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// A patch with zero parseable changed lines is ambiguous → treat as code.
|
|
147
|
+
return sawChangedLine;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function detectPostConvergenceSignificantChange(
|
|
151
|
+
{ repo, pr, currentHeadSha, reviews, changedFiles, roundCapReached, regularCopilotRounds },
|
|
152
|
+
{ env = process.env, ghCommand = "gh" } = {},
|
|
153
|
+
) {
|
|
154
|
+
if (!roundCapReached || !regularCopilotRounds) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
if (!currentHeadSha) {
|
|
161
|
+
// No usable current head → the compare call would be doomed; fail closed
|
|
162
|
+
// without issuing a wasted gh API request.
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const lastReviewedHeadSha = getLatestSubmittedCopilotReviewHeadSha(reviews);
|
|
166
|
+
if (!lastReviewedHeadSha || lastReviewedHeadSha === currentHeadSha) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const compareResult = await runChild(
|
|
170
|
+
ghCommand,
|
|
171
|
+
["api", `repos/${repo}/compare/${lastReviewedHeadSha}...${currentHeadSha}`],
|
|
172
|
+
env,
|
|
173
|
+
);
|
|
174
|
+
if (compareResult.code !== 0) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
let payload;
|
|
178
|
+
try {
|
|
179
|
+
payload = parseJsonText(compareResult.stdout, { label: "gh compare" });
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const rawFiles = Array.isArray(payload?.files) ? payload.files : [];
|
|
184
|
+
if (rawFiles.length === 0) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
// Content-aware filter (#1137): drop comment/JSDoc-only JS/TS changes BEFORE the
|
|
188
|
+
// existing size/count thresholds so trivial comment fixes no longer reopen a
|
|
189
|
+
// Copilot round past the cap. Doc paths and un-classifiable files fall through
|
|
190
|
+
// unchanged (isCommentOnlyFileChange returns false for them), preserving the
|
|
191
|
+
// prior threshold behavior for everything else.
|
|
192
|
+
const files = rawFiles.filter((file) => !isCommentOnlyFileChange(file));
|
|
193
|
+
if (files.length === 0) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
const hasNonDocChanges = files.some((file) => !isTrivialDocumentationOnlyPath(file?.filename));
|
|
197
|
+
if (!hasNonDocChanges) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
const totalChangedLines = files.reduce((sum, file) => {
|
|
201
|
+
const changes = Number(file?.changes);
|
|
202
|
+
if (Number.isFinite(changes) && changes > 0) {
|
|
203
|
+
return sum + changes;
|
|
204
|
+
}
|
|
205
|
+
const additions = Number(file?.additions);
|
|
206
|
+
const deletions = Number(file?.deletions);
|
|
207
|
+
const fallback = (Number.isFinite(additions) ? additions : 0) + (Number.isFinite(deletions) ? deletions : 0);
|
|
208
|
+
return sum + (fallback > 0 ? fallback : 0);
|
|
209
|
+
}, 0);
|
|
210
|
+
return totalChangedLines >= 20 || files.length >= 2;
|
|
211
|
+
}
|
|
@@ -610,3 +610,73 @@ export async function ensureAsyncRunnerOwnership({
|
|
|
610
610
|
}
|
|
611
611
|
return claimRunnerOwnership({ repo, pr, runId, cwd, mode: "claim" });
|
|
612
612
|
}
|
|
613
|
+
/**
|
|
614
|
+
* Env-aware, best-effort release for the run-completion/stop path (issue #1109).
|
|
615
|
+
*
|
|
616
|
+
* Mirrors {@link ensureAsyncRunnerOwnership}: a no-op when no async run id is
|
|
617
|
+
* present, so it is harness-agnostic — Claude Code with no DEVLOOPS_RUN_ID yields
|
|
618
|
+
* `skipped_no_async_run_id` and never touches the coordination file.
|
|
619
|
+
*
|
|
620
|
+
* Non-fatal by contract: a release conflict (the claim is owned by another run,
|
|
621
|
+
* or no record remains) is swallowed into an `ok:true` result so a failed release
|
|
622
|
+
* can never block a stop/checkpoint. Fail-closed competitor semantics are
|
|
623
|
+
* preserved — {@link releaseRunnerOwnership} only clears a claim THIS run owns, so
|
|
624
|
+
* a genuinely active competing run's claim is left intact.
|
|
625
|
+
*
|
|
626
|
+
* Resolves to one of `skipped_no_async_run_id` (no run id present),
|
|
627
|
+
* `release_skipped` (release conflict swallowed), `release_error` (release threw),
|
|
628
|
+
* or the passthrough `releaseRunnerOwnership` result — `released` when this run's
|
|
629
|
+
* claim was cleared, or `release_noop` when the target has no coordination record.
|
|
630
|
+
*/
|
|
631
|
+
export async function releaseAsyncRunnerOwnership({
|
|
632
|
+
repo,
|
|
633
|
+
pr,
|
|
634
|
+
env = process.env,
|
|
635
|
+
cwd = process.cwd(),
|
|
636
|
+
} = {}) {
|
|
637
|
+
const filePath = defaultRunnerCoordinationFilePathForTarget({ repo, pr }, cwd);
|
|
638
|
+
const runId = normalizeRunId(resolveRunId(env));
|
|
639
|
+
if (runId === null) {
|
|
640
|
+
return {
|
|
641
|
+
ok: true,
|
|
642
|
+
status: "skipped_no_async_run_id",
|
|
643
|
+
repo: normalizeRepoSlug(repo),
|
|
644
|
+
pr: normalizePr(pr),
|
|
645
|
+
runId: null,
|
|
646
|
+
activeRun: null,
|
|
647
|
+
exitSignals: [],
|
|
648
|
+
filePath,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
const released = await releaseRunnerOwnership({ repo, pr, runId, cwd });
|
|
653
|
+
if (released.ok) {
|
|
654
|
+
return released;
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
ok: true,
|
|
658
|
+
status: "release_skipped",
|
|
659
|
+
repo: released.repo ?? normalizeRepoSlug(repo),
|
|
660
|
+
pr: released.pr ?? normalizePr(pr),
|
|
661
|
+
runId,
|
|
662
|
+
activeRun: released.activeRun ?? null,
|
|
663
|
+
exitSignals: released.exitSignals ?? [],
|
|
664
|
+
skippedReason: released.error,
|
|
665
|
+
message: released.message,
|
|
666
|
+
filePath: released.filePath ?? filePath,
|
|
667
|
+
};
|
|
668
|
+
} catch (error) {
|
|
669
|
+
return {
|
|
670
|
+
ok: true,
|
|
671
|
+
status: "release_error",
|
|
672
|
+
repo: normalizeRepoSlug(repo),
|
|
673
|
+
pr: normalizePr(pr),
|
|
674
|
+
runId,
|
|
675
|
+
activeRun: null,
|
|
676
|
+
exitSignals: [],
|
|
677
|
+
skippedReason: "release_threw",
|
|
678
|
+
message: error instanceof Error ? error.message : String(error),
|
|
679
|
+
filePath,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared worktree-relative repo-root / ledger-checkout resolver (issue #1052,
|
|
3
|
+
* folds #1019 and #1050). ONE shared resolver: derive repo root from the
|
|
4
|
+
* checkout under operation (git-toplevel, not ambient cwd) for config reads,
|
|
5
|
+
* and enumerate ALL checkouts (main + every worktree) for ledger reads so a
|
|
6
|
+
* ledger written in any worktree is visible regardless of which checkout runs
|
|
7
|
+
* the check.
|
|
8
|
+
*
|
|
9
|
+
* #1050: the pre-PR-worktree flow means the session cwd is a DIFFERENT checkout
|
|
10
|
+
* than the PR worktree. Ledgers get written cwd-relative in the PR worktree but
|
|
11
|
+
* read cwd-relative from the session checkout, so they diverge and clean gates
|
|
12
|
+
* false-block on "missing pre-merge gate evidence". resolveLedgerCheckouts
|
|
13
|
+
* enumerates every checkout so a ledger written in one is found from any.
|
|
14
|
+
*
|
|
15
|
+
* #1019: .devloops is read at EXACTLY <repoRoot>/.devloops with no upward walk.
|
|
16
|
+
* Reading it from process.cwd() (a subdir or sibling checkout) silently falls
|
|
17
|
+
* back to defaults (maxCopilotRounds -> 5). resolveRepoRoot derives the root
|
|
18
|
+
* from the checkout's git-toplevel so config resolves correctly.
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { parseAllWorktreePaths, parseMainWorktreePath } from "@dev-loops/core/loop/worktree-guard";
|
|
22
|
+
|
|
23
|
+
export function resolveRepoRoot(cwd, { gitCommand = "git" } = {}) {
|
|
24
|
+
try {
|
|
25
|
+
return execFileSync(gitCommand, ["rev-parse", "--show-toplevel"], {
|
|
26
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
27
|
+
}).trim() || cwd;
|
|
28
|
+
} catch {
|
|
29
|
+
// git failed or cwd is not inside a git repo (or git is unavailable) -> fall back to cwd.
|
|
30
|
+
return cwd;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveLedgerCheckouts(cwd, { gitCommand = "git" } = {}) {
|
|
35
|
+
const roots = [];
|
|
36
|
+
const add = (p) => { if (typeof p === "string" && p.length > 0 && !roots.includes(p)) roots.push(p); };
|
|
37
|
+
add(resolveRepoRoot(cwd, { gitCommand }));
|
|
38
|
+
try {
|
|
39
|
+
const listing = execFileSync(gitCommand, ["worktree", "list"], {
|
|
40
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
41
|
+
});
|
|
42
|
+
add(parseMainWorktreePath(listing));
|
|
43
|
+
for (const p of parseAllWorktreePaths(listing)) add(p);
|
|
44
|
+
} catch { /* git failed, not inside a git repo, or git unavailable: cwd-toplevel is all we have */ }
|
|
45
|
+
if (roots.length === 0) add(cwd);
|
|
46
|
+
return roots;
|
|
47
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* dev-loops loop build-envelope --input resolver-output.json
|
|
13
13
|
*
|
|
14
14
|
* Or via npx:
|
|
15
|
-
* npx dev-loops loop build-envelope --input resolver-output.json
|
|
15
|
+
* npx dev-loops@<version> loop build-envelope --input resolver-output.json
|
|
16
16
|
*/
|
|
17
17
|
import { readFile } from "node:fs/promises";
|
|
18
18
|
import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
@@ -23,6 +23,8 @@ import { buildDevLoopHandoffEnvelope } from "@dev-loops/core/loop/handoff-envelo
|
|
|
23
23
|
import { loadDevLoopConfig } from "@dev-loops/core/config";
|
|
24
24
|
import { createPiAdapter } from "@dev-loops/core/harness";
|
|
25
25
|
import { parseArgs } from "node:util";
|
|
26
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
27
|
+
import { SANCTIONED_COMMANDS } from "./sanctioned-commands.mjs";
|
|
26
28
|
|
|
27
29
|
const USAGE = `Usage: build-handoff-envelope.mjs --input <path>
|
|
28
30
|
Build a deterministic handoff envelope from startup resolver output and settings.
|
|
@@ -30,7 +32,7 @@ Required:
|
|
|
30
32
|
--input <path> Path to resolver output JSON (from resolve-dev-loop-startup.mjs)
|
|
31
33
|
Optional:
|
|
32
34
|
--gate-state <json> Gate state JSON string
|
|
33
|
-
{ currentHeadSha?, ciStatus?, unresolvedThreadCount?, copilotRoundCount? }
|
|
35
|
+
{ currentHeadSha?, ciStatus?, unresolvedThreadCount?, copilotRoundCount?, retrospectiveFindings? }
|
|
34
36
|
--overrides <json> Overrides JSON string
|
|
35
37
|
{ mergeAuthorized?, preferLocal?, scopeConstraint?, customStopAt? }
|
|
36
38
|
--repo <owner/name> Repository slug override (falls back to bundle.repoSlug or bundle.repo)
|
|
@@ -41,9 +43,11 @@ Error output (stderr, JSON):
|
|
|
41
43
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
42
44
|
Runtime failures:
|
|
43
45
|
{ "ok": false, "error": "..." }
|
|
46
|
+
${JQ_OUTPUT_USAGE}
|
|
44
47
|
Exit codes:
|
|
45
48
|
0 Success
|
|
46
|
-
1 Argument error or runtime failure
|
|
49
|
+
1 Argument error or runtime failure
|
|
50
|
+
2 Invalid --jq filter`.trim();
|
|
47
51
|
|
|
48
52
|
const parseError = buildParseError(USAGE);
|
|
49
53
|
|
|
@@ -73,6 +77,7 @@ export function parseBuildHandoffEnvelopeCliArgs(argv) {
|
|
|
73
77
|
"gate-state": { type: "string" },
|
|
74
78
|
overrides: { type: "string" },
|
|
75
79
|
repo: { type: "string" },
|
|
80
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
76
81
|
},
|
|
77
82
|
allowPositionals: true,
|
|
78
83
|
strict: false,
|
|
@@ -105,6 +110,7 @@ export function parseBuildHandoffEnvelopeCliArgs(argv) {
|
|
|
105
110
|
options.repo = requireTokenValue(token, parseError);
|
|
106
111
|
continue;
|
|
107
112
|
}
|
|
113
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
108
114
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
109
115
|
}
|
|
110
116
|
|
|
@@ -143,6 +149,10 @@ export async function buildHandoffEnvelopeCli(
|
|
|
143
149
|
// Build options for envelope builder
|
|
144
150
|
const envelopeOptions = {};
|
|
145
151
|
|
|
152
|
+
// Carry the canonical sanctioned operation → wrapper command map (issue #1081)
|
|
153
|
+
// into every emitted envelope by DEFAULT so spawned subagents never re-derive it.
|
|
154
|
+
envelopeOptions.sanctionedCommands = SANCTIONED_COMMANDS;
|
|
155
|
+
|
|
146
156
|
// Repo slug: explicit --repo, then resolver output bundle, then git remote
|
|
147
157
|
const bundleSlug = resolverOutput?.bundle?.repoSlug ?? resolverOutput?.bundle?.repo ?? null;
|
|
148
158
|
const repoSlug = options.repo ?? bundleSlug ?? detectRepoSlug(repoRoot);
|
|
@@ -186,7 +196,7 @@ export async function runCli(
|
|
|
186
196
|
|
|
187
197
|
try {
|
|
188
198
|
const envelope = await buildHandoffEnvelopeCli(options, { adapter });
|
|
189
|
-
|
|
199
|
+
process.exitCode = emitResult(envelope, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
190
200
|
} catch (err) {
|
|
191
201
|
const msg = formatCliError(err);
|
|
192
202
|
stderr.write(`${msg}\n`);
|