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
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* This module maps that resolver's output into the persisted artifact:
|
|
18
18
|
* resolvedAngles = resolver.recommendedAngles
|
|
19
19
|
* rationale = resolver.skippedAngles (action 'dropped', reason from
|
|
20
|
-
* resolver.reasons) + the rest as action 'kept'
|
|
20
|
+
* resolver.reasons) + the rest as action 'kept', except
|
|
21
|
+
* entries present in resolver.addedAngles are recorded as
|
|
22
|
+
* action 'added' (reason from resolver.addedReasons) — see #1048
|
|
21
23
|
*
|
|
22
24
|
* The artifact records the resolved angle set + rationale + change scope
|
|
23
25
|
* (branch, head SHA, touched files, acceptance-criteria pointer, validation
|
|
@@ -26,6 +28,8 @@
|
|
|
26
28
|
* Path scheme mirrors write-gate-findings-log.mjs `buildLogPath`:
|
|
27
29
|
* <tmpRoot>/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json
|
|
28
30
|
*/
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
29
33
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
30
34
|
import path from "node:path";
|
|
31
35
|
import { parseArgs } from "node:util";
|
|
@@ -35,6 +39,7 @@ import { resolveGateAnglesDynamic } from "@dev-loops/core/config";
|
|
|
35
39
|
import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
|
|
36
40
|
import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
37
41
|
import { buildAdjacentBundle, DEFAULT_MAX_FILE_BYTES } from "./build-adjacent-bundle.mjs";
|
|
42
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
38
43
|
|
|
39
44
|
/**
|
|
40
45
|
* Map the artifact gate name (draft_gate | pre_approval_gate) to the config
|
|
@@ -52,8 +57,12 @@ export function mapGateToConfigKey(gate) {
|
|
|
52
57
|
* Map a resolveGateAnglesDynamic result into the persisted artifact fields.
|
|
53
58
|
* Does NOT re-derive angles — it only reshapes the resolver's output.
|
|
54
59
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
60
|
+
* Angles present in `resolverResult.addedAngles` (additive selection, #1048)
|
|
61
|
+
* are recorded with action 'added' (reason from `resolverResult.addedReasons`)
|
|
62
|
+
* instead of 'kept'.
|
|
63
|
+
*
|
|
64
|
+
* @param {{ recommendedAngles: string[]|null, skippedAngles?: string[], reasons?: Record<string,string>, addedAngles?: string[], addedReasons?: Record<string,string>, dynamicAnglesActive?: boolean }} resolverResult
|
|
65
|
+
* @returns {{ resolvedAngles: string[], rationale: Array<{angle: string, action: "kept"|"added"|"dropped", reason: string}> }}
|
|
57
66
|
*/
|
|
58
67
|
export function rationaleFromResolver(resolverResult) {
|
|
59
68
|
const recommended = Array.isArray(resolverResult?.recommendedAngles)
|
|
@@ -63,6 +72,8 @@ export function rationaleFromResolver(resolverResult) {
|
|
|
63
72
|
? resolverResult.skippedAngles
|
|
64
73
|
: [];
|
|
65
74
|
const reasons = resolverResult?.reasons ?? {};
|
|
75
|
+
const added = new Set(Array.isArray(resolverResult?.addedAngles) ? resolverResult.addedAngles : []);
|
|
76
|
+
const addedReasons = resolverResult?.addedReasons ?? {};
|
|
66
77
|
const dynamicActive = resolverResult?.dynamicAnglesActive === true;
|
|
67
78
|
const keptReason = dynamicActive
|
|
68
79
|
? "selected by dynamic angle resolver"
|
|
@@ -70,6 +81,16 @@ export function rationaleFromResolver(resolverResult) {
|
|
|
70
81
|
|
|
71
82
|
const rationale = [];
|
|
72
83
|
for (const angle of recommended) {
|
|
84
|
+
if (added.has(angle) && dynamicActive) {
|
|
85
|
+
rationale.push({
|
|
86
|
+
angle,
|
|
87
|
+
action: "added",
|
|
88
|
+
reason: typeof addedReasons[angle] === "string" && addedReasons[angle].length > 0
|
|
89
|
+
? addedReasons[angle]
|
|
90
|
+
: "added by dynamic angle resolver (catalog addition)",
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
73
94
|
rationale.push({ angle, action: "kept", reason: keptReason });
|
|
74
95
|
}
|
|
75
96
|
for (const angle of skipped) {
|
|
@@ -84,7 +105,7 @@ export function rationaleFromResolver(resolverResult) {
|
|
|
84
105
|
return { resolvedAngles: [...recommended], rationale };
|
|
85
106
|
}
|
|
86
107
|
|
|
87
|
-
const USAGE = `Usage: write-gate-context.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --angles <json> [--rationale <json>] [--branch <name>] [--touched-files <json>] [--acceptance-criteria <pointer>] [--validation-posture <text>] [--tmp-root <path>]
|
|
108
|
+
const USAGE = `Usage: write-gate-context.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --angles <json> [--rationale <json>] [--branch <name>] [--touched-files <json>] [--base <ref>] [--acceptance-criteria <pointer>] [--validation-posture <text>] [--tmp-root <path>]
|
|
88
109
|
Write a deterministic gate-review context-builder handoff artifact under tmp/ paths.
|
|
89
110
|
Required:
|
|
90
111
|
--repo <owner/name>
|
|
@@ -95,10 +116,15 @@ Required:
|
|
|
95
116
|
Optional:
|
|
96
117
|
--rationale <json> JSON array of {angle, action, reason} entries
|
|
97
118
|
--branch <name> Source branch name
|
|
98
|
-
--touched-files <json> JSON array of changed file path strings
|
|
99
|
-
--
|
|
119
|
+
--touched-files <json> JSON array of changed file path strings (separate from the diff-derived scope.changedFiles)
|
|
120
|
+
--base <ref> Git ref to diff against (git diff <ref>...HEAD); populates scope.diffPath, scope.changedFiles, and adjacentCode (the full build-once bundle). Without it, the CLI emits an explicit thin briefing (scope.diffSource="none") — see docs/gate-review-sub-loop-contract.md.
|
|
121
|
+
--acceptance-criteria <ptr> Pointer to acceptance criteria (issue ref, doc path, URL); also used as the linked-issue label in the rendered briefing prefix
|
|
100
122
|
--validation-posture <text> Short description of the validation posture
|
|
123
|
+
--pr-body <text> PR description text, inlined into the rendered briefing prefix
|
|
124
|
+
--issue-body <text> Linked-issue body text, inlined into the briefing prefix under --acceptance-criteria's label; omitted entirely when absent
|
|
101
125
|
--tmp-root <path> Root tmp directory (default: tmp/)
|
|
126
|
+
|
|
127
|
+
${JQ_OUTPUT_USAGE}
|
|
102
128
|
`.trim();
|
|
103
129
|
|
|
104
130
|
function parseError(message) {
|
|
@@ -116,6 +142,22 @@ function normalizeHeadSha(value) {
|
|
|
116
142
|
return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
|
|
117
143
|
}
|
|
118
144
|
|
|
145
|
+
// ponytail: a DENYLIST, not an allowlist — the `git diff` call runs via
|
|
146
|
+
// execFileSync's argv array (no shell), so `base` cannot inject shell syntax
|
|
147
|
+
// and we don't need to enumerate every valid git revision grammar. We reject
|
|
148
|
+
// only what is genuinely unsafe or malformed for OUR use, and let `git diff`
|
|
149
|
+
// itself resolve validity (a syntactically-allowed but nonexistent ref fails
|
|
150
|
+
// closed via the unresolvable-base path). Rejected: empty/whitespace-only; a
|
|
151
|
+
// leading "-" (flag-injection shape, never a valid ref); and ".." (ambiguous
|
|
152
|
+
// with our own "<base>...HEAD" triple-dot construction). Everything else —
|
|
153
|
+
// including HEAD@{upstream}, main@{1}, tag-peel v1.0.0^{commit}, HEAD~3 — is
|
|
154
|
+
// accepted.
|
|
155
|
+
function normalizeBaseRef(value) {
|
|
156
|
+
const trimmed = String(value).trim();
|
|
157
|
+
if (trimmed.length === 0 || trimmed.startsWith("-") || trimmed.includes("..")) return null;
|
|
158
|
+
return trimmed;
|
|
159
|
+
}
|
|
160
|
+
|
|
119
161
|
const VALID_ACTIONS = new Set(["kept", "added", "dropped", "joined"]);
|
|
120
162
|
|
|
121
163
|
function parseAnglesJson(raw) {
|
|
@@ -189,9 +231,13 @@ export function parseWriteGateContextCliArgs(argv) {
|
|
|
189
231
|
rationale: { type: "string" },
|
|
190
232
|
branch: { type: "string" },
|
|
191
233
|
"touched-files": { type: "string" },
|
|
234
|
+
base: { type: "string" },
|
|
192
235
|
"acceptance-criteria": { type: "string" },
|
|
193
236
|
"validation-posture": { type: "string" },
|
|
237
|
+
"pr-body": { type: "string" },
|
|
238
|
+
"issue-body": { type: "string" },
|
|
194
239
|
"tmp-root": { type: "string" },
|
|
240
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
195
241
|
},
|
|
196
242
|
allowPositionals: true,
|
|
197
243
|
strict: false,
|
|
@@ -206,8 +252,11 @@ export function parseWriteGateContextCliArgs(argv) {
|
|
|
206
252
|
rationale: [],
|
|
207
253
|
branch: null,
|
|
208
254
|
touchedFiles: [],
|
|
255
|
+
base: null,
|
|
209
256
|
acceptanceCriteria: null,
|
|
210
257
|
validationPosture: null,
|
|
258
|
+
prBody: null,
|
|
259
|
+
issueBody: null,
|
|
211
260
|
tmpRoot: "tmp",
|
|
212
261
|
};
|
|
213
262
|
for (const token of tokens) {
|
|
@@ -256,6 +305,12 @@ export function parseWriteGateContextCliArgs(argv) {
|
|
|
256
305
|
options.touchedFiles = parseStringArrayJson(requireTokenValue(token, parseError), "--touched-files");
|
|
257
306
|
continue;
|
|
258
307
|
}
|
|
308
|
+
if (token.name === "base") {
|
|
309
|
+
const base = normalizeBaseRef(requireTokenValue(token, parseError));
|
|
310
|
+
if (!base) throw parseError("--base must be a plausible git ref (no leading '-', no '..')");
|
|
311
|
+
options.base = base;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
259
314
|
if (token.name === "acceptance-criteria") {
|
|
260
315
|
options.acceptanceCriteria = requireTokenValue(token, parseError).trim();
|
|
261
316
|
continue;
|
|
@@ -264,10 +319,19 @@ export function parseWriteGateContextCliArgs(argv) {
|
|
|
264
319
|
options.validationPosture = requireTokenValue(token, parseError).trim();
|
|
265
320
|
continue;
|
|
266
321
|
}
|
|
322
|
+
if (token.name === "pr-body") {
|
|
323
|
+
options.prBody = requireTokenValue(token, parseError);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (token.name === "issue-body") {
|
|
327
|
+
options.issueBody = requireTokenValue(token, parseError);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
267
330
|
if (token.name === "tmp-root") {
|
|
268
331
|
options.tmpRoot = requireTokenValue(token, parseError).trim();
|
|
269
332
|
continue;
|
|
270
333
|
}
|
|
334
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
271
335
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
272
336
|
}
|
|
273
337
|
const missing = ["repo", "pr", "gate", "headSha", "angles"]
|
|
@@ -377,6 +441,137 @@ export function buildGateDiffPath({ repo, pr, gate, headSha, tmpRoot = "tmp" })
|
|
|
377
441
|
return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.diff`);
|
|
378
442
|
}
|
|
379
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Build the deterministic path for the rendered invariant briefing prefix
|
|
446
|
+
* (GATE-EXEC-BRIEFING-PREFIX): the byte-identical block every per-angle
|
|
447
|
+
* reviewer of this gate pass is seeded with, before their angle-specific
|
|
448
|
+
* suffix. Mirrors buildGateContextPath/buildGateDiffPath. Exported so the
|
|
449
|
+
* fan-out reviewers and `verify-fresh-review-context.mjs --prefix-file` agree
|
|
450
|
+
* on the path with the context-builder.
|
|
451
|
+
*
|
|
452
|
+
* @param {object} input
|
|
453
|
+
* @param {string} input.repo — owner/name
|
|
454
|
+
* @param {number|string} input.pr
|
|
455
|
+
* @param {string} input.gate — draft_gate | pre_approval_gate
|
|
456
|
+
* @param {string} input.headSha
|
|
457
|
+
* @param {string} [input.tmpRoot] — default "tmp"
|
|
458
|
+
* @returns {string} relative briefing-prefix path
|
|
459
|
+
*/
|
|
460
|
+
export function buildGateBriefingPrefixPath({ repo, pr, gate, headSha, tmpRoot = "tmp" }) {
|
|
461
|
+
const repoSlug = repoSlugFor(repo);
|
|
462
|
+
const { pr: safePr, gate: safeGate, headSha: safeSha } = validatePathSegments({ pr, gate, headSha });
|
|
463
|
+
return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.briefing-prefix.txt`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Size cap (bytes) above which the rendered briefing prefix falls back to
|
|
468
|
+
* pointer mode for the diff section (a scope.diffPath reference when present,
|
|
469
|
+
* else an explicit unavailable-pointer disclosure) instead of inlining the
|
|
470
|
+
* diff text in a fenced block. No `gates.*` config knob exists for this
|
|
471
|
+
* yet — a named constant is the right size for a single fixed threshold;
|
|
472
|
+
* promote to config only if a real need for tuning it emerges.
|
|
473
|
+
*/
|
|
474
|
+
export const BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES = 200 * 1024;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Render the invariant briefing-prefix text (GATE-EXEC-BRIEFING-PREFIX):
|
|
478
|
+
* header (repo/PR/head/gate/worktree + the mandatory verify-fresh-review-context.mjs
|
|
479
|
+
* instruction), PR body, linked-issue body (when present), the full diff at the
|
|
480
|
+
* reviewed head (inlined up to `capBytes`, else a pointer to `diffPath`), and a
|
|
481
|
+
* changed-files/adjacent-code summary — in that fixed order. Pure and
|
|
482
|
+
* deterministic: identical input always renders identical bytes, so two builds
|
|
483
|
+
* at the same head produce a byte-identical prefix (the fan-out's shared-prefix
|
|
484
|
+
* requirement).
|
|
485
|
+
*
|
|
486
|
+
* @param {object} input
|
|
487
|
+
* @param {string} input.repo
|
|
488
|
+
* @param {number|string} input.pr
|
|
489
|
+
* @param {string} input.gate
|
|
490
|
+
* @param {string} input.headSha
|
|
491
|
+
* @param {string} input.worktreeRoot — absolute path reviewers run in
|
|
492
|
+
* @param {string} input.contextPath — the sibling JSON artifact path
|
|
493
|
+
* @param {string} input.briefingPrefixPath — this rendered file's own path
|
|
494
|
+
* @param {string|null} [input.prBody]
|
|
495
|
+
* @param {string|null} [input.issueRef] — label for the linked-issue section (e.g. "#877")
|
|
496
|
+
* @param {string|null} [input.issueBody] — omit the whole section when null/empty
|
|
497
|
+
* @param {string|null} [input.diffOutput] — full diff text, when captured
|
|
498
|
+
* @param {string|null} [input.diffPath] — persisted `.diff` pointer (pointer-mode fallback)
|
|
499
|
+
* @param {string[]} [input.changedFiles]
|
|
500
|
+
* @param {object|null} [input.adjacentCode] — buildAdjacentBundle output
|
|
501
|
+
* @param {number} [input.capBytes] — default BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES
|
|
502
|
+
* @returns {{ text: string, prefixMode: "inline"|"pointer", diffBytes: number }}
|
|
503
|
+
*/
|
|
504
|
+
export function renderBriefingPrefix({
|
|
505
|
+
repo, pr, gate, headSha, worktreeRoot, contextPath, briefingPrefixPath,
|
|
506
|
+
prBody = null, issueRef = null, issueBody = null,
|
|
507
|
+
diffOutput = null, diffPath = null, changedFiles = [], adjacentCode = null,
|
|
508
|
+
capBytes = BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES,
|
|
509
|
+
}) {
|
|
510
|
+
const hasDiffText = typeof diffOutput === "string" && diffOutput.length > 0;
|
|
511
|
+
const diffBytes = hasDiffText ? Buffer.byteLength(diffOutput, "utf8") : 0;
|
|
512
|
+
const prefixMode = hasDiffText && diffBytes > capBytes ? "pointer" : "inline";
|
|
513
|
+
|
|
514
|
+
const lines = [];
|
|
515
|
+
lines.push("# Gate Review Briefing — invariant prefix (GATE-EXEC-BRIEFING-PREFIX)");
|
|
516
|
+
lines.push("");
|
|
517
|
+
lines.push(`repo: ${repo}`);
|
|
518
|
+
lines.push(`pr: #${pr}`);
|
|
519
|
+
lines.push(`gate: ${gate}`);
|
|
520
|
+
lines.push(`head: ${headSha}`);
|
|
521
|
+
lines.push(`worktree: ${worktreeRoot}`);
|
|
522
|
+
lines.push(`prefixMode: ${prefixMode}`);
|
|
523
|
+
lines.push("");
|
|
524
|
+
lines.push(
|
|
525
|
+
`Mandatory: before doing any angle-specific work, run \`node scripts/github/verify-fresh-review-context.mjs --scope <your-angle> --context-path ${contextPath} --prefix-file ${briefingPrefixPath}\`. Refuse to proceed on contamination or a missing artifact.`,
|
|
526
|
+
);
|
|
527
|
+
lines.push("");
|
|
528
|
+
lines.push("## PR body");
|
|
529
|
+
lines.push("");
|
|
530
|
+
lines.push(typeof prBody === "string" && prBody.trim().length > 0 ? prBody.trim() : "(no PR body provided)");
|
|
531
|
+
lines.push("");
|
|
532
|
+
const hasIssueBody = typeof issueBody === "string" && issueBody.trim().length > 0;
|
|
533
|
+
if (hasIssueBody) {
|
|
534
|
+
lines.push(`## Linked issue${issueRef ? ` ${issueRef}` : ""}`);
|
|
535
|
+
lines.push("");
|
|
536
|
+
lines.push(issueBody.trim());
|
|
537
|
+
lines.push("");
|
|
538
|
+
}
|
|
539
|
+
lines.push(`## Diff at reviewed head (${headSha})`);
|
|
540
|
+
lines.push("");
|
|
541
|
+
if (!hasDiffText) {
|
|
542
|
+
lines.push("(no diff text captured for this bundle)");
|
|
543
|
+
} else if (prefixMode === "inline") {
|
|
544
|
+
lines.push("```diff");
|
|
545
|
+
lines.push(diffOutput.endsWith("\n") ? diffOutput.slice(0, -1) : diffOutput);
|
|
546
|
+
lines.push("```");
|
|
547
|
+
} else {
|
|
548
|
+
lines.push(
|
|
549
|
+
`Diff exceeds the ${capBytes}-byte inline cap (${diffBytes} bytes) — pointer mode. Read the full diff from:`,
|
|
550
|
+
);
|
|
551
|
+
lines.push(` ${diffPath ?? "(diff pointer unavailable — re-derive with git diff)"}`);
|
|
552
|
+
}
|
|
553
|
+
lines.push("");
|
|
554
|
+
lines.push("## Changed files + adjacent-code summary");
|
|
555
|
+
lines.push("");
|
|
556
|
+
const files = Array.isArray(changedFiles) ? changedFiles : [];
|
|
557
|
+
lines.push(`Changed files (${files.length}):`);
|
|
558
|
+
for (const f of files) lines.push(`- ${f}`);
|
|
559
|
+
const adjacentFiles = adjacentCode && Array.isArray(adjacentCode.files)
|
|
560
|
+
? adjacentCode.files.filter((f) => f.role !== "changed")
|
|
561
|
+
: [];
|
|
562
|
+
if (adjacentCode) {
|
|
563
|
+
lines.push(`Adjacent files (${adjacentFiles.length}):`);
|
|
564
|
+
for (const f of adjacentFiles) lines.push(`- ${f.path} (${f.role})`);
|
|
565
|
+
lines.push(
|
|
566
|
+
`Stripped: ${adjacentCode.stripped?.length ?? 0}, Truncated: ${adjacentCode.truncated?.length ?? 0}, Missing: ${adjacentCode.missing?.length ?? 0}`,
|
|
567
|
+
);
|
|
568
|
+
} else {
|
|
569
|
+
lines.push("Adjacent files (0): (no adjacent-code bundle for this briefing)");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return { text: lines.join("\n") + "\n", prefixMode, diffBytes };
|
|
573
|
+
}
|
|
574
|
+
|
|
380
575
|
/**
|
|
381
576
|
* Parse `git diff --name-status` output into full repo-relative changed file
|
|
382
577
|
* paths. Handles rename/copy entries (R100 old new, C75 old new) by recording
|
|
@@ -437,15 +632,186 @@ export function buildGateContextArtifact(options) {
|
|
|
437
632
|
validationPosture: options.validationPosture ?? null,
|
|
438
633
|
},
|
|
439
634
|
};
|
|
635
|
+
// ADD (#1140): an explicit posture marker distinguishing a full build-once
|
|
636
|
+
// bundle from a thin briefing. Only set by the CLI (`--base` present → "base";
|
|
637
|
+
// absent → "none"); programmatic `buildGateContext`/`writeGateContext` callers
|
|
638
|
+
// that never pass `diffSource` leave the field out entirely, so this stays
|
|
639
|
+
// backward compatible with the artifact shape they already assert on.
|
|
640
|
+
if (typeof options.diffSource === "string" && options.diffSource.length > 0) {
|
|
641
|
+
artifact.scope.diffSource = options.diffSource;
|
|
642
|
+
}
|
|
440
643
|
// ADD (#895): the deterministic, neutral adjacent-code bundle. Only present
|
|
441
644
|
// when the context-builder computed it — keeps the artifact shape backward
|
|
442
645
|
// compatible for callers that build the artifact without an adjacency pass.
|
|
443
646
|
if (options.adjacentCode && typeof options.adjacentCode === "object") {
|
|
444
647
|
artifact.adjacentCode = options.adjacentCode;
|
|
445
648
|
}
|
|
649
|
+
// Whether the rendered briefing prefix inlined the reviewed-head diff (in a
|
|
650
|
+
// fenced block) or fell back to the diffPath pointer (size cap). Only set
|
|
651
|
+
// when a caller actually rendered a prefix (writeGateContext does, always).
|
|
652
|
+
if (typeof options.prefixMode === "string" && options.prefixMode.length > 0) {
|
|
653
|
+
artifact.prefixMode = options.prefixMode;
|
|
654
|
+
}
|
|
446
655
|
return artifact;
|
|
447
656
|
}
|
|
448
657
|
|
|
658
|
+
/**
|
|
659
|
+
* Resolve the diff-derived scope fields shared by `buildGateContext` (the
|
|
660
|
+
* programmatic `{ diff }` path) and the CLI `--base` path (#1140): the FULL
|
|
661
|
+
* diff persisted to a deterministic `.diff` file, the parsed `changedFiles`,
|
|
662
|
+
* and the neutral `adjacentCode` bundle built once from those changed files.
|
|
663
|
+
* Extracted to a single function so both callers stay in sync — see the
|
|
664
|
+
* doc comment on {@link buildGateContext} for the field semantics.
|
|
665
|
+
*
|
|
666
|
+
* @param {object} input
|
|
667
|
+
* @param {{ nameStatusOutput: string, diffOutput?: string }} [input.diff]
|
|
668
|
+
* @param {string} input.repo
|
|
669
|
+
* @param {number|string} input.pr
|
|
670
|
+
* @param {string} input.gate
|
|
671
|
+
* @param {string} input.headSha
|
|
672
|
+
* @param {string} input.tmpRoot
|
|
673
|
+
* @param {number} [input.maxFileBytes]
|
|
674
|
+
* @param {{ repoRoot: string }} opts
|
|
675
|
+
* @returns {Promise<{ diffPath: string|null, changedFiles: string[], adjacentCode: object|null, diffOutput: string|null }>}
|
|
676
|
+
*/
|
|
677
|
+
async function resolveDiffScope({ diff, repo, pr, gate, headSha, tmpRoot, maxFileBytes }, { repoRoot }) {
|
|
678
|
+
const diffOutput = diff?.diffOutput;
|
|
679
|
+
let diffPath = null;
|
|
680
|
+
const changedFiles = parseChangedFiles(diff?.nameStatusOutput);
|
|
681
|
+
if (typeof diffOutput === "string" && diffOutput.length > 0) {
|
|
682
|
+
diffPath = buildGateDiffPath({ repo, pr, gate, headSha, tmpRoot });
|
|
683
|
+
const fullDiffPath = path.resolve(repoRoot, diffPath);
|
|
684
|
+
try {
|
|
685
|
+
await mkdir(path.dirname(fullDiffPath), { recursive: true });
|
|
686
|
+
await writeFile(fullDiffPath, diffOutput.endsWith("\n") ? diffOutput : diffOutput + "\n", "utf8");
|
|
687
|
+
} catch (err) {
|
|
688
|
+
// Best-effort: a diff-file write failure (disk, permissions) must not block
|
|
689
|
+
// the context artifact. Degrade to diffPath=null; reviewers reconstruct the
|
|
690
|
+
// diff with `git diff`. changedFiles (from nameStatusOutput) is unaffected.
|
|
691
|
+
process.stderr.write(`[gate-context] full-diff capture failed (continuing without scope.diffPath): ${err?.message ?? err}\n`);
|
|
692
|
+
diffPath = null;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Build the deterministic, neutral adjacent-code bundle ONCE (#895): for each
|
|
697
|
+
// changed source file, include its 1-hop import out-edges (files it imports)
|
|
698
|
+
// and in-edges (files that import it), with size guards (skip
|
|
699
|
+
// lockfiles/generated/binary/minified; cap per-file bytes; truncate the long
|
|
700
|
+
// tail) recorded in a stripped/truncated manifest. Every independent reviewer
|
|
701
|
+
// is seeded with this identical bundle instead of re-deriving it — work-dedup.
|
|
702
|
+
// Best-effort: bundle computation must never block the context artifact.
|
|
703
|
+
let adjacentCode = null;
|
|
704
|
+
if (changedFiles.length > 0) {
|
|
705
|
+
try {
|
|
706
|
+
adjacentCode = await buildAdjacentBundle({
|
|
707
|
+
changedFiles,
|
|
708
|
+
repoRoot,
|
|
709
|
+
maxFileBytes: typeof maxFileBytes === "number" && maxFileBytes > 0 ? maxFileBytes : DEFAULT_MAX_FILE_BYTES,
|
|
710
|
+
});
|
|
711
|
+
} catch (err) {
|
|
712
|
+
process.stderr.write(`[gate-context] adjacent-code bundle failed (continuing without adjacentCode): ${err?.message ?? err}\n`);
|
|
713
|
+
adjacentCode = null;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return { diffPath, changedFiles, adjacentCode, diffOutput: typeof diffOutput === "string" ? diffOutput : null };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Capture a diff against `base` for the CLI `--base <ref>` flag: `git diff
|
|
722
|
+
* --name-status <base>...HEAD` and `git diff <base>...HEAD`, shaped as the
|
|
723
|
+
* `{ nameStatusOutput, diffOutput }` input `resolveDiffScope`/`buildGateContext`
|
|
724
|
+
* expect. Uses execFileSync with an argv array (no shell), so `base` cannot
|
|
725
|
+
* inject shell syntax; `parseWriteGateContextCliArgs` additionally validates it
|
|
726
|
+
* looks like a plausible ref before this runs.
|
|
727
|
+
*
|
|
728
|
+
* Split posture:
|
|
729
|
+
* - the `--name-status` capture is FAIL-CLOSED: it drives scope.changedFiles +
|
|
730
|
+
* the adjacentCode bundle, so an unresolvable base / non-git-repo throws and
|
|
731
|
+
* the CLI writes NO artifact (never a silent thin briefing).
|
|
732
|
+
* - the FULL diff capture is BEST-EFFORT: if it fails (output exceeds maxBuffer,
|
|
733
|
+
* a rendering error, etc.) we warn and return an EMPTY diffOutput. downstream
|
|
734
|
+
* resolveDiffScope then leaves scope.diffPath null while still populating
|
|
735
|
+
* changedFiles + adjacentCode; reviewers fall back to re-deriving the diff
|
|
736
|
+
* (the existing safety net). scope.diffSource stays "base" — it IS a
|
|
737
|
+
* base-derived bundle, just without the persisted full diff.
|
|
738
|
+
*
|
|
739
|
+
* @param {string} base
|
|
740
|
+
* @param {{ repoRoot: string, maxBuffer?: number }} opts — maxBuffer overridable for tests
|
|
741
|
+
* @returns {{ nameStatusOutput: string, diffOutput: string }}
|
|
742
|
+
*/
|
|
743
|
+
export function captureDiffFromBase(base, { repoRoot, maxBuffer = 64 * 1024 * 1024 }) {
|
|
744
|
+
const range = `${base}...HEAD`;
|
|
745
|
+
// Isolate the persisted .diff BYTES from ambient global/system gitconfig so
|
|
746
|
+
// every reviewer is seeded with an IDENTICAL neutral bundle (the whole point
|
|
747
|
+
// of build-once). Without this isolation, an operator/CI with
|
|
748
|
+
// color.diff=always, a configured diff.external/difftool, or non-default
|
|
749
|
+
// prefix settings would make scope.diffPath environment-dependent.
|
|
750
|
+
// color.ui=false + color.diff=false strip ANSI; core.pager=cat neutralizes a
|
|
751
|
+
// configured pager; diff.noprefix=false + diff.mnemonicPrefix=false pin the
|
|
752
|
+
// a/ b/ prefixes; the --no-ext-diff flag (below) disables any external diff
|
|
753
|
+
// driver (NOT `-c diff.external=`, which makes git try to exec the empty
|
|
754
|
+
// string and die).
|
|
755
|
+
// CROSS-environment reproducibility (#1168): the overrides above only pin
|
|
756
|
+
// bytes WITHIN a single run/machine — an operator/CI box with a contrary
|
|
757
|
+
// local diff.renames/diff.algorithm/diff.context/core.abbrev/core.autocrlf
|
|
758
|
+
// would still produce different bytes than another box on the SAME base and
|
|
759
|
+
// HEAD. diff.algorithm=myers + diff.context=3 + core.abbrev=12 +
|
|
760
|
+
// core.autocrlf=false pin the diff body/hunk headers/blob-id length/line
|
|
761
|
+
// endings. diff.renames=true additionally pins rename DETECTION itself: with
|
|
762
|
+
// it off, a moved-and-edited file shows as a straight D+A pair in
|
|
763
|
+
// `--name-status` instead of an R### pair, which changes the SET of names in
|
|
764
|
+
// scope.changedFiles (and therefore adjacentCode's membership) across
|
|
765
|
+
// environments, not just the diff body's bytes.
|
|
766
|
+
const isolation = [
|
|
767
|
+
"-c", "color.ui=false",
|
|
768
|
+
"-c", "color.diff=false",
|
|
769
|
+
"-c", "core.pager=cat",
|
|
770
|
+
"-c", "diff.noprefix=false",
|
|
771
|
+
"-c", "diff.mnemonicPrefix=false",
|
|
772
|
+
"-c", "diff.renames=true",
|
|
773
|
+
"-c", "diff.algorithm=myers",
|
|
774
|
+
"-c", "diff.context=3",
|
|
775
|
+
"-c", "core.abbrev=12",
|
|
776
|
+
"-c", "core.autocrlf=false",
|
|
777
|
+
];
|
|
778
|
+
const runGit = (args) => execFileSync("git", [...isolation, ...args], {
|
|
779
|
+
cwd: repoRoot,
|
|
780
|
+
encoding: "utf8",
|
|
781
|
+
maxBuffer,
|
|
782
|
+
});
|
|
783
|
+
// FAIL-CLOSED: --name-status feeds changedFiles + adjacentCode (the bundle's core).
|
|
784
|
+
let nameStatusOutput;
|
|
785
|
+
try {
|
|
786
|
+
nameStatusOutput = runGit(["diff", "--no-ext-diff", "--name-status", range]);
|
|
787
|
+
} catch (err) {
|
|
788
|
+
throw new Error(`git diff against --base ${JSON.stringify(base)} failed: ${err?.message ?? err}`);
|
|
789
|
+
}
|
|
790
|
+
// BEST-EFFORT: the full diff only feeds the persisted scope.diffPath; on
|
|
791
|
+
// failure degrade to an empty diffOutput (diffPath becomes null downstream).
|
|
792
|
+
let diffOutput = "";
|
|
793
|
+
try {
|
|
794
|
+
diffOutput = runGit(["diff", "--no-ext-diff", range]);
|
|
795
|
+
} catch (err) {
|
|
796
|
+
process.stderr.write(`[gate-context] full-diff capture against --base ${JSON.stringify(base)} failed (continuing without scope.diffPath): ${err?.message ?? err}\n`);
|
|
797
|
+
diffOutput = "";
|
|
798
|
+
}
|
|
799
|
+
return { nameStatusOutput, diffOutput };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Write both the JSON context artifact AND its sibling rendered briefing
|
|
804
|
+
* prefix (GATE-EXEC-BRIEFING-PREFIX): the byte-identical invariant block every
|
|
805
|
+
* per-angle reviewer of this gate pass is seeded with. The prefix's
|
|
806
|
+
* `prefixMode` (inline|pointer, from the diff-size cap) is recorded on the
|
|
807
|
+
* JSON artifact so both files stay in sync.
|
|
808
|
+
*
|
|
809
|
+
* @param {object} options — parsed CLI options shape, optionally carrying
|
|
810
|
+
* `diffOutput`, `prBody`, `issueBody` (all null-safe; a caller with none of
|
|
811
|
+
* these still gets a valid — if thin — prefix).
|
|
812
|
+
* @param {{ repoRoot?: string }} [runtime]
|
|
813
|
+
* @returns {Promise<{ ok: boolean, path: string, artifact: object, prefixPath: string, prefixHash: string, prefixMode: "inline"|"pointer" }>}
|
|
814
|
+
*/
|
|
449
815
|
export async function writeGateContext(options, { repoRoot = process.cwd() } = {}) {
|
|
450
816
|
const contextPath = buildGateContextPath({
|
|
451
817
|
repo: options.repo,
|
|
@@ -454,14 +820,49 @@ export async function writeGateContext(options, { repoRoot = process.cwd() } = {
|
|
|
454
820
|
headSha: options.headSha,
|
|
455
821
|
tmpRoot: options.tmpRoot || "tmp",
|
|
456
822
|
});
|
|
823
|
+
const briefingPrefixPath = buildGateBriefingPrefixPath({
|
|
824
|
+
repo: options.repo,
|
|
825
|
+
pr: options.pr,
|
|
826
|
+
gate: options.gate,
|
|
827
|
+
headSha: options.headSha,
|
|
828
|
+
tmpRoot: options.tmpRoot || "tmp",
|
|
829
|
+
});
|
|
830
|
+
const rendered = renderBriefingPrefix({
|
|
831
|
+
repo: options.repo,
|
|
832
|
+
pr: options.pr,
|
|
833
|
+
gate: options.gate,
|
|
834
|
+
headSha: options.headSha,
|
|
835
|
+
worktreeRoot: path.resolve(repoRoot),
|
|
836
|
+
contextPath,
|
|
837
|
+
briefingPrefixPath,
|
|
838
|
+
prBody: options.prBody ?? null,
|
|
839
|
+
issueRef: options.acceptanceCriteria ?? null,
|
|
840
|
+
issueBody: options.issueBody ?? null,
|
|
841
|
+
diffOutput: options.diffOutput ?? null,
|
|
842
|
+
diffPath: options.diffPath ?? null,
|
|
843
|
+
changedFiles: options.changedFiles ?? [],
|
|
844
|
+
adjacentCode: options.adjacentCode ?? null,
|
|
845
|
+
});
|
|
846
|
+
|
|
457
847
|
const fullPath = path.resolve(repoRoot, contextPath);
|
|
458
848
|
const artifact = {
|
|
459
|
-
...buildGateContextArtifact(options),
|
|
849
|
+
...buildGateContextArtifact({ ...options, prefixMode: rendered.prefixMode }),
|
|
460
850
|
loggedAt: new Date().toISOString(),
|
|
461
851
|
};
|
|
852
|
+
// Write ORDER matters: the sibling briefing prefix goes first and the JSON
|
|
853
|
+
// artifact last, so the artifact's existence is the completion marker for
|
|
854
|
+
// the whole set. Downstream consumers (readGateContext, the reviewers'
|
|
855
|
+
// --context-path guard) key on the JSON — a prefix-write failure must not
|
|
856
|
+
// leave a complete-looking artifact pointing at a missing prefix file.
|
|
857
|
+
const fullPrefixPath = path.resolve(repoRoot, briefingPrefixPath);
|
|
858
|
+
await mkdir(path.dirname(fullPrefixPath), { recursive: true });
|
|
859
|
+
await writeFile(fullPrefixPath, rendered.text, "utf8");
|
|
860
|
+
const prefixHash = createHash("sha256").update(rendered.text, "utf8").digest("hex");
|
|
861
|
+
|
|
462
862
|
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
463
863
|
await writeFile(fullPath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
|
|
464
|
-
|
|
864
|
+
|
|
865
|
+
return { ok: true, path: contextPath, artifact, prefixPath: briefingPrefixPath, prefixHash, prefixMode: rendered.prefixMode };
|
|
465
866
|
}
|
|
466
867
|
|
|
467
868
|
/**
|
|
@@ -484,10 +885,12 @@ export async function writeGateContext(options, { repoRoot = process.cwd() } = {
|
|
|
484
885
|
* @param {string[]} [input.touchedFiles]
|
|
485
886
|
* @param {string|null} [input.acceptanceCriteria]
|
|
486
887
|
* @param {string|null} [input.validationPosture]
|
|
888
|
+
* @param {string|null} [input.prBody] — PR description text, inlined into the rendered briefing prefix
|
|
889
|
+
* @param {string|null} [input.issueBody] — linked-issue body text, inlined under `acceptanceCriteria`'s label; omitted when absent
|
|
487
890
|
* @param {number} [input.maxFileBytes] — per-file cap for the adjacent-code bundle (default DEFAULT_MAX_FILE_BYTES)
|
|
488
891
|
* @param {string} [input.tmpRoot]
|
|
489
892
|
* @param {{ repoRoot?: string }} [opts]
|
|
490
|
-
* @returns {Promise<{ ok: boolean, path: string, artifact: object, resolver: object }>}
|
|
893
|
+
* @returns {Promise<{ ok: boolean, path: string, artifact: object, prefixPath: string, prefixHash: string, prefixMode: "inline"|"pointer", resolver: object }>}
|
|
491
894
|
*
|
|
492
895
|
* The artifact additionally carries a deterministic, neutral `adjacentCode`
|
|
493
896
|
* bundle (#895) when changed files are present: 1-hop import in/out-edges of the
|
|
@@ -503,58 +906,13 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
|
|
|
503
906
|
|
|
504
907
|
const tmpRoot = input.tmpRoot || "tmp";
|
|
505
908
|
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
let diffPath = null;
|
|
514
|
-
let changedFiles = parseChangedFiles(input.diff?.nameStatusOutput);
|
|
515
|
-
if (typeof diffOutput === "string" && diffOutput.length > 0) {
|
|
516
|
-
diffPath = buildGateDiffPath({
|
|
517
|
-
repo: input.repo,
|
|
518
|
-
pr: input.pr,
|
|
519
|
-
gate: input.gate,
|
|
520
|
-
headSha: input.headSha,
|
|
521
|
-
tmpRoot,
|
|
522
|
-
});
|
|
523
|
-
const fullDiffPath = path.resolve(repoRoot, diffPath);
|
|
524
|
-
try {
|
|
525
|
-
await mkdir(path.dirname(fullDiffPath), { recursive: true });
|
|
526
|
-
await writeFile(fullDiffPath, diffOutput.endsWith("\n") ? diffOutput : diffOutput + "\n", "utf8");
|
|
527
|
-
} catch (err) {
|
|
528
|
-
// Best-effort: a diff-file write failure (disk, permissions) must not block
|
|
529
|
-
// the context artifact. Degrade to diffPath=null; reviewers reconstruct the
|
|
530
|
-
// diff with `git diff`. changedFiles (from nameStatusOutput) is unaffected.
|
|
531
|
-
process.stderr.write(`[gate-context] full-diff capture failed (continuing without scope.diffPath): ${err?.message ?? err}\n`);
|
|
532
|
-
diffPath = null;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// Build the deterministic, neutral adjacent-code bundle ONCE (#895): for each
|
|
537
|
-
// changed source file, include its 1-hop import out-edges (files it imports)
|
|
538
|
-
// and in-edges (files that import it), with size guards (skip
|
|
539
|
-
// lockfiles/generated/binary/minified; cap per-file bytes; truncate the long
|
|
540
|
-
// tail) recorded in a stripped/truncated manifest. Every independent reviewer
|
|
541
|
-
// is seeded with this identical bundle instead of re-deriving it — work-dedup.
|
|
542
|
-
// Best-effort: bundle computation must never block the context artifact.
|
|
543
|
-
let adjacentCode = null;
|
|
544
|
-
if (changedFiles.length > 0) {
|
|
545
|
-
try {
|
|
546
|
-
adjacentCode = await buildAdjacentBundle({
|
|
547
|
-
changedFiles,
|
|
548
|
-
repoRoot,
|
|
549
|
-
maxFileBytes: typeof input.maxFileBytes === "number" && input.maxFileBytes > 0
|
|
550
|
-
? input.maxFileBytes
|
|
551
|
-
: DEFAULT_MAX_FILE_BYTES,
|
|
552
|
-
});
|
|
553
|
-
} catch (err) {
|
|
554
|
-
process.stderr.write(`[gate-context] adjacent-code bundle failed (continuing without adjacentCode): ${err?.message ?? err}\n`);
|
|
555
|
-
adjacentCode = null;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
909
|
+
// Diff-derived scope: persisted FULL diff (scope.diffPath), parsed
|
|
910
|
+
// scope.changedFiles, and the neutral adjacentCode bundle, all built ONCE by
|
|
911
|
+
// the shared resolveDiffScope helper (also used by the CLI --base path, #1140).
|
|
912
|
+
const { diffPath, changedFiles, adjacentCode, diffOutput } = await resolveDiffScope(
|
|
913
|
+
{ diff: input.diff, repo: input.repo, pr: input.pr, gate: input.gate, headSha: input.headSha, tmpRoot, maxFileBytes: input.maxFileBytes },
|
|
914
|
+
{ repoRoot },
|
|
915
|
+
);
|
|
558
916
|
|
|
559
917
|
const writeResult = await writeGateContext(
|
|
560
918
|
{
|
|
@@ -568,9 +926,12 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
|
|
|
568
926
|
touchedFiles: input.touchedFiles ?? [],
|
|
569
927
|
changedFiles,
|
|
570
928
|
diffPath,
|
|
929
|
+
diffOutput,
|
|
571
930
|
adjacentCode,
|
|
572
931
|
acceptanceCriteria: input.acceptanceCriteria ?? null,
|
|
573
932
|
validationPosture: input.validationPosture ?? null,
|
|
933
|
+
prBody: input.prBody ?? null,
|
|
934
|
+
issueBody: input.issueBody ?? null,
|
|
574
935
|
tmpRoot,
|
|
575
936
|
},
|
|
576
937
|
{ repoRoot },
|
|
@@ -604,10 +965,17 @@ export async function readGateContext(input, { repoRoot = process.cwd() } = {})
|
|
|
604
965
|
}
|
|
605
966
|
}
|
|
606
967
|
|
|
607
|
-
|
|
968
|
+
/**
|
|
969
|
+
* CLI entrypoint. Exported (argv + repoRoot both overridable) so tests can
|
|
970
|
+
* drive the `--base` diff-capture path against a throwaway git repo fixture
|
|
971
|
+
* without spawning a subprocess.
|
|
972
|
+
* @param {string[]} [argv]
|
|
973
|
+
* @param {{ repoRoot?: string }} [runtime]
|
|
974
|
+
*/
|
|
975
|
+
export async function main(argv = process.argv.slice(2), { repoRoot = process.cwd() } = {}) {
|
|
608
976
|
let options;
|
|
609
977
|
try {
|
|
610
|
-
options = parseWriteGateContextCliArgs(
|
|
978
|
+
options = parseWriteGateContextCliArgs(argv);
|
|
611
979
|
} catch (error) {
|
|
612
980
|
process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
|
|
613
981
|
process.exitCode = 1;
|
|
@@ -618,8 +986,33 @@ async function main() {
|
|
|
618
986
|
return;
|
|
619
987
|
}
|
|
620
988
|
try {
|
|
621
|
-
|
|
622
|
-
|
|
989
|
+
// AC3 (#1140): the CLI only produces the full build-once bundle
|
|
990
|
+
// (scope.diffPath + scope.changedFiles + adjacentCode) when it has a
|
|
991
|
+
// resolvable diff source. --base is OPTIONAL rather than required — making
|
|
992
|
+
// it required would break any existing caller that only ever passed
|
|
993
|
+
// --touched-files (angles + scope already resolved elsewhere) — so a run
|
|
994
|
+
// without --base does not fail closed; it explicitly WARNS and records the
|
|
995
|
+
// thin-briefing posture in scope.diffSource so it is never mistaken for a
|
|
996
|
+
// full bundle. A run WITH --base that then fails to resolve (bad ref, not a
|
|
997
|
+
// git repo, etc.) DOES fail closed: the caller opted into the full bundle,
|
|
998
|
+
// so a silent thin degrade there would be a worse surprise than an error.
|
|
999
|
+
if (options.base) {
|
|
1000
|
+
const diff = captureDiffFromBase(options.base, { repoRoot });
|
|
1001
|
+
const scope = await resolveDiffScope(
|
|
1002
|
+
{ diff, repo: options.repo, pr: options.pr, gate: options.gate, headSha: options.headSha, tmpRoot: options.tmpRoot || "tmp" },
|
|
1003
|
+
{ repoRoot },
|
|
1004
|
+
);
|
|
1005
|
+
options.changedFiles = scope.changedFiles;
|
|
1006
|
+
options.diffPath = scope.diffPath;
|
|
1007
|
+
options.adjacentCode = scope.adjacentCode;
|
|
1008
|
+
options.diffOutput = scope.diffOutput;
|
|
1009
|
+
options.diffSource = "base";
|
|
1010
|
+
} else {
|
|
1011
|
+
process.stderr.write("[write-gate-context] warning: no --base given; emitting a THIN briefing (scope.diffPath=null, scope.changedFiles=[], no adjacentCode). Pass --base <ref> for the full build-once bundle.\n");
|
|
1012
|
+
options.diffSource = "none";
|
|
1013
|
+
}
|
|
1014
|
+
const result = await writeGateContext(options, { repoRoot });
|
|
1015
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
|
|
623
1016
|
} catch (error) {
|
|
624
1017
|
process.stderr.write(JSON.stringify({
|
|
625
1018
|
ok: false,
|