gentle-pi 2.4.0 → 2.5.0
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/README.md +170 -12
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/orchestrator-delegation.md +19 -9
- package/assets/orchestrator.md +5 -5
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +898 -79
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +88 -12
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-integration-v2.ts +58 -8
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +87 -11
- package/runtime/review-integration-v2.mjs +58 -8
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +6 -2
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +28 -12
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +17 -6
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +3 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +25 -0
- package/tests/review-host-relay-routing.test.ts +20 -4
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { execFileSync, type ExecFileSyncOptions } from "node:child_process";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync } from "node:fs";
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, utimesSync } from "node:fs";
|
|
4
4
|
import { mkdtempSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
8
|
+
import { assertCandidateOwnerParent, createCandidateOwner, removeCandidateOwner, sweepCandidateOwners, type CandidateViewOwner } from "./review-candidate-view-owner.ts";
|
|
8
9
|
|
|
9
10
|
const REVIEW_LENS = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
|
|
10
11
|
export type ReviewLens = (typeof REVIEW_LENS)[number];
|
|
@@ -12,6 +13,11 @@ const CANDIDATE_GIT_TIMEOUT_MS = 10_000;
|
|
|
12
13
|
const CANDIDATE_GIT_TIMEOUT_MAX_MS = 120_000;
|
|
13
14
|
const CANDIDATE_GIT_TIMEOUT_ENV = "GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS";
|
|
14
15
|
const CANDIDATE_GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
// Keep the copied index at least one timestamp tick behind its source. The
|
|
17
|
+
// two-second target also clears filesystems whose mtime granularity is a second
|
|
18
|
+
// or coarser without needlessly assigning an arbitrary historical timestamp.
|
|
19
|
+
const PRIVATE_INDEX_RACY_SAFETY_NS = 1_000_000_000n;
|
|
20
|
+
const PRIVATE_INDEX_RACY_BACKDATE_NS = 2_000_000_000n;
|
|
15
21
|
|
|
16
22
|
// Candidate views may materialize full repository trees. Large repositories can
|
|
17
23
|
// raise this bounded deadline without creating an unbounded child process.
|
|
@@ -94,6 +100,7 @@ interface CandidateViewScope {
|
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
interface CandidateViewRecord {
|
|
103
|
+
owner: CandidateViewOwner;
|
|
97
104
|
token: string;
|
|
98
105
|
root: string;
|
|
99
106
|
parent: string;
|
|
@@ -333,12 +340,37 @@ function isMaterializedCandidateMode(mode: string): boolean {
|
|
|
333
340
|
return mode === "100644" || mode === "100755" || mode === "120000";
|
|
334
341
|
}
|
|
335
342
|
|
|
343
|
+
export function hasExpectedExecutableBits(filesystemMode: number, gitMode: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
344
|
+
return gitMode === "100755" && platform === "win32"
|
|
345
|
+
|| (filesystemMode & 0o111) === (gitMode === "100755" ? 0o111 : 0);
|
|
346
|
+
}
|
|
347
|
+
|
|
336
348
|
function isCanonicalObjectId(objectId: string): boolean { return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(objectId); }
|
|
337
349
|
function gitlinkMapsEqual(left: Readonly<Record<string, string>>, right: Readonly<Record<string, string>>): boolean {
|
|
338
350
|
const entries = Object.entries(left);
|
|
339
351
|
return entries.length === Object.keys(right).length && entries.every(([path, objectId]) => right[path] === objectId);
|
|
340
352
|
}
|
|
341
353
|
|
|
354
|
+
// Two materialized candidate views describe the exact same reviewable content
|
|
355
|
+
// when their base, candidate tree, commit-state, and changed scope all match.
|
|
356
|
+
// This is what makes a retried native START's freshly-materialized duplicate
|
|
357
|
+
// view safely discardable in favor of an already-bound one (gentle-pi
|
|
358
|
+
// candidate-view rebind defect, ga#4085 / ga#4050): the comparison never
|
|
359
|
+
// trusts a caller-supplied claim, only Git-derived identity already computed
|
|
360
|
+
// by materializeCandidateView for both records.
|
|
361
|
+
function candidateRecordsShareIdentity(left: CandidateViewRecord, right: CandidateViewRecord): boolean {
|
|
362
|
+
return left.contributorRoot === right.contributorRoot &&
|
|
363
|
+
left.baseCommit === right.baseCommit &&
|
|
364
|
+
left.baseTree === right.baseTree &&
|
|
365
|
+
left.candidateTree === right.candidateTree &&
|
|
366
|
+
left.committedOnly === right.committedOnly &&
|
|
367
|
+
JSON.stringify(left.intendedUntracked ?? null) === JSON.stringify(right.intendedUntracked ?? null) &&
|
|
368
|
+
JSON.stringify(left.scope.paths) === JSON.stringify(right.scope.paths) &&
|
|
369
|
+
JSON.stringify(left.scope.modes) === JSON.stringify(right.scope.modes) &&
|
|
370
|
+
gitlinkMapsEqual(left.scope.gitlinks, right.scope.gitlinks) &&
|
|
371
|
+
JSON.stringify(left.scope.deletedPaths) === JSON.stringify(right.scope.deletedPaths);
|
|
372
|
+
}
|
|
373
|
+
|
|
342
374
|
function decodeCanonicalPath(value: Buffer): string {
|
|
343
375
|
const path = value.toString("utf8");
|
|
344
376
|
if (!Buffer.from(path, "utf8").equals(value) || !isSafeCandidatePath(path)) {
|
|
@@ -603,11 +635,15 @@ function makeWritableForCleanup(path: string): void {
|
|
|
603
635
|
}
|
|
604
636
|
|
|
605
637
|
function candidateViewParent(commonDir: string): string {
|
|
606
|
-
const
|
|
638
|
+
const control = join(commonDir, "gentle-ai");
|
|
639
|
+
mkdirSync(control, { recursive: true, mode: 0o700 });
|
|
640
|
+
const controlStat = lstatSync(control);
|
|
641
|
+
if (!controlStat.isDirectory() || controlStat.isSymbolicLink() || realpathSync(control) !== control) throw new CandidateViewError("candidate view ancestor is unsafe");
|
|
642
|
+
const parent = join(control, "candidate-views");
|
|
607
643
|
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
608
644
|
const stat = lstatSync(parent);
|
|
609
645
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CandidateViewError("candidate view parent is unsafe");
|
|
610
|
-
return
|
|
646
|
+
return assertCandidateOwnerParent(commonDir);
|
|
611
647
|
}
|
|
612
648
|
|
|
613
649
|
export interface ResolvedCandidateBase {
|
|
@@ -780,11 +816,29 @@ function isErrnoCode(error: unknown, code: string): boolean {
|
|
|
780
816
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
781
817
|
}
|
|
782
818
|
|
|
819
|
+
function timestampSeconds(timestampNs: bigint): number {
|
|
820
|
+
// Node's utimes API takes Unix seconds as a number. Splitting the value keeps
|
|
821
|
+
// the seconds conversion exact; the explicit racy-clean backdate below is far
|
|
822
|
+
// larger than the fractional precision a Number can represent at this epoch.
|
|
823
|
+
return Number(timestampNs / 1_000_000_000n) + Number(timestampNs % 1_000_000_000n) / 1_000_000_000;
|
|
824
|
+
}
|
|
825
|
+
|
|
783
826
|
function seedPrivateIndexFromLiveIndex(cwd: string, indexPath: string, executor: CandidateGitExecutor): boolean {
|
|
784
827
|
const liveIndex = resolve(cwd, git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"], process.env, executor));
|
|
785
|
-
const entry = lstatSync(liveIndex, { throwIfNoEntry: false });
|
|
828
|
+
const entry = lstatSync(liveIndex, { bigint: true, throwIfNoEntry: false });
|
|
786
829
|
if (entry === undefined) return false; if (!entry.isFile()) throw new CandidateViewError("candidate live Git index is not a regular file");
|
|
830
|
+
if (entry.mtimeNs < PRIVATE_INDEX_RACY_BACKDATE_NS) throw new CandidateViewError("candidate live Git index timestamp is too early for racy-clean protection");
|
|
787
831
|
copyFileSync(liveIndex, indexPath);
|
|
832
|
+
// Git's racy-clean check compares index and tracked-file mtimes. copyFileSync
|
|
833
|
+
// gives the private index a new timestamp; restoring a Date can also lose
|
|
834
|
+
// nanoseconds. Backdate the bigint live timestamp instead, then verify the
|
|
835
|
+
// filesystem applied enough of that bounded interval for Git to refresh
|
|
836
|
+
// content rather than trusting a racy-clean stat match.
|
|
837
|
+
utimesSync(indexPath, timestampSeconds(entry.atimeNs), timestampSeconds(entry.mtimeNs - PRIVATE_INDEX_RACY_BACKDATE_NS));
|
|
838
|
+
const privateMtimeNs = lstatSync(indexPath, { bigint: true }).mtimeNs;
|
|
839
|
+
if (privateMtimeNs >= entry.mtimeNs || entry.mtimeNs - privateMtimeNs < PRIVATE_INDEX_RACY_SAFETY_NS) {
|
|
840
|
+
throw new CandidateViewError("candidate private Git index timestamp cannot preserve racy-clean protection");
|
|
841
|
+
}
|
|
788
842
|
for (const name of readdirSync(dirname(liveIndex))) if (/^sharedindex\.[0-9a-f]+$/.test(name)) {
|
|
789
843
|
try {
|
|
790
844
|
const sharedIndex = join(dirname(liveIndex), name);
|
|
@@ -809,6 +863,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
809
863
|
? resolveCandidateBase(contributorRoot, "HEAD", process.env, executor)
|
|
810
864
|
: base;
|
|
811
865
|
const parent = candidateViewParent(canonicalCommonDir);
|
|
866
|
+
sweepCandidateOwners(canonicalCommonDir, (args) => git(canonicalCommonDir, args, process.env, executor), makeWritableForCleanup);
|
|
812
867
|
const index = mkdtempSync(join(tmpdir(), "gentle-ai-candidate-index-"));
|
|
813
868
|
const indexPath = join(index, "index");
|
|
814
869
|
const environment = { ...process.env, GIT_INDEX_FILE: indexPath };
|
|
@@ -832,6 +887,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
832
887
|
}
|
|
833
888
|
const candidateTree = git(contributorRoot, ["write-tree"], environment, executor);
|
|
834
889
|
const root = join(parent, randomUUID());
|
|
890
|
+
const owner = createCandidateOwner(canonicalCommonDir, root);
|
|
835
891
|
// The worktree is created under the same try/catch cleanup boundary as
|
|
836
892
|
// the read-tree materialization that follows. addUnbornWorktree's
|
|
837
893
|
// fallback path can register a worktree with `worktree add` and then
|
|
@@ -852,9 +908,9 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
852
908
|
const scope = deriveChangedScope(contributorRoot, base.tree, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
|
|
853
909
|
for (const gitlink of tree.gitlinks) if (lstatSync(join(root, gitlink.path), { throwIfNoEntry: false })) throw new CandidateViewError("candidate view materialized a metadata-only gitlink");
|
|
854
910
|
makeReadonly(root, entries);
|
|
855
|
-
return { token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, intendedUntracked, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor };
|
|
911
|
+
return { owner, token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, intendedUntracked, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor };
|
|
856
912
|
} catch (error) {
|
|
857
|
-
try {
|
|
913
|
+
try { removeCandidateOwner(owner, (args) => git(canonicalCommonDir, args, process.env, executor), makeWritableForCleanup); } catch { /* Preserve the marker and any partial worktree for conservative recovery. */ }
|
|
858
914
|
throw error;
|
|
859
915
|
}
|
|
860
916
|
} finally {
|
|
@@ -862,7 +918,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
862
918
|
}
|
|
863
919
|
}
|
|
864
920
|
|
|
865
|
-
function assertRecordSafe(record: CandidateViewRecord): void {
|
|
921
|
+
function assertRecordSafe(record: CandidateViewRecord, platform: NodeJS.Platform = process.platform): void {
|
|
866
922
|
try {
|
|
867
923
|
const contributor = lstatSync(record.contributorRoot);
|
|
868
924
|
if (!contributor.isDirectory() || contributor.isSymbolicLink() || realpathSync(record.contributorRoot) !== record.contributorRoot) {
|
|
@@ -902,7 +958,7 @@ function assertRecordSafe(record: CandidateViewRecord): void {
|
|
|
902
958
|
if (!item) throw new CandidateViewError("candidate view entry is missing or moved");
|
|
903
959
|
if (entry.mode === "120000") {
|
|
904
960
|
if (!item.isSymbolicLink()) throw new CandidateViewError("candidate view symlink is unsafe or changed");
|
|
905
|
-
} else if (!item.isFile() || item.isSymbolicLink() || (item.mode & 0o222) !== 0 || (
|
|
961
|
+
} else if (!item.isFile() || item.isSymbolicLink() || (item.mode & 0o222) !== 0 || !hasExpectedExecutableBits(item.mode, entry.mode, platform)) {
|
|
906
962
|
throw new CandidateViewError("candidate view entry is unsafe, writable, or has a changed mode");
|
|
907
963
|
}
|
|
908
964
|
const actualHash = entryContentHash(root, entry);
|
|
@@ -913,8 +969,10 @@ function assertRecordSafe(record: CandidateViewRecord): void {
|
|
|
913
969
|
export class CandidateViewRegistry {
|
|
914
970
|
private readonly records = new Map<string, CandidateViewRecord>();
|
|
915
971
|
private readonly gitExecutor: CandidateGitExecutor;
|
|
916
|
-
|
|
972
|
+
private readonly platform: NodeJS.Platform;
|
|
973
|
+
constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor, platform: NodeJS.Platform = process.platform) {
|
|
917
974
|
this.gitExecutor = gitExecutor;
|
|
975
|
+
this.platform = platform;
|
|
918
976
|
}
|
|
919
977
|
// Lifecycle state is scoped to the canonical target worktree as well as the
|
|
920
978
|
// provider lineage. Lineage text is repository-local and may legitimately be
|
|
@@ -1000,15 +1058,25 @@ export class CandidateViewRegistry {
|
|
|
1000
1058
|
const key = this.lineageKey(contributorRoot, lineageId);
|
|
1001
1059
|
const token = this.lineages.get(key);
|
|
1002
1060
|
const record = token === undefined ? undefined : this.records.get(token);
|
|
1003
|
-
if (record !== undefined) assertRecordSafe(record);
|
|
1061
|
+
if (record !== undefined) assertRecordSafe(record, this.platform);
|
|
1004
1062
|
}
|
|
1005
1063
|
|
|
1006
1064
|
create(request: CreateCandidateViewRequest): CandidateView {
|
|
1007
1065
|
return this.createOrReuse(request);
|
|
1008
1066
|
}
|
|
1009
1067
|
|
|
1068
|
+
sweepOrphans(contributorRoot: string): void {
|
|
1069
|
+
try {
|
|
1070
|
+
const cwd = realpathSync(contributorRoot);
|
|
1071
|
+
const common = realpathSync(resolve(cwd, git(cwd, ["rev-parse", "--git-common-dir"], process.env, this.gitExecutor)));
|
|
1072
|
+
sweepCandidateOwners(common, (args) => git(common, args, process.env, this.gitExecutor), makeWritableForCleanup);
|
|
1073
|
+
} catch { /* Non-repositories and unavailable ownership are preserved. */ }
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1010
1076
|
cleanupAll(): void {
|
|
1011
|
-
for (const token of [...this.records.keys()])
|
|
1077
|
+
for (const token of [...this.records.keys()]) {
|
|
1078
|
+
try { this.cleanup(token); } catch { /* Continue other owned views; failed records remain retryable. */ }
|
|
1079
|
+
}
|
|
1012
1080
|
}
|
|
1013
1081
|
|
|
1014
1082
|
createOrReuse(request: CreateCandidateViewRequest): CandidateView {
|
|
@@ -1017,7 +1085,7 @@ export class CandidateViewRegistry {
|
|
|
1017
1085
|
const scopedReplayKey = normalizedRequest.replayKey === undefined ? undefined : this.replayKey(contributorRoot, normalizedRequest.replayKey);
|
|
1018
1086
|
const token = scopedReplayKey === undefined ? undefined : this.replays.get(scopedReplayKey);
|
|
1019
1087
|
const existing = token === undefined ? undefined : this.records.get(token);
|
|
1020
|
-
if (existing) { assertRecordSafe(existing); return this.expose(existing); }
|
|
1088
|
+
if (existing) { assertRecordSafe(existing, this.platform); return this.expose(existing); }
|
|
1021
1089
|
const record = materializeCandidateView(normalizedRequest, this.gitExecutor);
|
|
1022
1090
|
this.records.set(record.token, record);
|
|
1023
1091
|
if (scopedReplayKey !== undefined) this.replays.set(scopedReplayKey, record.token);
|
|
@@ -1030,6 +1098,32 @@ export class CandidateViewRegistry {
|
|
|
1030
1098
|
|
|
1031
1099
|
bindCurrent(request: BindCandidateViewRequest): void {
|
|
1032
1100
|
const selectedLenses = this.validateSelectedLenses(request.selectedLenses);
|
|
1101
|
+
const candidate = this.records.get(request.token);
|
|
1102
|
+
const key = candidate === undefined ? undefined : this.lineageKey(candidate.contributorRoot, request.lineageId);
|
|
1103
|
+
const existingToken = key === undefined ? undefined : this.lineages.get(key);
|
|
1104
|
+
// A retried native START for a lineage this controller already bound
|
|
1105
|
+
// (native reports it "resumed") re-materializes a fresh, content-
|
|
1106
|
+
// identical candidate view under a new token before it reaches here.
|
|
1107
|
+
// Rebinding that duplicate to the same lineage key used to fail closed
|
|
1108
|
+
// with "candidate view lineage binding is missing or ambiguous" even
|
|
1109
|
+
// though nothing is actually ambiguous: it is the exact same reviewable
|
|
1110
|
+
// content, re-verified from Git. Discard the redundant duplicate and
|
|
1111
|
+
// keep the already-bound view current instead of failing the retry
|
|
1112
|
+
// (gentle-pi candidate-view rebind defect, ga#4085 / ga#4050).
|
|
1113
|
+
if (candidate !== undefined && existingToken !== undefined && existingToken !== request.token) {
|
|
1114
|
+
const existing = this.records.get(existingToken);
|
|
1115
|
+
let existingSafe = false;
|
|
1116
|
+
if (existing !== undefined && existing.lineageId === request.lineageId) {
|
|
1117
|
+
try { assertRecordSafe(existing, this.platform); existingSafe = true; } catch { existingSafe = false; }
|
|
1118
|
+
}
|
|
1119
|
+
if (existing !== undefined && existingSafe && candidateRecordsShareIdentity(existing, candidate)) {
|
|
1120
|
+
existing.selectedLenses = selectedLenses;
|
|
1121
|
+
this.remove(candidate);
|
|
1122
|
+
this.forget(candidate);
|
|
1123
|
+
this.current.set(existing.contributorRoot, { lineageId: request.lineageId, token: existingToken });
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1033
1127
|
const record = this.bindRecord(request.token, request.lineageId, selectedLenses);
|
|
1034
1128
|
this.current.set(record.contributorRoot, { lineageId: request.lineageId, token: request.token });
|
|
1035
1129
|
}
|
|
@@ -1043,7 +1137,7 @@ export class CandidateViewRegistry {
|
|
|
1043
1137
|
const record = this.records.get(request.token);
|
|
1044
1138
|
if (!record || record.lineageId !== undefined) throw new CandidateViewError("native reviewing candidate view is missing or already bound", "authoritative-current-match-missing");
|
|
1045
1139
|
if (this.current.has(record.contributorRoot)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
|
|
1046
|
-
assertRecordSafe(record);
|
|
1140
|
+
assertRecordSafe(record, this.platform);
|
|
1047
1141
|
this.assertCurrentBindingMatchesLiveCandidate(record);
|
|
1048
1142
|
this.bindCurrent(request);
|
|
1049
1143
|
}
|
|
@@ -1085,7 +1179,7 @@ export class CandidateViewRegistry {
|
|
|
1085
1179
|
const existing = existingToken === undefined ? undefined : this.records.get(existingToken);
|
|
1086
1180
|
if (existing) {
|
|
1087
1181
|
if (existing.lineageId !== undefined) throw new CandidateViewError("corrected candidate replay is no longer pending");
|
|
1088
|
-
assertRecordSafe(existing);
|
|
1182
|
+
assertRecordSafe(existing, this.platform);
|
|
1089
1183
|
return this.expose(existing);
|
|
1090
1184
|
}
|
|
1091
1185
|
const record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor);
|
|
@@ -1115,8 +1209,8 @@ export class CandidateViewRegistry {
|
|
|
1115
1209
|
if (currentBinding !== undefined && currentBinding.lineageId !== lineageId) {
|
|
1116
1210
|
throw new CandidateViewError("corrected candidate replacement conflicts with the current lineage binding");
|
|
1117
1211
|
}
|
|
1118
|
-
assertRecordSafe(replacement);
|
|
1119
|
-
if (current) assertRecordSafe(current);
|
|
1212
|
+
assertRecordSafe(replacement, this.platform);
|
|
1213
|
+
if (current) assertRecordSafe(current, this.platform);
|
|
1120
1214
|
if (
|
|
1121
1215
|
replacement.contributorRoot !== projection.contributorRoot ||
|
|
1122
1216
|
replacement.baseCommit !== projection.baseCommit ||
|
|
@@ -1177,7 +1271,7 @@ export class CandidateViewRegistry {
|
|
|
1177
1271
|
const record = this.records.get(token);
|
|
1178
1272
|
const key = record === undefined ? undefined : this.lineageKey(record.contributorRoot, lineageId);
|
|
1179
1273
|
if (!record || record.lineageId !== undefined || !key || this.lineages.has(key)) throw new CandidateViewError("candidate view lineage binding is missing or ambiguous");
|
|
1180
|
-
assertRecordSafe(record);
|
|
1274
|
+
assertRecordSafe(record, this.platform);
|
|
1181
1275
|
record.lineageId = lineageId;
|
|
1182
1276
|
record.selectedLenses = selectedLenses;
|
|
1183
1277
|
this.lineages.set(key, record.token);
|
|
@@ -1276,21 +1370,18 @@ export class CandidateViewRegistry {
|
|
|
1276
1370
|
* correction.
|
|
1277
1371
|
*/
|
|
1278
1372
|
rebindForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
|
|
1279
|
-
|
|
1280
|
-
const key = this.lineageKey(root, lineageId);
|
|
1281
|
-
const staleToken = this.lineages.get(key);
|
|
1282
|
-
const stale = staleToken === undefined ? undefined : this.records.get(staleToken);
|
|
1283
|
-
this.projections.delete(key);
|
|
1284
|
-
if (stale !== undefined) {
|
|
1285
|
-
this.remove(stale);
|
|
1286
|
-
this.forget(stale);
|
|
1287
|
-
}
|
|
1288
|
-
return this.restoreForFinalizeFromNative(lineageId, root, descriptor);
|
|
1373
|
+
return this.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor);
|
|
1289
1374
|
}
|
|
1290
1375
|
|
|
1376
|
+
/**
|
|
1377
|
+
* Restores a lineage's FINALIZE binding (gentle-pi #185): the stale entry
|
|
1378
|
+
* is only detached, not destroyed, so a failed restore can undo it.
|
|
1379
|
+
*/
|
|
1291
1380
|
restoreForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
|
|
1292
1381
|
const root = this.canonicalRoot(contributorRoot);
|
|
1293
1382
|
const key = this.lineageKey(root, lineageId);
|
|
1383
|
+
const staleToken = this.lineages.get(key), staleProjection = this.projections.get(key);
|
|
1384
|
+
this.lineages.delete(key); this.projections.delete(key);
|
|
1294
1385
|
let projectionRestored = false;
|
|
1295
1386
|
let record: CandidateViewRecord | undefined;
|
|
1296
1387
|
try {
|
|
@@ -1308,6 +1399,8 @@ export class CandidateViewRegistry {
|
|
|
1308
1399
|
if (!matchesProjection(record)) throw new CandidateViewError("live candidate does not match the native frozen projection");
|
|
1309
1400
|
this.records.set(record.token, record);
|
|
1310
1401
|
this.bindRecord(record.token, lineageId, []);
|
|
1402
|
+
const stale = staleToken === undefined ? undefined : this.records.get(staleToken);
|
|
1403
|
+
if (stale !== undefined) { this.remove(stale); this.forget(stale); }
|
|
1311
1404
|
return this.expose(record);
|
|
1312
1405
|
} catch (error) {
|
|
1313
1406
|
if (projectionRestored) this.projections.delete(key);
|
|
@@ -1315,6 +1408,8 @@ export class CandidateViewRegistry {
|
|
|
1315
1408
|
this.forget(record);
|
|
1316
1409
|
this.remove(record);
|
|
1317
1410
|
}
|
|
1411
|
+
if (staleToken !== undefined) this.lineages.set(key, staleToken);
|
|
1412
|
+
if (staleProjection !== undefined) this.projections.set(key, staleProjection);
|
|
1318
1413
|
throw error;
|
|
1319
1414
|
}
|
|
1320
1415
|
}
|
|
@@ -1375,7 +1470,7 @@ export class CandidateViewRegistry {
|
|
|
1375
1470
|
const token = this.lineages.get(key);
|
|
1376
1471
|
const record = token === undefined ? undefined : this.records.get(token);
|
|
1377
1472
|
if (!record || record.lineageId !== lineageId || !record.selectedLenses?.includes(lens as ReviewLens)) throw new CandidateViewError("candidate view context is missing, ambiguous, stale, or lens-unselected");
|
|
1378
|
-
assertRecordSafe(record);
|
|
1473
|
+
assertRecordSafe(record, this.platform);
|
|
1379
1474
|
return this.expose(record);
|
|
1380
1475
|
}
|
|
1381
1476
|
|
|
@@ -1426,7 +1521,7 @@ export class CandidateViewRegistry {
|
|
|
1426
1521
|
const current = this.currentBinding(contributorRoot);
|
|
1427
1522
|
const record = this.records.get(current.token);
|
|
1428
1523
|
if (!record || record.lineageId !== current.lineageId || this.lineages.get(this.lineageKey(current.root, current.lineageId)) !== current.token) throw new CandidateViewError("review subagent dispatch current lineage binding is stale or ambiguous", "current-binding-stale");
|
|
1429
|
-
assertRecordSafe(record);
|
|
1524
|
+
assertRecordSafe(record, this.platform);
|
|
1430
1525
|
this.assertCurrentBindingMatchesLiveCandidate(record);
|
|
1431
1526
|
if (!lenses.every((lens) => record.selectedLenses?.includes(lens as ReviewLens))) throw new CandidateViewError("candidate view context is missing, ambiguous, stale, or lens-unselected", "current-binding-lens-unselected");
|
|
1432
1527
|
return lenses.map(() => this.expose(record));
|
|
@@ -1455,7 +1550,7 @@ export class CandidateViewRegistry {
|
|
|
1455
1550
|
const token = this.lineages.get(key);
|
|
1456
1551
|
const record = token === undefined ? undefined : this.records.get(token);
|
|
1457
1552
|
if (!record || record.lineageId !== lineageId) throw new CandidateViewError("candidate view context is missing or ambiguous for FINALIZE");
|
|
1458
|
-
assertRecordSafe(record);
|
|
1553
|
+
assertRecordSafe(record, this.platform);
|
|
1459
1554
|
return this.expose(record);
|
|
1460
1555
|
}
|
|
1461
1556
|
|
|
@@ -1476,10 +1571,7 @@ export class CandidateViewRegistry {
|
|
|
1476
1571
|
|
|
1477
1572
|
private remove(record: CandidateViewRecord): void {
|
|
1478
1573
|
if (!isWithin(record.parent, record.root)) throw new CandidateViewError("candidate view cleanup escaped its owned parent");
|
|
1479
|
-
|
|
1480
|
-
try { git(record.contributorRoot, ["worktree", "remove", "--force", record.root], process.env, record.gitExecutor); } catch {}
|
|
1481
|
-
// Git removes worktree metadata; physical removal remains this owner's duty.
|
|
1482
|
-
rmSync(record.root, { recursive: true, force: true });
|
|
1574
|
+
removeCandidateOwner(record.owner, (args) => git(record.commonDir, args, process.env, record.gitExecutor), makeWritableForCleanup);
|
|
1483
1575
|
}
|
|
1484
1576
|
|
|
1485
1577
|
private forget(record: CandidateViewRecord): void {
|
|
@@ -1511,7 +1603,7 @@ export class CandidateViewRegistry {
|
|
|
1511
1603
|
modes: record.scope.modes,
|
|
1512
1604
|
gitlinks: record.scope.gitlinks,
|
|
1513
1605
|
deletedPaths: record.scope.deletedPaths,
|
|
1514
|
-
verify: () => assertRecordSafe(record),
|
|
1606
|
+
verify: () => assertRecordSafe(record, this.platform),
|
|
1515
1607
|
cleanup: () => this.cleanup(record.token),
|
|
1516
1608
|
};
|
|
1517
1609
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ReviewConsentEnvelope, ReviewConsentV3 } from "./review-integration-v2.ts";
|
|
3
|
+
|
|
4
|
+
export const HOST_REVIEW_SESSION_PERMISSION_LABEL = "Run this review and allow reviews for this Pi session";
|
|
5
|
+
|
|
6
|
+
const HOST_REVIEW_SESSION_PERMISSION_EFFECT =
|
|
7
|
+
"Runs the provider's exact grant for this frozen candidate, then lets the Pi host use each later candidate's fresh validated provider grant once while this exact Pi session and canonical Git repository identity, including sibling worktrees, remain active. Reload keeps it; new, resume, fork, quit, process restart, or explicit revocation ends it. It grants no verdict, acknowledgement, maintenance, delivery, or cross-repository authority.";
|
|
8
|
+
|
|
9
|
+
export type ReviewConsentUiSelection =
|
|
10
|
+
| { kind: "provider"; answer: "granted" | "declined" }
|
|
11
|
+
| { kind: "host-session" };
|
|
12
|
+
|
|
13
|
+
export interface ReviewConsentUiModel {
|
|
14
|
+
readonly title: string;
|
|
15
|
+
readonly options: readonly [string, string, string];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isPiConsentV3(consent: ReviewConsentEnvelope): consent is ReviewConsentV3 {
|
|
19
|
+
return consent.schema === "gentle-ai.review-integration.consent/v3" && consent.agent === "pi";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatReviewConsentUi(consent: ReviewConsentV3): ReviewConsentUiModel {
|
|
23
|
+
const evidence = consent.riskEvidence.map((item) => `- ${item}`).join("\n");
|
|
24
|
+
const title = [
|
|
25
|
+
consent.headline,
|
|
26
|
+
"",
|
|
27
|
+
`Reason: ${consent.reason}`,
|
|
28
|
+
`Value: ${consent.value}`,
|
|
29
|
+
`Risk: ${consent.riskLevel}; ${consent.changedFiles} changed file(s), ${consent.changedLines} changed line(s).`,
|
|
30
|
+
`Target: ${consent.targetIdentity}`,
|
|
31
|
+
`Projection: ${consent.projection}`,
|
|
32
|
+
"Risk evidence:",
|
|
33
|
+
evidence,
|
|
34
|
+
"",
|
|
35
|
+
"Ownership: The first two actions are provider-owned and apply only to this candidate. The third action is owned by the Pi host and controls only an in-memory permission for this exact Pi session and canonical Git repository identity, including sibling worktrees.",
|
|
36
|
+
"",
|
|
37
|
+
`Off-path note: ${consent.offPath.note}`,
|
|
38
|
+
`Off-path command: ${consent.offPath.command}`,
|
|
39
|
+
].join("\n");
|
|
40
|
+
return {
|
|
41
|
+
title,
|
|
42
|
+
options: [
|
|
43
|
+
`1. ${consent.choices[0].label}\nEffect: ${consent.choices[0].effect}`,
|
|
44
|
+
`2. ${consent.choices[1].label}\nEffect: ${consent.choices[1].effect}`,
|
|
45
|
+
`3. ${HOST_REVIEW_SESSION_PERMISSION_LABEL}\nEffect: ${HOST_REVIEW_SESSION_PERMISSION_EFFECT}`,
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function presentReviewConsentUi(
|
|
51
|
+
context: ExtensionContext,
|
|
52
|
+
consent: ReviewConsentEnvelope,
|
|
53
|
+
): Promise<ReviewConsentUiSelection | undefined> {
|
|
54
|
+
if (!isPiConsentV3(consent)) return undefined;
|
|
55
|
+
const model = formatReviewConsentUi(consent);
|
|
56
|
+
try {
|
|
57
|
+
const selected = await context.ui.select(model.title, [...model.options]);
|
|
58
|
+
if (selected === model.options[0]) return { kind: "provider", answer: "granted" };
|
|
59
|
+
if (selected === model.options[1]) return { kind: "provider", answer: "declined" };
|
|
60
|
+
if (selected === model.options[2]) return { kind: "host-session" };
|
|
61
|
+
return undefined;
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -79,7 +79,10 @@ const RISK_LEVELS = ["low", "medium", "high"] as const;
|
|
|
79
79
|
const REVIEW_LENSES = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
|
|
80
80
|
const RISK_REASON_CODES = ["configuration_change", "empty_content", "executable_change", "executable_mode", "hot_path", "large_change", "non_executable_only", "process_boundary", "process_scan_limit", "service_token", "shell_source"] as const;
|
|
81
81
|
const RISK_SIGNALS = ["auth", "update", "security", "payments", "permissions", "shell_process"] as const;
|
|
82
|
-
|
|
82
|
+
// "collect" and "execute" are the v2 envelope's projection of a live
|
|
83
|
+
// transaction whose next_transition is mandatory: the root action names the
|
|
84
|
+
// transition kind instead of reading as a terminal stop.
|
|
85
|
+
const STATUS_ACTIONS = ["start", "recover", "maintainer_action", "select_lineage", "repair_authority", "stop", "collect", "execute"] as const;
|
|
83
86
|
const RECEIPT_STATUSES = ["expected_missing", "present", "publication_pending", "not_applicable"] as const;
|
|
84
87
|
type ReviewReceiptStatus = (typeof RECEIPT_STATUSES)[number];
|
|
85
88
|
export const REVIEW_STATUS_ACTION_DISPOSITION = {
|
|
@@ -385,6 +388,8 @@ export interface ReviewStatusFrozenV1 {
|
|
|
385
388
|
tier: RiskLevel;
|
|
386
389
|
originalChangedLines: number;
|
|
387
390
|
correctionBudget: number;
|
|
391
|
+
/** Digest of the frozen changed-path manifest; binds manifest-less capture inputs (gentle-ai#3922). */
|
|
392
|
+
changedPathManifestSha256?: string;
|
|
388
393
|
}
|
|
389
394
|
|
|
390
395
|
export interface ReviewStatusReconciliationV1 {
|
|
@@ -545,6 +550,16 @@ export interface ReviewCollectInputV3 {
|
|
|
545
550
|
validationRequest?: ReviewTargetedValidationRequestV1;
|
|
546
551
|
}
|
|
547
552
|
|
|
553
|
+
// gentle-pi#627: the exact `gentle-ai sync` invocation that resolves a stale
|
|
554
|
+
// managed-asset set (STATUS next_transition stop, and START's preflight
|
|
555
|
+
// failure envelope both carry this same shape).
|
|
556
|
+
export interface ReviewManagedAssetsContinuationV1 {
|
|
557
|
+
operation: "sync";
|
|
558
|
+
command: string;
|
|
559
|
+
agent?: string;
|
|
560
|
+
staleAssets?: readonly string[];
|
|
561
|
+
}
|
|
562
|
+
|
|
548
563
|
export interface ReviewNextTransitionV3 {
|
|
549
564
|
kind: "execute" | "collect" | "stop";
|
|
550
565
|
reasonCode: string;
|
|
@@ -552,6 +567,8 @@ export interface ReviewNextTransitionV3 {
|
|
|
552
567
|
collect?: { inputs: readonly ReviewCollectInputV3[] };
|
|
553
568
|
/** status/v5 only: the bounded correction plan request. */
|
|
554
569
|
correctionRequest?: ReviewCorrectionPlanRequestV1;
|
|
570
|
+
/** stop only, reason_code managed_assets_outdated: the sync continuation. */
|
|
571
|
+
continuation?: ReviewManagedAssetsContinuationV1;
|
|
555
572
|
}
|
|
556
573
|
|
|
557
574
|
export interface ReviewStatusV3 {
|
|
@@ -705,6 +722,8 @@ export interface ReviewFailureV2 {
|
|
|
705
722
|
causeCategory?: ReviewFailureCauseCategoryV2;
|
|
706
723
|
cause?: string;
|
|
707
724
|
context?: ReviewFailureContextV2;
|
|
725
|
+
/** code=managed_assets_outdated only: the sync continuation (gentle-pi#627). */
|
|
726
|
+
continuation?: ReviewManagedAssetsContinuationV1;
|
|
708
727
|
raw: Readonly<Record<string, unknown>>;
|
|
709
728
|
}
|
|
710
729
|
|
|
@@ -1639,8 +1658,11 @@ function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: bool
|
|
|
1639
1658
|
}
|
|
1640
1659
|
|
|
1641
1660
|
if (captureOperation === "review.capture-result") {
|
|
1642
|
-
|
|
1643
|
-
|
|
1661
|
+
// changed_path_manifest is optional here: the artifact subject's
|
|
1662
|
+
// changed_path_manifest_sha256 already binds the frozen manifest, and
|
|
1663
|
+
// gentle-ai stops inlining one copy per lens (gentle-ai#3922).
|
|
1664
|
+
if (input.artifact_subject === undefined || input.base_tree === undefined || input.candidate_tree === undefined) {
|
|
1665
|
+
throw new TypeError(`${label} requires artifact_subject, base_tree, and candidate_tree`);
|
|
1644
1666
|
}
|
|
1645
1667
|
if (schema !== "https://gentle-ai.dev/schema/review/reviewer/v1") throw new TypeError(`${label}.schema must be https://gentle-ai.dev/schema/review/reviewer/v1`);
|
|
1646
1668
|
} else if (input.artifact_subject !== undefined || input.base_tree !== undefined || input.candidate_tree !== undefined || input.changed_path_manifest !== undefined) {
|
|
@@ -1673,12 +1695,32 @@ function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: bool
|
|
|
1673
1695
|
};
|
|
1674
1696
|
}
|
|
1675
1697
|
|
|
1698
|
+
// gentle-pi#627: gentle-ai's managed-assets continuation (STATUS next_transition
|
|
1699
|
+
// stop reason_code=managed_assets_outdated, and START's preflight failure
|
|
1700
|
+
// envelope) names the exact `gentle-ai sync` invocation that resolves a stale
|
|
1701
|
+
// managed-asset set without abandoning the frozen candidate. Decoded verbatim
|
|
1702
|
+
// from internal/cli/review_operation_contract.go's ReviewManagedAssetsContinuation.
|
|
1703
|
+
export function decodeReviewManagedAssetsContinuationV1(value: unknown, label: string): ReviewManagedAssetsContinuationV1 {
|
|
1704
|
+
const body = exactRecord(value, label, ["operation", "command"], ["agent", "stale_assets"]);
|
|
1705
|
+
const operation = enumeration(body.operation, ["sync"] as const, `${label}.operation`);
|
|
1706
|
+
const command = nonempty(body.command, `${label}.command`);
|
|
1707
|
+
const agent = body.agent === undefined ? undefined : nonempty(body.agent, `${label}.agent`);
|
|
1708
|
+
const staleAssets = body.stale_assets === undefined ? undefined : stringArray(body.stale_assets, `${label}.stale_assets`, { minimum: 1 });
|
|
1709
|
+
return { operation, command, ...(agent === undefined ? {} : { agent }), ...(staleAssets === undefined ? {} : { staleAssets }) };
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1676
1712
|
export function decodeReviewNextTransitionV3(value: unknown, options: { v5?: boolean; v6?: boolean } = {}): ReviewNextTransitionV3 {
|
|
1677
1713
|
const v6 = options.v6 === true;
|
|
1678
1714
|
const v5 = options.v5 === true || v6;
|
|
1679
|
-
const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect", ...(v5 ? ["correction_request"] : [])]);
|
|
1715
|
+
const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect", ...(v5 ? ["correction_request"] : []), "continuation"]);
|
|
1680
1716
|
const kind = enumeration(transition.kind, ["execute", "collect", "stop"] as const, "next_transition.kind");
|
|
1681
1717
|
const reasonCode = text(transition.reason_code, "next_transition.reason_code", { minimum: 1, pattern: /^[a-z0-9_]+$/ });
|
|
1718
|
+
const continuation = transition.continuation === undefined ? undefined : decodeReviewManagedAssetsContinuationV1(transition.continuation, "next_transition.continuation");
|
|
1719
|
+
// gentle-pi#627: continuation is optional on every status version an older
|
|
1720
|
+
// gentle-ai may emit a managed_assets_outdated stop with no continuation at
|
|
1721
|
+
// all, and decode must not refuse the whole envelope for that. When present
|
|
1722
|
+
// it is only valid on a stop with this exact reason_code.
|
|
1723
|
+
if (continuation !== undefined && !(kind === "stop" && reasonCode === "managed_assets_outdated")) throw new TypeError("next_transition.continuation is only valid for a stop transition with reason_code managed_assets_outdated");
|
|
1682
1724
|
|
|
1683
1725
|
// status/v5: the bounded correction plan request rides exactly its two
|
|
1684
1726
|
// reason codes and never any other (vendored status-v5.schema.json).
|
|
@@ -1728,7 +1770,7 @@ export function decodeReviewNextTransitionV3(value: unknown, options: { v5?: boo
|
|
|
1728
1770
|
return { kind, reasonCode, collect: { inputs }, ...(correctionRequest === undefined ? {} : { correctionRequest }) };
|
|
1729
1771
|
}
|
|
1730
1772
|
if (transition.execute !== undefined || transition.collect !== undefined) throw new TypeError("next_transition stop cannot carry a transition");
|
|
1731
|
-
return { kind, reasonCode, ...(correctionRequest === undefined ? {} : { correctionRequest }) };
|
|
1773
|
+
return { kind, reasonCode, ...(correctionRequest === undefined ? {} : { correctionRequest }), ...(continuation === undefined ? {} : { continuation }) };
|
|
1732
1774
|
}
|
|
1733
1775
|
|
|
1734
1776
|
// ---------------------------------------------------------------------------
|
|
@@ -1817,11 +1859,12 @@ export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 {
|
|
|
1817
1859
|
|
|
1818
1860
|
let frozen: ReviewStatusFrozenV1 | undefined;
|
|
1819
1861
|
if (body.frozen !== undefined) {
|
|
1820
|
-
const source = exactRecord(body.frozen, "status.frozen", ["tier", "original_changed_lines", "correction_budget"]);
|
|
1862
|
+
const source = exactRecord(body.frozen, "status.frozen", ["tier", "original_changed_lines", "correction_budget"], ["changed_path_manifest_sha256"]);
|
|
1821
1863
|
frozen = {
|
|
1822
1864
|
tier: enumeration(source.tier, RISK_LEVELS, "status.frozen.tier"),
|
|
1823
1865
|
originalChangedLines: integer(source.original_changed_lines, "status.frozen.original_changed_lines"),
|
|
1824
1866
|
correctionBudget: integer(source.correction_budget, "status.frozen.correction_budget", 0, 200),
|
|
1867
|
+
...(source.changed_path_manifest_sha256 === undefined ? {} : { changedPathManifestSha256: sha256(source.changed_path_manifest_sha256, "status.frozen.changed_path_manifest_sha256") }),
|
|
1825
1868
|
};
|
|
1826
1869
|
}
|
|
1827
1870
|
if (authority?.version === REVIEW_AUTHORITY_VERSION.COMPACT_V2 && frozen === undefined) throw new TypeError("compact-v2 status requires frozen metadata");
|
|
@@ -2048,9 +2091,10 @@ function decodeFailureContext(value: unknown, label: string): ReviewFailureConte
|
|
|
2048
2091
|
export function decodeReviewFailureV2(value: unknown): ReviewFailureV2 {
|
|
2049
2092
|
const body = exactRecord(value, "failure", [
|
|
2050
2093
|
"schema", "contract", "operation", "phase", "code", "message", "mutation_outcome", "authority_applicability", "retry_safe", "replayability", "required_inputs", "next_action",
|
|
2051
|
-
], ["lineage_id", "request_digest", "progress_identity", "cause_category", "cause", "context"]);
|
|
2094
|
+
], ["lineage_id", "request_digest", "progress_identity", "cause_category", "cause", "context", "continuation"]);
|
|
2052
2095
|
requireIdentity(body, "gentle-ai.review-integration.failure/v2");
|
|
2053
2096
|
const operation = enumeration(body.operation, FAILURE_OPERATIONS, "failure.operation");
|
|
2097
|
+
const code = text(body.code, "failure.code", { pattern: /^[a-z0-9]+(?:_[a-z0-9]+)*$/ });
|
|
2054
2098
|
|
|
2055
2099
|
if (body.progress_identity !== undefined) {
|
|
2056
2100
|
if (body.lineage_id === undefined || body.request_digest === undefined) throw new TypeError("failure.progress_identity requires lineage_id and request_digest");
|
|
@@ -2059,13 +2103,18 @@ export function decodeReviewFailureV2(value: unknown): ReviewFailureV2 {
|
|
|
2059
2103
|
if (body.request_digest !== undefined && operation === REVIEW_INTEGRATION_OPERATION.REPAIR && body.progress_identity === undefined) {
|
|
2060
2104
|
throw new TypeError("failure.request_digest with review.repair requires progress_identity");
|
|
2061
2105
|
}
|
|
2106
|
+
// gentle-pi#627: continuation is optional on every version — an older
|
|
2107
|
+
// gentle-ai may emit managed_assets_outdated with no continuation, and
|
|
2108
|
+
// decode must not refuse the whole envelope for that. When present it is
|
|
2109
|
+
// only valid on this exact code.
|
|
2110
|
+
if (body.continuation !== undefined && code !== "managed_assets_outdated") throw new TypeError("failure.continuation is only valid for code managed_assets_outdated");
|
|
2062
2111
|
|
|
2063
2112
|
return {
|
|
2064
2113
|
schema: "gentle-ai.review-integration.failure/v2",
|
|
2065
2114
|
contract: REVIEW_INTEGRATION_CONTRACT,
|
|
2066
2115
|
operation,
|
|
2067
2116
|
phase: enumeration(body.phase, ["preflight", "pre_native", "native_running", "native_committed", "reconciliation"] as const, "failure.phase"),
|
|
2068
|
-
code
|
|
2117
|
+
code,
|
|
2069
2118
|
message: text(body.message, "failure.message", { minimum: 1, maximum: 240, pattern: /^[^\r\n]+$/ }),
|
|
2070
2119
|
mutationOutcome: enumeration(body.mutation_outcome, Object.values(REVIEW_MUTATION_OUTCOME), "failure.mutation_outcome"),
|
|
2071
2120
|
authorityApplicability: enumeration(body.authority_applicability, Object.values(REVIEW_AUTHORITY_APPLICABILITY), "failure.authority_applicability"),
|
|
@@ -2079,6 +2128,7 @@ export function decodeReviewFailureV2(value: unknown): ReviewFailureV2 {
|
|
|
2079
2128
|
...(body.cause_category === undefined ? {} : { causeCategory: text(body.cause_category, "failure.cause_category", { minimum: 1, pattern: /^[a-z0-9]+(?:_[a-z0-9]+)*$/ }) }),
|
|
2080
2129
|
...(body.cause === undefined ? {} : { cause: text(body.cause, "failure.cause", { minimum: 1, maximum: 4000, pattern: /^[^\r\n]+$/ }) }),
|
|
2081
2130
|
...(body.context === undefined ? {} : { context: decodeFailureContext(body.context, "failure.context") }),
|
|
2131
|
+
...(body.continuation === undefined ? {} : { continuation: decodeReviewManagedAssetsContinuationV1(body.continuation, "failure.continuation") }),
|
|
2082
2132
|
raw: body,
|
|
2083
2133
|
};
|
|
2084
2134
|
}
|
|
@@ -14,3 +14,14 @@
|
|
|
14
14
|
|
|
15
15
|
export const GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV = "GENTLE_PI_REVIEW_RELAY_CONTRACT";
|
|
16
16
|
export const GENTLE_PI_REVIEW_RELAY_CONTRACT = "gentle-pi.review-relay/v1";
|
|
17
|
+
|
|
18
|
+
// The host is this extension: every process the session starts (pi's shell
|
|
19
|
+
// tools, Gentle Agents children) runs under it, so the handshake belongs in
|
|
20
|
+
// the session environment too, not only in the extension's own invocations.
|
|
21
|
+
// Without it the first `gentle-ai review status --agent pi` typed in the
|
|
22
|
+
// session shell fails closed (gentle-pi#550). Returns whether anything changed.
|
|
23
|
+
export function declareReviewRelayHandshake(env: NodeJS.ProcessEnv): boolean {
|
|
24
|
+
if (env[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV] === GENTLE_PI_REVIEW_RELAY_CONTRACT) return false;
|
|
25
|
+
env[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV] = GENTLE_PI_REVIEW_RELAY_CONTRACT;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
package/lib/review-repository.ts
CHANGED
|
@@ -55,8 +55,8 @@ export function reviewGitEnvironment(): NodeJS.ProcessEnv {
|
|
|
55
55
|
const environment: NodeJS.ProcessEnv = {};
|
|
56
56
|
for (const [key, value] of Object.entries(process.env)) if (!key.startsWith("GIT_")) environment[key] = value;
|
|
57
57
|
environment.GIT_CONFIG_NOSYSTEM = "1";
|
|
58
|
-
environment.GIT_CONFIG_GLOBAL =
|
|
59
|
-
environment.GIT_CONFIG_SYSTEM =
|
|
58
|
+
environment.GIT_CONFIG_GLOBAL = "/dev/null";
|
|
59
|
+
environment.GIT_CONFIG_SYSTEM = "/dev/null";
|
|
60
60
|
environment.GIT_OPTIONAL_LOCKS = "0";
|
|
61
61
|
environment.LC_ALL = "C";
|
|
62
62
|
environment.LANG = "C";
|