dev-loops 0.6.0 → 0.7.1
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 +2 -1
- package/.claude/agents/review.md +2 -1
- 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} +1 -1
- 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 +24 -12
- package/.claude/skills/dev-loop/SKILL.md +15 -11
- package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
- package/.claude/skills/docs/anti-patterns.md +2 -1
- package/.claude/skills/docs/artifact-authority-contract.md +22 -4
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- package/.claude/skills/docs/issue-intake-procedure.md +1 -0
- 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 +17 -1
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +25 -14
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +95 -0
- package/README.md +9 -9
- package/agents/dev-loop.agent.md +3 -2
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +1 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +1 -1
- package/agents/review.agent.md +3 -2
- 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 +9 -10
- 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/detect-checkpoint-evidence.mjs +170 -14
- 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/manage-sub-issues.mjs +46 -10
- package/scripts/github/offer-human-handoff.mjs +8 -5
- package/scripts/github/post-gate-findings.mjs +14 -2
- 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 +8 -2
- 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 +31 -10
- package/scripts/github/verify-fresh-review-context.mjs +150 -31
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/write-gate-context.mjs +248 -61
- package/scripts/github/write-gate-findings-log.mjs +75 -1
- 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 +135 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +36 -7
- package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
- 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 +149 -66
- 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 +76 -7
- 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 +104 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +207 -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 +441 -0
- package/scripts/projects/_resolve-project.mjs +148 -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 +116 -65
- package/scripts/projects/move-queue-item.mjs +28 -49
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +41 -47
- 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 +24 -12
- package/skills/dev-loop/SKILL.md +10 -6
- package/skills/docs/acceptance-criteria-verification.md +6 -2
- package/skills/docs/anti-patterns.md +2 -1
- package/skills/docs/artifact-authority-contract.md +22 -4
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- package/skills/docs/issue-intake-procedure.md +1 -0
- 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 +17 -1
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/public-dev-loop-contract.md +1 -1
- package/skills/docs/retrospective-checkpoint-contract.md +90 -76
- package/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +25 -14
- package/skills/loop-grill/SKILL.md +165 -0
- package/.claude/commands/continue.md +0 -15
- 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,7 @@
|
|
|
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";
|
|
29
32
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
30
33
|
import path from "node:path";
|
|
31
34
|
import { parseArgs } from "node:util";
|
|
@@ -35,6 +38,7 @@ import { resolveGateAnglesDynamic } from "@dev-loops/core/config";
|
|
|
35
38
|
import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
|
|
36
39
|
import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
37
40
|
import { buildAdjacentBundle, DEFAULT_MAX_FILE_BYTES } from "./build-adjacent-bundle.mjs";
|
|
41
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
38
42
|
|
|
39
43
|
/**
|
|
40
44
|
* Map the artifact gate name (draft_gate | pre_approval_gate) to the config
|
|
@@ -52,8 +56,12 @@ export function mapGateToConfigKey(gate) {
|
|
|
52
56
|
* Map a resolveGateAnglesDynamic result into the persisted artifact fields.
|
|
53
57
|
* Does NOT re-derive angles — it only reshapes the resolver's output.
|
|
54
58
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
59
|
+
* Angles present in `resolverResult.addedAngles` (additive selection, #1048)
|
|
60
|
+
* are recorded with action 'added' (reason from `resolverResult.addedReasons`)
|
|
61
|
+
* instead of 'kept'.
|
|
62
|
+
*
|
|
63
|
+
* @param {{ recommendedAngles: string[]|null, skippedAngles?: string[], reasons?: Record<string,string>, addedAngles?: string[], addedReasons?: Record<string,string>, dynamicAnglesActive?: boolean }} resolverResult
|
|
64
|
+
* @returns {{ resolvedAngles: string[], rationale: Array<{angle: string, action: "kept"|"added"|"dropped", reason: string}> }}
|
|
57
65
|
*/
|
|
58
66
|
export function rationaleFromResolver(resolverResult) {
|
|
59
67
|
const recommended = Array.isArray(resolverResult?.recommendedAngles)
|
|
@@ -63,6 +71,8 @@ export function rationaleFromResolver(resolverResult) {
|
|
|
63
71
|
? resolverResult.skippedAngles
|
|
64
72
|
: [];
|
|
65
73
|
const reasons = resolverResult?.reasons ?? {};
|
|
74
|
+
const added = new Set(Array.isArray(resolverResult?.addedAngles) ? resolverResult.addedAngles : []);
|
|
75
|
+
const addedReasons = resolverResult?.addedReasons ?? {};
|
|
66
76
|
const dynamicActive = resolverResult?.dynamicAnglesActive === true;
|
|
67
77
|
const keptReason = dynamicActive
|
|
68
78
|
? "selected by dynamic angle resolver"
|
|
@@ -70,6 +80,16 @@ export function rationaleFromResolver(resolverResult) {
|
|
|
70
80
|
|
|
71
81
|
const rationale = [];
|
|
72
82
|
for (const angle of recommended) {
|
|
83
|
+
if (added.has(angle) && dynamicActive) {
|
|
84
|
+
rationale.push({
|
|
85
|
+
angle,
|
|
86
|
+
action: "added",
|
|
87
|
+
reason: typeof addedReasons[angle] === "string" && addedReasons[angle].length > 0
|
|
88
|
+
? addedReasons[angle]
|
|
89
|
+
: "added by dynamic angle resolver (catalog addition)",
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
73
93
|
rationale.push({ angle, action: "kept", reason: keptReason });
|
|
74
94
|
}
|
|
75
95
|
for (const angle of skipped) {
|
|
@@ -84,7 +104,7 @@ export function rationaleFromResolver(resolverResult) {
|
|
|
84
104
|
return { resolvedAngles: [...recommended], rationale };
|
|
85
105
|
}
|
|
86
106
|
|
|
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>]
|
|
107
|
+
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
108
|
Write a deterministic gate-review context-builder handoff artifact under tmp/ paths.
|
|
89
109
|
Required:
|
|
90
110
|
--repo <owner/name>
|
|
@@ -95,10 +115,13 @@ Required:
|
|
|
95
115
|
Optional:
|
|
96
116
|
--rationale <json> JSON array of {angle, action, reason} entries
|
|
97
117
|
--branch <name> Source branch name
|
|
98
|
-
--touched-files <json> JSON array of changed file path strings
|
|
118
|
+
--touched-files <json> JSON array of changed file path strings (separate from the diff-derived scope.changedFiles)
|
|
119
|
+
--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.
|
|
99
120
|
--acceptance-criteria <ptr> Pointer to acceptance criteria (issue ref, doc path, URL)
|
|
100
121
|
--validation-posture <text> Short description of the validation posture
|
|
101
122
|
--tmp-root <path> Root tmp directory (default: tmp/)
|
|
123
|
+
|
|
124
|
+
${JQ_OUTPUT_USAGE}
|
|
102
125
|
`.trim();
|
|
103
126
|
|
|
104
127
|
function parseError(message) {
|
|
@@ -116,6 +139,22 @@ function normalizeHeadSha(value) {
|
|
|
116
139
|
return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
|
|
117
140
|
}
|
|
118
141
|
|
|
142
|
+
// ponytail: a DENYLIST, not an allowlist — the `git diff` call runs via
|
|
143
|
+
// execFileSync's argv array (no shell), so `base` cannot inject shell syntax
|
|
144
|
+
// and we don't need to enumerate every valid git revision grammar. We reject
|
|
145
|
+
// only what is genuinely unsafe or malformed for OUR use, and let `git diff`
|
|
146
|
+
// itself resolve validity (a syntactically-allowed but nonexistent ref fails
|
|
147
|
+
// closed via the unresolvable-base path). Rejected: empty/whitespace-only; a
|
|
148
|
+
// leading "-" (flag-injection shape, never a valid ref); and ".." (ambiguous
|
|
149
|
+
// with our own "<base>...HEAD" triple-dot construction). Everything else —
|
|
150
|
+
// including HEAD@{upstream}, main@{1}, tag-peel v1.0.0^{commit}, HEAD~3 — is
|
|
151
|
+
// accepted.
|
|
152
|
+
function normalizeBaseRef(value) {
|
|
153
|
+
const trimmed = String(value).trim();
|
|
154
|
+
if (trimmed.length === 0 || trimmed.startsWith("-") || trimmed.includes("..")) return null;
|
|
155
|
+
return trimmed;
|
|
156
|
+
}
|
|
157
|
+
|
|
119
158
|
const VALID_ACTIONS = new Set(["kept", "added", "dropped", "joined"]);
|
|
120
159
|
|
|
121
160
|
function parseAnglesJson(raw) {
|
|
@@ -189,9 +228,11 @@ export function parseWriteGateContextCliArgs(argv) {
|
|
|
189
228
|
rationale: { type: "string" },
|
|
190
229
|
branch: { type: "string" },
|
|
191
230
|
"touched-files": { type: "string" },
|
|
231
|
+
base: { type: "string" },
|
|
192
232
|
"acceptance-criteria": { type: "string" },
|
|
193
233
|
"validation-posture": { type: "string" },
|
|
194
234
|
"tmp-root": { type: "string" },
|
|
235
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
195
236
|
},
|
|
196
237
|
allowPositionals: true,
|
|
197
238
|
strict: false,
|
|
@@ -206,6 +247,7 @@ export function parseWriteGateContextCliArgs(argv) {
|
|
|
206
247
|
rationale: [],
|
|
207
248
|
branch: null,
|
|
208
249
|
touchedFiles: [],
|
|
250
|
+
base: null,
|
|
209
251
|
acceptanceCriteria: null,
|
|
210
252
|
validationPosture: null,
|
|
211
253
|
tmpRoot: "tmp",
|
|
@@ -256,6 +298,12 @@ export function parseWriteGateContextCliArgs(argv) {
|
|
|
256
298
|
options.touchedFiles = parseStringArrayJson(requireTokenValue(token, parseError), "--touched-files");
|
|
257
299
|
continue;
|
|
258
300
|
}
|
|
301
|
+
if (token.name === "base") {
|
|
302
|
+
const base = normalizeBaseRef(requireTokenValue(token, parseError));
|
|
303
|
+
if (!base) throw parseError("--base must be a plausible git ref (no leading '-', no '..')");
|
|
304
|
+
options.base = base;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
259
307
|
if (token.name === "acceptance-criteria") {
|
|
260
308
|
options.acceptanceCriteria = requireTokenValue(token, parseError).trim();
|
|
261
309
|
continue;
|
|
@@ -268,6 +316,7 @@ export function parseWriteGateContextCliArgs(argv) {
|
|
|
268
316
|
options.tmpRoot = requireTokenValue(token, parseError).trim();
|
|
269
317
|
continue;
|
|
270
318
|
}
|
|
319
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
271
320
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
272
321
|
}
|
|
273
322
|
const missing = ["repo", "pr", "gate", "headSha", "angles"]
|
|
@@ -437,6 +486,14 @@ export function buildGateContextArtifact(options) {
|
|
|
437
486
|
validationPosture: options.validationPosture ?? null,
|
|
438
487
|
},
|
|
439
488
|
};
|
|
489
|
+
// ADD (#1140): an explicit posture marker distinguishing a full build-once
|
|
490
|
+
// bundle from a thin briefing. Only set by the CLI (`--base` present → "base";
|
|
491
|
+
// absent → "none"); programmatic `buildGateContext`/`writeGateContext` callers
|
|
492
|
+
// that never pass `diffSource` leave the field out entirely, so this stays
|
|
493
|
+
// backward compatible with the artifact shape they already assert on.
|
|
494
|
+
if (typeof options.diffSource === "string" && options.diffSource.length > 0) {
|
|
495
|
+
artifact.scope.diffSource = options.diffSource;
|
|
496
|
+
}
|
|
440
497
|
// ADD (#895): the deterministic, neutral adjacent-code bundle. Only present
|
|
441
498
|
// when the context-builder computed it — keeps the artifact shape backward
|
|
442
499
|
// compatible for callers that build the artifact without an adjacency pass.
|
|
@@ -446,6 +503,150 @@ export function buildGateContextArtifact(options) {
|
|
|
446
503
|
return artifact;
|
|
447
504
|
}
|
|
448
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Resolve the diff-derived scope fields shared by `buildGateContext` (the
|
|
508
|
+
* programmatic `{ diff }` path) and the CLI `--base` path (#1140): the FULL
|
|
509
|
+
* diff persisted to a deterministic `.diff` file, the parsed `changedFiles`,
|
|
510
|
+
* and the neutral `adjacentCode` bundle built once from those changed files.
|
|
511
|
+
* Extracted to a single function so both callers stay in sync — see the
|
|
512
|
+
* doc comment on {@link buildGateContext} for the field semantics.
|
|
513
|
+
*
|
|
514
|
+
* @param {object} input
|
|
515
|
+
* @param {{ nameStatusOutput: string, diffOutput?: string }} [input.diff]
|
|
516
|
+
* @param {string} input.repo
|
|
517
|
+
* @param {number|string} input.pr
|
|
518
|
+
* @param {string} input.gate
|
|
519
|
+
* @param {string} input.headSha
|
|
520
|
+
* @param {string} input.tmpRoot
|
|
521
|
+
* @param {number} [input.maxFileBytes]
|
|
522
|
+
* @param {{ repoRoot: string }} opts
|
|
523
|
+
* @returns {Promise<{ diffPath: string|null, changedFiles: string[], adjacentCode: object|null }>}
|
|
524
|
+
*/
|
|
525
|
+
async function resolveDiffScope({ diff, repo, pr, gate, headSha, tmpRoot, maxFileBytes }, { repoRoot }) {
|
|
526
|
+
const diffOutput = diff?.diffOutput;
|
|
527
|
+
let diffPath = null;
|
|
528
|
+
const changedFiles = parseChangedFiles(diff?.nameStatusOutput);
|
|
529
|
+
if (typeof diffOutput === "string" && diffOutput.length > 0) {
|
|
530
|
+
diffPath = buildGateDiffPath({ repo, pr, gate, headSha, tmpRoot });
|
|
531
|
+
const fullDiffPath = path.resolve(repoRoot, diffPath);
|
|
532
|
+
try {
|
|
533
|
+
await mkdir(path.dirname(fullDiffPath), { recursive: true });
|
|
534
|
+
await writeFile(fullDiffPath, diffOutput.endsWith("\n") ? diffOutput : diffOutput + "\n", "utf8");
|
|
535
|
+
} catch (err) {
|
|
536
|
+
// Best-effort: a diff-file write failure (disk, permissions) must not block
|
|
537
|
+
// the context artifact. Degrade to diffPath=null; reviewers reconstruct the
|
|
538
|
+
// diff with `git diff`. changedFiles (from nameStatusOutput) is unaffected.
|
|
539
|
+
process.stderr.write(`[gate-context] full-diff capture failed (continuing without scope.diffPath): ${err?.message ?? err}\n`);
|
|
540
|
+
diffPath = null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Build the deterministic, neutral adjacent-code bundle ONCE (#895): for each
|
|
545
|
+
// changed source file, include its 1-hop import out-edges (files it imports)
|
|
546
|
+
// and in-edges (files that import it), with size guards (skip
|
|
547
|
+
// lockfiles/generated/binary/minified; cap per-file bytes; truncate the long
|
|
548
|
+
// tail) recorded in a stripped/truncated manifest. Every independent reviewer
|
|
549
|
+
// is seeded with this identical bundle instead of re-deriving it — work-dedup.
|
|
550
|
+
// Best-effort: bundle computation must never block the context artifact.
|
|
551
|
+
let adjacentCode = null;
|
|
552
|
+
if (changedFiles.length > 0) {
|
|
553
|
+
try {
|
|
554
|
+
adjacentCode = await buildAdjacentBundle({
|
|
555
|
+
changedFiles,
|
|
556
|
+
repoRoot,
|
|
557
|
+
maxFileBytes: typeof maxFileBytes === "number" && maxFileBytes > 0 ? maxFileBytes : DEFAULT_MAX_FILE_BYTES,
|
|
558
|
+
});
|
|
559
|
+
} catch (err) {
|
|
560
|
+
process.stderr.write(`[gate-context] adjacent-code bundle failed (continuing without adjacentCode): ${err?.message ?? err}\n`);
|
|
561
|
+
adjacentCode = null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return { diffPath, changedFiles, adjacentCode };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Capture a diff against `base` for the CLI `--base <ref>` flag: `git diff
|
|
570
|
+
* --name-status <base>...HEAD` and `git diff <base>...HEAD`, shaped as the
|
|
571
|
+
* `{ nameStatusOutput, diffOutput }` input `resolveDiffScope`/`buildGateContext`
|
|
572
|
+
* expect. Uses execFileSync with an argv array (no shell), so `base` cannot
|
|
573
|
+
* inject shell syntax; `parseWriteGateContextCliArgs` additionally validates it
|
|
574
|
+
* looks like a plausible ref before this runs.
|
|
575
|
+
*
|
|
576
|
+
* Split posture:
|
|
577
|
+
* - the `--name-status` capture is FAIL-CLOSED: it drives scope.changedFiles +
|
|
578
|
+
* the adjacentCode bundle, so an unresolvable base / non-git-repo throws and
|
|
579
|
+
* the CLI writes NO artifact (never a silent thin briefing).
|
|
580
|
+
* - the FULL diff capture is BEST-EFFORT: if it fails (output exceeds maxBuffer,
|
|
581
|
+
* a rendering error, etc.) we warn and return an EMPTY diffOutput. downstream
|
|
582
|
+
* resolveDiffScope then leaves scope.diffPath null while still populating
|
|
583
|
+
* changedFiles + adjacentCode; reviewers fall back to re-deriving the diff
|
|
584
|
+
* (the existing safety net). scope.diffSource stays "base" — it IS a
|
|
585
|
+
* base-derived bundle, just without the persisted full diff.
|
|
586
|
+
*
|
|
587
|
+
* @param {string} base
|
|
588
|
+
* @param {{ repoRoot: string, maxBuffer?: number }} opts — maxBuffer overridable for tests
|
|
589
|
+
* @returns {{ nameStatusOutput: string, diffOutput: string }}
|
|
590
|
+
*/
|
|
591
|
+
export function captureDiffFromBase(base, { repoRoot, maxBuffer = 64 * 1024 * 1024 }) {
|
|
592
|
+
const range = `${base}...HEAD`;
|
|
593
|
+
// Isolate the persisted .diff BYTES from ambient global/system gitconfig so
|
|
594
|
+
// every reviewer is seeded with an IDENTICAL neutral bundle (the whole point
|
|
595
|
+
// of build-once). Without this isolation, an operator/CI with
|
|
596
|
+
// color.diff=always, a configured diff.external/difftool, or non-default
|
|
597
|
+
// prefix settings would make scope.diffPath environment-dependent.
|
|
598
|
+
// color.ui=false + color.diff=false strip ANSI; core.pager=cat neutralizes a
|
|
599
|
+
// configured pager; diff.noprefix=false + diff.mnemonicPrefix=false pin the
|
|
600
|
+
// a/ b/ prefixes; the --no-ext-diff flag (below) disables any external diff
|
|
601
|
+
// driver (NOT `-c diff.external=`, which makes git try to exec the empty
|
|
602
|
+
// string and die).
|
|
603
|
+
// CROSS-environment reproducibility (#1168): the overrides above only pin
|
|
604
|
+
// bytes WITHIN a single run/machine — an operator/CI box with a contrary
|
|
605
|
+
// local diff.renames/diff.algorithm/diff.context/core.abbrev/core.autocrlf
|
|
606
|
+
// would still produce different bytes than another box on the SAME base and
|
|
607
|
+
// HEAD. diff.algorithm=myers + diff.context=3 + core.abbrev=12 +
|
|
608
|
+
// core.autocrlf=false pin the diff body/hunk headers/blob-id length/line
|
|
609
|
+
// endings. diff.renames=true additionally pins rename DETECTION itself: with
|
|
610
|
+
// it off, a moved-and-edited file shows as a straight D+A pair in
|
|
611
|
+
// `--name-status` instead of an R### pair, which changes the SET of names in
|
|
612
|
+
// scope.changedFiles (and therefore adjacentCode's membership) across
|
|
613
|
+
// environments, not just the diff body's bytes.
|
|
614
|
+
const isolation = [
|
|
615
|
+
"-c", "color.ui=false",
|
|
616
|
+
"-c", "color.diff=false",
|
|
617
|
+
"-c", "core.pager=cat",
|
|
618
|
+
"-c", "diff.noprefix=false",
|
|
619
|
+
"-c", "diff.mnemonicPrefix=false",
|
|
620
|
+
"-c", "diff.renames=true",
|
|
621
|
+
"-c", "diff.algorithm=myers",
|
|
622
|
+
"-c", "diff.context=3",
|
|
623
|
+
"-c", "core.abbrev=12",
|
|
624
|
+
"-c", "core.autocrlf=false",
|
|
625
|
+
];
|
|
626
|
+
const runGit = (args) => execFileSync("git", [...isolation, ...args], {
|
|
627
|
+
cwd: repoRoot,
|
|
628
|
+
encoding: "utf8",
|
|
629
|
+
maxBuffer,
|
|
630
|
+
});
|
|
631
|
+
// FAIL-CLOSED: --name-status feeds changedFiles + adjacentCode (the bundle's core).
|
|
632
|
+
let nameStatusOutput;
|
|
633
|
+
try {
|
|
634
|
+
nameStatusOutput = runGit(["diff", "--no-ext-diff", "--name-status", range]);
|
|
635
|
+
} catch (err) {
|
|
636
|
+
throw new Error(`git diff against --base ${JSON.stringify(base)} failed: ${err?.message ?? err}`);
|
|
637
|
+
}
|
|
638
|
+
// BEST-EFFORT: the full diff only feeds the persisted scope.diffPath; on
|
|
639
|
+
// failure degrade to an empty diffOutput (diffPath becomes null downstream).
|
|
640
|
+
let diffOutput = "";
|
|
641
|
+
try {
|
|
642
|
+
diffOutput = runGit(["diff", "--no-ext-diff", range]);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
process.stderr.write(`[gate-context] full-diff capture against --base ${JSON.stringify(base)} failed (continuing without scope.diffPath): ${err?.message ?? err}\n`);
|
|
645
|
+
diffOutput = "";
|
|
646
|
+
}
|
|
647
|
+
return { nameStatusOutput, diffOutput };
|
|
648
|
+
}
|
|
649
|
+
|
|
449
650
|
export async function writeGateContext(options, { repoRoot = process.cwd() } = {}) {
|
|
450
651
|
const contextPath = buildGateContextPath({
|
|
451
652
|
repo: options.repo,
|
|
@@ -503,58 +704,13 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
|
|
|
503
704
|
|
|
504
705
|
const tmpRoot = input.tmpRoot || "tmp";
|
|
505
706
|
|
|
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
|
-
}
|
|
707
|
+
// Diff-derived scope: persisted FULL diff (scope.diffPath), parsed
|
|
708
|
+
// scope.changedFiles, and the neutral adjacentCode bundle, all built ONCE by
|
|
709
|
+
// the shared resolveDiffScope helper (also used by the CLI --base path, #1140).
|
|
710
|
+
const { diffPath, changedFiles, adjacentCode } = await resolveDiffScope(
|
|
711
|
+
{ diff: input.diff, repo: input.repo, pr: input.pr, gate: input.gate, headSha: input.headSha, tmpRoot, maxFileBytes: input.maxFileBytes },
|
|
712
|
+
{ repoRoot },
|
|
713
|
+
);
|
|
558
714
|
|
|
559
715
|
const writeResult = await writeGateContext(
|
|
560
716
|
{
|
|
@@ -604,10 +760,17 @@ export async function readGateContext(input, { repoRoot = process.cwd() } = {})
|
|
|
604
760
|
}
|
|
605
761
|
}
|
|
606
762
|
|
|
607
|
-
|
|
763
|
+
/**
|
|
764
|
+
* CLI entrypoint. Exported (argv + repoRoot both overridable) so tests can
|
|
765
|
+
* drive the `--base` diff-capture path against a throwaway git repo fixture
|
|
766
|
+
* without spawning a subprocess.
|
|
767
|
+
* @param {string[]} [argv]
|
|
768
|
+
* @param {{ repoRoot?: string }} [runtime]
|
|
769
|
+
*/
|
|
770
|
+
export async function main(argv = process.argv.slice(2), { repoRoot = process.cwd() } = {}) {
|
|
608
771
|
let options;
|
|
609
772
|
try {
|
|
610
|
-
options = parseWriteGateContextCliArgs(
|
|
773
|
+
options = parseWriteGateContextCliArgs(argv);
|
|
611
774
|
} catch (error) {
|
|
612
775
|
process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
|
|
613
776
|
process.exitCode = 1;
|
|
@@ -618,8 +781,32 @@ async function main() {
|
|
|
618
781
|
return;
|
|
619
782
|
}
|
|
620
783
|
try {
|
|
621
|
-
|
|
622
|
-
|
|
784
|
+
// AC3 (#1140): the CLI only produces the full build-once bundle
|
|
785
|
+
// (scope.diffPath + scope.changedFiles + adjacentCode) when it has a
|
|
786
|
+
// resolvable diff source. --base is OPTIONAL rather than required — making
|
|
787
|
+
// it required would break any existing caller that only ever passed
|
|
788
|
+
// --touched-files (angles + scope already resolved elsewhere) — so a run
|
|
789
|
+
// without --base does not fail closed; it explicitly WARNS and records the
|
|
790
|
+
// thin-briefing posture in scope.diffSource so it is never mistaken for a
|
|
791
|
+
// full bundle. A run WITH --base that then fails to resolve (bad ref, not a
|
|
792
|
+
// git repo, etc.) DOES fail closed: the caller opted into the full bundle,
|
|
793
|
+
// so a silent thin degrade there would be a worse surprise than an error.
|
|
794
|
+
if (options.base) {
|
|
795
|
+
const diff = captureDiffFromBase(options.base, { repoRoot });
|
|
796
|
+
const scope = await resolveDiffScope(
|
|
797
|
+
{ diff, repo: options.repo, pr: options.pr, gate: options.gate, headSha: options.headSha, tmpRoot: options.tmpRoot || "tmp" },
|
|
798
|
+
{ repoRoot },
|
|
799
|
+
);
|
|
800
|
+
options.changedFiles = scope.changedFiles;
|
|
801
|
+
options.diffPath = scope.diffPath;
|
|
802
|
+
options.adjacentCode = scope.adjacentCode;
|
|
803
|
+
options.diffSource = "base";
|
|
804
|
+
} else {
|
|
805
|
+
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");
|
|
806
|
+
options.diffSource = "none";
|
|
807
|
+
}
|
|
808
|
+
const result = await writeGateContext(options, { repoRoot });
|
|
809
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
|
|
623
810
|
} catch (error) {
|
|
624
811
|
process.stderr.write(JSON.stringify({
|
|
625
812
|
ok: false,
|
|
@@ -4,6 +4,8 @@ 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 { provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
|
|
7
9
|
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
10
|
Write a durable <gate>-<headSha>.json log under deterministic tmp/ paths.
|
|
9
11
|
Required:
|
|
@@ -14,7 +16,11 @@ Required:
|
|
|
14
16
|
--verdict <clean|findings_present|blocked>
|
|
15
17
|
--findings <json> JSON array of finding objects with severity, disposition, angle, and summary
|
|
16
18
|
Optional:
|
|
19
|
+
--provenance <json> Fan-out provenance object: { distinctReviewers: <int>, perAngle: [{ angle, reviewer?, dispatchId?, model? }] }
|
|
20
|
+
distinctReviewers must be <= the distinct reviewers recorded in perAngle (perAngle non-empty when distinctReviewers > 0)
|
|
17
21
|
--tmp-root <path> Root tmp directory (default: tmp/)
|
|
22
|
+
|
|
23
|
+
${JQ_OUTPUT_USAGE}
|
|
18
24
|
`.trim();
|
|
19
25
|
function parseError(message) {
|
|
20
26
|
return Object.assign(new Error(message), { usage: USAGE });
|
|
@@ -89,6 +95,59 @@ function parseFindingsJson(raw) {
|
|
|
89
95
|
return entry;
|
|
90
96
|
});
|
|
91
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Validate + normalize the fan-out provenance object. Records how many distinct
|
|
100
|
+
* reviewer agents were dispatched (distinctReviewers) and per-angle dispatch
|
|
101
|
+
* provenance (perAngle). Rejects MALFORMED or self-INCONSISTENT provenance (bad
|
|
102
|
+
* shape, or a distinctReviewers claim not backed by recorded dispatch entries).
|
|
103
|
+
* This raises the bar; it does NOT make provenance un-forgeable — a determined
|
|
104
|
+
* single agent can still write an internally-consistent blob. Un-forgeable
|
|
105
|
+
* recording is the Pi-harness bridge (subagent tool at child depth). Returns the
|
|
106
|
+
* normalized object.
|
|
107
|
+
*/
|
|
108
|
+
export function parseProvenanceJson(raw) {
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(raw);
|
|
112
|
+
} catch {
|
|
113
|
+
throw parseError("--provenance must be valid JSON");
|
|
114
|
+
}
|
|
115
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
116
|
+
throw parseError("--provenance must be a JSON object");
|
|
117
|
+
}
|
|
118
|
+
if (!Number.isInteger(parsed.distinctReviewers) || parsed.distinctReviewers < 0) {
|
|
119
|
+
throw parseError("--provenance.distinctReviewers must be a non-negative integer");
|
|
120
|
+
}
|
|
121
|
+
if (!Array.isArray(parsed.perAngle)) {
|
|
122
|
+
throw parseError("--provenance.perAngle must be an array");
|
|
123
|
+
}
|
|
124
|
+
const perAngle = parsed.perAngle.map((a, i) => {
|
|
125
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) {
|
|
126
|
+
throw parseError(`--provenance.perAngle[${i}] must be an object`);
|
|
127
|
+
}
|
|
128
|
+
if (typeof a.angle !== "string" || a.angle.trim().length === 0) {
|
|
129
|
+
throw parseError(`--provenance.perAngle[${i}].angle is required`);
|
|
130
|
+
}
|
|
131
|
+
const entry = { angle: a.angle.trim() };
|
|
132
|
+
for (const key of ["reviewer", "dispatchId", "model"]) {
|
|
133
|
+
if (key in a) {
|
|
134
|
+
if (typeof a[key] !== "string" || a[key].trim().length === 0) {
|
|
135
|
+
throw parseError(`--provenance.perAngle[${i}].${key} must be a non-empty string`);
|
|
136
|
+
}
|
|
137
|
+
entry[key] = a[key].trim();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return entry;
|
|
141
|
+
});
|
|
142
|
+
const normalized = { distinctReviewers: parsed.distinctReviewers, perAngle };
|
|
143
|
+
// Internal-consistency gate: a distinctReviewers claim must be backed by that
|
|
144
|
+
// many distinct recorded reviewer identities (closes the {n, perAngle:[]} loophole).
|
|
145
|
+
const consistencyError = provenanceConsistencyError(normalized);
|
|
146
|
+
if (consistencyError) {
|
|
147
|
+
throw parseError(`--${consistencyError}`);
|
|
148
|
+
}
|
|
149
|
+
return normalized;
|
|
150
|
+
}
|
|
92
151
|
export function parseWriteGateFindingsLogCliArgs(argv) {
|
|
93
152
|
const { tokens } = parseArgs({
|
|
94
153
|
args: [...argv],
|
|
@@ -100,7 +159,9 @@ export function parseWriteGateFindingsLogCliArgs(argv) {
|
|
|
100
159
|
"head-sha": { type: "string" },
|
|
101
160
|
verdict: { type: "string" },
|
|
102
161
|
findings: { type: "string" },
|
|
162
|
+
provenance: { type: "string" },
|
|
103
163
|
"tmp-root": { type: "string" },
|
|
164
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
104
165
|
},
|
|
105
166
|
allowPositionals: true,
|
|
106
167
|
strict: false,
|
|
@@ -155,10 +216,15 @@ export function parseWriteGateFindingsLogCliArgs(argv) {
|
|
|
155
216
|
options.findings = requireTokenValue(token, parseError);
|
|
156
217
|
continue;
|
|
157
218
|
}
|
|
219
|
+
if (token.name === "provenance") {
|
|
220
|
+
options.provenance = requireTokenValue(token, parseError);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
158
223
|
if (token.name === "tmp-root") {
|
|
159
224
|
options.tmpRoot = requireTokenValue(token, parseError).trim();
|
|
160
225
|
continue;
|
|
161
226
|
}
|
|
227
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
162
228
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
163
229
|
}
|
|
164
230
|
const missing = ["repo", "pr", "gate", "headSha", "verdict", "findings"]
|
|
@@ -183,6 +249,7 @@ export function buildLogPath({ repo, pr, gate, headSha, tmpRoot }) {
|
|
|
183
249
|
}
|
|
184
250
|
export async function writeGateFindingsLog(options, { repoRoot = process.cwd() } = {}) {
|
|
185
251
|
const findings = parseFindingsJson(options.findings);
|
|
252
|
+
const provenance = options.provenance === undefined ? undefined : parseProvenanceJson(options.provenance);
|
|
186
253
|
const logPath = buildLogPath({
|
|
187
254
|
repo: options.repo,
|
|
188
255
|
pr: options.pr,
|
|
@@ -200,6 +267,13 @@ export async function writeGateFindingsLog(options, { repoRoot = process.cwd() }
|
|
|
200
267
|
loggedAt: new Date().toISOString(),
|
|
201
268
|
findings,
|
|
202
269
|
};
|
|
270
|
+
// Provenance is optional and additive: when absent the ledger writes exactly
|
|
271
|
+
// as before (no provenance key), preserving byte-identical output for the
|
|
272
|
+
// default / Claude-Code path. When present it records fan-out provenance for
|
|
273
|
+
// gates.requireFanoutProvenance enforcement.
|
|
274
|
+
if (provenance !== undefined) {
|
|
275
|
+
log.provenance = provenance;
|
|
276
|
+
}
|
|
203
277
|
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
204
278
|
await writeFile(fullPath, JSON.stringify(log, null, 2) + "\n", "utf8");
|
|
205
279
|
return { ok: true, path: logPath, log };
|
|
@@ -219,7 +293,7 @@ async function main() {
|
|
|
219
293
|
}
|
|
220
294
|
try {
|
|
221
295
|
const result = await writeGateFindingsLog(options);
|
|
222
|
-
process.
|
|
296
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
|
|
223
297
|
} catch (error) {
|
|
224
298
|
process.stderr.write(JSON.stringify({
|
|
225
299
|
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).
|