gentle-pi 2.2.0 → 2.3.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 +51 -80
- package/assets/agents/review-readability.md +0 -2
- package/assets/agents/review-reliability.md +0 -2
- package/assets/agents/review-resilience.md +0 -2
- package/assets/agents/review-risk.md +0 -2
- package/assets/agents/sdd-apply.md +5 -3
- package/assets/agents/sdd-proposal.md +2 -0
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-status.md +4 -4
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +21 -1
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +54 -248
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +22 -38
- package/assets/sdd-orchestrator-workflow.md +30 -37
- package/assets/support/sdd-status-contract.md +7 -7
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
- package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
- package/docs/native-authority-architecture.md +9 -11
- package/docs/review-integration.md +27 -373
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +1778 -3673
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +4 -8
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +372 -940
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/review-candidate-view.ts +341 -132
- package/lib/review-host-relay.ts +210 -68
- package/lib/review-integration-v2.ts +839 -307
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/sdd-preflight.ts +177 -66
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -82
- package/runtime/gentle-ai-binary.mjs +1 -1
- package/runtime/native-review-cli.mjs +322 -890
- package/runtime/review-integration-v2.mjs +789 -257
- package/runtime/review-relay-contract.mjs +1 -1
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
- package/scripts/gentle-ai-installer.mjs +75 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
- package/scripts/test-packed-runner.mjs +4 -7
- package/scripts/verify-package-files.mjs +12 -13
- package/skills/_shared/review-ledger-contract.md +8 -14
- package/skills/chained-pr/SKILL.md +3 -0
- package/skills/cognitive-doc-design/SKILL.md +1 -1
- package/skills/comment-writer/SKILL.md +1 -1
- package/skills/gentle-ai/SKILL.md +7 -74
- package/skills/judgment-day/SKILL.md +5 -7
- package/skills/rdd-defect-workflow/SKILL.md +3 -3
- package/skills/release/SKILL.md +3 -3
- package/skills/skill-registry/SKILL.md +1 -1
- package/skills/work-unit-commits/SKILL.md +3 -1
- package/tests/artifact-language.test.ts +24 -11
- package/tests/ask-user-choice.test.ts +264 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +15 -1168
- package/tests/delegated-key-learnings-contract.test.ts +8 -6
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
- package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
- package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary.test.ts +51 -228
- package/tests/gentle-ai-installer.test.ts +137 -47
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +340 -4
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +28 -1
- package/tests/native-review-cli.test.ts +586 -962
- package/tests/native-review-consent.test.ts +150 -66
- package/tests/native-review-parity-runtime.test.ts +80 -359
- package/tests/native-review-parity.test.ts +674 -925
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +132 -88
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +37 -35
- package/tests/provider-defect-handoff.test.ts +15 -118
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +409 -3
- package/tests/review-compact-contract.test.ts +4 -30
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +363 -1450
- package/tests/review-controller-native-routing.test.ts +1251 -5642
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +222 -71
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +112 -153
- package/tests/review-dispatch-hydration-gap.test.ts +1 -53
- package/tests/review-gate.test.ts +0 -45
- package/tests/review-host-relay-restart-parity.test.ts +360 -0
- package/tests/review-host-relay-routing.test.ts +117 -82
- package/tests/review-host-relay.test.ts +242 -8
- package/tests/review-integration-v2-forward.test.ts +576 -553
- package/tests/review-integration-v2.test.ts +239 -165
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +97 -35
- package/tests/review-recovered-lineage-routing.test.ts +0 -47
- package/tests/review-relay-transport-agent.test.ts +119 -46
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +361 -196
- package/tests/sdd-agent-tools.test.ts +36 -0
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +5 -8
- package/tests/writer-edit-surface-scope.test.ts +230 -0
- package/themes/Gentleman-Cute.json +94 -0
- package/themes/Gentleman-Sexy.json +92 -0
- package/lib/git-commit-transaction.ts +0 -861
- package/runtime/git-commit-transaction.mjs +0 -862
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
- package/tests/git-commit-transaction.test.ts +0 -530
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync, type ExecFileSyncOptions } from "node:child_process";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync } from "node:fs";
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync } 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";
|
|
@@ -103,6 +103,7 @@ interface CandidateViewRecord {
|
|
|
103
103
|
baseTree: string;
|
|
104
104
|
candidateTree: string;
|
|
105
105
|
committedOnly: boolean;
|
|
106
|
+
intendedUntracked?: readonly string[];
|
|
106
107
|
entries: readonly CandidateViewEntry[];
|
|
107
108
|
gitlinks: readonly CandidateGitlink[];
|
|
108
109
|
scope: CandidateViewScope;
|
|
@@ -114,10 +115,12 @@ interface CandidateViewRecord {
|
|
|
114
115
|
export interface CandidateView {
|
|
115
116
|
token: string;
|
|
116
117
|
root: string;
|
|
118
|
+
contributorRoot: string;
|
|
117
119
|
baseCommit: string;
|
|
118
120
|
baseTree: string;
|
|
119
121
|
candidateTree: string;
|
|
120
122
|
committedOnly: boolean;
|
|
123
|
+
intendedUntracked?: readonly string[];
|
|
121
124
|
paths: readonly string[];
|
|
122
125
|
modes: Readonly<Record<string, string>>;
|
|
123
126
|
gitlinks: Readonly<Record<string, string>>;
|
|
@@ -159,6 +162,7 @@ export interface FrozenCandidateProjection {
|
|
|
159
162
|
baseTree: string;
|
|
160
163
|
candidateTree: string;
|
|
161
164
|
committedOnly: boolean;
|
|
165
|
+
intendedUntracked?: readonly string[];
|
|
162
166
|
paths: readonly string[];
|
|
163
167
|
modes: Readonly<Record<string, string>>;
|
|
164
168
|
gitlinks: Readonly<Record<string, string>>;
|
|
@@ -169,6 +173,8 @@ export interface CreateCandidateViewRequest {
|
|
|
169
173
|
contributorRoot: string;
|
|
170
174
|
baseRef?: string;
|
|
171
175
|
committedOnly?: boolean;
|
|
176
|
+
/** Undefined keeps legacy all-untracked capture; [] excludes untracked files. */
|
|
177
|
+
intendedUntracked?: readonly string[];
|
|
172
178
|
replayKey?: string;
|
|
173
179
|
}
|
|
174
180
|
|
|
@@ -185,6 +191,7 @@ export interface AuthoritativeReviewingCandidateState {
|
|
|
185
191
|
baseTree: string;
|
|
186
192
|
candidateTree: string;
|
|
187
193
|
committedOnly?: boolean;
|
|
194
|
+
intendedUntracked?: readonly string[];
|
|
188
195
|
paths: readonly string[];
|
|
189
196
|
modes: Readonly<Record<string, string>>;
|
|
190
197
|
gitlinks?: Readonly<Record<string, string>>;
|
|
@@ -416,23 +423,30 @@ export interface ChangedPathEntry {
|
|
|
416
423
|
readonly modeOnly: boolean;
|
|
417
424
|
}
|
|
418
425
|
|
|
426
|
+
//
|
|
427
|
+
// gentle-pi#518: both derivations diff with `--no-renames`, exactly as the
|
|
428
|
+
// native provider does. A rename is then its source deletion plus its
|
|
429
|
+
// destination addition, one path each, so the projection identity Pi freezes
|
|
430
|
+
// is the identity native STATUS projects. Rename detection here previously
|
|
431
|
+
// kept only the destination, and an exact staged rename was rejected before
|
|
432
|
+
// native admission with candidate-target-projection-drift.
|
|
419
433
|
export function deriveChangedPathManifest(cwd: string, baseTree: string, candidateTree: string, executor: CandidateGitExecutor = defaultCandidateGitExecutor): readonly ChangedPathEntry[] {
|
|
420
|
-
const tokens = gitPathTokens(cwd, ["diff", "--raw", "-z", "--abbrev=40", "--no-ext-diff", "--
|
|
434
|
+
const tokens = gitPathTokens(cwd, ["diff", "--raw", "-z", "--abbrev=40", "--no-ext-diff", "--no-renames", baseTree, candidateTree], executor);
|
|
421
435
|
const entries: ChangedPathEntry[] = [];
|
|
422
436
|
for (let index = 0; index < tokens.length;) {
|
|
423
437
|
const header = tokens[index++]?.toString("ascii");
|
|
424
438
|
if (header === undefined) break;
|
|
425
|
-
// `:<old_mode> <new_mode> <old_sha> <new_sha> <status
|
|
426
|
-
|
|
439
|
+
// `:<old_mode> <new_mode> <old_sha> <new_sha> <status>`; with rename
|
|
440
|
+
// detection off, Git never emits a two-path R or C record here.
|
|
441
|
+
const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{7,64}) ([0-9a-f]{7,64}) ([AMDT])$/.exec(header);
|
|
427
442
|
if (match === null) throw new CandidateViewError("candidate manifest Git output contains an unsafe raw header", "manifest-derivation-invalid");
|
|
428
443
|
const [, oldMode, newMode, oldSha, newSha, status] = match;
|
|
429
|
-
const
|
|
430
|
-
if (
|
|
431
|
-
|
|
432
|
-
const path = status.startsWith("R") ? decodeCanonicalPath(tokens[index++] ?? firstPath) : decodeCanonicalPath(firstPath);
|
|
444
|
+
const rawPath = tokens[index++];
|
|
445
|
+
if (rawPath === undefined) throw new CandidateViewError("candidate manifest Git output is incomplete", "manifest-derivation-invalid");
|
|
446
|
+
const path = decodeCanonicalPath(rawPath);
|
|
433
447
|
entries.push(Object.freeze({
|
|
434
448
|
path,
|
|
435
|
-
status
|
|
449
|
+
status,
|
|
436
450
|
oldMode,
|
|
437
451
|
newMode,
|
|
438
452
|
deleted: status === "D",
|
|
@@ -508,20 +522,16 @@ function deriveChangedScope(cwd: string, baseTree: string, candidateTree: string
|
|
|
508
522
|
const present = new Map(entries.map((entry) => [entry.path, entry]));
|
|
509
523
|
const paths = new Set<string>();
|
|
510
524
|
const deleted = new Set<string>();
|
|
511
|
-
|
|
525
|
+
// `--no-renames` mirrors the native projection: a rename is one deleted
|
|
526
|
+
// path plus one added path (gentle-pi#518), and every record carries
|
|
527
|
+
// exactly one path.
|
|
528
|
+
const tokens = gitPathTokens(cwd, ["diff", "--name-status", "-z", "--no-ext-diff", "--no-renames", baseTree, candidateTree], executor);
|
|
512
529
|
for (let index = 0; index < tokens.length;) {
|
|
513
530
|
const status = tokens[index++]?.toString("ascii");
|
|
514
|
-
if (status === undefined || !/^
|
|
515
|
-
const
|
|
516
|
-
if (
|
|
517
|
-
const
|
|
518
|
-
const path = status.startsWith("R")
|
|
519
|
-
? (() => {
|
|
520
|
-
const newPath = tokens[index++];
|
|
521
|
-
if (newPath === undefined) throw new CandidateViewError("candidate scope rename output is incomplete");
|
|
522
|
-
return decodeCanonicalPath(newPath);
|
|
523
|
-
})()
|
|
524
|
-
: firstPath;
|
|
531
|
+
if (status === undefined || !/^[AMDT]$/.test(status)) throw new CandidateViewError("candidate scope Git output contains an unsafe status");
|
|
532
|
+
const rawPath = tokens[index++];
|
|
533
|
+
if (rawPath === undefined) throw new CandidateViewError("candidate scope Git output is incomplete");
|
|
534
|
+
const path = decodeCanonicalPath(rawPath);
|
|
525
535
|
if (paths.has(path) || deleted.has(path)) throw new CandidateViewError("candidate scope Git output contains duplicate paths");
|
|
526
536
|
if (status === "D") {
|
|
527
537
|
if (present.has(path)) throw new CandidateViewError("candidate scope deletion is present in the candidate tree");
|
|
@@ -758,6 +768,34 @@ function addUnbornWorktree(cwd: string, root: string, branch: string, env: NodeJ
|
|
|
758
768
|
git(root, ["symbolic-ref", "HEAD", `refs/heads/${branch}`], env, executor);
|
|
759
769
|
}
|
|
760
770
|
|
|
771
|
+
function normalizeIntendedUntracked(paths: readonly string[] | undefined): readonly string[] | undefined {
|
|
772
|
+
if (paths === undefined) return undefined;
|
|
773
|
+
if (!Array.isArray(paths) || paths.some((path) => !isSafeCandidatePath(path)) || new Set(paths).size !== paths.length) {
|
|
774
|
+
throw new CandidateViewError("candidate intended-untracked selection is invalid");
|
|
775
|
+
}
|
|
776
|
+
return Object.freeze([...paths]);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function isErrnoCode(error: unknown, code: string): boolean {
|
|
780
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function seedPrivateIndexFromLiveIndex(cwd: string, indexPath: string, executor: CandidateGitExecutor): boolean {
|
|
784
|
+
const liveIndex = resolve(cwd, git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"], process.env, executor));
|
|
785
|
+
const entry = lstatSync(liveIndex, { throwIfNoEntry: false });
|
|
786
|
+
if (entry === undefined) return false; if (!entry.isFile()) throw new CandidateViewError("candidate live Git index is not a regular file");
|
|
787
|
+
copyFileSync(liveIndex, indexPath);
|
|
788
|
+
for (const name of readdirSync(dirname(liveIndex))) if (/^sharedindex\.[0-9a-f]+$/.test(name)) {
|
|
789
|
+
try {
|
|
790
|
+
const sharedIndex = join(dirname(liveIndex), name);
|
|
791
|
+
if (lstatSync(sharedIndex).isFile()) copyFileSync(sharedIndex, join(dirname(indexPath), name));
|
|
792
|
+
} catch (error) {
|
|
793
|
+
if (!isErrnoCode(error, "ENOENT")) throw error;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
|
|
761
799
|
function materializeCandidateView(request: CreateCandidateViewRequest, executor: CandidateGitExecutor): CandidateViewRecord {
|
|
762
800
|
const contributorRoot = realpathSync(request.contributorRoot);
|
|
763
801
|
if (!lstatSync(contributorRoot).isDirectory()) throw new CandidateViewError("contributor root is not a directory");
|
|
@@ -766,6 +804,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
766
804
|
const canonicalCommonDir = realpathSync(commonDir);
|
|
767
805
|
const base = resolveCandidateBase(contributorRoot, request.baseRef, process.env, executor);
|
|
768
806
|
const committedOnly = request.committedOnly === true;
|
|
807
|
+
const intendedUntracked = normalizeIntendedUntracked(request.intendedUntracked);
|
|
769
808
|
const candidateCommit = committedOnly
|
|
770
809
|
? resolveCandidateBase(contributorRoot, "HEAD", process.env, executor)
|
|
771
810
|
: base;
|
|
@@ -776,11 +815,21 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
776
815
|
try {
|
|
777
816
|
const baseCommit = base.commit;
|
|
778
817
|
const unborn = baseCommit === "HEAD";
|
|
779
|
-
//
|
|
780
|
-
|
|
781
|
-
if (
|
|
782
|
-
|
|
783
|
-
|
|
818
|
+
// Workspace candidates seed their isolated index from the resolved live index; missing indexes use the frozen base.
|
|
819
|
+
const seededFromLiveIndex = !committedOnly && intendedUntracked !== undefined && seedPrivateIndexFromLiveIndex(contributorRoot, indexPath, executor);
|
|
820
|
+
if (!seededFromLiveIndex) {
|
|
821
|
+
// For an unborn repository the base tree is Git's empty tree, so seed the
|
|
822
|
+
// private candidate index from `--empty` instead of a non-existent commit.
|
|
823
|
+
if (unborn) git(contributorRoot, ["read-tree", "--empty"], environment, executor);
|
|
824
|
+
else git(contributorRoot, ["read-tree", candidateCommit.commit], environment, executor);
|
|
825
|
+
}
|
|
826
|
+
if (!committedOnly) {
|
|
827
|
+
if (intendedUntracked === undefined) git(contributorRoot, ["add", "-A"], environment, executor);
|
|
828
|
+
else {
|
|
829
|
+
git(contributorRoot, ["add", "-u"], environment, executor);
|
|
830
|
+
if (intendedUntracked.length > 0) git(contributorRoot, ["add", "--", ...intendedUntracked], { ...environment, GIT_LITERAL_PATHSPECS: "1" }, executor);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
784
833
|
const candidateTree = git(contributorRoot, ["write-tree"], environment, executor);
|
|
785
834
|
const root = join(parent, randomUUID());
|
|
786
835
|
// The worktree is created under the same try/catch cleanup boundary as
|
|
@@ -803,7 +852,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
803
852
|
const scope = deriveChangedScope(contributorRoot, base.tree, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
|
|
804
853
|
for (const gitlink of tree.gitlinks) if (lstatSync(join(root, gitlink.path), { throwIfNoEntry: false })) throw new CandidateViewError("candidate view materialized a metadata-only gitlink");
|
|
805
854
|
makeReadonly(root, entries);
|
|
806
|
-
return { token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor };
|
|
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 };
|
|
807
856
|
} catch (error) {
|
|
808
857
|
try { git(contributorRoot, ["worktree", "remove", "--force", root], process.env, executor); } catch { rmSync(root, { recursive: true, force: true }); }
|
|
809
858
|
throw error;
|
|
@@ -814,6 +863,20 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
814
863
|
}
|
|
815
864
|
|
|
816
865
|
function assertRecordSafe(record: CandidateViewRecord): void {
|
|
866
|
+
try {
|
|
867
|
+
const contributor = lstatSync(record.contributorRoot);
|
|
868
|
+
if (!contributor.isDirectory() || contributor.isSymbolicLink() || realpathSync(record.contributorRoot) !== record.contributorRoot) {
|
|
869
|
+
throw new CandidateViewError("candidate contributor root identity changed", "contributor-root-drift");
|
|
870
|
+
}
|
|
871
|
+
const toplevel = realpathSync(git(record.contributorRoot, ["rev-parse", "--show-toplevel"], process.env, record.gitExecutor));
|
|
872
|
+
const commonDir = realpathSync(resolve(record.contributorRoot, git(record.contributorRoot, ["rev-parse", "--git-common-dir"], process.env, record.gitExecutor)));
|
|
873
|
+
if (toplevel !== record.contributorRoot || commonDir !== record.commonDir) {
|
|
874
|
+
throw new CandidateViewError("candidate contributor root Git identity changed", "contributor-root-drift");
|
|
875
|
+
}
|
|
876
|
+
} catch (error) {
|
|
877
|
+
if (error instanceof CandidateViewError) throw error;
|
|
878
|
+
throw new CandidateViewError("candidate contributor root identity cannot be verified", "contributor-root-drift");
|
|
879
|
+
}
|
|
817
880
|
const root = record.root;
|
|
818
881
|
if (!isWithin(record.parent, root) || !existsSync(root)) throw new CandidateViewError("candidate view is missing or moved");
|
|
819
882
|
const rootStat = lstatSync(root);
|
|
@@ -853,27 +916,111 @@ export class CandidateViewRegistry {
|
|
|
853
916
|
constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor) {
|
|
854
917
|
this.gitExecutor = gitExecutor;
|
|
855
918
|
}
|
|
919
|
+
// Lifecycle state is scoped to the canonical target worktree as well as the
|
|
920
|
+
// provider lineage. Lineage text is repository-local and may legitimately be
|
|
921
|
+
// identical in two repositories owned by one Pi session.
|
|
856
922
|
private readonly lineages = new Map<string, string>();
|
|
857
923
|
private readonly projections = new Map<string, FrozenCandidateProjection>();
|
|
858
924
|
private readonly replays = new Map<string, string>();
|
|
859
|
-
private current
|
|
925
|
+
private readonly current = new Map<string, { lineageId: string; token: string }>();
|
|
860
926
|
// The last dispatch-binding hydration that was attempted and failed. A
|
|
861
927
|
// swallowed hydration failure is its own defect (field report 2026-08-16):
|
|
862
928
|
// without it the later dispatch refusal claims no binding was ever
|
|
863
929
|
// available instead of naming the attempt and its typed cause.
|
|
864
|
-
private
|
|
930
|
+
private readonly lastHydrationFailures = new Map<string, { lineageId: string; reason: string; message: string }>();
|
|
931
|
+
|
|
932
|
+
private canonicalRoot(contributorRoot: string): string {
|
|
933
|
+
try {
|
|
934
|
+
return realpathSync(contributorRoot);
|
|
935
|
+
} catch {
|
|
936
|
+
throw new CandidateViewError("candidate contributor root could not be resolved", "contributor-root-unresolvable");
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private lineageKey(contributorRoot: string, lineageId: string): string {
|
|
941
|
+
return `${this.canonicalRoot(contributorRoot)}\u0000${lineageId}`;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
private replayKey(contributorRoot: string, replayKey: string): string {
|
|
945
|
+
return `${this.canonicalRoot(contributorRoot)}\u0000${replayKey}`;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
private uniqueKey(
|
|
949
|
+
entries: ReadonlyMap<string, unknown>,
|
|
950
|
+
lineageId: string,
|
|
951
|
+
contributorRoot: string | undefined,
|
|
952
|
+
): string | undefined {
|
|
953
|
+
if (contributorRoot !== undefined) {
|
|
954
|
+
const key = this.lineageKey(contributorRoot, lineageId);
|
|
955
|
+
return entries.has(key) ? key : undefined;
|
|
956
|
+
}
|
|
957
|
+
const suffix = `\u0000${lineageId}`;
|
|
958
|
+
const matches = [...entries.keys()].filter((key) => key.endsWith(suffix));
|
|
959
|
+
if (matches.length === 0) return undefined;
|
|
960
|
+
if (matches.length !== 1) {
|
|
961
|
+
throw new CandidateViewError(`candidate lifecycle lineage ${lineageId} is ambiguous across target roots; pass an explicit workspaceRoot`, "lineage-root-ambiguous");
|
|
962
|
+
}
|
|
963
|
+
return matches[0]!;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
private requireKey(
|
|
967
|
+
entries: ReadonlyMap<string, unknown>,
|
|
968
|
+
lineageId: string,
|
|
969
|
+
contributorRoot: string | undefined,
|
|
970
|
+
missing: string,
|
|
971
|
+
): string {
|
|
972
|
+
return this.uniqueKey(entries, lineageId, contributorRoot)
|
|
973
|
+
?? (() => { throw new CandidateViewError(missing); })();
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Returns the one active target root bound to a lineage, or undefined.
|
|
978
|
+
* Throws when that lineage is bound across multiple roots; callers must pass
|
|
979
|
+
* an explicit workspaceRoot to resolve the ambiguity.
|
|
980
|
+
*/
|
|
981
|
+
resolveWorkspaceRoot(lineageId: string): string | undefined {
|
|
982
|
+
const key = this.uniqueKey(this.lineages, lineageId, undefined);
|
|
983
|
+
return key === undefined ? undefined : key.slice(0, key.lastIndexOf("\u0000"));
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
assertWorkspaceRoot(lineageId: string, contributorRoot: string): void {
|
|
987
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
988
|
+
const exactKey = this.lineageKey(root, lineageId);
|
|
989
|
+
if (this.lineages.has(exactKey)) {
|
|
990
|
+
this.assertLineageRootIdentity(lineageId, root);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
const bound = this.resolveWorkspaceRoot(lineageId);
|
|
994
|
+
if (bound !== undefined) {
|
|
995
|
+
throw new CandidateViewError(`candidate lifecycle lineage ${lineageId} is bound to ${bound}, not the requested workspaceRoot ${root}`, "lineage-root-drift");
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
private assertLineageRootIdentity(lineageId: string, contributorRoot: string): void {
|
|
1000
|
+
const key = this.lineageKey(contributorRoot, lineageId);
|
|
1001
|
+
const token = this.lineages.get(key);
|
|
1002
|
+
const record = token === undefined ? undefined : this.records.get(token);
|
|
1003
|
+
if (record !== undefined) assertRecordSafe(record);
|
|
1004
|
+
}
|
|
865
1005
|
|
|
866
1006
|
create(request: CreateCandidateViewRequest): CandidateView {
|
|
867
1007
|
return this.createOrReuse(request);
|
|
868
1008
|
}
|
|
869
1009
|
|
|
1010
|
+
cleanupAll(): void {
|
|
1011
|
+
for (const token of [...this.records.keys()]) this.cleanup(token);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
870
1014
|
createOrReuse(request: CreateCandidateViewRequest): CandidateView {
|
|
871
|
-
const
|
|
1015
|
+
const contributorRoot = this.canonicalRoot(request.contributorRoot);
|
|
1016
|
+
const normalizedRequest = { ...request, contributorRoot };
|
|
1017
|
+
const scopedReplayKey = normalizedRequest.replayKey === undefined ? undefined : this.replayKey(contributorRoot, normalizedRequest.replayKey);
|
|
1018
|
+
const token = scopedReplayKey === undefined ? undefined : this.replays.get(scopedReplayKey);
|
|
872
1019
|
const existing = token === undefined ? undefined : this.records.get(token);
|
|
873
1020
|
if (existing) { assertRecordSafe(existing); return this.expose(existing); }
|
|
874
|
-
const record = materializeCandidateView(
|
|
1021
|
+
const record = materializeCandidateView(normalizedRequest, this.gitExecutor);
|
|
875
1022
|
this.records.set(record.token, record);
|
|
876
|
-
if (
|
|
1023
|
+
if (scopedReplayKey !== undefined) this.replays.set(scopedReplayKey, record.token);
|
|
877
1024
|
return this.expose(record);
|
|
878
1025
|
}
|
|
879
1026
|
|
|
@@ -883,36 +1030,37 @@ export class CandidateViewRegistry {
|
|
|
883
1030
|
|
|
884
1031
|
bindCurrent(request: BindCandidateViewRequest): void {
|
|
885
1032
|
const selectedLenses = this.validateSelectedLenses(request.selectedLenses);
|
|
886
|
-
this.bindRecord(request.token, request.lineageId, selectedLenses);
|
|
887
|
-
this.current
|
|
1033
|
+
const record = this.bindRecord(request.token, request.lineageId, selectedLenses);
|
|
1034
|
+
this.current.set(record.contributorRoot, { lineageId: request.lineageId, token: request.token });
|
|
888
1035
|
}
|
|
889
1036
|
|
|
890
1037
|
retain(token: string, lineageId: string): void {
|
|
891
|
-
this.bindRecord(token, lineageId, []);
|
|
892
|
-
this.current
|
|
1038
|
+
const record = this.bindRecord(token, lineageId, []);
|
|
1039
|
+
this.current.set(record.contributorRoot, { lineageId, token });
|
|
893
1040
|
}
|
|
894
1041
|
|
|
895
1042
|
restoreCurrentFromNativeStart(request: BindCandidateViewRequest): void {
|
|
896
|
-
if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
|
|
897
1043
|
const record = this.records.get(request.token);
|
|
898
1044
|
if (!record || record.lineageId !== undefined) throw new CandidateViewError("native reviewing candidate view is missing or already bound", "authoritative-current-match-missing");
|
|
1045
|
+
if (this.current.has(record.contributorRoot)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
|
|
899
1046
|
assertRecordSafe(record);
|
|
900
1047
|
this.assertCurrentBindingMatchesLiveCandidate(record);
|
|
901
1048
|
this.bindCurrent(request);
|
|
902
1049
|
}
|
|
903
1050
|
|
|
904
|
-
hasCurrentBinding(): boolean {
|
|
905
|
-
return this.current
|
|
1051
|
+
hasCurrentBinding(contributorRoot?: string): boolean {
|
|
1052
|
+
return contributorRoot === undefined ? this.current.size > 0 : this.current.has(this.canonicalRoot(contributorRoot));
|
|
906
1053
|
}
|
|
907
1054
|
|
|
908
1055
|
restoreCurrentFromAuthoritativeReviewingStates(
|
|
909
1056
|
contributorRoot: string,
|
|
910
1057
|
states: readonly AuthoritativeReviewingCandidateState[],
|
|
911
1058
|
): void {
|
|
912
|
-
|
|
1059
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
1060
|
+
if (this.current.has(root)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
|
|
913
1061
|
if (states.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing");
|
|
914
1062
|
if (states.length !== 1) throw new CandidateViewError("multiple authoritative reviewing lineages exactly match the live candidate", "authoritative-current-match-ambiguous");
|
|
915
|
-
const live = materializeCandidateView({ contributorRoot, baseRef: states[0]!.baseCommit, committedOnly: states[0]!.committedOnly === true }, this.gitExecutor);
|
|
1063
|
+
const live = materializeCandidateView({ contributorRoot: root, baseRef: states[0]!.baseCommit, committedOnly: states[0]!.committedOnly === true, ...(states[0]!.intendedUntracked === undefined ? {} : { intendedUntracked: states[0]!.intendedUntracked }) }, this.gitExecutor);
|
|
916
1064
|
try {
|
|
917
1065
|
const matches = states.filter((state) => this.matchesAuthoritativeState(live, state));
|
|
918
1066
|
if (matches.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing");
|
|
@@ -921,28 +1069,31 @@ export class CandidateViewRegistry {
|
|
|
921
1069
|
const selectedLenses = this.validateSelectedLenses(state.selectedLenses);
|
|
922
1070
|
this.records.set(live.token, live);
|
|
923
1071
|
this.bindRecord(live.token, state.lineageId, selectedLenses);
|
|
924
|
-
this.current
|
|
1072
|
+
this.current.set(root, { lineageId: state.lineageId, token: live.token });
|
|
925
1073
|
} catch (error) {
|
|
926
|
-
|
|
1074
|
+
this.records.delete(live.token);
|
|
1075
|
+
this.remove(live);
|
|
927
1076
|
throw error;
|
|
928
1077
|
}
|
|
929
1078
|
}
|
|
930
1079
|
|
|
931
1080
|
createCorrected(lineageId: string, contributorRoot: string, replayKey: string): CandidateView {
|
|
932
|
-
const
|
|
933
|
-
const
|
|
1081
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
1082
|
+
const projection = this.resolveProjection(lineageId, root);
|
|
1083
|
+
const scopedReplayKey = this.replayKey(root, replayKey);
|
|
1084
|
+
const existingToken = this.replays.get(scopedReplayKey);
|
|
934
1085
|
const existing = existingToken === undefined ? undefined : this.records.get(existingToken);
|
|
935
1086
|
if (existing) {
|
|
936
1087
|
if (existing.lineageId !== undefined) throw new CandidateViewError("corrected candidate replay is no longer pending");
|
|
937
1088
|
assertRecordSafe(existing);
|
|
938
1089
|
return this.expose(existing);
|
|
939
1090
|
}
|
|
940
|
-
const record = materializeCandidateView({ contributorRoot, baseRef: projection.baseCommit, committedOnly: projection.committedOnly }, this.gitExecutor);
|
|
1091
|
+
const record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor);
|
|
941
1092
|
try {
|
|
942
1093
|
if (record.baseCommit !== projection.baseCommit || record.baseTree !== projection.baseTree) throw new CandidateViewError("corrected candidate base does not match the frozen genesis base");
|
|
943
1094
|
if (!record.scope.paths.every((path) => projection.paths.includes(path))) throw new CandidateViewError("corrected candidate scope escapes the frozen genesis paths");
|
|
944
1095
|
this.records.set(record.token, record);
|
|
945
|
-
this.replays.set(
|
|
1096
|
+
this.replays.set(scopedReplayKey, record.token);
|
|
946
1097
|
return this.expose(record);
|
|
947
1098
|
} catch (error) {
|
|
948
1099
|
this.remove(record);
|
|
@@ -950,15 +1101,18 @@ export class CandidateViewRegistry {
|
|
|
950
1101
|
}
|
|
951
1102
|
}
|
|
952
1103
|
|
|
953
|
-
promoteCorrected(lineageId: string, token: string): void {
|
|
1104
|
+
promoteCorrected(lineageId: string, token: string, contributorRoot?: string): void {
|
|
954
1105
|
const replacement = this.records.get(token);
|
|
955
|
-
const
|
|
956
|
-
const
|
|
1106
|
+
const root = contributorRoot === undefined ? replacement?.contributorRoot : this.canonicalRoot(contributorRoot);
|
|
1107
|
+
const key = root === undefined ? undefined : this.uniqueKey(this.projections, lineageId, root);
|
|
1108
|
+
const projection = key === undefined ? undefined : this.projections.get(key);
|
|
1109
|
+
const currentToken = key === undefined ? undefined : this.lineages.get(key);
|
|
957
1110
|
const current = currentToken === undefined ? undefined : this.records.get(currentToken);
|
|
958
|
-
if (!replacement || replacement.lineageId !== undefined || !projection || (currentToken !== undefined && (!current || current.lineageId !== lineageId))) {
|
|
1111
|
+
if (!replacement || replacement.lineageId !== undefined || !key || !projection || (currentToken !== undefined && (!current || current.lineageId !== lineageId))) {
|
|
959
1112
|
throw new CandidateViewError("corrected candidate replacement is missing or ambiguous");
|
|
960
1113
|
}
|
|
961
|
-
|
|
1114
|
+
const currentBinding = this.current.get(root);
|
|
1115
|
+
if (currentBinding !== undefined && currentBinding.lineageId !== lineageId) {
|
|
962
1116
|
throw new CandidateViewError("corrected candidate replacement conflicts with the current lineage binding");
|
|
963
1117
|
}
|
|
964
1118
|
assertRecordSafe(replacement);
|
|
@@ -968,26 +1122,28 @@ export class CandidateViewRegistry {
|
|
|
968
1122
|
replacement.baseCommit !== projection.baseCommit ||
|
|
969
1123
|
replacement.baseTree !== projection.baseTree ||
|
|
970
1124
|
replacement.committedOnly !== projection.committedOnly ||
|
|
1125
|
+
JSON.stringify(replacement.intendedUntracked ?? null) !== JSON.stringify(projection.intendedUntracked ?? null) ||
|
|
971
1126
|
!replacement.scope.paths.every((path) => projection.paths.includes(path))
|
|
972
1127
|
) {
|
|
973
1128
|
throw new CandidateViewError("corrected candidate replacement does not preserve its frozen lineage projection");
|
|
974
1129
|
}
|
|
975
1130
|
replacement.lineageId = lineageId;
|
|
976
1131
|
replacement.selectedLenses = [];
|
|
977
|
-
this.lineages.set(
|
|
978
|
-
for (const [
|
|
979
|
-
this.projections.set(
|
|
1132
|
+
this.lineages.set(key, token);
|
|
1133
|
+
for (const [pendingKey, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(pendingKey);
|
|
1134
|
+
this.projections.set(key, {
|
|
980
1135
|
contributorRoot: replacement.contributorRoot,
|
|
981
1136
|
baseCommit: replacement.baseCommit,
|
|
982
1137
|
baseTree: replacement.baseTree,
|
|
983
1138
|
candidateTree: replacement.candidateTree,
|
|
984
1139
|
committedOnly: replacement.committedOnly,
|
|
1140
|
+
intendedUntracked: replacement.intendedUntracked,
|
|
985
1141
|
paths: replacement.scope.paths,
|
|
986
1142
|
modes: replacement.scope.modes,
|
|
987
1143
|
gitlinks: replacement.scope.gitlinks,
|
|
988
1144
|
deletedPaths: replacement.scope.deletedPaths,
|
|
989
1145
|
});
|
|
990
|
-
this.current
|
|
1146
|
+
this.current.set(root, { lineageId, token });
|
|
991
1147
|
if (current) {
|
|
992
1148
|
this.remove(current);
|
|
993
1149
|
this.forget(current);
|
|
@@ -1007,6 +1163,7 @@ export class CandidateViewRegistry {
|
|
|
1007
1163
|
state.baseTree === record.baseTree &&
|
|
1008
1164
|
state.candidateTree === record.candidateTree &&
|
|
1009
1165
|
(state.committedOnly ?? false) === record.committedOnly &&
|
|
1166
|
+
(state.intendedUntracked === undefined || JSON.stringify(state.intendedUntracked) === JSON.stringify(record.intendedUntracked)) &&
|
|
1010
1167
|
JSON.stringify(state.paths) === JSON.stringify(record.scope.paths) &&
|
|
1011
1168
|
JSON.stringify(state.modes) === JSON.stringify(record.scope.modes) &&
|
|
1012
1169
|
gitlinkMapsEqual(state.gitlinks ?? {}, record.scope.gitlinks) &&
|
|
@@ -1016,41 +1173,46 @@ export class CandidateViewRegistry {
|
|
|
1016
1173
|
}
|
|
1017
1174
|
}
|
|
1018
1175
|
|
|
1019
|
-
private bindRecord(token: string, lineageId: string, selectedLenses: readonly ReviewLens[]):
|
|
1176
|
+
private bindRecord(token: string, lineageId: string, selectedLenses: readonly ReviewLens[]): CandidateViewRecord {
|
|
1020
1177
|
const record = this.records.get(token);
|
|
1021
|
-
|
|
1178
|
+
const key = record === undefined ? undefined : this.lineageKey(record.contributorRoot, lineageId);
|
|
1179
|
+
if (!record || record.lineageId !== undefined || !key || this.lineages.has(key)) throw new CandidateViewError("candidate view lineage binding is missing or ambiguous");
|
|
1022
1180
|
assertRecordSafe(record);
|
|
1023
1181
|
record.lineageId = lineageId;
|
|
1024
1182
|
record.selectedLenses = selectedLenses;
|
|
1025
|
-
this.lineages.set(
|
|
1026
|
-
this.projections.set(
|
|
1183
|
+
this.lineages.set(key, record.token);
|
|
1184
|
+
this.projections.set(key, {
|
|
1027
1185
|
contributorRoot: record.contributorRoot,
|
|
1028
1186
|
baseCommit: record.baseCommit,
|
|
1029
1187
|
baseTree: record.baseTree,
|
|
1030
1188
|
candidateTree: record.candidateTree,
|
|
1031
1189
|
committedOnly: record.committedOnly,
|
|
1190
|
+
intendedUntracked: record.intendedUntracked,
|
|
1032
1191
|
paths: record.scope.paths,
|
|
1033
1192
|
modes: record.scope.modes,
|
|
1034
1193
|
gitlinks: record.scope.gitlinks,
|
|
1035
1194
|
deletedPaths: record.scope.deletedPaths,
|
|
1036
1195
|
});
|
|
1037
|
-
for (const [
|
|
1196
|
+
for (const [replayKey, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(replayKey);
|
|
1197
|
+
return record;
|
|
1038
1198
|
}
|
|
1039
1199
|
|
|
1040
|
-
hasProjection(lineageId: string): boolean {
|
|
1041
|
-
return this.projections
|
|
1200
|
+
hasProjection(lineageId: string, contributorRoot?: string): boolean {
|
|
1201
|
+
return this.uniqueKey(this.projections, lineageId, contributorRoot) !== undefined;
|
|
1042
1202
|
}
|
|
1043
1203
|
|
|
1044
1204
|
restoreProjection(lineageId: string, contributorRoot: string, baseCommit: string, baseTree: string, candidateTree: string, paths: readonly string[]): void {
|
|
1045
|
-
const root =
|
|
1205
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
1206
|
+
const key = this.lineageKey(root, lineageId);
|
|
1046
1207
|
const base = resolveCandidateBase(root, baseCommit, process.env, this.gitExecutor);
|
|
1047
|
-
if (!lineageId || this.projections.has(
|
|
1048
|
-
this.projections.set(
|
|
1208
|
+
if (!lineageId || this.projections.has(key) || base.commit !== baseCommit || base.tree !== baseTree || !isFullCommitId(candidateTree) || paths.some((path) => !isSafeCandidatePath(path)) || new Set(paths).size !== paths.length) throw new CandidateViewError("frozen correction projection is invalid or already restored");
|
|
1209
|
+
this.projections.set(key, { contributorRoot: root, baseCommit, baseTree, candidateTree, committedOnly: false, paths: [...paths], modes: {}, gitlinks: {}, deletedPaths: [] });
|
|
1049
1210
|
}
|
|
1050
1211
|
|
|
1051
1212
|
restoreProjectionFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): void {
|
|
1052
|
-
const root =
|
|
1053
|
-
|
|
1213
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
1214
|
+
const key = this.lineageKey(root, lineageId);
|
|
1215
|
+
if (!lineageId || this.projections.has(key) || !isFullCommitId(descriptor.baseTree) || !isFullCommitId(descriptor.currentCandidateTree)) throw new CandidateViewError("native frozen projection is invalid or already restored");
|
|
1054
1216
|
if (descriptor.paths.some((path) => !isSafeCandidatePath(path)) || new Set(descriptor.paths).size !== descriptor.paths.length) throw new CandidateViewError("native frozen projection paths are invalid");
|
|
1055
1217
|
if (descriptor.intendedUntracked.some((path) => !descriptor.paths.includes(path)) || new Set(descriptor.intendedUntracked).size !== descriptor.intendedUntracked.length) throw new CandidateViewError("native intended-untracked projection is invalid");
|
|
1056
1218
|
const head = resolveCandidateBase(root, "HEAD", process.env, this.gitExecutor);
|
|
@@ -1079,12 +1241,13 @@ export class CandidateViewRegistry {
|
|
|
1079
1241
|
} else if (JSON.stringify(scope.paths) !== JSON.stringify([...descriptor.paths].sort())) {
|
|
1080
1242
|
throw new CandidateViewError("native projection paths do not match Git content");
|
|
1081
1243
|
}
|
|
1082
|
-
this.projections.set(
|
|
1244
|
+
this.projections.set(key, {
|
|
1083
1245
|
contributorRoot: root,
|
|
1084
1246
|
baseCommit: base.commit,
|
|
1085
1247
|
baseTree: descriptor.baseTree,
|
|
1086
1248
|
candidateTree: descriptor.currentCandidateTree,
|
|
1087
1249
|
committedOnly,
|
|
1250
|
+
intendedUntracked: Object.freeze([...descriptor.intendedUntracked]),
|
|
1088
1251
|
paths: scope.paths,
|
|
1089
1252
|
modes: scope.modes,
|
|
1090
1253
|
gitlinks: scope.gitlinks,
|
|
@@ -1113,30 +1276,45 @@ export class CandidateViewRegistry {
|
|
|
1113
1276
|
* correction.
|
|
1114
1277
|
*/
|
|
1115
1278
|
rebindForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
|
|
1116
|
-
const
|
|
1279
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
1280
|
+
const key = this.lineageKey(root, lineageId);
|
|
1281
|
+
const staleToken = this.lineages.get(key);
|
|
1117
1282
|
const stale = staleToken === undefined ? undefined : this.records.get(staleToken);
|
|
1118
|
-
this.projections.delete(
|
|
1283
|
+
this.projections.delete(key);
|
|
1119
1284
|
if (stale !== undefined) {
|
|
1120
1285
|
this.remove(stale);
|
|
1121
1286
|
this.forget(stale);
|
|
1122
1287
|
}
|
|
1123
|
-
return this.restoreForFinalizeFromNative(lineageId,
|
|
1288
|
+
return this.restoreForFinalizeFromNative(lineageId, root, descriptor);
|
|
1124
1289
|
}
|
|
1125
1290
|
|
|
1126
1291
|
restoreForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
|
|
1127
|
-
this.
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1292
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
1293
|
+
const key = this.lineageKey(root, lineageId);
|
|
1294
|
+
let projectionRestored = false;
|
|
1295
|
+
let record: CandidateViewRecord | undefined;
|
|
1130
1296
|
try {
|
|
1131
|
-
|
|
1132
|
-
|
|
1297
|
+
this.restoreProjectionFromNative(lineageId, root, descriptor);
|
|
1298
|
+
projectionRestored = true;
|
|
1299
|
+
const projection = this.resolveProjection(lineageId, root);
|
|
1300
|
+
const matchesProjection = (candidate: CandidateViewRecord): boolean => candidate.baseTree === projection.baseTree && candidate.candidateTree === projection.candidateTree && JSON.stringify(candidate.scope.paths) === JSON.stringify(projection.paths);
|
|
1301
|
+
const emptyIntendedUntracked = projection.intendedUntracked?.length === 0;
|
|
1302
|
+
record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(!emptyIntendedUntracked && projection.intendedUntracked !== undefined ? { intendedUntracked: projection.intendedUntracked } : {}) }, this.gitExecutor);
|
|
1303
|
+
if (!matchesProjection(record) && emptyIntendedUntracked) {
|
|
1304
|
+
this.remove(record);
|
|
1305
|
+
record = undefined;
|
|
1306
|
+
record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, intendedUntracked: [] }, this.gitExecutor);
|
|
1133
1307
|
}
|
|
1308
|
+
if (!matchesProjection(record)) throw new CandidateViewError("live candidate does not match the native frozen projection");
|
|
1134
1309
|
this.records.set(record.token, record);
|
|
1135
1310
|
this.bindRecord(record.token, lineageId, []);
|
|
1136
1311
|
return this.expose(record);
|
|
1137
1312
|
} catch (error) {
|
|
1138
|
-
this.projections.delete(
|
|
1139
|
-
|
|
1313
|
+
if (projectionRestored) this.projections.delete(key);
|
|
1314
|
+
if (record !== undefined) {
|
|
1315
|
+
this.forget(record);
|
|
1316
|
+
this.remove(record);
|
|
1317
|
+
}
|
|
1140
1318
|
throw error;
|
|
1141
1319
|
}
|
|
1142
1320
|
}
|
|
@@ -1151,55 +1329,62 @@ export class CandidateViewRegistry {
|
|
|
1151
1329
|
* pending lenses.
|
|
1152
1330
|
*/
|
|
1153
1331
|
restoreCurrentForDispatchFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor, selectedLenses: readonly string[]): void {
|
|
1154
|
-
|
|
1332
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
1333
|
+
const key = this.lineageKey(root, lineageId);
|
|
1334
|
+
if (this.current.has(root)) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
|
|
1335
|
+
let projectionRestored = false;
|
|
1336
|
+
let record: CandidateViewRecord | undefined;
|
|
1155
1337
|
try {
|
|
1156
1338
|
const lenses = this.validateSelectedLenses(selectedLenses);
|
|
1157
|
-
this.restoreProjectionFromNative(lineageId,
|
|
1158
|
-
|
|
1159
|
-
const
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
}
|
|
1164
|
-
this.records.set(record.token, record);
|
|
1165
|
-
this.bindRecord(record.token, lineageId, lenses);
|
|
1166
|
-
this.current = { lineageId, token: record.token };
|
|
1167
|
-
} catch (error) {
|
|
1168
|
-
this.projections.delete(lineageId);
|
|
1169
|
-
this.records.delete(record.token);
|
|
1170
|
-
this.remove(record);
|
|
1171
|
-
throw error;
|
|
1339
|
+
this.restoreProjectionFromNative(lineageId, root, descriptor);
|
|
1340
|
+
projectionRestored = true;
|
|
1341
|
+
const projection = this.resolveProjection(lineageId, root);
|
|
1342
|
+
record = materializeCandidateView({ contributorRoot: root, baseRef: projection.baseCommit, committedOnly: projection.committedOnly, ...(projection.intendedUntracked === undefined ? {} : { intendedUntracked: projection.intendedUntracked }) }, this.gitExecutor);
|
|
1343
|
+
if (record.baseTree !== projection.baseTree || record.candidateTree !== projection.candidateTree || JSON.stringify(record.scope.paths) !== JSON.stringify(projection.paths)) {
|
|
1344
|
+
throw new CandidateViewError("live candidate does not match the native frozen projection");
|
|
1172
1345
|
}
|
|
1173
|
-
this.
|
|
1346
|
+
this.records.set(record.token, record);
|
|
1347
|
+
this.bindRecord(record.token, lineageId, lenses);
|
|
1348
|
+
this.current.set(root, { lineageId, token: record.token });
|
|
1349
|
+
this.lastHydrationFailures.delete(root);
|
|
1174
1350
|
} catch (error) {
|
|
1175
|
-
this.
|
|
1351
|
+
if (projectionRestored) this.projections.delete(key);
|
|
1352
|
+
if (record !== undefined) {
|
|
1353
|
+
this.forget(record);
|
|
1354
|
+
this.remove(record);
|
|
1355
|
+
}
|
|
1356
|
+
this.lastHydrationFailures.set(root, {
|
|
1176
1357
|
lineageId,
|
|
1177
1358
|
reason: error instanceof CandidateViewError ? error.reason : "candidate-view-invalid",
|
|
1178
1359
|
message: error instanceof Error ? error.message : String(error),
|
|
1179
|
-
};
|
|
1360
|
+
});
|
|
1180
1361
|
throw error;
|
|
1181
1362
|
}
|
|
1182
1363
|
}
|
|
1183
1364
|
|
|
1184
1365
|
resolveProjection(lineageId: string, contributorRoot: string): FrozenCandidateProjection {
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1366
|
+
const key = this.lineageKey(contributorRoot, lineageId);
|
|
1367
|
+
const projection = this.projections.get(key);
|
|
1368
|
+
if (!projection) throw new CandidateViewError("candidate projection is missing, ambiguous, or belongs to a different contributor root");
|
|
1369
|
+
this.assertLineageRootIdentity(lineageId, contributorRoot);
|
|
1189
1370
|
return projection;
|
|
1190
1371
|
}
|
|
1191
1372
|
|
|
1192
|
-
resolveForLens(lineageId: string, lens: string): CandidateView {
|
|
1193
|
-
const
|
|
1373
|
+
resolveForLens(lineageId: string, lens: string, contributorRoot?: string): CandidateView {
|
|
1374
|
+
const key = this.requireKey(this.lineages, lineageId, contributorRoot, "candidate view context is missing, ambiguous, stale, or lens-unselected");
|
|
1375
|
+
const token = this.lineages.get(key);
|
|
1194
1376
|
const record = token === undefined ? undefined : this.records.get(token);
|
|
1195
1377
|
if (!record || record.lineageId !== lineageId || !record.selectedLenses?.includes(lens as ReviewLens)) throw new CandidateViewError("candidate view context is missing, ambiguous, stale, or lens-unselected");
|
|
1196
1378
|
assertRecordSafe(record);
|
|
1197
1379
|
return this.expose(record);
|
|
1198
1380
|
}
|
|
1199
1381
|
|
|
1200
|
-
|
|
1201
|
-
if (
|
|
1202
|
-
const
|
|
1382
|
+
private currentBinding(contributorRoot?: string): { root: string; lineageId: string; token: string } {
|
|
1383
|
+
if (contributorRoot !== undefined) {
|
|
1384
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
1385
|
+
const binding = this.current.get(root);
|
|
1386
|
+
if (binding !== undefined) return { root, ...binding };
|
|
1387
|
+
const failure = this.lastHydrationFailures.get(root);
|
|
1203
1388
|
if (failure !== undefined) {
|
|
1204
1389
|
throw new CandidateViewError(
|
|
1205
1390
|
`review subagent dispatch has no current controller-owned candidate view lineage binding: hydration for lineage ${failure.lineageId} was attempted from authoritative native status and failed (${failure.reason}): ${failure.message}`,
|
|
@@ -1208,23 +1393,39 @@ export class CandidateViewRegistry {
|
|
|
1208
1393
|
}
|
|
1209
1394
|
throw new CandidateViewError("review subagent dispatch has no current controller-owned candidate view lineage binding", "current-binding-missing");
|
|
1210
1395
|
}
|
|
1211
|
-
|
|
1396
|
+
if (this.current.size !== 1) {
|
|
1397
|
+
if (this.current.size > 1) throw new CandidateViewError("review subagent dispatch has multiple current lineage bindings across target roots; pass an explicit workspaceRoot", "current-binding-root-ambiguous");
|
|
1398
|
+
const failure = [...this.lastHydrationFailures.values()][0];
|
|
1399
|
+
if (failure !== undefined) {
|
|
1400
|
+
throw new CandidateViewError(
|
|
1401
|
+
`review subagent dispatch has no current controller-owned candidate view lineage binding: hydration for lineage ${failure.lineageId} was attempted from authoritative native status and failed (${failure.reason}): ${failure.message}`,
|
|
1402
|
+
"current-binding-hydration-failed",
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
throw new CandidateViewError("review subagent dispatch has no current controller-owned candidate view lineage binding", "current-binding-missing");
|
|
1406
|
+
}
|
|
1407
|
+
const [root, binding] = this.current.entries().next().value as [string, { lineageId: string; token: string }];
|
|
1408
|
+
return { root, ...binding };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
currentLineageId(contributorRoot?: string): string {
|
|
1412
|
+
return this.currentBinding(contributorRoot).lineageId;
|
|
1212
1413
|
}
|
|
1213
1414
|
|
|
1214
1415
|
/** The last failed dispatch-binding hydration, for controller envelopes. */
|
|
1215
|
-
lastDispatchHydrationFailure(): Readonly<{ lineageId: string; reason: string; message: string }> | undefined {
|
|
1216
|
-
return this.
|
|
1416
|
+
lastDispatchHydrationFailure(contributorRoot?: string): Readonly<{ lineageId: string; reason: string; message: string }> | undefined {
|
|
1417
|
+
if (contributorRoot !== undefined) return this.lastHydrationFailures.get(this.canonicalRoot(contributorRoot));
|
|
1418
|
+
return this.lastHydrationFailures.size === 1 ? [...this.lastHydrationFailures.values()][0] : undefined;
|
|
1217
1419
|
}
|
|
1218
1420
|
|
|
1219
|
-
resolveCurrentForLens(lens: string): CandidateView {
|
|
1220
|
-
return this.resolveCurrentForLenses([lens])[0]!;
|
|
1421
|
+
resolveCurrentForLens(lens: string, contributorRoot?: string): CandidateView {
|
|
1422
|
+
return this.resolveCurrentForLenses([lens], contributorRoot)[0]!;
|
|
1221
1423
|
}
|
|
1222
1424
|
|
|
1223
|
-
resolveCurrentForLenses(lenses: readonly string[]): CandidateView[] {
|
|
1224
|
-
const
|
|
1225
|
-
const
|
|
1226
|
-
|
|
1227
|
-
if (!record || record.lineageId !== lineageId || this.lineages.get(lineageId) !== token) throw new CandidateViewError("review subagent dispatch current lineage binding is stale or ambiguous", "current-binding-stale");
|
|
1425
|
+
resolveCurrentForLenses(lenses: readonly string[], contributorRoot?: string): CandidateView[] {
|
|
1426
|
+
const current = this.currentBinding(contributorRoot);
|
|
1427
|
+
const record = this.records.get(current.token);
|
|
1428
|
+
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");
|
|
1228
1429
|
assertRecordSafe(record);
|
|
1229
1430
|
this.assertCurrentBindingMatchesLiveCandidate(record);
|
|
1230
1431
|
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");
|
|
@@ -1232,7 +1433,7 @@ export class CandidateViewRegistry {
|
|
|
1232
1433
|
}
|
|
1233
1434
|
|
|
1234
1435
|
private assertCurrentBindingMatchesLiveCandidate(record: CandidateViewRecord): void {
|
|
1235
|
-
const live = materializeCandidateView({ contributorRoot: record.contributorRoot, baseRef: record.baseCommit, committedOnly: record.committedOnly }, this.gitExecutor);
|
|
1436
|
+
const live = materializeCandidateView({ contributorRoot: record.contributorRoot, baseRef: record.baseCommit, committedOnly: record.committedOnly, ...(record.intendedUntracked === undefined ? {} : { intendedUntracked: record.intendedUntracked }) }, this.gitExecutor);
|
|
1236
1437
|
try {
|
|
1237
1438
|
if (
|
|
1238
1439
|
live.baseCommit !== record.baseCommit ||
|
|
@@ -1249,8 +1450,9 @@ export class CandidateViewRegistry {
|
|
|
1249
1450
|
}
|
|
1250
1451
|
}
|
|
1251
1452
|
|
|
1252
|
-
resolveForFinalize(lineageId: string): CandidateView {
|
|
1253
|
-
const
|
|
1453
|
+
resolveForFinalize(lineageId: string, contributorRoot?: string): CandidateView {
|
|
1454
|
+
const key = this.requireKey(this.lineages, lineageId, contributorRoot, "candidate view context is missing or ambiguous for FINALIZE");
|
|
1455
|
+
const token = this.lineages.get(key);
|
|
1254
1456
|
const record = token === undefined ? undefined : this.records.get(token);
|
|
1255
1457
|
if (!record || record.lineageId !== lineageId) throw new CandidateViewError("candidate view context is missing or ambiguous for FINALIZE");
|
|
1256
1458
|
assertRecordSafe(record);
|
|
@@ -1264,40 +1466,47 @@ export class CandidateViewRegistry {
|
|
|
1264
1466
|
this.forget(record);
|
|
1265
1467
|
}
|
|
1266
1468
|
|
|
1267
|
-
cleanupTerminal(lineageId: string, state: string): void {
|
|
1469
|
+
cleanupTerminal(lineageId: string, state: string, contributorRoot?: string): void {
|
|
1268
1470
|
if (state !== "approved" && state !== "escalated") return;
|
|
1269
|
-
const
|
|
1270
|
-
|
|
1471
|
+
const key = this.uniqueKey(this.lineages, lineageId, contributorRoot);
|
|
1472
|
+
const token = key === undefined ? undefined : this.lineages.get(key);
|
|
1473
|
+
if (token) this.cleanup(token);
|
|
1474
|
+
if (state === "escalated" && key !== undefined) this.projections.delete(key);
|
|
1271
1475
|
}
|
|
1272
1476
|
|
|
1273
1477
|
private remove(record: CandidateViewRecord): void {
|
|
1274
1478
|
if (!isWithin(record.parent, record.root)) throw new CandidateViewError("candidate view cleanup escaped its owned parent");
|
|
1275
1479
|
try { makeWritableForCleanup(record.root); } catch {}
|
|
1276
|
-
try { git(record.contributorRoot, ["worktree", "remove", "--force", record.root], process.env, record.gitExecutor); } catch {
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
}
|
|
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 });
|
|
1280
1483
|
}
|
|
1281
1484
|
|
|
1282
1485
|
private forget(record: CandidateViewRecord): void {
|
|
1283
1486
|
this.records.delete(record.token);
|
|
1284
|
-
if (record.lineageId
|
|
1285
|
-
|
|
1286
|
-
|
|
1487
|
+
if (record.lineageId) {
|
|
1488
|
+
const key = this.lineageKey(record.contributorRoot, record.lineageId);
|
|
1489
|
+
if (this.lineages.get(key) === record.token) this.lineages.delete(key);
|
|
1490
|
+
}
|
|
1491
|
+
if (this.current.get(record.contributorRoot)?.token === record.token) this.current.delete(record.contributorRoot);
|
|
1492
|
+
for (const [replayKey, pendingToken] of this.replays) if (pendingToken === record.token) this.replays.delete(replayKey);
|
|
1287
1493
|
}
|
|
1288
1494
|
|
|
1289
|
-
consumeProjection(lineageId: string): void {
|
|
1290
|
-
this.projections
|
|
1495
|
+
consumeProjection(lineageId: string, contributorRoot?: string): void {
|
|
1496
|
+
const key = this.uniqueKey(this.projections, lineageId, contributorRoot);
|
|
1497
|
+
if (key !== undefined) this.projections.delete(key);
|
|
1291
1498
|
}
|
|
1292
1499
|
|
|
1293
1500
|
private expose(record: CandidateViewRecord): CandidateView {
|
|
1294
1501
|
return {
|
|
1295
1502
|
token: record.token,
|
|
1296
1503
|
root: record.root,
|
|
1504
|
+
contributorRoot: record.contributorRoot,
|
|
1297
1505
|
baseCommit: record.baseCommit,
|
|
1298
1506
|
baseTree: record.baseTree,
|
|
1299
1507
|
candidateTree: record.candidateTree,
|
|
1300
1508
|
committedOnly: record.committedOnly,
|
|
1509
|
+
intendedUntracked: record.intendedUntracked,
|
|
1301
1510
|
paths: record.scope.paths,
|
|
1302
1511
|
modes: record.scope.modes,
|
|
1303
1512
|
gitlinks: record.scope.gitlinks,
|