@yagni-app/code 0.3.2 → 0.3.4
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/cli.js +13 -0
- package/dist/extension/config.d.ts +8 -0
- package/dist/extension/config.js +6 -0
- package/dist/extension/footer.d.ts +1 -1
- package/dist/extension/hooks.d.ts +111 -0
- package/dist/extension/hooks.js +666 -0
- package/dist/extension/index.d.ts +13 -6
- package/dist/extension/index.js +68 -8
- package/dist/extension/{approvedPrefixes.js → permission/approvedPrefixes.js} +1 -1
- package/dist/extension/permission/dbReadPolicy.d.ts +90 -0
- package/dist/extension/permission/dbReadPolicy.js +227 -0
- package/dist/extension/{execPolicy.js → permission/execPolicy.js} +41 -13
- package/dist/extension/{permission.d.ts → permission/gate.d.ts} +9 -2
- package/dist/extension/{permission.js → permission/gate.js} +103 -4
- package/dist/extension/{guardian.d.ts → permission/guardian.d.ts} +22 -3
- package/dist/extension/{guardian.js → permission/guardian.js} +42 -6
- package/dist/extension/permission/index.d.ts +14 -0
- package/dist/extension/permission/index.js +14 -0
- package/dist/extension/permission/packageManagerPolicy.d.ts +55 -0
- package/dist/extension/permission/packageManagerPolicy.js +170 -0
- package/dist/extension/pipeline/activityFeed.js +19 -5
- package/dist/extension/pipeline/checker.d.ts +99 -0
- package/dist/extension/pipeline/checker.js +238 -0
- package/dist/extension/pipeline/fanout.d.ts +116 -0
- package/dist/extension/pipeline/fanout.js +248 -0
- package/dist/extension/pipeline/fanoutBeats.d.ts +31 -0
- package/dist/extension/pipeline/fanoutBeats.js +86 -0
- package/dist/extension/pipeline/goCommand.d.ts +14 -0
- package/dist/extension/pipeline/goCommand.js +38 -1
- package/dist/extension/pipeline/headlessGo.d.ts +163 -0
- package/dist/extension/pipeline/headlessGo.js +333 -0
- package/dist/extension/pipeline/invocation.d.ts +7 -1
- package/dist/extension/pipeline/invocation.js +7 -1
- package/dist/extension/pipeline/mission.d.ts +55 -0
- package/dist/extension/pipeline/mission.js +70 -0
- package/dist/extension/pipeline/orchestrator.d.ts +48 -3
- package/dist/extension/pipeline/orchestrator.js +450 -9
- package/dist/extension/pipeline/personas.d.ts +16 -1
- package/dist/extension/pipeline/personas.js +117 -6
- package/dist/extension/pipeline/runSession.d.ts +45 -1
- package/dist/extension/pipeline/runState.d.ts +57 -12
- package/dist/extension/pipeline/runState.js +60 -18
- package/dist/extension/pipeline/runner.js +10 -1
- package/dist/extension/pipeline/stages.d.ts +84 -7
- package/dist/extension/pipeline/stages.js +166 -0
- package/dist/extension/pipeline/tierCap.d.ts +32 -0
- package/dist/extension/pipeline/tierCap.js +57 -0
- package/dist/extension/pipeline/types.d.ts +130 -1
- package/dist/extension/pipeline/types.js +17 -0
- package/dist/extension/pipeline/verify.d.ts +86 -3
- package/dist/extension/pipeline/verify.js +175 -6
- package/dist/extension/turnLog.d.ts +38 -0
- package/dist/extension/turnLog.js +93 -0
- package/dist/goHeadless.d.ts +75 -0
- package/dist/goHeadless.js +132 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +12 -0
- package/package.json +2 -2
- /package/dist/extension/{approvedPrefixes.d.ts → permission/approvedPrefixes.d.ts} +0 -0
- /package/dist/extension/{execPolicy.d.ts → permission/execPolicy.d.ts} +0 -0
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
* the fix stage gets the real errors.
|
|
10
10
|
*
|
|
11
11
|
* `detectVerifyCommands`, `detectVerifyTestCommands`, `parseVerifyFailures`,
|
|
12
|
-
* `classifyTestFailure`, `isFannedOutScript`, `parseChangedPaths
|
|
13
|
-
* `resolvePackageDirs` are PURE (unit-tested directly).
|
|
14
|
-
*
|
|
12
|
+
* `classifyTestFailure`, `isFannedOutScript`, `parseChangedPaths`,
|
|
13
|
+
* `resolvePackageDirs` and `resolveClaimDirs` are PURE (unit-tested directly).
|
|
14
|
+
* `makeRunVerify` (the whole gate) and `makeWorkstreamCheck` (the implement
|
|
15
|
+
* diamond's per-workstream scoped typecheck, spec decision 6) are the thin impure
|
|
16
|
+
* runners; they shell out (pi's ExtensionContext exposes no exec,
|
|
15
17
|
* mirroring workspace.ts) and is wrapped with R1's timeout composition
|
|
16
18
|
* (`composeAbortSignal`) so a slow or flaky suite is bounded.
|
|
17
19
|
*
|
|
@@ -53,6 +55,7 @@ import { readFileSync } from "node:fs";
|
|
|
53
55
|
// and would break the walk-up termination check.
|
|
54
56
|
import { basename, dirname, join } from "node:path/posix";
|
|
55
57
|
import { killTreeSync, trackChild } from "./childRegistry.js";
|
|
58
|
+
import { claimCovers } from "./fanout.js";
|
|
56
59
|
import { composeAbortSignal } from "./resilience.js";
|
|
57
60
|
import { scrubSecrets } from "./scrubSecrets.js";
|
|
58
61
|
import { snapshotWorkspace } from "./workspace.js";
|
|
@@ -544,6 +547,49 @@ export function parseVerifyFailures(output, exitCode, label) {
|
|
|
544
547
|
},
|
|
545
548
|
];
|
|
546
549
|
}
|
|
550
|
+
/** Trim a trailing slash and normalise separators for the path math below. */
|
|
551
|
+
const normDir = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
552
|
+
/** Does this look like an absolute path on either platform? */
|
|
553
|
+
const isAbsolutePath = (p) => p.startsWith("/") || /^[A-Za-z]:\//.test(p);
|
|
554
|
+
/**
|
|
555
|
+
* One finding's file as the REPO ROOT sees it.
|
|
556
|
+
*
|
|
557
|
+
* The gate is scoped by design: a monorepo package's `typecheck` / `test` runs
|
|
558
|
+
* IN that package's dir, so its diagnostics come back package-relative
|
|
559
|
+
* (`src/routes/x.ts`). Everything downstream speaks repo-root-relative paths —
|
|
560
|
+
* the partition's file claims, the handoff, the fix loop's attribution
|
|
561
|
+
* (`checker.ts`'s `ownerOf`) — so an un-lifted path can never match the
|
|
562
|
+
* workstream that owns it and the implicated builder is never re-engaged.
|
|
563
|
+
*
|
|
564
|
+
* Lifts a package-relative path onto the command's dir, makes an absolute path
|
|
565
|
+
* under the repo root relative to it, and leaves anything else (a path outside
|
|
566
|
+
* the tree, a path already carrying the package prefix) untouched. Idempotent,
|
|
567
|
+
* so a finding that passes through twice is not double-prefixed.
|
|
568
|
+
*/
|
|
569
|
+
export function toRepoRelativePath(file, repoRoot, commandCwd) {
|
|
570
|
+
const raw = file.replace(/\\/g, "/").trim();
|
|
571
|
+
if (!raw)
|
|
572
|
+
return file;
|
|
573
|
+
const root = normDir(repoRoot);
|
|
574
|
+
if (isAbsolutePath(raw)) {
|
|
575
|
+
return raw.startsWith(`${root}/`) ? raw.slice(root.length + 1) : raw;
|
|
576
|
+
}
|
|
577
|
+
const cleaned = raw.replace(/^\.\/+/, "");
|
|
578
|
+
const cwd = normDir(commandCwd);
|
|
579
|
+
if (cwd === root || !cwd.startsWith(`${root}/`))
|
|
580
|
+
return cleaned;
|
|
581
|
+
const prefix = cwd.slice(root.length + 1);
|
|
582
|
+
if (cleaned === prefix || cleaned.startsWith(`${prefix}/`))
|
|
583
|
+
return cleaned;
|
|
584
|
+
return `${prefix}/${cleaned}`;
|
|
585
|
+
}
|
|
586
|
+
/** {@link toRepoRelativePath} applied to a finding's location, if it has one. */
|
|
587
|
+
function liftFinding(finding, repoRoot, commandCwd) {
|
|
588
|
+
if (!finding.file)
|
|
589
|
+
return finding;
|
|
590
|
+
const file = toRepoRelativePath(finding.file, repoRoot, commandCwd);
|
|
591
|
+
return file === finding.file ? finding : { ...finding, file };
|
|
592
|
+
}
|
|
547
593
|
const SAFE_ENV_KEYS = [
|
|
548
594
|
"PATH",
|
|
549
595
|
"HOME",
|
|
@@ -644,6 +690,128 @@ export function makeDefaultExec(opts = {}) {
|
|
|
644
690
|
});
|
|
645
691
|
}
|
|
646
692
|
const defaultExec = makeDefaultExec();
|
|
693
|
+
/**
|
|
694
|
+
* A workstream's claims resolved to package dirs. Claims are PREFIXES and a claim
|
|
695
|
+
* is very often a directory (`packages/backend/src/routes`), while
|
|
696
|
+
* {@link resolvePackageDirs} takes the FILE paths git porcelain emits and walks up
|
|
697
|
+
* from each one's dirname. Appending a probe segment makes a directory claim walk
|
|
698
|
+
* up from the directory ITSELF, so a claim that IS a package dir resolves to that
|
|
699
|
+
* package instead of its parent; a claim that is a real file still resolves exactly
|
|
700
|
+
* as it does for the full gate. Pure (the `readFile` seam keeps it so).
|
|
701
|
+
*/
|
|
702
|
+
export function resolveClaimDirs(repoRoot, claims, readFile) {
|
|
703
|
+
const probes = claims
|
|
704
|
+
.map((c) => c.trim().replace(/^\.\/+/, "").replace(/\/+$/, ""))
|
|
705
|
+
.filter((c) => c.length > 0 && c !== ".")
|
|
706
|
+
.map((c) => `${c}/.claim`);
|
|
707
|
+
return resolvePackageDirs(repoRoot, probes, readFile);
|
|
708
|
+
}
|
|
709
|
+
/** Attribute a scoped finding to the workstream whose claims produced it. */
|
|
710
|
+
function tagFinding(finding, name) {
|
|
711
|
+
return { ...finding, message: `[${name}] ${finding.message}` };
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* The per-workstream scoped check the implement diamond runs right after the fan
|
|
715
|
+
* (spec decision 6): the TYPECHECK half only, run in the package dirs a
|
|
716
|
+
* workstream's own claims resolve to, so a broken workstream is caught WITH
|
|
717
|
+
* attribution instead of surfacing later as an anonymous failure of the merged
|
|
718
|
+
* tree. The test half is deliberately not run here — the repo's tests run ONCE, on
|
|
719
|
+
* the merged candidate, through {@link makeRunVerify}.
|
|
720
|
+
*
|
|
721
|
+
* Three rules keep the attribution honest:
|
|
722
|
+
* - a command is kept only when it runs IN one of the workstream's own claim dirs.
|
|
723
|
+
* {@link detectVerifyCommands} falls back to the repo-root script when nothing
|
|
724
|
+
* scoped qualifies, and a repo-wide typecheck blamed on one workstream is
|
|
725
|
+
* exactly the false attribution this check exists to avoid.
|
|
726
|
+
* - identical commands are executed ONCE per pass and their result shared, so two
|
|
727
|
+
* workstreams inside the same package do not typecheck it twice.
|
|
728
|
+
* - a workstream is tagged only with the failures its OWN claims cover. A shared
|
|
729
|
+
* package's check can fail on its sibling's files (or on files neither claims):
|
|
730
|
+
* tagging every target with the whole output would report a healthy workstream
|
|
731
|
+
* broken and defeat the checker's file+line+message dedupe against the full
|
|
732
|
+
* verify. Failures outside the target's claims surface ONCE, untagged, so
|
|
733
|
+
* {@link attributeFindings} routes them by file or hands them to the synthesizer.
|
|
734
|
+
*
|
|
735
|
+
* Fail-open everywhere, like the full gate: an unrunnable check records a reason
|
|
736
|
+
* and produces no findings.
|
|
737
|
+
*/
|
|
738
|
+
export function makeWorkstreamCheck(opts = {}) {
|
|
739
|
+
const claimDirs = opts.claimDirs ?? ((cwd, claims) => resolveClaimDirs(cwd, claims, (p) => readFileSync(p, "utf8")));
|
|
740
|
+
const detect = opts.detect ?? ((cwd, dirs) => detectVerifyCommands(cwd, { changedDirs: dirs }));
|
|
741
|
+
const exec = opts.exec ?? defaultExec;
|
|
742
|
+
const timeoutMs = opts.timeoutMs ?? VERIFY_TIMEOUT_MS;
|
|
743
|
+
return async function runWorkstreamCheck(cwd, targets, signal) {
|
|
744
|
+
const runs = new Map();
|
|
745
|
+
// Failures a target's own claims do not cover are emitted exactly once per
|
|
746
|
+
// command, untagged, by whichever target reaches the command first.
|
|
747
|
+
const uncoveredEmitted = new Set();
|
|
748
|
+
const results = [];
|
|
749
|
+
for (const target of targets) {
|
|
750
|
+
const dirs = claimDirs(cwd, target.files);
|
|
751
|
+
const commands = dirs.length > 0 ? detect(cwd, dirs).filter((c) => dirs.includes(c.cwd)) : [];
|
|
752
|
+
if (commands.length === 0) {
|
|
753
|
+
results.push({
|
|
754
|
+
name: target.name,
|
|
755
|
+
ran: false,
|
|
756
|
+
ok: true,
|
|
757
|
+
reason: "no scoped build check for its claimed packages",
|
|
758
|
+
findings: [],
|
|
759
|
+
});
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
const findings = [];
|
|
763
|
+
const reasons = [];
|
|
764
|
+
const labels = [];
|
|
765
|
+
let anyRan = false;
|
|
766
|
+
for (const command of commands) {
|
|
767
|
+
const memoKey = `${command.cwd}\u0000${command.argv.join(" ")}`;
|
|
768
|
+
let run = runs.get(memoKey);
|
|
769
|
+
if (!run) {
|
|
770
|
+
const composed = composeAbortSignal(signal, AbortSignal.timeout(timeoutMs));
|
|
771
|
+
try {
|
|
772
|
+
const { code, output } = await exec(command.argv, command.cwd, composed);
|
|
773
|
+
run = { code, output: scrubSecrets(output) };
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
run = { error: scrubSecrets(err instanceof Error ? err.message : String(err)) };
|
|
777
|
+
}
|
|
778
|
+
runs.set(memoKey, run);
|
|
779
|
+
}
|
|
780
|
+
if ("error" in run) {
|
|
781
|
+
reasons.push(`check (${command.label}) could not run: ${run.error}`);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (run.code !== 0) {
|
|
785
|
+
const infra = classifyVerifyInfraFailure(run.output, run.code);
|
|
786
|
+
if (infra) {
|
|
787
|
+
reasons.push(`check (${command.label}) could not run: ${infra}`);
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
anyRan = true;
|
|
792
|
+
labels.push(command.label);
|
|
793
|
+
if (run.code !== 0) {
|
|
794
|
+
const parsed = parseVerifyFailures(run.output, run.code, command.label).map((f) => liftFinding(f, cwd, command.cwd));
|
|
795
|
+
const covered = parsed.filter((f) => f.file != null && target.files.some((claim) => claimCovers(claim, f.file)));
|
|
796
|
+
findings.push(...covered.map((f) => tagFinding(f, target.name)));
|
|
797
|
+
if (!uncoveredEmitted.has(memoKey)) {
|
|
798
|
+
uncoveredEmitted.add(memoKey);
|
|
799
|
+
findings.push(...parsed.filter((f) => !covered.includes(f)));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
results.push({
|
|
804
|
+
name: target.name,
|
|
805
|
+
ran: anyRan,
|
|
806
|
+
ok: findings.length === 0,
|
|
807
|
+
...(labels.length > 0 ? { command: [...new Set(labels)].join(", ") } : {}),
|
|
808
|
+
...(reasons.length > 0 ? { reason: reasons.join("; ") } : {}),
|
|
809
|
+
findings,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
return results;
|
|
813
|
+
};
|
|
814
|
+
}
|
|
647
815
|
/**
|
|
648
816
|
* Build the runVerify function injected into the orchestrator. It discovers the
|
|
649
817
|
* changed package(s) (scoped to this run's diff via `baselinePaths`), detects a
|
|
@@ -710,8 +878,9 @@ export function makeRunVerify(opts = {}) {
|
|
|
710
878
|
}
|
|
711
879
|
anyRan = true;
|
|
712
880
|
typecheckLabels.push(command.label);
|
|
713
|
-
if (code !== 0)
|
|
714
|
-
findings.push(...parseVerifyFailures(output, code, command.label));
|
|
881
|
+
if (code !== 0) {
|
|
882
|
+
findings.push(...parseVerifyFailures(output, code, command.label).map((f) => liftFinding(f, cwd, command.cwd)));
|
|
883
|
+
}
|
|
715
884
|
}
|
|
716
885
|
catch (err) {
|
|
717
886
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -754,7 +923,7 @@ export function makeRunVerify(opts = {}) {
|
|
|
754
923
|
testLabels.push(command.label);
|
|
755
924
|
if (cls.kind === "failures") {
|
|
756
925
|
const room = Math.max(0, MAX_LOCATED_FINDINGS - testFindingCount);
|
|
757
|
-
const take = cls.findings.slice(0, room);
|
|
926
|
+
const take = cls.findings.slice(0, room).map((f) => liftFinding(f, cwd, command.cwd));
|
|
758
927
|
testFindingCount += take.length;
|
|
759
928
|
findings.push(...take);
|
|
760
929
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side turn-lifecycle log — makes a "stuck on Working…" turn legible
|
|
3
|
+
* without spelunking the raw session `.jsonl`.
|
|
4
|
+
*
|
|
5
|
+
* A session write-only WAL, not a buffered in-memory structure: the whole
|
|
6
|
+
* point is that a turn which STARTS but never ENDS (the silent-freeze failure
|
|
7
|
+
* this log exists to catch) still leaves a `turn_start` record with no
|
|
8
|
+
* matching `turn_end`, even if the process is later killed. Each record is a
|
|
9
|
+
* single sanitized JSON line (no message content, tokens, or tool payloads —
|
|
10
|
+
* only a timestamp, the session id, a stable event name, and the turn's final
|
|
11
|
+
* stop reason / error class when it ends). Follows `diagnostics.ts` exactly:
|
|
12
|
+
* rotating file under `~/.yagni-code/logs/`, fail-soft, hermetic under
|
|
13
|
+
* `node --test`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function _setTurnLogHomeForTest(dir: string | null): void;
|
|
16
|
+
export declare function turnLogPath(): string;
|
|
17
|
+
export type TurnLifecycleEvent = {
|
|
18
|
+
kind: "turn_start";
|
|
19
|
+
sessionId?: string;
|
|
20
|
+
turnIndex?: number;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "turn_end";
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
turnIndex?: number;
|
|
25
|
+
/** The fixed stopReason enum from the final assistant message — never a raw error message. */
|
|
26
|
+
stopReason?: string;
|
|
27
|
+
/** Time since the matching turn_start, ms. */
|
|
28
|
+
elapsedMs?: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Append one sanitized lifecycle record. Fail-soft — a logging failure must
|
|
32
|
+
* never break the turn. `errorClass` is a caller-mapped category, never a
|
|
33
|
+
* raw error message.
|
|
34
|
+
*/
|
|
35
|
+
export declare function logTurnLifecycle(ev: TurnLifecycleEvent): void;
|
|
36
|
+
/** List existing lifecycle log files (active + rotations), for a report. */
|
|
37
|
+
export declare function listTurnLogFiles(): string[];
|
|
38
|
+
//# sourceMappingURL=turnLog.d.ts.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side turn-lifecycle log — makes a "stuck on Working…" turn legible
|
|
3
|
+
* without spelunking the raw session `.jsonl`.
|
|
4
|
+
*
|
|
5
|
+
* A session write-only WAL, not a buffered in-memory structure: the whole
|
|
6
|
+
* point is that a turn which STARTS but never ENDS (the silent-freeze failure
|
|
7
|
+
* this log exists to catch) still leaves a `turn_start` record with no
|
|
8
|
+
* matching `turn_end`, even if the process is later killed. Each record is a
|
|
9
|
+
* single sanitized JSON line (no message content, tokens, or tool payloads —
|
|
10
|
+
* only a timestamp, the session id, a stable event name, and the turn's final
|
|
11
|
+
* stop reason / error class when it ends). Follows `diagnostics.ts` exactly:
|
|
12
|
+
* rotating file under `~/.yagni-code/logs/`, fail-soft, hermetic under
|
|
13
|
+
* `node --test`.
|
|
14
|
+
*/
|
|
15
|
+
import { appendFileSync, mkdirSync, readdirSync, renameSync, statSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { codeStateHome } from "./stateHome.js";
|
|
18
|
+
/** Test seam: point the log at a tmpdir (mirrors _setDiagnosticsHomeForTest). */
|
|
19
|
+
let homeOverride = null;
|
|
20
|
+
export function _setTurnLogHomeForTest(dir) {
|
|
21
|
+
homeOverride = dir;
|
|
22
|
+
}
|
|
23
|
+
function turnLogDir() {
|
|
24
|
+
return join(codeStateHome(homeOverride), "logs");
|
|
25
|
+
}
|
|
26
|
+
export function turnLogPath() {
|
|
27
|
+
return join(turnLogDir(), "turn-lifecycle.log");
|
|
28
|
+
}
|
|
29
|
+
const MAX_LOG_BYTES = 256 * 1024;
|
|
30
|
+
const KEEP_ROTATIONS = 2;
|
|
31
|
+
/** Shift turn-lifecycle.log -> .1 -> .2 once the active file passes the cap. */
|
|
32
|
+
function rotateIfNeeded(path) {
|
|
33
|
+
try {
|
|
34
|
+
if (!statSync(path).isFile() || statSync(path).size < MAX_LOG_BYTES)
|
|
35
|
+
return;
|
|
36
|
+
for (let i = KEEP_ROTATIONS; i >= 1; i--) {
|
|
37
|
+
const from = i === 1 ? path : `${path}.${i - 1}`;
|
|
38
|
+
const to = `${path}.${i}`;
|
|
39
|
+
try {
|
|
40
|
+
renameSync(from, to);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* absent source — fine */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* rotation is best-effort */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Append one sanitized lifecycle record. Fail-soft — a logging failure must
|
|
53
|
+
* never break the turn. `errorClass` is a caller-mapped category, never a
|
|
54
|
+
* raw error message.
|
|
55
|
+
*/
|
|
56
|
+
export function logTurnLifecycle(ev) {
|
|
57
|
+
try {
|
|
58
|
+
// Hermetic under `node --test`: never touch the real home dir unless a test
|
|
59
|
+
// explicitly overrode it (same rule as diagnostics.ts, which prevents the
|
|
60
|
+
// suite's exit from depending on the CI filesystem).
|
|
61
|
+
if (process.env.NODE_TEST_CONTEXT && homeOverride === null)
|
|
62
|
+
return;
|
|
63
|
+
const line = {
|
|
64
|
+
ts: new Date().toISOString(),
|
|
65
|
+
event: ev.kind,
|
|
66
|
+
...(ev.sessionId ? { sessionId: ev.sessionId } : {}),
|
|
67
|
+
...(ev.turnIndex !== undefined ? { turnIndex: ev.turnIndex } : {}),
|
|
68
|
+
...(ev.kind === "turn_end" && ev.stopReason !== undefined ? { stopReason: ev.stopReason } : {}),
|
|
69
|
+
...(ev.kind === "turn_end" && ev.elapsedMs !== undefined ? { elapsedMs: ev.elapsedMs } : {}),
|
|
70
|
+
};
|
|
71
|
+
const path = turnLogPath();
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
73
|
+
rotateIfNeeded(path);
|
|
74
|
+
appendFileSync(path, JSON.stringify(line) + "\n", "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* logging must never throw into the editor */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** List existing lifecycle log files (active + rotations), for a report. */
|
|
81
|
+
export function listTurnLogFiles() {
|
|
82
|
+
try {
|
|
83
|
+
const dir = turnLogDir();
|
|
84
|
+
return readdirSync(dir)
|
|
85
|
+
.filter((f) => f.startsWith("turn-lifecycle.log"))
|
|
86
|
+
.sort()
|
|
87
|
+
.map((f) => join(dir, f));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=turnLog.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni go --headless …` — the scriptable front door to the /go pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The pipeline itself lives in the bundled extension (pi-extension-yagni's
|
|
5
|
+
* `pipeline/headlessGo.ts`). The launcher keeps its usual job: resolve the
|
|
6
|
+
* active environment's credentials, build the hermetic child environment, point
|
|
7
|
+
* the pipeline's child resolver at the pinned pi binary, and hand over. As
|
|
8
|
+
* everywhere else in this package, the extension is reached by FILE PATH and
|
|
9
|
+
* called through a locally-declared structural type — the two packages stay
|
|
10
|
+
* independent, and the CLI never takes a build-time dependency on a private
|
|
11
|
+
* workspace package.
|
|
12
|
+
*
|
|
13
|
+
* The mission sandbox invokes exactly this command (`--ticket-file`,
|
|
14
|
+
* `--plan-file`, optional `--memo-file`, `--run-id`, `--json`), so what Jack
|
|
15
|
+
* runs locally and what the fleet executes are the same binary and the same
|
|
16
|
+
* pipeline.
|
|
17
|
+
*/
|
|
18
|
+
/** Mirrors HEADLESS_GO_EXIT in the extension; duplicated to keep the packages independent. */
|
|
19
|
+
export declare const GO_EXIT: {
|
|
20
|
+
readonly verified: 0;
|
|
21
|
+
readonly unverified: 1;
|
|
22
|
+
readonly usage: 2;
|
|
23
|
+
};
|
|
24
|
+
/** The structural slice of the extension's headless entry that we call. */
|
|
25
|
+
interface HeadlessGoModule {
|
|
26
|
+
runHeadlessGo: (argv: string[], deps: {
|
|
27
|
+
cwd?: string;
|
|
28
|
+
env?: NodeJS.ProcessEnv;
|
|
29
|
+
childEnv?: NodeJS.ProcessEnv;
|
|
30
|
+
}) => Promise<{
|
|
31
|
+
exitCode: number;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
export interface GoCommandDeps {
|
|
35
|
+
/** Import seam for the bundled headless entry (tests inject a fake). */
|
|
36
|
+
loadHeadless?: () => Promise<HeadlessGoModule>;
|
|
37
|
+
/** Credential seam; defaults to the active profile on disk. */
|
|
38
|
+
loadCredentials?: () => Promise<{
|
|
39
|
+
token?: string;
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
name: string;
|
|
42
|
+
expiresAt?: string;
|
|
43
|
+
workspaceId?: string;
|
|
44
|
+
}>;
|
|
45
|
+
/** Resolve pi's CLI entry (the binary every stage child is spawned with). */
|
|
46
|
+
piCliPath?: () => string;
|
|
47
|
+
writeErr?: (line: string) => void;
|
|
48
|
+
env?: NodeJS.ProcessEnv;
|
|
49
|
+
cwd?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the environment every pipeline stage child inherits: the proxy
|
|
53
|
+
* credentials (so the `yagni` provider and the grounding tools reach the
|
|
54
|
+
* backend), the hermetic pi agent dir, and pi's quiet flags. `YAGNI_CALLER` /
|
|
55
|
+
* `YAGNI_RUN_ID` are NOT set here — the pipeline stamps a per-stage caller onto
|
|
56
|
+
* every child, and the run id rides the `--run-id` flag.
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildHeadlessChildEnv(opts: {
|
|
59
|
+
baseEnv: NodeJS.ProcessEnv;
|
|
60
|
+
token: string;
|
|
61
|
+
baseUrl: string;
|
|
62
|
+
profileName: string;
|
|
63
|
+
expiresAt?: string;
|
|
64
|
+
workspaceId?: string;
|
|
65
|
+
cliVersion?: string;
|
|
66
|
+
sessionId?: string;
|
|
67
|
+
}): NodeJS.ProcessEnv;
|
|
68
|
+
/**
|
|
69
|
+
* Run `yagni go`. Returns the process exit code: 0 only on a verified
|
|
70
|
+
* candidate, 1 on any other pipeline outcome, 2 on a usage or credential
|
|
71
|
+
* problem. Never throws.
|
|
72
|
+
*/
|
|
73
|
+
export declare function goCommand(args: string[], deps?: GoCommandDeps, cliVersion?: string): Promise<number>;
|
|
74
|
+
export {};
|
|
75
|
+
//# sourceMappingURL=goHeadless.d.ts.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni go --headless …` — the scriptable front door to the /go pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The pipeline itself lives in the bundled extension (pi-extension-yagni's
|
|
5
|
+
* `pipeline/headlessGo.ts`). The launcher keeps its usual job: resolve the
|
|
6
|
+
* active environment's credentials, build the hermetic child environment, point
|
|
7
|
+
* the pipeline's child resolver at the pinned pi binary, and hand over. As
|
|
8
|
+
* everywhere else in this package, the extension is reached by FILE PATH and
|
|
9
|
+
* called through a locally-declared structural type — the two packages stay
|
|
10
|
+
* independent, and the CLI never takes a build-time dependency on a private
|
|
11
|
+
* workspace package.
|
|
12
|
+
*
|
|
13
|
+
* The mission sandbox invokes exactly this command (`--ticket-file`,
|
|
14
|
+
* `--plan-file`, optional `--memo-file`, `--run-id`, `--json`), so what Jack
|
|
15
|
+
* runs locally and what the fleet executes are the same binary and the same
|
|
16
|
+
* pipeline.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { mkdirSync } from "node:fs";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
import { agentDirEnvVar } from "./branding.js";
|
|
22
|
+
import { agentDir, credentialsDir } from "./credentials.js";
|
|
23
|
+
import { credentialsFromProfile, profilePath, readActiveProfile } from "./profiles.js";
|
|
24
|
+
import { resolveHeadlessGoPath, resolvePiCliPath } from "./paths.js";
|
|
25
|
+
/** Mirrors HEADLESS_GO_EXIT in the extension; duplicated to keep the packages independent. */
|
|
26
|
+
export const GO_EXIT = { verified: 0, unverified: 1, usage: 2 };
|
|
27
|
+
async function defaultLoadHeadless() {
|
|
28
|
+
const mod = (await import(pathToFileURL(resolveHeadlessGoPath()).href));
|
|
29
|
+
if (typeof mod?.runHeadlessGo !== "function") {
|
|
30
|
+
throw new Error("The bundled pipeline is missing its headless entry point.");
|
|
31
|
+
}
|
|
32
|
+
return mod;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build the environment every pipeline stage child inherits: the proxy
|
|
36
|
+
* credentials (so the `yagni` provider and the grounding tools reach the
|
|
37
|
+
* backend), the hermetic pi agent dir, and pi's quiet flags. `YAGNI_CALLER` /
|
|
38
|
+
* `YAGNI_RUN_ID` are NOT set here — the pipeline stamps a per-stage caller onto
|
|
39
|
+
* every child, and the run id rides the `--run-id` flag.
|
|
40
|
+
*/
|
|
41
|
+
export function buildHeadlessChildEnv(opts) {
|
|
42
|
+
const piAgentDir = agentDir(opts.profileName);
|
|
43
|
+
return {
|
|
44
|
+
...opts.baseEnv,
|
|
45
|
+
YAGNI_SESSION_ID: opts.sessionId ?? randomUUID(),
|
|
46
|
+
YAGNI_TOKEN: opts.token,
|
|
47
|
+
YAGNI_BASE_URL: opts.baseUrl,
|
|
48
|
+
...(opts.expiresAt ? { YAGNI_TOKEN_EXPIRES_AT: opts.expiresAt } : {}),
|
|
49
|
+
...(opts.workspaceId ? { YAGNI_WORKSPACE_ID: opts.workspaceId } : {}),
|
|
50
|
+
YAGNI_PROFILE_PATH: profilePath(opts.profileName),
|
|
51
|
+
YAGNI_CODE_HOME: credentialsDir(),
|
|
52
|
+
...(opts.cliVersion ? { YAGNI_CODE_VERSION: opts.cliVersion } : {}),
|
|
53
|
+
[agentDirEnvVar()]: piAgentDir,
|
|
54
|
+
PI_CODING_AGENT_DIR: piAgentDir,
|
|
55
|
+
PI_SKIP_VERSION_CHECK: "1",
|
|
56
|
+
PI_TELEMETRY: "0",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Run `yagni go`. Returns the process exit code: 0 only on a verified
|
|
61
|
+
* candidate, 1 on any other pipeline outcome, 2 on a usage or credential
|
|
62
|
+
* problem. Never throws.
|
|
63
|
+
*/
|
|
64
|
+
export async function goCommand(args, deps = {}, cliVersion) {
|
|
65
|
+
const writeErr = deps.writeErr ?? ((line) => void process.stderr.write(`${line}\n`));
|
|
66
|
+
const baseEnv = deps.env ?? process.env;
|
|
67
|
+
const profile = await (deps.loadCredentials ?? (async () => {
|
|
68
|
+
const active = await readActiveProfile();
|
|
69
|
+
const creds = credentialsFromProfile(active);
|
|
70
|
+
return {
|
|
71
|
+
name: active.name,
|
|
72
|
+
baseUrl: creds?.baseUrl ?? active.baseUrl,
|
|
73
|
+
...(creds?.token ? { token: creds.token } : {}),
|
|
74
|
+
...(creds?.expiresAt ? { expiresAt: creds.expiresAt } : {}),
|
|
75
|
+
...(creds?.workspaceId ? { workspaceId: creds.workspaceId } : {}),
|
|
76
|
+
};
|
|
77
|
+
}))();
|
|
78
|
+
// A mission sandbox has no profile on disk: it is handed a run-scoped
|
|
79
|
+
// capability token in the environment instead (the same `YAGNI_TOKEN` /
|
|
80
|
+
// `YAGNI_BASE_URL` pair every stage child already runs with). Fall back to it
|
|
81
|
+
// ONLY when nothing is logged in, so an exported token can never quietly
|
|
82
|
+
// hijack a developer's active profile.
|
|
83
|
+
const envToken = baseEnv.YAGNI_TOKEN?.trim();
|
|
84
|
+
const envBaseUrl = baseEnv.YAGNI_BASE_URL?.trim();
|
|
85
|
+
const token = profile.token ?? (envToken || undefined);
|
|
86
|
+
const baseUrl = profile.token ? profile.baseUrl : (envBaseUrl || profile.baseUrl);
|
|
87
|
+
if (!token) {
|
|
88
|
+
writeErr(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`yagni login\` first.`);
|
|
89
|
+
return GO_EXIT.usage;
|
|
90
|
+
}
|
|
91
|
+
const childEnv = buildHeadlessChildEnv({
|
|
92
|
+
baseEnv,
|
|
93
|
+
token,
|
|
94
|
+
baseUrl,
|
|
95
|
+
profileName: profile.name,
|
|
96
|
+
...(profile.expiresAt ? { expiresAt: profile.expiresAt } : {}),
|
|
97
|
+
...(profile.workspaceId ? { workspaceId: profile.workspaceId } : {}),
|
|
98
|
+
...(cliVersion ? { cliVersion } : {}),
|
|
99
|
+
});
|
|
100
|
+
// The hermetic agent dir must exist before a stage child tries to read it.
|
|
101
|
+
try {
|
|
102
|
+
mkdirSync(childEnv[agentDirEnvVar()], { recursive: true, mode: 0o700 });
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* best-effort: pi creates it too, and a failure here is not worth stopping for */
|
|
106
|
+
}
|
|
107
|
+
// The pipeline spawns each stage as `node <piCli> -e <extension> …`, resolving
|
|
108
|
+
// piCli from `process.argv[1]` (in a session that IS pi). Headlessly the entry
|
|
109
|
+
// process is this launcher, so point the resolver at pi's own CLI entry.
|
|
110
|
+
try {
|
|
111
|
+
process.argv[1] = (deps.piCliPath ?? resolvePiCliPath)();
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
writeErr(`Could not locate the agent binary: ${err instanceof Error ? err.message : String(err)}`);
|
|
115
|
+
return GO_EXIT.usage;
|
|
116
|
+
}
|
|
117
|
+
let headless;
|
|
118
|
+
try {
|
|
119
|
+
headless = await (deps.loadHeadless ?? defaultLoadHeadless)();
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
writeErr(err instanceof Error ? err.message : String(err));
|
|
123
|
+
return GO_EXIT.usage;
|
|
124
|
+
}
|
|
125
|
+
const outcome = await headless.runHeadlessGo(args, {
|
|
126
|
+
cwd: deps.cwd ?? process.cwd(),
|
|
127
|
+
env: baseEnv,
|
|
128
|
+
childEnv,
|
|
129
|
+
});
|
|
130
|
+
return outcome.exitCode;
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=goHeadless.js.map
|
package/dist/paths.d.ts
CHANGED
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
* and a fresh checkout working before anything is bundled.
|
|
21
21
|
*/
|
|
22
22
|
export declare function resolveExtensionPath(): string;
|
|
23
|
+
/**
|
|
24
|
+
* Absolute path to the extension's headless pipeline entry
|
|
25
|
+
* (`pipeline/headlessGo.js`), the module `yagni go --headless` imports.
|
|
26
|
+
*
|
|
27
|
+
* Derived from the extension entry so the bundled-vs-workspace fallback above
|
|
28
|
+
* is resolved once. Only this one module is loaded (not the whole extension):
|
|
29
|
+
* it pulls in the pipeline alone, with none of pi's TUI surface.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveHeadlessGoPath(): string;
|
|
23
32
|
/**
|
|
24
33
|
* Absolute path to pi's package root — the dir whose package.json names the
|
|
25
34
|
* package. The shadow package dir is built from this (we read its package.json
|
package/dist/paths.js
CHANGED
|
@@ -31,6 +31,18 @@ export function resolveExtensionPath() {
|
|
|
31
31
|
return bundled;
|
|
32
32
|
return resolveModulePath("pi-extension-yagni");
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Absolute path to the extension's headless pipeline entry
|
|
36
|
+
* (`pipeline/headlessGo.js`), the module `yagni go --headless` imports.
|
|
37
|
+
*
|
|
38
|
+
* Derived from the extension entry so the bundled-vs-workspace fallback above
|
|
39
|
+
* is resolved once. Only this one module is loaded (not the whole extension):
|
|
40
|
+
* it pulls in the pipeline alone, with none of pi's TUI surface.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveHeadlessGoPath() {
|
|
43
|
+
const entry = resolveExtensionPath();
|
|
44
|
+
return join(dirname(entry), "pipeline", "headlessGo.js");
|
|
45
|
+
}
|
|
34
46
|
/**
|
|
35
47
|
* Absolute path to pi's package root — the dir whose package.json names the
|
|
36
48
|
* package. The shadow package dir is built from this (we read its package.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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)",
|
|
@@ -39,5 +39,5 @@
|
|
|
39
39
|
"smol-toml": "^1.8.0",
|
|
40
40
|
"typebox": "^1.3.11"
|
|
41
41
|
},
|
|
42
|
-
"yagniSourceSha": "
|
|
42
|
+
"yagniSourceSha": "e48db7745c85aeb3907d42ad4481bf7d70bdf36c"
|
|
43
43
|
}
|
|
File without changes
|
|
File without changes
|