@yagni-app/code-staging 1.2.4-staging.1803.1 → 1.2.4-staging.1821.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/dist/agentSources.d.ts +78 -0
- package/dist/agentSources.js +194 -0
- package/dist/claudeCompat.d.ts +83 -0
- package/dist/claudeCompat.js +234 -14
- package/dist/claudePlugins.d.ts +15 -1
- package/dist/claudePlugins.js +12 -4
- package/dist/extension/pipeline/headlessGo.d.ts +11 -0
- package/dist/extension/pipeline/headlessGo.js +21 -0
- package/dist/extension/pipeline/invocation.d.ts +1 -0
- package/dist/extension/pipeline/invocation.js +8 -1
- package/dist/extension/pipeline/missionChecks.d.ts +66 -0
- package/dist/extension/pipeline/missionChecks.js +197 -0
- package/dist/extension/pipeline/orchestrator.d.ts +8 -0
- package/dist/extension/pipeline/orchestrator.js +9 -7
- package/dist/extension/pipeline/runner.d.ts +6 -0
- package/dist/extension/pipeline/runner.js +5 -1
- package/dist/extension/pipeline/verify.d.ts +60 -1
- package/dist/extension/pipeline/verify.js +320 -9
- package/dist/goHeadless.d.ts +24 -0
- package/dist/goHeadless.js +107 -1
- package/package.json +2 -2
|
@@ -54,8 +54,10 @@ import { readFileSync } from "node:fs";
|
|
|
54
54
|
// backslashed strings that no longer compare equal to the git-derived repoRoot
|
|
55
55
|
// and would break the walk-up termination check.
|
|
56
56
|
import { basename, dirname, join } from "node:path/posix";
|
|
57
|
+
import { readToolchainFile } from "../toolchain/spawnHook.js";
|
|
57
58
|
import { killTreeSync, trackChild } from "./childRegistry.js";
|
|
58
59
|
import { claimCovers } from "./fanout.js";
|
|
60
|
+
import { ciJobInScope } from "./missionChecks.js";
|
|
59
61
|
import { composeAbortSignal } from "./resilience.js";
|
|
60
62
|
import { scrubSecrets } from "./scrubSecrets.js";
|
|
61
63
|
import { snapshotWorkspace } from "./workspace.js";
|
|
@@ -67,6 +69,21 @@ import { snapshotWorkspace } from "./workspace.js";
|
|
|
67
69
|
export const VERIFY_TIMEOUT_MS = 20 * 60_000;
|
|
68
70
|
/** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. Ten minutes until 2026-10-09. */
|
|
69
71
|
export const VERIFY_TEST_TIMEOUT_MS = 20 * 60_000;
|
|
72
|
+
/**
|
|
73
|
+
* The repository-checks half (the plan's approved checks and the pull
|
|
74
|
+
* request's CI steps) across a whole build: the gate runs after implement,
|
|
75
|
+
* after each fix turn and every review round, so a cap per call alone could
|
|
76
|
+
* add an hour to a build.
|
|
77
|
+
*/
|
|
78
|
+
export const REPO_CHECKS_BUILD_BUDGET_MS = 30 * 60_000;
|
|
79
|
+
/** The same half's cap in one gate call, inside the run budget's verify allowance. */
|
|
80
|
+
export const REPO_CHECKS_CALL_BUDGET_MS = 15 * 60_000;
|
|
81
|
+
/** One approved check's or CI step's cap. */
|
|
82
|
+
export const REPO_CHECK_TIMEOUT_MS = 10 * 60_000;
|
|
83
|
+
/** Below this much budget left, the next repository check is not started. */
|
|
84
|
+
const REPO_CHECK_MIN_START_MS = 5_000;
|
|
85
|
+
/** Cannot-run entries named in the note before "and N more". */
|
|
86
|
+
const MAX_NAMED_CANNOT_RUN = 10;
|
|
70
87
|
/** Cap on located findings parsed from one failing run, and on the opaque-tail message. */
|
|
71
88
|
const MAX_LOCATED_FINDINGS = 25;
|
|
72
89
|
const MAX_TAIL_CHARS = 1000;
|
|
@@ -129,14 +146,17 @@ export function isFannedOutScript(body) {
|
|
|
129
146
|
* Parse `git status --porcelain` into repo-relative changed paths. Each line is
|
|
130
147
|
* `XY <path>`; we drop the 2-char XY status + its separating space, and for a
|
|
131
148
|
* rename/copy (`R old -> new`) take the destination. Pure; the impure git
|
|
132
|
-
* shell-out is {@link snapshotWorkspace}
|
|
149
|
+
* shell-out is {@link snapshotWorkspace}, whose status is trimmed as a whole:
|
|
150
|
+
* a first line ` M path` arrives as `M path`. A whole line always has a space
|
|
151
|
+
* third, so a line without one lost its leading space and its path starts one
|
|
152
|
+
* column earlier.
|
|
133
153
|
*/
|
|
134
154
|
export function parseChangedPaths(status) {
|
|
135
155
|
const paths = [];
|
|
136
156
|
for (const raw of status.split("\n")) {
|
|
137
|
-
if (raw.length <
|
|
138
|
-
continue; // need at least "
|
|
139
|
-
const entry = raw.slice(3)
|
|
157
|
+
if (raw.length < 3)
|
|
158
|
+
continue; // need at least "X p"
|
|
159
|
+
const entry = raw[2] === " " ? raw.slice(3) : raw.slice(2);
|
|
140
160
|
const arrow = entry.indexOf(" -> ");
|
|
141
161
|
const path = (arrow >= 0 ? entry.slice(arrow + 4) : entry).trim();
|
|
142
162
|
if (path)
|
|
@@ -640,6 +660,70 @@ export function buildTestEnv(source = process.env) {
|
|
|
640
660
|
env.CI = "true";
|
|
641
661
|
return env;
|
|
642
662
|
}
|
|
663
|
+
/**
|
|
664
|
+
* The secrets a repository check never sees: a copy of the host's
|
|
665
|
+
* `VERIFICATION_SECRET_ENV_VARS` in
|
|
666
|
+
* `packages/backend/src/integrations/e2b/supervisedCandidateSession.ts`
|
|
667
|
+
* (`YAGNI_*` goes with the test env). This package cannot import the backend:
|
|
668
|
+
* a change to either list is a change to both.
|
|
669
|
+
*/
|
|
670
|
+
const REPO_CHECK_SECRET_KEYS = [
|
|
671
|
+
"OPENAI_API_KEY",
|
|
672
|
+
"ANTHROPIC_API_KEY",
|
|
673
|
+
"FIREWORKS_API_KEY",
|
|
674
|
+
"BASETEN_API_KEY",
|
|
675
|
+
"GITHUB_TOKEN",
|
|
676
|
+
"GH_TOKEN",
|
|
677
|
+
];
|
|
678
|
+
/**
|
|
679
|
+
* The environment an approved check or CI step runs under: the test half's
|
|
680
|
+
* (no `YAGNI_*`, no NODE_OPTIONS, CI on) minus the secrets the host strips
|
|
681
|
+
* from its own check runs, on the repository's toolchain when the build named
|
|
682
|
+
* one (the same PATH and variables the model's shell gets), then the step's
|
|
683
|
+
* own literal variables.
|
|
684
|
+
*/
|
|
685
|
+
export function buildRepoCheckEnv(source = process.env, toolchain = null, stepEnv = {}) {
|
|
686
|
+
const env = buildTestEnv(source);
|
|
687
|
+
for (const key of REPO_CHECK_SECRET_KEYS)
|
|
688
|
+
delete env[key];
|
|
689
|
+
const withToolchain = toolchain ? { ...toolchain.env, ...env, PATH: toolchain.path } : env;
|
|
690
|
+
return { ...withToolchain, ...stepEnv, CI: "true" };
|
|
691
|
+
}
|
|
692
|
+
/** One repository command as the gate runs it: through bash, as Actions runs a `run:` step. */
|
|
693
|
+
function repoCheckArgv(run) {
|
|
694
|
+
return ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", run];
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* The porcelain entries in `after` that `before` did not have: what the
|
|
698
|
+
* repository checks wrote into the tree. A path that was already dirty is the
|
|
699
|
+
* build's, and is not listed. Pure.
|
|
700
|
+
*/
|
|
701
|
+
export function newTreeEntries(before, after) {
|
|
702
|
+
const known = new Set(parseChangedPaths(before));
|
|
703
|
+
return after
|
|
704
|
+
.split("\n")
|
|
705
|
+
.filter((line) => line.trim().length >= 3)
|
|
706
|
+
.flatMap((line) => {
|
|
707
|
+
const [path] = parseChangedPaths(line);
|
|
708
|
+
if (!path || known.has(path))
|
|
709
|
+
return [];
|
|
710
|
+
return [{ path, untracked: line.trimStart().startsWith("??") }];
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
function runGitQuiet(args, cwd) {
|
|
714
|
+
return new Promise((resolve, reject) => {
|
|
715
|
+
execFile("git", args, { cwd, maxBuffer: 1024 * 1024 }, (err) => (err ? reject(err) : resolve()));
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
/** Undo what the repository checks wrote: an untracked path is removed, a tracked one restored. */
|
|
719
|
+
async function defaultUndoTreeWrites(cwd, entries) {
|
|
720
|
+
const untracked = entries.filter((entry) => entry.untracked).map((entry) => entry.path);
|
|
721
|
+
const tracked = entries.filter((entry) => !entry.untracked).map((entry) => entry.path);
|
|
722
|
+
if (untracked.length > 0)
|
|
723
|
+
await runGitQuiet(["clean", "-fdq", "--", ...untracked], cwd);
|
|
724
|
+
if (tracked.length > 0)
|
|
725
|
+
await runGitQuiet(["checkout", "-q", "--", ...tracked], cwd);
|
|
726
|
+
}
|
|
643
727
|
// `signal` is deliberately NOT passed into execFile: Node's built-in abort
|
|
644
728
|
// handling kills via the child's pid, and when the spawn itself failed (missing
|
|
645
729
|
// command, missing cwd) an abort landing in the same tick resolves that to pid 0
|
|
@@ -845,7 +929,213 @@ export function makeRunVerify(opts = {}) {
|
|
|
845
929
|
const exec = opts.exec ?? defaultExec;
|
|
846
930
|
const timeoutMs = opts.timeoutMs ?? VERIFY_TIMEOUT_MS;
|
|
847
931
|
const testTimeoutMs = opts.testTimeoutMs ?? VERIFY_TEST_TIMEOUT_MS;
|
|
932
|
+
const now = opts.now ?? Date.now;
|
|
933
|
+
const changedPaths = opts.changedPaths ??
|
|
934
|
+
(async (cwd, signal, baselinePaths) => {
|
|
935
|
+
const snapshot = await snapshotWorkspace(cwd, signal);
|
|
936
|
+
if (!snapshot.tracked)
|
|
937
|
+
return [];
|
|
938
|
+
const baseline = new Set(baselinePaths ?? []);
|
|
939
|
+
return parseChangedPaths(snapshot.status).filter((p) => !baseline.has(p));
|
|
940
|
+
});
|
|
941
|
+
const toolchain = opts.toolchain ??
|
|
942
|
+
(() => {
|
|
943
|
+
const path = process.env.YAGNI_TOOLCHAIN_FILE?.trim();
|
|
944
|
+
return path ? readToolchainFile(path) : null;
|
|
945
|
+
});
|
|
946
|
+
const treeStatus = opts.treeStatus ?? (async (cwd, signal) => (await snapshotWorkspace(cwd, signal)).status);
|
|
947
|
+
const undoTreeWrites = opts.undoTreeWrites ?? defaultUndoTreeWrites;
|
|
948
|
+
// Across the whole build: the half's time spent, and each CI step's failure
|
|
949
|
+
// keys from the last call it failed in.
|
|
950
|
+
let repoChecksSpentMs = 0;
|
|
951
|
+
const lastCiFailure = new Map();
|
|
952
|
+
/**
|
|
953
|
+
* The repository-checks half: the plan's approved checks, then the pull
|
|
954
|
+
* request's CI steps its changed paths trigger. A failure becomes `critical`
|
|
955
|
+
* findings the fix turn works on, except where it can never clear: a check
|
|
956
|
+
* the sandbox cannot start, an infra failure, an approved check that already
|
|
957
|
+
* failed on the unchanged code (the host compares it after the build), and a
|
|
958
|
+
* CI step that fails exactly as it did before a fix turn (it may fail on the
|
|
959
|
+
* base too, and the pull request's CI shows it). Those are notes.
|
|
960
|
+
*/
|
|
961
|
+
async function runRepoChecks(input) {
|
|
962
|
+
const { file, cwd, signal } = input;
|
|
963
|
+
const findings = [];
|
|
964
|
+
const reasons = [];
|
|
965
|
+
const labels = [];
|
|
966
|
+
const cannotRun = [];
|
|
967
|
+
for (const check of file.checks) {
|
|
968
|
+
if (check.status === "cannot_run")
|
|
969
|
+
cannotRun.push(`\`${check.command}\`${check.cause ? ` (${check.cause})` : ""}`);
|
|
970
|
+
}
|
|
971
|
+
const paths = await changedPaths(cwd, signal, input.baselinePaths);
|
|
972
|
+
const jobs = file.ciJobs.filter((job) => ciJobInScope(job, paths));
|
|
973
|
+
for (const job of jobs) {
|
|
974
|
+
for (const entry of job.cannotRun)
|
|
975
|
+
cannotRun.push(`CI ${job.name}, "${entry.step}" (${entry.reason})`);
|
|
976
|
+
}
|
|
977
|
+
if (cannotRun.length > 0) {
|
|
978
|
+
const named = cannotRun.slice(0, MAX_NAMED_CANNOT_RUN).join("; ");
|
|
979
|
+
const more = cannotRun.length > MAX_NAMED_CANNOT_RUN ? `; and ${cannotRun.length - MAX_NAMED_CANNOT_RUN} more` : "";
|
|
980
|
+
reasons.push(`could not run in the sandbox: ${named}${more}`);
|
|
981
|
+
}
|
|
982
|
+
const approved = file.checks.filter((check) => check.status !== "cannot_run");
|
|
983
|
+
const steps = jobs.flatMap((job) => job.steps.map((step) => ({ job, step })));
|
|
984
|
+
if (approved.length === 0 && steps.length === 0)
|
|
985
|
+
return { findings, reasons, labels };
|
|
986
|
+
const buildLeft = (opts.repoChecksBuildBudgetMs ?? REPO_CHECKS_BUILD_BUDGET_MS) - repoChecksSpentMs;
|
|
987
|
+
if (buildLeft < REPO_CHECK_MIN_START_MS) {
|
|
988
|
+
reasons.push("approved checks and CI steps not run again: the build's check budget is spent; the host still runs the approved checks after the build");
|
|
989
|
+
return { findings, reasons, labels };
|
|
990
|
+
}
|
|
991
|
+
const callBudget = Math.min(opts.repoChecksCallBudgetMs ?? REPO_CHECKS_CALL_BUDGET_MS, timeoutMs - (now() - input.callStartedAt), buildLeft);
|
|
992
|
+
const halfStartedAt = now();
|
|
993
|
+
const run = {
|
|
994
|
+
cwd,
|
|
995
|
+
signal,
|
|
996
|
+
left: () => callBudget - (now() - halfStartedAt),
|
|
997
|
+
toolchainFile: toolchain(),
|
|
998
|
+
findings,
|
|
999
|
+
reasons,
|
|
1000
|
+
labels,
|
|
1001
|
+
notReached: 0,
|
|
1002
|
+
};
|
|
1003
|
+
const before = await treeStatus(cwd, signal).catch((err) => {
|
|
1004
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1005
|
+
reasons.push(`could not read the tree before the checks, so what they write is not undone: ${scrubSecrets(msg)}`);
|
|
1006
|
+
return null;
|
|
1007
|
+
});
|
|
1008
|
+
try {
|
|
1009
|
+
await runApprovedChecks(run, approved);
|
|
1010
|
+
await runCiSteps(run, steps, new Set(file.checks.map((check) => check.command.trim())));
|
|
1011
|
+
}
|
|
1012
|
+
finally {
|
|
1013
|
+
repoChecksSpentMs += now() - halfStartedAt;
|
|
1014
|
+
// What the checks wrote into the tree (a coverage folder, an updated
|
|
1015
|
+
// snapshot, a build output) is not the build's change: undone, so the
|
|
1016
|
+
// candidate never carries it.
|
|
1017
|
+
if (before !== null) {
|
|
1018
|
+
try {
|
|
1019
|
+
const written = newTreeEntries(before, await treeStatus(cwd));
|
|
1020
|
+
if (written.length > 0)
|
|
1021
|
+
await undoTreeWrites(cwd, written);
|
|
1022
|
+
}
|
|
1023
|
+
catch (err) {
|
|
1024
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1025
|
+
reasons.push(`could not undo what the checks wrote into the tree: ${scrubSecrets(msg)}`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (run.notReached > 0) {
|
|
1030
|
+
reasons.push(`${run.notReached} approved check(s) or CI step(s) not reached in the gate's time budget; the host still runs the approved checks after the build`);
|
|
1031
|
+
}
|
|
1032
|
+
return { findings, reasons, labels };
|
|
1033
|
+
}
|
|
1034
|
+
/** One repository command's verdict: passed, a note (it could not run), or a failure with its output. */
|
|
1035
|
+
async function runRepoCommand(run, command, dir, stepEnv) {
|
|
1036
|
+
const capMs = Math.min(opts.repoCheckTimeoutMs ?? REPO_CHECK_TIMEOUT_MS, run.left());
|
|
1037
|
+
const composed = composeAbortSignal(run.signal, AbortSignal.timeout(Math.max(1, capMs)));
|
|
1038
|
+
const env = opts.repoChecksEnv
|
|
1039
|
+
? { ...opts.repoChecksEnv, ...(stepEnv ?? {}) }
|
|
1040
|
+
: buildRepoCheckEnv(process.env, run.toolchainFile, stepEnv);
|
|
1041
|
+
try {
|
|
1042
|
+
const { code, output: rawOutput } = await exec(repoCheckArgv(command), dir, composed, env);
|
|
1043
|
+
if (code === 0)
|
|
1044
|
+
return { kind: "passed" };
|
|
1045
|
+
const output = scrubSecrets(rawOutput);
|
|
1046
|
+
const cls = classifyTestFailure(output, code);
|
|
1047
|
+
if (cls.kind === "infra")
|
|
1048
|
+
return { kind: "note", reason: `could not run: ${cls.reason}` };
|
|
1049
|
+
return { kind: "failed", output, code };
|
|
1050
|
+
}
|
|
1051
|
+
catch (err) {
|
|
1052
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1053
|
+
return { kind: "note", reason: `could not run: ${scrubSecrets(msg)}` };
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/** A failure's findings, located from the folder the command ran in and prefixed with what ran. */
|
|
1057
|
+
function repoFailureFindings(run, failure, label, prefix, dir) {
|
|
1058
|
+
const cls = classifyTestFailure(failure.output, failure.code);
|
|
1059
|
+
const parsed = cls.kind === "failures" ? cls.findings : parseVerifyFailures(failure.output, failure.code, label);
|
|
1060
|
+
return parsed.map((f) => ({ ...liftFinding(f, run.cwd, dir), message: `${prefix}: ${f.message}` }));
|
|
1061
|
+
}
|
|
1062
|
+
const findingRoom = (run) => Math.max(0, MAX_LOCATED_FINDINGS - run.findings.length);
|
|
1063
|
+
/**
|
|
1064
|
+
* The plan's approved checks, at the repository root as the host runs them.
|
|
1065
|
+
* One that already failed on the unchanged code is a note: the host compares
|
|
1066
|
+
* the two after the build.
|
|
1067
|
+
*/
|
|
1068
|
+
async function runApprovedChecks(run, approved) {
|
|
1069
|
+
for (const check of approved) {
|
|
1070
|
+
if (run.signal?.aborted)
|
|
1071
|
+
return;
|
|
1072
|
+
if (run.left() < REPO_CHECK_MIN_START_MS) {
|
|
1073
|
+
run.notReached += 1;
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const label = check.command.length > 80 ? `${check.command.slice(0, 77)}...` : check.command;
|
|
1077
|
+
const verdict = await runRepoCommand(run, check.command, run.cwd, undefined);
|
|
1078
|
+
if (verdict.kind === "note") {
|
|
1079
|
+
run.reasons.push(`approved check \`${label}\` ${verdict.reason}`);
|
|
1080
|
+
}
|
|
1081
|
+
else if (verdict.kind === "failed" && check.status === "fails_on_base") {
|
|
1082
|
+
run.reasons.push(`approved check \`${label}\` failed, and it already failed on the unchanged code; the host compares the two after the build`);
|
|
1083
|
+
}
|
|
1084
|
+
else {
|
|
1085
|
+
run.labels.push(label);
|
|
1086
|
+
if (verdict.kind === "failed") {
|
|
1087
|
+
const found = repoFailureFindings(run, verdict, label, `approved check \`${label}\``, run.cwd);
|
|
1088
|
+
run.findings.push(...found.slice(0, findingRoom(run)));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* The pull request's CI steps, job by job. A job stops at its first failing
|
|
1095
|
+
* step, as it does on the runner; a step that is one of the approved checks
|
|
1096
|
+
* already ran. A step that fails with the same findings it failed with on
|
|
1097
|
+
* the previous call (so after a fix turn) is a note: it may fail on the base
|
|
1098
|
+
* too, and the pull request's CI shows it.
|
|
1099
|
+
*/
|
|
1100
|
+
async function runCiSteps(run, steps, approvedCommands) {
|
|
1101
|
+
const stoppedJobs = new Set();
|
|
1102
|
+
for (const { job, step } of steps) {
|
|
1103
|
+
if (run.signal?.aborted)
|
|
1104
|
+
return;
|
|
1105
|
+
if (stoppedJobs.has(job) || approvedCommands.has(step.run.trim()))
|
|
1106
|
+
continue;
|
|
1107
|
+
if (run.left() < REPO_CHECK_MIN_START_MS) {
|
|
1108
|
+
run.notReached += 1;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
const where = `CI ${job.name}, step "${step.name}"`;
|
|
1112
|
+
const key = `${job.workflow}\u0000${job.job}\u0000${step.name}\u0000${step.run}`;
|
|
1113
|
+
const dir = step.workingDirectory ? join(run.cwd, step.workingDirectory.replace(/^\.\/+/, "")) : run.cwd;
|
|
1114
|
+
const verdict = await runRepoCommand(run, step.run, dir, step.env);
|
|
1115
|
+
if (verdict.kind === "passed") {
|
|
1116
|
+
lastCiFailure.delete(key);
|
|
1117
|
+
run.labels.push(`CI ${job.name}: ${step.name}`);
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
stoppedJobs.add(job);
|
|
1121
|
+
if (verdict.kind === "note") {
|
|
1122
|
+
run.reasons.push(`${where} ${verdict.reason}`);
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
const stepFindings = repoFailureFindings(run, verdict, step.name, where, dir);
|
|
1126
|
+
const keys = new Set(stepFindings.map((f) => f.message));
|
|
1127
|
+
const previous = lastCiFailure.get(key);
|
|
1128
|
+
lastCiFailure.set(key, keys);
|
|
1129
|
+
if (previous && keys.size > 0 && [...keys].every((k) => previous.has(k))) {
|
|
1130
|
+
run.reasons.push(`${where} still fails the same way after a fix turn; it may fail on the base too, and the pull request's CI shows it`);
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
run.labels.push(`CI ${job.name}: ${step.name}`);
|
|
1134
|
+
run.findings.push(...stepFindings.slice(0, findingRoom(run)));
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
848
1137
|
return async function runVerify(cwd, signal, baselinePaths) {
|
|
1138
|
+
const callStartedAt = now();
|
|
849
1139
|
const dirs = await changedDirs(cwd, signal, baselinePaths);
|
|
850
1140
|
const commands = detect(cwd, dirs);
|
|
851
1141
|
// An explicitly-allowed .yagni-code/verify override IS the whole gate (spec
|
|
@@ -853,7 +1143,10 @@ export function makeRunVerify(opts = {}) {
|
|
|
853
1143
|
// alongside it — the repo already told us exactly how to verify.
|
|
854
1144
|
const overridden = commands.some((c) => c.source === "override");
|
|
855
1145
|
const testCommands = overridden ? [] : detectTests(cwd, dirs);
|
|
856
|
-
|
|
1146
|
+
// The approved checks carry the plan's approval, not the repository's own
|
|
1147
|
+
// choice of how to verify, so an override never takes them out of the loop.
|
|
1148
|
+
const repoFile = opts.repoChecks?.() ?? null;
|
|
1149
|
+
if (commands.length === 0 && testCommands.length === 0 && !repoFile) {
|
|
857
1150
|
return { ran: false, ok: true, reason: "verify skipped: no verify command detected", findings: [] };
|
|
858
1151
|
}
|
|
859
1152
|
const findings = [];
|
|
@@ -898,6 +1191,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
898
1191
|
// root cause, and the loop must fix the compile errors first anyway; the
|
|
899
1192
|
// round where typecheck goes green runs the tests, so `clean` is never
|
|
900
1193
|
// reached without the tests having run (or their absence honestly noted).
|
|
1194
|
+
const typecheckFailed = findings.length > 0;
|
|
901
1195
|
const testLabels = [];
|
|
902
1196
|
if (testCommands.length > 0) {
|
|
903
1197
|
if (findings.length > 0) {
|
|
@@ -939,10 +1233,27 @@ export function makeRunVerify(opts = {}) {
|
|
|
939
1233
|
}
|
|
940
1234
|
}
|
|
941
1235
|
}
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
const
|
|
1236
|
+
// REPOSITORY-CHECKS HALF: the plan's approved checks and the pull
|
|
1237
|
+
// request's CI steps (`checks.json`), after the test half, under the same
|
|
1238
|
+
// rule: a tree that does not compile fails them for the same root cause.
|
|
1239
|
+
const repoLabels = [];
|
|
1240
|
+
if (repoFile) {
|
|
1241
|
+
if (typecheckFailed) {
|
|
1242
|
+
reasons.push("approved checks and CI steps skipped: the typecheck half failed; a later round runs them");
|
|
1243
|
+
}
|
|
1244
|
+
else {
|
|
1245
|
+
const repo = await runRepoChecks({ file: repoFile, cwd, signal, baselinePaths, callStartedAt });
|
|
1246
|
+
reasons.push(...repo.reasons);
|
|
1247
|
+
repoLabels.push(...repo.labels);
|
|
1248
|
+
if (repo.labels.length > 0)
|
|
1249
|
+
anyRan = true;
|
|
1250
|
+
findings.push(...repo.findings);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
// The command labels list only what produced a verdict — on every half —
|
|
1254
|
+
// so the FINISH trailer's `passed (<cmds>)` never claims a check that was
|
|
1255
|
+
// infra-skipped or threw (invariant §0.1).
|
|
1256
|
+
const command = [...new Set(typecheckLabels), ...new Set(testLabels), ...new Set(repoLabels)].join(", ");
|
|
946
1257
|
if (!anyRan) {
|
|
947
1258
|
return { ran: false, ok: true, command, reason: reasons.join("; ") || "verify skipped", findings: [] };
|
|
948
1259
|
}
|
package/dist/goHeadless.d.ts
CHANGED
|
@@ -14,7 +14,18 @@
|
|
|
14
14
|
* `--plan-file`, optional `--memo-file`, `--run-id`, `--json`), so what Jack
|
|
15
15
|
* runs locally and what the fleet executes are the same binary and the same
|
|
16
16
|
* pipeline.
|
|
17
|
+
*
|
|
18
|
+
* Skills (YAG-784): the launcher also wires the same Claude Code compat seams
|
|
19
|
+
* an interactive launch gets, minus plugin MCP. In a cloud run the backend's
|
|
20
|
+
* `sources.json` mission file decides: the repo's committed skills load only
|
|
21
|
+
* when an admin marked it trusted for agent runs, and the org marketplace and
|
|
22
|
+
* personal skills snapshots load from AGENT_SOURCES_ROOT. With no such file
|
|
23
|
+
* (every local run) the folder's own pi trust decides, as it does
|
|
24
|
+
* interactively. The skill flags reach only the build and fix stages; rules
|
|
25
|
+
* and agent dirs ride the env to every stage.
|
|
17
26
|
*/
|
|
27
|
+
import { type AgentRunSources } from "./agentSources.js";
|
|
28
|
+
import { type ClaudeCompatDeps, type ClaudeCompatLaunch, type CompatSourceSummary } from "./claudeCompat.js";
|
|
18
29
|
import { type OtelLaunchConfig } from "./otel.js";
|
|
19
30
|
/** Mirrors HEADLESS_GO_EXIT in the extension; duplicated to keep the packages independent. */
|
|
20
31
|
export declare const GO_EXIT: {
|
|
@@ -28,6 +39,7 @@ interface HeadlessGoModule {
|
|
|
28
39
|
cwd?: string;
|
|
29
40
|
env?: NodeJS.ProcessEnv;
|
|
30
41
|
childEnv?: NodeJS.ProcessEnv;
|
|
42
|
+
extraArgs?: string[];
|
|
31
43
|
}) => Promise<{
|
|
32
44
|
exitCode: number;
|
|
33
45
|
}>;
|
|
@@ -48,6 +60,12 @@ export interface GoCommandDeps {
|
|
|
48
60
|
writeErr?: (line: string) => void;
|
|
49
61
|
env?: NodeJS.ProcessEnv;
|
|
50
62
|
cwd?: string;
|
|
63
|
+
/** Compat seam (tests inject one so nothing reads the real home). */
|
|
64
|
+
claudeCompat?: (deps: ClaudeCompatDeps) => Promise<ClaudeCompatLaunch>;
|
|
65
|
+
/** The `sources.json` reader; defaults to `$YAGNI_MISSION_DIR/sources.json`. */
|
|
66
|
+
readSources?: (env: NodeJS.ProcessEnv) => AgentRunSources | null;
|
|
67
|
+
/** Root every cloud snapshot dir must sit under; tests point it at a temp dir. */
|
|
68
|
+
sourcesRoot?: string;
|
|
51
69
|
}
|
|
52
70
|
/**
|
|
53
71
|
* Build the environment every pipeline stage child inherits: the proxy
|
|
@@ -68,6 +86,12 @@ export declare function buildHeadlessChildEnv(opts: {
|
|
|
68
86
|
/** Resolved OTel export config; stage children export when present. */
|
|
69
87
|
otel?: OtelLaunchConfig;
|
|
70
88
|
}): NodeJS.ProcessEnv;
|
|
89
|
+
/** The one stderr line naming what each source contributed; null when there is nothing to say. */
|
|
90
|
+
export declare function skillsSummaryLine(opts: {
|
|
91
|
+
cloud: AgentRunSources | null;
|
|
92
|
+
sources: CompatSourceSummary[];
|
|
93
|
+
refused: string[];
|
|
94
|
+
}): string | null;
|
|
71
95
|
/**
|
|
72
96
|
* Run `yagni go`. Returns the process exit code: 0 only on a verified
|
|
73
97
|
* candidate, 1 on any other pipeline outcome, 2 on a usage or credential
|
package/dist/goHeadless.js
CHANGED
|
@@ -14,11 +14,23 @@
|
|
|
14
14
|
* `--plan-file`, optional `--memo-file`, `--run-id`, `--json`), so what Jack
|
|
15
15
|
* runs locally and what the fleet executes are the same binary and the same
|
|
16
16
|
* pipeline.
|
|
17
|
+
*
|
|
18
|
+
* Skills (YAG-784): the launcher also wires the same Claude Code compat seams
|
|
19
|
+
* an interactive launch gets, minus plugin MCP. In a cloud run the backend's
|
|
20
|
+
* `sources.json` mission file decides: the repo's committed skills load only
|
|
21
|
+
* when an admin marked it trusted for agent runs, and the org marketplace and
|
|
22
|
+
* personal skills snapshots load from AGENT_SOURCES_ROOT. With no such file
|
|
23
|
+
* (every local run) the folder's own pi trust decides, as it does
|
|
24
|
+
* interactively. The skill flags reach only the build and fix stages; rules
|
|
25
|
+
* and agent dirs ride the env to every stage.
|
|
17
26
|
*/
|
|
18
27
|
import { randomUUID } from "node:crypto";
|
|
19
28
|
import { mkdirSync } from "node:fs";
|
|
29
|
+
import { resolve } from "node:path";
|
|
20
30
|
import { pathToFileURL } from "node:url";
|
|
31
|
+
import { AGENT_SOURCES_ROOT, cloudCompatOptions, missionDirFromEnv, readAgentRunSources, } from "./agentSources.js";
|
|
21
32
|
import { agentDirEnvVar } from "./branding.js";
|
|
33
|
+
import { CLAUDE_AGENT_DIRS_ENV, CLAUDE_PLUGIN_KEYS_ENV, CLAUDE_PLUGIN_MCP_ENV, CLAUDE_RULES_DIRS_ENV, claudeCompatArgs, } from "./claudeCompat.js";
|
|
22
34
|
import { agentDir, credentialsDir } from "./credentials.js";
|
|
23
35
|
import { otelChildEnv, resolveOtelLaunchWithWorkspace } from "./otel.js";
|
|
24
36
|
import { credentialsFromProfile, profilePath, readActiveProfile } from "./profiles.js";
|
|
@@ -60,6 +72,83 @@ export function buildHeadlessChildEnv(opts) {
|
|
|
60
72
|
...(opts.otel ? otelChildEnv(opts.otel, opts.baseEnv) : {}),
|
|
61
73
|
};
|
|
62
74
|
}
|
|
75
|
+
/** The compat keys a headless launch computes itself rather than inherits. */
|
|
76
|
+
const COMPAT_ENV_KEYS = [CLAUDE_AGENT_DIRS_ENV, CLAUDE_RULES_DIRS_ENV, CLAUDE_PLUGIN_MCP_ENV, CLAUDE_PLUGIN_KEYS_ENV];
|
|
77
|
+
/** The run's working dir as the pipeline will resolve it (`--cwd` wins). */
|
|
78
|
+
function pipelineCwd(args, fallback) {
|
|
79
|
+
for (let i = 0; i < args.length; i++) {
|
|
80
|
+
const token = args[i];
|
|
81
|
+
if (token === "--cwd" && args[i + 1])
|
|
82
|
+
return resolve(fallback, args[i + 1]);
|
|
83
|
+
if (token.startsWith("--cwd=") && token.length > 6)
|
|
84
|
+
return resolve(fallback, token.slice(6));
|
|
85
|
+
}
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
function countsOf(s) {
|
|
89
|
+
const parts = [];
|
|
90
|
+
const n = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
91
|
+
if (s.plugins.length > 0)
|
|
92
|
+
parts.push(n(s.plugins.length, "plugin"));
|
|
93
|
+
if (s.skills.length > 0)
|
|
94
|
+
parts.push(n(s.skills.length, "skills dir"));
|
|
95
|
+
if (s.commands.length > 0)
|
|
96
|
+
parts.push(n(s.commands.length, "commands dir"));
|
|
97
|
+
if (s.rulesDirs.length > 0)
|
|
98
|
+
parts.push(n(s.rulesDirs.length, "rules dir"));
|
|
99
|
+
if (s.unsupported.length > 0)
|
|
100
|
+
parts.push(`${s.unsupported.length} not loaded (${s.unsupported.join("; ")})`);
|
|
101
|
+
return parts.length > 0 ? parts.join(", ") : "nothing to load";
|
|
102
|
+
}
|
|
103
|
+
/** The one stderr line naming what each source contributed; null when there is nothing to say. */
|
|
104
|
+
export function skillsSummaryLine(opts) {
|
|
105
|
+
const parts = opts.sources.map((s) => {
|
|
106
|
+
const label = s.kind === "project" ? `${opts.cloud?.repo.fullName ?? "repository"} (trusted)` : s.label;
|
|
107
|
+
return `${label}: ${countsOf(s)}`;
|
|
108
|
+
});
|
|
109
|
+
if (opts.cloud && !opts.cloud.repo.trusted)
|
|
110
|
+
parts.push(`${opts.cloud.repo.fullName}: repository skills off (not trusted for agent runs)`);
|
|
111
|
+
for (const refused of opts.refused)
|
|
112
|
+
parts.push(`${refused} (skipped)`);
|
|
113
|
+
return parts.length > 0 ? `Skills: ${parts.join(" | ")}` : null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The compat argv + env for a headless run. Cloud mode (a valid sources.json)
|
|
117
|
+
* trusts what the backend decided and never reads HOME; local mode keeps the
|
|
118
|
+
* folder's pi trust decision and the user's own `~/.claude`. Plugin MCP never
|
|
119
|
+
* loads headlessly. Never throws: a failure here only means no skills, and
|
|
120
|
+
* it says so on stderr so it is not mistaken for "nothing configured".
|
|
121
|
+
*/
|
|
122
|
+
async function headlessCompat(opts) {
|
|
123
|
+
const { cwd, agentDir, childEnv, deps, writeErr } = opts;
|
|
124
|
+
try {
|
|
125
|
+
const cloud = (deps.readSources ?? ((env) => readAgentRunSources(missionDirFromEnv(env), writeErr)))(childEnv);
|
|
126
|
+
const compat = deps.claudeCompat ?? claudeCompatArgs;
|
|
127
|
+
const base = { cwd, agentDir, env: childEnv, interactive: false, pluginMcp: false };
|
|
128
|
+
let refused = [];
|
|
129
|
+
let launch;
|
|
130
|
+
if (cloud) {
|
|
131
|
+
const plan = cloudCompatOptions(cloud, deps.sourcesRoot ?? AGENT_SOURCES_ROOT);
|
|
132
|
+
refused = plan.refused;
|
|
133
|
+
launch = await compat({
|
|
134
|
+
...base,
|
|
135
|
+
trustOverride: plan.trustOverride,
|
|
136
|
+
userScope: false,
|
|
137
|
+
projectNativeSkills: true,
|
|
138
|
+
extraRoots: plan.extraRoots,
|
|
139
|
+
marketplaces: plan.marketplaces,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
launch = await compat(base);
|
|
144
|
+
}
|
|
145
|
+
return { launch, summary: skillsSummaryLine({ cloud, sources: launch.sources ?? [], refused }) };
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const why = err instanceof Error && err.message ? err.message : String(err);
|
|
149
|
+
return { launch: { argv: [], env: {} }, summary: `Skills: not loaded (the skills setup failed: ${why})` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
63
152
|
/**
|
|
64
153
|
* Run `yagni go`. Returns the process exit code: 0 only on a verified
|
|
65
154
|
* candidate, 1 on any other pipeline outcome, 2 on a usage or credential
|
|
@@ -135,10 +224,27 @@ export async function goCommand(args, deps = {}, cliVersion) {
|
|
|
135
224
|
writeErr(err instanceof Error ? err.message : String(err));
|
|
136
225
|
return GO_EXIT.usage;
|
|
137
226
|
}
|
|
227
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
228
|
+
const compat = await headlessCompat({
|
|
229
|
+
cwd: pipelineCwd(args, cwd),
|
|
230
|
+
agentDir: childEnv[agentDirEnvVar()],
|
|
231
|
+
childEnv,
|
|
232
|
+
deps,
|
|
233
|
+
writeErr,
|
|
234
|
+
});
|
|
235
|
+
// Recompute the compat keys rather than inherit a parent session's: plugin
|
|
236
|
+
// MCP especially must never reach a headless stage child.
|
|
237
|
+
for (const key of COMPAT_ENV_KEYS)
|
|
238
|
+
delete childEnv[key];
|
|
239
|
+
Object.assign(childEnv, compat.launch.env);
|
|
240
|
+
delete childEnv[CLAUDE_PLUGIN_MCP_ENV];
|
|
241
|
+
if (compat.summary)
|
|
242
|
+
writeErr(compat.summary);
|
|
138
243
|
const outcome = await headless.runHeadlessGo(args, {
|
|
139
|
-
cwd
|
|
244
|
+
cwd,
|
|
140
245
|
env: baseEnv,
|
|
141
246
|
childEnv,
|
|
247
|
+
...(compat.launch.argv.length > 0 ? { extraArgs: compat.launch.argv } : {}),
|
|
142
248
|
});
|
|
143
249
|
return outcome.exitCode;
|
|
144
250
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.2.4-staging.
|
|
3
|
+
"version": "1.2.4-staging.1821.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "60f51163d9b1184bea0c2dca85cf2b7d07d6d275"
|
|
62
62
|
}
|