@miraland-labs/conduit-bridge 0.16.4 → 0.16.6
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/dist/attempt-worktree.js +27 -0
- package/dist/driver.js +15 -1
- package/dist/ensure-change-evidence.js +49 -0
- package/dist/ensure-land-commit.js +8 -1
- package/dist/ensure-normative-evidence.js +22 -0
- package/dist/execution.js +90 -44
- package/dist/git-witness.js +52 -0
- package/dist/integration-obligations.js +69 -0
- package/dist/land-contract.js +6 -1
- package/dist/normative-refs.js +207 -0
- package/package.json +1 -1
package/dist/attempt-worktree.js
CHANGED
|
@@ -53,8 +53,35 @@ export async function createAttemptWorktree(input) {
|
|
|
53
53
|
// "commit on the current branch" accurate. -b fails if the branch exists, which is the desired
|
|
54
54
|
// guard — createAttemptWorktree only ever runs for a fresh attempt id (resume reuses its worktree).
|
|
55
55
|
["-C", input.sourceWorkspace, "worktree", "add", "-b", attemptBranchName(input.attemptId), path, input.startCommit], { timeout: 60_000, maxBuffer: 2_000_000 });
|
|
56
|
+
await ensureWorktreeGitIdentity(path, input.sourceWorkspace);
|
|
56
57
|
return path;
|
|
57
58
|
}
|
|
59
|
+
/** Attempt worktrees are separate checkouts; agents cannot always run git config. Inherit identity from source or set Bridge defaults. */
|
|
60
|
+
export async function ensureWorktreeGitIdentity(worktree, sourceWorkspace) {
|
|
61
|
+
const git = async (...args) => execFileAsync("git", ["-C", worktree, ...args], {
|
|
62
|
+
timeout: 30_000,
|
|
63
|
+
maxBuffer: 1_000_000,
|
|
64
|
+
});
|
|
65
|
+
const readConfig = async (repo, key) => {
|
|
66
|
+
try {
|
|
67
|
+
const { stdout } = await execFileAsync("git", ["-C", repo, "config", "--get", key], {
|
|
68
|
+
timeout: 30_000,
|
|
69
|
+
maxBuffer: 1_000_000,
|
|
70
|
+
});
|
|
71
|
+
return stdout.trim();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
for (const key of ["user.email", "user.name"]) {
|
|
78
|
+
if (await readConfig(worktree, key))
|
|
79
|
+
continue;
|
|
80
|
+
const inherited = await readConfig(sourceWorkspace, key);
|
|
81
|
+
const fallback = key === "user.email" ? "conduit-bridge@miraland.io" : "Conduit Bridge";
|
|
82
|
+
await git("config", key, inherited || fallback);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
58
85
|
export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
|
|
59
86
|
// Settled repair releases are returned on every assignment poll until the local tree is gone.
|
|
60
87
|
// Make that at-least-once signal cheap and idempotent instead of invoking Git on a missing path.
|
package/dist/driver.js
CHANGED
|
@@ -99,6 +99,18 @@ export function buildAssignmentPrompt(context) {
|
|
|
99
99
|
}
|
|
100
100
|
if (changeScope?.length)
|
|
101
101
|
lines.push("", `CHANGE SCOPE — only modify paths under\n${changeScope.map((item) => `- ${item}`).join("\n")}`);
|
|
102
|
+
if (context.normativeRefs?.length) {
|
|
103
|
+
lines.push("", "NORMATIVE REFERENCES — read-only contract files; your implementation must align. Do not edit anything under .conduit/normative/.");
|
|
104
|
+
for (const ref of context.normativeRefs) {
|
|
105
|
+
lines.push(`- ${ref.label} (${ref.repository} @ ${ref.revision.slice(0, 12)})`);
|
|
106
|
+
for (const path of ref.workspace_paths)
|
|
107
|
+
lines.push(` - ${path}`);
|
|
108
|
+
const specRef = context.spec.normative_refs?.find((entry) => entry.label === ref.label);
|
|
109
|
+
if (specRef?.markers?.length) {
|
|
110
|
+
lines.push(` - Required markers in changed files: ${specRef.markers.join(", ")}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
102
114
|
if (spec.repository?.base_commit)
|
|
103
115
|
lines.push("", `The workspace is expected to be at or after base commit ${spec.repository.base_commit}.`);
|
|
104
116
|
if (context.currentHead)
|
|
@@ -127,7 +139,9 @@ export function buildAssignmentPrompt(context) {
|
|
|
127
139
|
mustCommit,
|
|
128
140
|
mustOpenPr,
|
|
129
141
|
});
|
|
130
|
-
lines.push("", "RULES", "- Stay within the change scope and boundaries.", ...(
|
|
142
|
+
lines.push("", "RULES", "- Stay within the change scope and boundaries.", ...(context.normativeRefs?.length
|
|
143
|
+
? ["- When normative references are listed, treat them as the authoritative contract for any cross-repo API, signature, or protocol shape they describe."]
|
|
144
|
+
: []), ...(languageRule ? [languageRule] : []), ...classRules, "- Never merge, deploy, force-push, push to protected branches, or touch production.", `- Hard-denied commands (all drivers): ${deniedCommands.join("; ")}.`, "- Do not invent evidence. Report unknown when you could not verify a criterion.",
|
|
131
145
|
// The reviewer requests changes when "verification output is only summarized (not verbatim)"
|
|
132
146
|
// (REVIEWER_PROMPT). Only the bounded-shell rule said to report real output, so publish_artifact
|
|
133
147
|
// — the class the reviewer scrutinises hardest — summarized, and every criterion came back
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { changedPathsSince, diffStatSince } from "./git-witness.js";
|
|
2
|
+
export function needsChangeEvidence(_report, spec, grants) {
|
|
3
|
+
if (!grants.includes("repo_write"))
|
|
4
|
+
return false;
|
|
5
|
+
return (spec.required_evidence ?? []).includes("change");
|
|
6
|
+
}
|
|
7
|
+
/** Reuse criteria the agent mapped onto change-kind evidence. */
|
|
8
|
+
export function criteriaForChangeEvidence(report) {
|
|
9
|
+
const mapped = new Set();
|
|
10
|
+
for (const item of report.evidence) {
|
|
11
|
+
if (item.kind !== "change")
|
|
12
|
+
continue;
|
|
13
|
+
for (const criterion of item.acceptance_criteria ?? []) {
|
|
14
|
+
if (criterion)
|
|
15
|
+
mapped.add(criterion);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return [...mapped];
|
|
19
|
+
}
|
|
20
|
+
export async function ensureChangeEvidence(input) {
|
|
21
|
+
if (!needsChangeEvidence(input.report, input.spec, input.grants))
|
|
22
|
+
return input.report;
|
|
23
|
+
const paths = input.changedPaths
|
|
24
|
+
?? (input.baseCommit ? await changedPathsSince(input.workspace, input.baseCommit) : null);
|
|
25
|
+
if (!paths?.length)
|
|
26
|
+
return input.report;
|
|
27
|
+
const stat = input.baseCommit ? await diffStatSince(input.workspace, input.baseCommit) : null;
|
|
28
|
+
const baseLabel = input.baseCommit?.slice(0, 12) ?? "base";
|
|
29
|
+
const details = [
|
|
30
|
+
`git diff --name-only ${baseLabel}..HEAD`,
|
|
31
|
+
...paths,
|
|
32
|
+
...(stat ? ["", stat] : []),
|
|
33
|
+
];
|
|
34
|
+
const withoutAgentChange = input.report.evidence.filter((item) => item.kind !== "change");
|
|
35
|
+
const criteria = criteriaForChangeEvidence(input.report);
|
|
36
|
+
return {
|
|
37
|
+
...input.report,
|
|
38
|
+
evidence: [
|
|
39
|
+
...withoutAgentChange,
|
|
40
|
+
{
|
|
41
|
+
kind: "change",
|
|
42
|
+
name: "Repository diff (Bridge witness)",
|
|
43
|
+
details,
|
|
44
|
+
acceptance_criteria: criteria,
|
|
45
|
+
witness: "bridge",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -36,6 +36,8 @@ async function uncommittedPaths(workspace) {
|
|
|
36
36
|
}
|
|
37
37
|
return paths;
|
|
38
38
|
}
|
|
39
|
+
const LAND_COMMIT_EMAIL = "conduit-bridge@miraland.io";
|
|
40
|
+
const LAND_COMMIT_NAME = "Conduit Bridge";
|
|
39
41
|
async function commitScopedPaths(workspace, paths) {
|
|
40
42
|
if (!paths.length)
|
|
41
43
|
throw new Error("Cannot land an empty path set");
|
|
@@ -43,7 +45,12 @@ async function commitScopedPaths(workspace, paths) {
|
|
|
43
45
|
timeout: 120_000,
|
|
44
46
|
maxBuffer: 8_000_000,
|
|
45
47
|
});
|
|
46
|
-
await execFileAsync("git", [
|
|
48
|
+
await execFileAsync("git", [
|
|
49
|
+
"-C", workspace,
|
|
50
|
+
"-c", `user.email=${LAND_COMMIT_EMAIL}`,
|
|
51
|
+
"-c", `user.name=${LAND_COMMIT_NAME}`,
|
|
52
|
+
"commit", "-m", "Conduit: land scoped agent changes",
|
|
53
|
+
], { timeout: 120_000, maxBuffer: 8_000_000 });
|
|
47
54
|
}
|
|
48
55
|
/**
|
|
49
56
|
* When the land contract applies, ensure the attempt branch has at least one commit after base.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function ensureNormativeEvidence(input) {
|
|
2
|
+
if (!input.materialized.length)
|
|
3
|
+
return input.report;
|
|
4
|
+
const without = input.report.evidence.filter((item) => item.name !== "Normative references (Bridge witness)");
|
|
5
|
+
const details = input.materialized.flatMap((entry) => [
|
|
6
|
+
`${entry.label} @ ${entry.repository} (${entry.revision.slice(0, 12)})`,
|
|
7
|
+
...entry.workspace_paths.map((path) => ` ${path}`),
|
|
8
|
+
]);
|
|
9
|
+
return {
|
|
10
|
+
...input.report,
|
|
11
|
+
evidence: [
|
|
12
|
+
...without,
|
|
13
|
+
{
|
|
14
|
+
kind: "documentation",
|
|
15
|
+
name: "Normative references (Bridge witness)",
|
|
16
|
+
details,
|
|
17
|
+
acceptance_criteria: [],
|
|
18
|
+
witness: "bridge",
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
}
|
package/dist/execution.js
CHANGED
|
@@ -16,6 +16,11 @@ import { AgentNoLandCommitError, agentNoLandCommitMessage, requiresLandCommit }
|
|
|
16
16
|
import { agentClaimsRepositoryWork, claimsVsGitMismatch, isLandTreeEmpty, reportClaimsRepositoryWork, } from "./land-git-reality.js";
|
|
17
17
|
const execFileAsync = promisify(execFile);
|
|
18
18
|
import { captureVerificationFailure, ensureTestEvidence } from "./ensure-test-evidence.js";
|
|
19
|
+
import { ensureChangeEvidence } from "./ensure-change-evidence.js";
|
|
20
|
+
import { ensureNormativeEvidence } from "./ensure-normative-evidence.js";
|
|
21
|
+
import { changedPathsSince } from "./git-witness.js";
|
|
22
|
+
import { assertNormativeRefsMaterialized, checkNormativeMarkers, materializeNormativeRefs, NORMATIVE_MARKER_PREFIX, NORMATIVE_REF_PREFIX, parseNormativeRefs, } from "./normative-refs.js";
|
|
23
|
+
import { checkIntegrationObligations, INTEGRATION_OBLIGATION_PREFIX, parseIntegrationObligations, } from "./integration-obligations.js";
|
|
19
24
|
/** Keep a Mac awake only while an assignment is active; display sleep remains allowed. */
|
|
20
25
|
export function startIdleSleepGuard(options = {}) {
|
|
21
26
|
if ((options.platform ?? process.platform) !== "darwin")
|
|
@@ -66,7 +71,7 @@ export function forgeTransportFailure(message) {
|
|
|
66
71
|
/** Environment faults that must Hold — mirror CP ENVIRONMENT_FAILURES message patterns. */
|
|
67
72
|
const FINALIZE_ENVIRONMENT_PATTERN = /base[_ ]not[_ ]ancestor|required base commit is not available|source[_ ]workspace[_ ]dirty|uncommitted changes|dirty workspace|workspace[_ ]head[_ ]changed|workspace[_ ]repository|workspace[_ ]unavailable|driver[_ ]not[_ ]authenticated|not logged in|no login|not authenticated|login required|no[_ ]online[_ ]driver|bridge[_ ]preflight|stale bridge|bridge version/i;
|
|
68
73
|
/** Delivery-report / grant / evidence contract defects (non-retryable rework). */
|
|
69
|
-
const FINALIZE_CONTRACT_PATTERN = /missing required evidence|without the repo_write grant|pull_request_url|Artifact delivery requires|outside the approved scope|head commit|test evidence must include|Met acceptance criteria|Agent report|Agent reported|Agent did not land repository changes|Delivery report/i;
|
|
74
|
+
const FINALIZE_CONTRACT_PATTERN = /missing required evidence|without the repo_write grant|pull_request_url|Artifact delivery requires|outside the approved scope|head commit|test evidence must include|Met acceptance criteria|Agent report|Agent reported|Agent did not land repository changes|Delivery report|Integration obligation not met|Normative reference unavailable|Normative marker not found/i;
|
|
70
75
|
/**
|
|
71
76
|
* Classify throws from ensureDeliveryPullRequest + validateDeliveryReport.
|
|
72
77
|
* Unknown non-contract faults must not be laundered as execution_contract_failed.
|
|
@@ -125,6 +130,18 @@ const taskSpecSchema = z.object({
|
|
|
125
130
|
instruction: z.string().optional(),
|
|
126
131
|
evidence_standard: z.unknown().optional(),
|
|
127
132
|
working_language: z.enum(["en", "zh"]).optional(),
|
|
133
|
+
integration_obligations: z.array(z.object({
|
|
134
|
+
kind: z.literal("symbol_referenced_from"),
|
|
135
|
+
symbol: z.string(),
|
|
136
|
+
from_globs: z.array(z.string()).optional(),
|
|
137
|
+
})).optional(),
|
|
138
|
+
normative_refs: z.array(z.object({
|
|
139
|
+
label: z.string(),
|
|
140
|
+
repository: z.string(),
|
|
141
|
+
paths: z.array(z.string()),
|
|
142
|
+
revision: z.string().optional(),
|
|
143
|
+
markers: z.array(z.string()).optional(),
|
|
144
|
+
})).optional(),
|
|
128
145
|
});
|
|
129
146
|
const executionContractSchema = z.object({
|
|
130
147
|
repository_fingerprint: z.string().nullable().optional().default(null),
|
|
@@ -612,6 +629,34 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
612
629
|
}
|
|
613
630
|
await client.updateAttempt(taskId, { worktreePath: attemptWorkspace });
|
|
614
631
|
}
|
|
632
|
+
let normativeMaterialized = [];
|
|
633
|
+
if (!diagnosis) {
|
|
634
|
+
const normativeRefs = parseNormativeRefs(spec.normative_refs);
|
|
635
|
+
if (normativeRefs.length) {
|
|
636
|
+
try {
|
|
637
|
+
normativeMaterialized = await materializeNormativeRefs({
|
|
638
|
+
sourceWorkspace: workspace,
|
|
639
|
+
attemptWorkspace,
|
|
640
|
+
refs: normativeRefs,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
645
|
+
const retryable = message.startsWith(NORMATIVE_REF_PREFIX)
|
|
646
|
+
&& /could not clone|could not checkout|timed out|Could not resolve|connection/i.test(message);
|
|
647
|
+
await queueTerminal(client, taskId, {
|
|
648
|
+
action: "fail",
|
|
649
|
+
body: {
|
|
650
|
+
error: message,
|
|
651
|
+
retryable,
|
|
652
|
+
idempotency_key: `bridge:normative-ref:${active.attemptId}`,
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
console.error(`Assignment ${taskId} normative reference materialization failed: ${redactSecrets(message)}`);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
615
660
|
// Diagnosis reads manifests and verification commands from the failed tree itself. The source
|
|
616
661
|
// checkout may be clean but stale relative to edits that caused the failure.
|
|
617
662
|
const attemptBrief = diagnosis
|
|
@@ -707,7 +752,19 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
707
752
|
failureOutput: diagnosticFailure,
|
|
708
753
|
verificationCommands: attemptBrief?.verification ?? [],
|
|
709
754
|
})
|
|
710
|
-
: buildAssignmentPrompt({
|
|
755
|
+
: buildAssignmentPrompt({
|
|
756
|
+
taskId,
|
|
757
|
+
objective: task.objective,
|
|
758
|
+
spec,
|
|
759
|
+
grants,
|
|
760
|
+
workspace: attemptWorkspace,
|
|
761
|
+
currentHead: worktreeStart,
|
|
762
|
+
reworkFeedback,
|
|
763
|
+
workPackage,
|
|
764
|
+
verificationCommands: attemptBrief?.verification ?? [],
|
|
765
|
+
executionClass,
|
|
766
|
+
normativeRefs: normativeMaterialized,
|
|
767
|
+
});
|
|
711
768
|
const resuming = Boolean(options.forceResumeSessionId);
|
|
712
769
|
await client.attemptRequest(taskId, "progress", {
|
|
713
770
|
phase: diagnosis ? "inspecting" : "changing",
|
|
@@ -1151,6 +1208,11 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1151
1208
|
title: task.objective,
|
|
1152
1209
|
repositoryFingerprint: executionContract.repository_fingerprint,
|
|
1153
1210
|
});
|
|
1211
|
+
// Ask git what changed before trusting what the report says changed.
|
|
1212
|
+
const startCommit = executionContract.requested_base_commit && executionContract.claimed_head
|
|
1213
|
+
? await resolveAttemptStartCommit(workspace, executionContract.requested_base_commit, executionContract.claimed_head)
|
|
1214
|
+
: null;
|
|
1215
|
+
const actualPaths = startCommit ? await changedPathsSince(attemptWorkspace, startCommit) : null;
|
|
1154
1216
|
// Mechanical test path: agent often forgets verbatim make test / npm test output.
|
|
1155
1217
|
report = await ensureTestEvidence({
|
|
1156
1218
|
workspace: attemptWorkspace,
|
|
@@ -1159,11 +1221,25 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1159
1221
|
grants,
|
|
1160
1222
|
verificationCommands: attemptBrief?.verification ?? [],
|
|
1161
1223
|
});
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1224
|
+
report = await ensureChangeEvidence({
|
|
1225
|
+
workspace: attemptWorkspace,
|
|
1226
|
+
baseCommit: startCommit,
|
|
1227
|
+
report,
|
|
1228
|
+
spec,
|
|
1229
|
+
grants,
|
|
1230
|
+
changedPaths: actualPaths,
|
|
1231
|
+
});
|
|
1232
|
+
const integrationCheck = await checkIntegrationObligations(attemptWorkspace, parseIntegrationObligations(spec.integration_obligations));
|
|
1233
|
+
if (!integrationCheck.ok) {
|
|
1234
|
+
throw new Error(`${INTEGRATION_OBLIGATION_PREFIX}: ${integrationCheck.failures.join("; ")}`);
|
|
1235
|
+
}
|
|
1236
|
+
const normativeRefs = parseNormativeRefs(spec.normative_refs);
|
|
1237
|
+
const markerCheck = await checkNormativeMarkers(attemptWorkspace, normativeRefs, actualPaths);
|
|
1238
|
+
if (!markerCheck.ok) {
|
|
1239
|
+
throw new Error(`${NORMATIVE_MARKER_PREFIX}: ${markerCheck.failures.join("; ")}`);
|
|
1240
|
+
}
|
|
1241
|
+
assertNormativeRefsMaterialized(normativeRefs, normativeMaterialized);
|
|
1242
|
+
report = ensureNormativeEvidence({ report, spec, materialized: normativeMaterialized });
|
|
1167
1243
|
validateDeliveryReport(report, spec, grants, actualPaths ?? undefined);
|
|
1168
1244
|
}
|
|
1169
1245
|
catch (error) {
|
|
@@ -1582,43 +1658,9 @@ function referenceKind(kind) {
|
|
|
1582
1658
|
}
|
|
1583
1659
|
/**
|
|
1584
1660
|
* What this attempt actually changed, according to git rather than to the agent's prose.
|
|
1585
|
-
*
|
|
1586
|
-
* The scope check used to compare approved paths against strings the agent wrote about itself, so a
|
|
1587
|
-
* run that edited an approved file and named it "tools.ts" failed the contract and spent an attempt
|
|
1588
|
-
* on a path format. Git knows the truth, and Bridge holds the worktree: this is both stricter (a
|
|
1589
|
-
* change cannot be omitted from the report to escape the check) and more forgiving (how the agent
|
|
1590
|
-
* phrases a path stops mattering).
|
|
1591
|
-
*
|
|
1592
|
-
* Null when git cannot answer — an unknown must not fail a delivery, so the caller falls back to the
|
|
1593
|
-
* report exactly as before.
|
|
1661
|
+
* Implemented in git-witness.ts; re-exported for control-plane test pins and investigation.
|
|
1594
1662
|
*/
|
|
1595
|
-
export
|
|
1596
|
-
const run = async (args) => {
|
|
1597
|
-
try {
|
|
1598
|
-
const { stdout } = await execFileAsync("git", ["-C", worktree, ...args], { timeout: 30_000, maxBuffer: 8_000_000 });
|
|
1599
|
-
return stdout;
|
|
1600
|
-
}
|
|
1601
|
-
catch {
|
|
1602
|
-
return null;
|
|
1603
|
-
}
|
|
1604
|
-
};
|
|
1605
|
-
const committed = await run(["diff", "--name-only", `${startCommit}..HEAD`]);
|
|
1606
|
-
if (committed === null)
|
|
1607
|
-
return null;
|
|
1608
|
-
const paths = new Set(committed.split("\n").map((line) => line.trim()).filter(Boolean));
|
|
1609
|
-
// Work the agent left uncommitted still lands in the delivery for lands=false packages.
|
|
1610
|
-
const pending = await run(["status", "--porcelain"]);
|
|
1611
|
-
for (const line of (pending ?? "").split("\n")) {
|
|
1612
|
-
const entry = line.slice(3).trim();
|
|
1613
|
-
if (!entry)
|
|
1614
|
-
continue;
|
|
1615
|
-
// A rename reports "old -> new"; the new path is the one that exists.
|
|
1616
|
-
const path = entry.includes(" -> ") ? entry.slice(entry.lastIndexOf(" -> ") + 4).trim() : entry;
|
|
1617
|
-
if (path)
|
|
1618
|
-
paths.add(path.replace(/^"|"$/g, ""));
|
|
1619
|
-
}
|
|
1620
|
-
return [...paths];
|
|
1621
|
-
}
|
|
1663
|
+
export { changedPathsSince } from "./git-witness.js";
|
|
1622
1664
|
export function validateChangeScope(report, changeScope, options = {}) {
|
|
1623
1665
|
if (!changeScope.length)
|
|
1624
1666
|
return;
|
|
@@ -1662,7 +1704,11 @@ function pathMatchesScope(path, scope) {
|
|
|
1662
1704
|
const prefix = normalized.slice(0, -3);
|
|
1663
1705
|
return path === prefix || path.startsWith(`${prefix}/`);
|
|
1664
1706
|
}
|
|
1665
|
-
|
|
1707
|
+
if (path === normalized)
|
|
1708
|
+
return true;
|
|
1709
|
+
if (!normalized.includes("*") && path.startsWith(`${normalized}/`))
|
|
1710
|
+
return true;
|
|
1711
|
+
return false;
|
|
1666
1712
|
}
|
|
1667
1713
|
function retainDiagnosticWorktree(response) {
|
|
1668
1714
|
// Lease expiry is reconciled by the CP into another diagnostic attempt. The old terminal cannot
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git facts Bridge witnesses during finalize — not agent prose.
|
|
3
|
+
*/
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
async function gitLines(worktree, args) {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await execFileAsync("git", ["-C", worktree, ...args], {
|
|
10
|
+
timeout: 30_000,
|
|
11
|
+
maxBuffer: 8_000_000,
|
|
12
|
+
});
|
|
13
|
+
return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Paths changed on the attempt branch since `startCommit`, including uncommitted work.
|
|
21
|
+
* Null when git cannot answer — callers must not treat null as failure.
|
|
22
|
+
*/
|
|
23
|
+
export async function changedPathsSince(worktree, startCommit) {
|
|
24
|
+
const committed = await gitLines(worktree, ["diff", "--name-only", `${startCommit}..HEAD`]);
|
|
25
|
+
if (committed === null)
|
|
26
|
+
return null;
|
|
27
|
+
const paths = new Set(committed);
|
|
28
|
+
const pending = await gitLines(worktree, ["status", "--porcelain"]);
|
|
29
|
+
for (const line of pending ?? []) {
|
|
30
|
+
const entry = line.slice(3).trim();
|
|
31
|
+
if (!entry)
|
|
32
|
+
continue;
|
|
33
|
+
const path = entry.includes(" -> ") ? entry.slice(entry.lastIndexOf(" -> ") + 4).trim() : entry;
|
|
34
|
+
if (path)
|
|
35
|
+
paths.add(path.replace(/^"|"$/g, ""));
|
|
36
|
+
}
|
|
37
|
+
return [...paths];
|
|
38
|
+
}
|
|
39
|
+
/** Short stat summary for change evidence artifacts. */
|
|
40
|
+
export async function diffStatSince(worktree, startCommit) {
|
|
41
|
+
try {
|
|
42
|
+
const { stdout } = await execFileAsync("git", ["-C", worktree, "diff", "--stat", `${startCommit}..HEAD`], {
|
|
43
|
+
timeout: 30_000,
|
|
44
|
+
maxBuffer: 2_000_000,
|
|
45
|
+
});
|
|
46
|
+
const text = stdout.trim();
|
|
47
|
+
return text || null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration obligations — planner-authored wiring checks, enforced at Bridge finalize.
|
|
3
|
+
*
|
|
4
|
+
* Generic across stacks: a new implementation must be referenced from runtime entrypoints,
|
|
5
|
+
* not only defined in an isolated module.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
export const integrationObligationSchema = z.discriminatedUnion("kind", [
|
|
12
|
+
z.object({
|
|
13
|
+
kind: z.literal("symbol_referenced_from"),
|
|
14
|
+
symbol: z.string().trim().min(1).max(200),
|
|
15
|
+
from_globs: z.array(z.string().trim().min(1).max(200)).max(20).optional(),
|
|
16
|
+
}),
|
|
17
|
+
]);
|
|
18
|
+
/** Default runtime entrypoint globs when the planner omits from_globs. */
|
|
19
|
+
export const DEFAULT_ENTRYPOINT_GLOBS = [
|
|
20
|
+
"**/main.rs",
|
|
21
|
+
"**/lib.rs",
|
|
22
|
+
"**/mod.rs",
|
|
23
|
+
"**/index.ts",
|
|
24
|
+
"**/index.tsx",
|
|
25
|
+
"**/index.js",
|
|
26
|
+
"**/App.tsx",
|
|
27
|
+
"**/app/main.py",
|
|
28
|
+
"**/main.go",
|
|
29
|
+
];
|
|
30
|
+
export function parseIntegrationObligations(value) {
|
|
31
|
+
if (!Array.isArray(value))
|
|
32
|
+
return [];
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const entry of value) {
|
|
35
|
+
const parsed = integrationObligationSchema.safeParse(entry);
|
|
36
|
+
if (parsed.success)
|
|
37
|
+
out.push(parsed.data);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
async function symbolReferencedFrom(workspace, symbol, globs) {
|
|
42
|
+
const pathspecs = globs.map((glob) => `:(glob)${glob}`);
|
|
43
|
+
try {
|
|
44
|
+
const { stdout } = await execFileAsync("git", ["-C", workspace, "grep", "-l", "-F", symbol, "--", ...pathspecs], { timeout: 30_000, maxBuffer: 4_000_000 });
|
|
45
|
+
return stdout.trim().length > 0;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const code = error.code;
|
|
49
|
+
if (code === 1 || code === "1")
|
|
50
|
+
return false;
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function checkIntegrationObligations(workspace, obligations) {
|
|
55
|
+
if (!obligations.length)
|
|
56
|
+
return { ok: true };
|
|
57
|
+
const failures = [];
|
|
58
|
+
for (const obligation of obligations) {
|
|
59
|
+
if (obligation.kind === "symbol_referenced_from") {
|
|
60
|
+
const globs = obligation.from_globs?.length ? obligation.from_globs : [...DEFAULT_ENTRYPOINT_GLOBS];
|
|
61
|
+
const referenced = await symbolReferencedFrom(workspace, obligation.symbol, globs);
|
|
62
|
+
if (!referenced) {
|
|
63
|
+
failures.push(`symbol "${obligation.symbol}" is not referenced from any entrypoint (${globs.join(", ")})`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return failures.length ? { ok: false, failures } : { ok: true };
|
|
68
|
+
}
|
|
69
|
+
export const INTEGRATION_OBLIGATION_PREFIX = "Integration obligation not met";
|
package/dist/land-contract.js
CHANGED
|
@@ -38,7 +38,12 @@ export function pathMatchesScope(path, scope) {
|
|
|
38
38
|
const prefix = normalized.slice(0, -3);
|
|
39
39
|
return path === prefix || path.startsWith(`${prefix}/`);
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
if (path === normalized)
|
|
42
|
+
return true;
|
|
43
|
+
// Directory scopes without /** still cover nested files (oracle-file-delivery/src/fetcher.rs).
|
|
44
|
+
if (!normalized.includes("*") && path.startsWith(`${normalized}/`))
|
|
45
|
+
return true;
|
|
46
|
+
return false;
|
|
42
47
|
}
|
|
43
48
|
export function filterPathsInScope(paths, changeScope) {
|
|
44
49
|
if (!changeScope.length)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Materialize planner-authored normative references into the attempt workspace.
|
|
3
|
+
*
|
|
4
|
+
* Cached under {sourceWorkspace}/.conduit/normative-cache/ so sibling-repo specs are
|
|
5
|
+
* fetched once per machine checkout, not once per attempt.
|
|
6
|
+
*/
|
|
7
|
+
import { access, cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { constants } from "node:fs";
|
|
9
|
+
import { dirname, join, relative } from "node:path";
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { normalizeRepositoryUrl } from "./brief.js";
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
export const normativeRefSchema = z.object({
|
|
16
|
+
label: z.string().trim().min(1).max(120),
|
|
17
|
+
repository: z.string().trim().min(3).max(500),
|
|
18
|
+
paths: z.array(z.string().trim().min(1).max(500)).min(1).max(20),
|
|
19
|
+
revision: z.string().trim().min(1).max(120).optional(),
|
|
20
|
+
markers: z.array(z.string().trim().min(1).max(500)).max(20).optional(),
|
|
21
|
+
});
|
|
22
|
+
export const NORMATIVE_REF_PREFIX = "Normative reference unavailable";
|
|
23
|
+
export const NORMATIVE_MARKER_PREFIX = "Normative marker not found";
|
|
24
|
+
export function parseNormativeRefs(value) {
|
|
25
|
+
if (!Array.isArray(value))
|
|
26
|
+
return [];
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const entry of value) {
|
|
29
|
+
const parsed = normativeRefSchema.safeParse(entry);
|
|
30
|
+
if (parsed.success)
|
|
31
|
+
out.push(parsed.data);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function fingerprint(repository) {
|
|
36
|
+
return normalizeRepositoryUrl(repository.includes("://") ? repository : `https://${repository}`);
|
|
37
|
+
}
|
|
38
|
+
function sanitizeLabel(label) {
|
|
39
|
+
const cleaned = label.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
40
|
+
return cleaned || "ref";
|
|
41
|
+
}
|
|
42
|
+
function validateNormativePath(path) {
|
|
43
|
+
const normalized = path.replaceAll("\\", "/");
|
|
44
|
+
if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) {
|
|
45
|
+
throw new Error(`${NORMATIVE_REF_PREFIX}: invalid path ${path}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function cacheDir(sourceWorkspace, repository) {
|
|
49
|
+
return join(sourceWorkspace, ".conduit", "normative-cache", fingerprint(repository).replaceAll("/", "__"));
|
|
50
|
+
}
|
|
51
|
+
function cloneUrl(repository) {
|
|
52
|
+
return `https://${fingerprint(repository)}.git`;
|
|
53
|
+
}
|
|
54
|
+
async function pathExists(path) {
|
|
55
|
+
try {
|
|
56
|
+
await access(path, constants.F_OK);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function readPinnedRevision(repoDir) {
|
|
64
|
+
try {
|
|
65
|
+
const { stdout } = await execFileAsync("git", ["-C", repoDir, "rev-parse", "HEAD"], {
|
|
66
|
+
timeout: 30_000,
|
|
67
|
+
maxBuffer: 256_000,
|
|
68
|
+
});
|
|
69
|
+
return stdout.trim() || null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function ensureRepoCache(sourceWorkspace, ref) {
|
|
76
|
+
const dir = cacheDir(sourceWorkspace, ref.repository);
|
|
77
|
+
const wantedRevision = ref.revision ?? "HEAD";
|
|
78
|
+
const marker = join(dir, ".conduit-normative-revision");
|
|
79
|
+
let cachedRevision = null;
|
|
80
|
+
if (await pathExists(marker)) {
|
|
81
|
+
cachedRevision = (await readFile(marker, "utf8")).trim() || null;
|
|
82
|
+
}
|
|
83
|
+
const repoReady = await pathExists(join(dir, ".git"));
|
|
84
|
+
if (!repoReady) {
|
|
85
|
+
await mkdir(dirname(dir), { recursive: true });
|
|
86
|
+
const cloneArgs = ["clone", "--depth", "1"];
|
|
87
|
+
if (ref.revision && !/^[0-9a-f]{40}$/i.test(ref.revision)) {
|
|
88
|
+
cloneArgs.push("--branch", ref.revision);
|
|
89
|
+
}
|
|
90
|
+
cloneArgs.push(cloneUrl(ref.repository), dir);
|
|
91
|
+
try {
|
|
92
|
+
await execFileAsync("git", cloneArgs, { timeout: 180_000, maxBuffer: 4_000_000 });
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
96
|
+
throw new Error(`${NORMATIVE_REF_PREFIX}: could not clone ${fingerprint(ref.repository)} (${message})`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (ref.revision && cachedRevision !== wantedRevision) {
|
|
100
|
+
try {
|
|
101
|
+
if (/^[0-9a-f]{40}$/i.test(ref.revision)) {
|
|
102
|
+
await execFileAsync("git", ["-C", dir, "fetch", "--depth", "1", "origin", ref.revision], {
|
|
103
|
+
timeout: 120_000,
|
|
104
|
+
maxBuffer: 4_000_000,
|
|
105
|
+
});
|
|
106
|
+
await execFileAsync("git", ["-C", dir, "checkout", "--detach", "FETCH_HEAD"], {
|
|
107
|
+
timeout: 60_000,
|
|
108
|
+
maxBuffer: 1_000_000,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
await execFileAsync("git", ["-C", dir, "fetch", "--depth", "1", "origin", ref.revision], {
|
|
113
|
+
timeout: 120_000,
|
|
114
|
+
maxBuffer: 4_000_000,
|
|
115
|
+
});
|
|
116
|
+
await execFileAsync("git", ["-C", dir, "checkout", "--detach", "FETCH_HEAD"], {
|
|
117
|
+
timeout: 60_000,
|
|
118
|
+
maxBuffer: 1_000_000,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
throw new Error(`${NORMATIVE_REF_PREFIX}: could not checkout ${ref.revision} in ${fingerprint(ref.repository)} (${message})`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const revision = (await readPinnedRevision(dir)) ?? wantedRevision;
|
|
128
|
+
await writeFile(marker, `${revision}\n`, "utf8");
|
|
129
|
+
return { dir, revision };
|
|
130
|
+
}
|
|
131
|
+
async function copyNormativePath(cacheRepo, attemptWorkspace, label, path) {
|
|
132
|
+
validateNormativePath(path);
|
|
133
|
+
const src = join(cacheRepo, path);
|
|
134
|
+
if (!(await pathExists(src))) {
|
|
135
|
+
throw new Error(`${NORMATIVE_REF_PREFIX}: ${path} not found in ${cacheRepo}`);
|
|
136
|
+
}
|
|
137
|
+
const dest = join(attemptWorkspace, ".conduit", "normative", sanitizeLabel(label), path);
|
|
138
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
139
|
+
await cp(src, dest, { recursive: true });
|
|
140
|
+
return relative(attemptWorkspace, dest).replaceAll("\\", "/");
|
|
141
|
+
}
|
|
142
|
+
export async function materializeNormativeRefs(input) {
|
|
143
|
+
if (!input.refs.length)
|
|
144
|
+
return [];
|
|
145
|
+
const materialized = [];
|
|
146
|
+
for (const ref of input.refs) {
|
|
147
|
+
const { dir, revision } = await ensureRepoCache(input.sourceWorkspace, ref);
|
|
148
|
+
const workspace_paths = [];
|
|
149
|
+
for (const path of ref.paths) {
|
|
150
|
+
workspace_paths.push(await copyNormativePath(dir, input.attemptWorkspace, ref.label, path));
|
|
151
|
+
}
|
|
152
|
+
materialized.push({
|
|
153
|
+
label: ref.label,
|
|
154
|
+
repository: fingerprint(ref.repository),
|
|
155
|
+
revision,
|
|
156
|
+
workspace_paths,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return materialized;
|
|
160
|
+
}
|
|
161
|
+
export function assertNormativeRefsMaterialized(refs, materialized) {
|
|
162
|
+
if (!refs.length)
|
|
163
|
+
return;
|
|
164
|
+
if (materialized.length !== refs.length) {
|
|
165
|
+
throw new Error(`${NORMATIVE_REF_PREFIX}: expected ${refs.length} materialized references, got ${materialized.length}`);
|
|
166
|
+
}
|
|
167
|
+
for (const entry of materialized) {
|
|
168
|
+
if (!entry.workspace_paths.length) {
|
|
169
|
+
throw new Error(`${NORMATIVE_REF_PREFIX}: ${entry.label} has no workspace paths`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function markerPresent(workspace, marker, paths) {
|
|
174
|
+
const args = ["-C", workspace, "grep", "-l", "-F", marker];
|
|
175
|
+
if (paths?.length)
|
|
176
|
+
args.push("--", ...paths);
|
|
177
|
+
try {
|
|
178
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
179
|
+
timeout: 30_000,
|
|
180
|
+
maxBuffer: 4_000_000,
|
|
181
|
+
});
|
|
182
|
+
return stdout.trim().length > 0;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
const code = error.code;
|
|
186
|
+
if (code === 1 || code === "1")
|
|
187
|
+
return false;
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
export async function checkNormativeMarkers(workspace, refs, changedPaths) {
|
|
192
|
+
const scoped = refs.filter((ref) => ref.markers?.length);
|
|
193
|
+
if (!scoped.length)
|
|
194
|
+
return { ok: true };
|
|
195
|
+
const searchPaths = changedPaths?.length ? changedPaths : null;
|
|
196
|
+
const failures = [];
|
|
197
|
+
for (const ref of scoped) {
|
|
198
|
+
for (const marker of ref.markers ?? []) {
|
|
199
|
+
const found = await markerPresent(workspace, marker, searchPaths);
|
|
200
|
+
if (!found) {
|
|
201
|
+
const scope = searchPaths ? "changed files" : "workspace";
|
|
202
|
+
failures.push(`marker "${marker}" from ${ref.label} not found in ${scope}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return failures.length ? { ok: false, failures } : { ok: true };
|
|
207
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.6",
|
|
4
4
|
"description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|