gentle-pi 2.1.2 → 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 +108 -79
- package/assets/agents/gentle-ai-worker.md +7 -3
- package/assets/agents/jd-fix-agent.md +1 -1
- package/assets/agents/jd-judge-a.md +3 -1
- package/assets/agents/jd-judge-b.md +3 -1
- package/assets/agents/review-readability.md +4 -3
- package/assets/agents/review-reliability.md +4 -3
- package/assets/agents/review-resilience.md +4 -3
- package/assets/agents/review-risk.md +4 -3
- package/assets/agents/sdd-apply.md +11 -4
- package/assets/agents/sdd-archive.md +6 -1
- package/assets/agents/sdd-design.md +6 -1
- package/assets/agents/sdd-explore.md +6 -2
- package/assets/agents/sdd-init.md +10 -2
- package/assets/agents/sdd-onboard.md +6 -1
- package/assets/agents/sdd-proposal.md +8 -1
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-spec.md +6 -1
- package/assets/agents/sdd-status.md +10 -5
- package/assets/agents/sdd-sync.md +6 -1
- package/assets/agents/sdd-tasks.md +8 -4
- package/assets/agents/sdd-verify.md +27 -2
- package/assets/chains/4r-review.chain.md +2 -0
- package/assets/chains/sdd-full.chain.md +2 -2
- package/assets/chains/sdd-plan.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +2 -2
- package/assets/orchestrator-delegation.md +145 -160
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +25 -48
- package/assets/sdd-orchestrator-workflow.md +163 -25
- package/assets/support/sdd-status-contract.md +24 -6
- package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
- package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
- package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
- package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
- package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
- package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
- package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
- package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
- package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
- package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
- 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/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
- package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
- package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
- package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
- package/docs/native-authority-architecture.md +14 -16
- package/docs/review-integration.md +27 -353
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +2710 -3211
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +21 -12
- package/extensions/skill-registry.ts +10 -2
- package/extensions/startup-banner.ts +10 -4
- package/lib/gentle-ai-binary.ts +173 -2
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +676 -781
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/provider-contract-bundle.ts +704 -0
- package/lib/review-candidate-view.ts +836 -118
- package/lib/review-compact-contract.ts +59 -248
- package/lib/review-host-relay.ts +578 -0
- package/lib/review-integration-v2.ts +1254 -221
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/review-relay-contract.ts +16 -0
- package/lib/sdd-preflight.ts +230 -67
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -79
- package/runtime/gentle-ai-binary.mjs +174 -3
- package/runtime/native-review-cli.mjs +610 -715
- package/runtime/review-integration-v2.mjs +1223 -190
- package/runtime/review-relay-contract.mjs +17 -0
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
- package/scripts/check-provider-contract.mjs +138 -0
- package/scripts/gentle-ai-installer.mjs +85 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
- package/scripts/mirror-provider-contract.mjs +143 -0
- package/scripts/test-packed-runner.mjs +20 -9
- package/scripts/verify-package-files.mjs +119 -43
- package/skills/_shared/review-ledger-contract.md +11 -19
- 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/issue-creation/SKILL.md +94 -168
- package/skills/judgment-day/SKILL.md +9 -5
- package/skills/judgment-day/references/prompts-and-formats.md +2 -0
- package/skills/rdd-defect-workflow/SKILL.md +54 -0
- 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/background-subagents.test.ts +771 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +16 -0
- package/tests/delegated-key-learnings-contract.test.ts +240 -0
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
- package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
- package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
- package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -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/result-artifact-v2-path.captured.json +12 -0
- package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -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-consent-declined.captured.json +19 -0
- package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
- package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
- package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
- package/tests/gentle-ai-dev-binary.test.ts +159 -0
- package/tests/gentle-ai-installer.test.ts +138 -48
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +601 -0
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +75 -2
- package/tests/native-review-cli.test.ts +588 -908
- package/tests/native-review-consent.test.ts +283 -38
- package/tests/native-review-parity-runtime.test.ts +108 -350
- package/tests/native-review-parity.test.ts +684 -732
- package/tests/native-sdd-attempt-authority.test.ts +235 -0
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +158 -89
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +128 -100
- package/tests/provider-contract-bundle.test.ts +385 -0
- package/tests/provider-contract-mirror.test.ts +206 -0
- package/tests/provider-defect-handoff.test.ts +252 -0
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-actor-tool-deny.test.ts +12 -13
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +898 -12
- package/tests/review-compact-contract.test.ts +29 -122
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +366 -857
- package/tests/review-controller-native-routing.test.ts +1253 -4199
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +236 -70
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +134 -0
- package/tests/review-dispatch-hydration-gap.test.ts +145 -0
- 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 +352 -0
- package/tests/review-host-relay.test.ts +754 -0
- package/tests/review-integration-v2-forward.test.ts +654 -0
- package/tests/review-integration-v2.test.ts +317 -129
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +106 -60
- package/tests/review-recovered-lineage-routing.test.ts +199 -0
- package/tests/review-relay-transport-agent.test.ts +322 -0
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +573 -180
- package/tests/sdd-agent-tools.test.ts +53 -32
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +6 -8
- package/tests/skill-registry.test.ts +50 -1
- package/tests/verify-package-files.test.ts +62 -0
- 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/assets/agents/review-refuter.md +0 -40
- package/assets/agents/review-validator.md +0 -23
- package/lib/git-commit-transaction.ts +0 -801
- package/lib/native-review-remediation.ts +0 -49
- package/lib/review-compact.ts +0 -947
- package/lib/review-refuter-adapter.ts +0 -129
- package/lib/review-runtime-contract.ts +0 -68
- package/prompts/gcl.md +0 -54
- package/prompts/gis.md +0 -25
- package/prompts/gpr.md +0 -41
- package/prompts/gwr.md +0 -31
- package/runtime/git-commit-transaction.mjs +0 -802
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
- package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
- package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
- package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
- 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 -302
- package/tests/review-compact.test.ts +0 -243
- package/tests/review-refuter-adapter.test.ts +0 -89
|
@@ -1,13 +1,54 @@
|
|
|
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";
|
|
7
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
7
8
|
|
|
8
9
|
const REVIEW_LENS = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
|
|
9
10
|
export type ReviewLens = (typeof REVIEW_LENS)[number];
|
|
10
11
|
const CANDIDATE_GIT_TIMEOUT_MS = 10_000;
|
|
12
|
+
const CANDIDATE_GIT_TIMEOUT_MAX_MS = 120_000;
|
|
13
|
+
const CANDIDATE_GIT_TIMEOUT_ENV = "GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS";
|
|
14
|
+
const CANDIDATE_GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
// Candidate views may materialize full repository trees. Large repositories can
|
|
17
|
+
// raise this bounded deadline without creating an unbounded child process.
|
|
18
|
+
function resolveCandidateGitTimeoutMs(environment: NodeJS.ProcessEnv = process.env): number {
|
|
19
|
+
const value = environment[CANDIDATE_GIT_TIMEOUT_ENV];
|
|
20
|
+
if (value === undefined || !/^[1-9]\d*$/.test(value)) return CANDIDATE_GIT_TIMEOUT_MS;
|
|
21
|
+
const parsed = Number(value);
|
|
22
|
+
return Number.isSafeInteger(parsed) && parsed <= CANDIDATE_GIT_TIMEOUT_MAX_MS
|
|
23
|
+
? parsed
|
|
24
|
+
: CANDIDATE_GIT_TIMEOUT_MS;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isCandidateGitTimeoutMs(value: number): boolean {
|
|
28
|
+
return Number.isSafeInteger(value) && value > 0 && value <= CANDIDATE_GIT_TIMEOUT_MAX_MS;
|
|
29
|
+
}
|
|
30
|
+
const CANDIDATE_VIEW_DIAGNOSTIC_PHASE = "candidate-view";
|
|
31
|
+
const CANDIDATE_VIEW_GIT_FAILURE_CATEGORY = {
|
|
32
|
+
TIMEOUT: "timeout",
|
|
33
|
+
OUTPUT_LIMIT: "output-limit",
|
|
34
|
+
GIT_FAILURE: "git-failure",
|
|
35
|
+
} as const;
|
|
36
|
+
export type CandidateViewGitFailureCategory = (typeof CANDIDATE_VIEW_GIT_FAILURE_CATEGORY)[keyof typeof CANDIDATE_VIEW_GIT_FAILURE_CATEGORY];
|
|
37
|
+
const CANDIDATE_GIT_SUBCOMMAND = {
|
|
38
|
+
ADD: "add",
|
|
39
|
+
CHECKOUT_INDEX: "checkout-index",
|
|
40
|
+
DIFF: "diff",
|
|
41
|
+
FOR_EACH_REF: "for-each-ref",
|
|
42
|
+
LOG: "log",
|
|
43
|
+
LS_FILES: "ls-files",
|
|
44
|
+
LS_TREE: "ls-tree",
|
|
45
|
+
READ_TREE: "read-tree",
|
|
46
|
+
REV_PARSE: "rev-parse",
|
|
47
|
+
WORKTREE: "worktree",
|
|
48
|
+
WRITE_TREE: "write-tree",
|
|
49
|
+
OTHER: "other",
|
|
50
|
+
} as const;
|
|
51
|
+
type CandidateGitSubcommand = (typeof CANDIDATE_GIT_SUBCOMMAND)[keyof typeof CANDIDATE_GIT_SUBCOMMAND];
|
|
11
52
|
|
|
12
53
|
export type CandidateGitExecutor = (file: string, arguments_: readonly string[], options: ExecFileSyncOptions) => string | Buffer;
|
|
13
54
|
const defaultCandidateGitExecutor: CandidateGitExecutor = (file, arguments_, options) => execFileSync(file, arguments_, options);
|
|
@@ -16,6 +57,20 @@ const CONTROLLER_CANDIDATE_VIEW_HEADING = "## Controller-owned candidate view";
|
|
|
16
57
|
const MAX_SUBAGENT_TASK_LENGTH = 16_384;
|
|
17
58
|
const MAX_SUBAGENT_CONTEXT_LENGTH = 4_096;
|
|
18
59
|
const MAX_CANDIDATE_CONTEXT_LENGTH = 4_096;
|
|
60
|
+
const MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES = 1024 * 1024;
|
|
61
|
+
const MAX_CANDIDATE_SCOPE_PAGE_BYTES = 16 * 1024;
|
|
62
|
+
const MAX_CANDIDATE_SCOPE_PAGE_ENTRIES = 128;
|
|
63
|
+
const CANDIDATE_CONTEXT_MANIFEST = {
|
|
64
|
+
VERSION: 1,
|
|
65
|
+
} as const;
|
|
66
|
+
const CANDIDATE_CONTEXT_MODE = {
|
|
67
|
+
REGULAR: "100644",
|
|
68
|
+
EXECUTABLE: "100755",
|
|
69
|
+
SYMLINK: "120000",
|
|
70
|
+
GITLINK: "160000",
|
|
71
|
+
DELETED: "deleted",
|
|
72
|
+
} as const;
|
|
73
|
+
export type CandidateContextMode = (typeof CANDIDATE_CONTEXT_MODE)[keyof typeof CANDIDATE_CONTEXT_MODE];
|
|
19
74
|
const SUBAGENT_RUN_KEYS = new Set(["agent", "agents", "task", "context", "mode"]);
|
|
20
75
|
|
|
21
76
|
interface CandidateTreeEntry {
|
|
@@ -48,6 +103,7 @@ interface CandidateViewRecord {
|
|
|
48
103
|
baseTree: string;
|
|
49
104
|
candidateTree: string;
|
|
50
105
|
committedOnly: boolean;
|
|
106
|
+
intendedUntracked?: readonly string[];
|
|
51
107
|
entries: readonly CandidateViewEntry[];
|
|
52
108
|
gitlinks: readonly CandidateGitlink[];
|
|
53
109
|
scope: CandidateViewScope;
|
|
@@ -59,10 +115,12 @@ interface CandidateViewRecord {
|
|
|
59
115
|
export interface CandidateView {
|
|
60
116
|
token: string;
|
|
61
117
|
root: string;
|
|
118
|
+
contributorRoot: string;
|
|
62
119
|
baseCommit: string;
|
|
63
120
|
baseTree: string;
|
|
64
121
|
candidateTree: string;
|
|
65
122
|
committedOnly: boolean;
|
|
123
|
+
intendedUntracked?: readonly string[];
|
|
66
124
|
paths: readonly string[];
|
|
67
125
|
modes: Readonly<Record<string, string>>;
|
|
68
126
|
gitlinks: Readonly<Record<string, string>>;
|
|
@@ -71,12 +129,40 @@ export interface CandidateView {
|
|
|
71
129
|
cleanup(): void;
|
|
72
130
|
}
|
|
73
131
|
|
|
132
|
+
export interface CandidateContextManifest {
|
|
133
|
+
version: typeof CANDIDATE_CONTEXT_MANIFEST.VERSION;
|
|
134
|
+
scopeByMode: Readonly<Record<string, readonly string[]>>;
|
|
135
|
+
gitlinks: Readonly<Record<string, string>>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface DecodedCandidateContextManifest {
|
|
139
|
+
manifest: CandidateContextManifest;
|
|
140
|
+
bytes: Buffer;
|
|
141
|
+
sha256: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface CandidateContextPageEntry {
|
|
145
|
+
path: string;
|
|
146
|
+
mode: CandidateContextMode;
|
|
147
|
+
gitlinkObjectId?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface CandidateContextPage {
|
|
151
|
+
version: typeof CANDIDATE_CONTEXT_MANIFEST.VERSION;
|
|
152
|
+
sha256: string;
|
|
153
|
+
cursor: number;
|
|
154
|
+
totalPaths: number;
|
|
155
|
+
entries: readonly CandidateContextPageEntry[];
|
|
156
|
+
nextCursor?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
74
159
|
export interface FrozenCandidateProjection {
|
|
75
160
|
contributorRoot: string;
|
|
76
161
|
baseCommit: string;
|
|
77
162
|
baseTree: string;
|
|
78
163
|
candidateTree: string;
|
|
79
164
|
committedOnly: boolean;
|
|
165
|
+
intendedUntracked?: readonly string[];
|
|
80
166
|
paths: readonly string[];
|
|
81
167
|
modes: Readonly<Record<string, string>>;
|
|
82
168
|
gitlinks: Readonly<Record<string, string>>;
|
|
@@ -87,6 +173,8 @@ export interface CreateCandidateViewRequest {
|
|
|
87
173
|
contributorRoot: string;
|
|
88
174
|
baseRef?: string;
|
|
89
175
|
committedOnly?: boolean;
|
|
176
|
+
/** Undefined keeps legacy all-untracked capture; [] excludes untracked files. */
|
|
177
|
+
intendedUntracked?: readonly string[];
|
|
90
178
|
replayKey?: string;
|
|
91
179
|
}
|
|
92
180
|
|
|
@@ -103,6 +191,7 @@ export interface AuthoritativeReviewingCandidateState {
|
|
|
103
191
|
baseTree: string;
|
|
104
192
|
candidateTree: string;
|
|
105
193
|
committedOnly?: boolean;
|
|
194
|
+
intendedUntracked?: readonly string[];
|
|
106
195
|
paths: readonly string[];
|
|
107
196
|
modes: Readonly<Record<string, string>>;
|
|
108
197
|
gitlinks?: Readonly<Record<string, string>>;
|
|
@@ -130,22 +219,96 @@ export interface NativeCandidateProjectionDescriptor {
|
|
|
130
219
|
providerManifestHashVerified?: true;
|
|
131
220
|
}
|
|
132
221
|
|
|
222
|
+
export interface CandidateViewDiagnostic {
|
|
223
|
+
phase: typeof CANDIDATE_VIEW_DIAGNOSTIC_PHASE;
|
|
224
|
+
category: CandidateViewGitFailureCategory;
|
|
225
|
+
git_subcommand: CandidateGitSubcommand;
|
|
226
|
+
timeout_ms: number;
|
|
227
|
+
max_buffer_bytes: number;
|
|
228
|
+
message: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
133
231
|
export class CandidateViewError extends Error {
|
|
134
232
|
readonly reason: string;
|
|
135
|
-
|
|
233
|
+
readonly diagnostics?: CandidateViewDiagnostic;
|
|
234
|
+
constructor(message: string, reason = "candidate-view-invalid", diagnostics?: CandidateViewDiagnostic) {
|
|
136
235
|
super(message);
|
|
137
236
|
this.name = "CandidateViewError";
|
|
138
237
|
this.reason = reason;
|
|
238
|
+
this.diagnostics = diagnostics === undefined ? undefined : sanitizeCandidateViewDiagnostic(diagnostics);
|
|
139
239
|
}
|
|
140
240
|
}
|
|
141
241
|
|
|
242
|
+
function candidateGitSubcommand(arguments_: readonly string[]): CandidateGitSubcommand {
|
|
243
|
+
switch (arguments_[0]) {
|
|
244
|
+
case CANDIDATE_GIT_SUBCOMMAND.ADD: return CANDIDATE_GIT_SUBCOMMAND.ADD;
|
|
245
|
+
case CANDIDATE_GIT_SUBCOMMAND.CHECKOUT_INDEX: return CANDIDATE_GIT_SUBCOMMAND.CHECKOUT_INDEX;
|
|
246
|
+
case CANDIDATE_GIT_SUBCOMMAND.DIFF: return CANDIDATE_GIT_SUBCOMMAND.DIFF;
|
|
247
|
+
case CANDIDATE_GIT_SUBCOMMAND.FOR_EACH_REF: return CANDIDATE_GIT_SUBCOMMAND.FOR_EACH_REF;
|
|
248
|
+
case CANDIDATE_GIT_SUBCOMMAND.LOG: return CANDIDATE_GIT_SUBCOMMAND.LOG;
|
|
249
|
+
case CANDIDATE_GIT_SUBCOMMAND.LS_FILES: return CANDIDATE_GIT_SUBCOMMAND.LS_FILES;
|
|
250
|
+
case CANDIDATE_GIT_SUBCOMMAND.LS_TREE: return CANDIDATE_GIT_SUBCOMMAND.LS_TREE;
|
|
251
|
+
case CANDIDATE_GIT_SUBCOMMAND.READ_TREE: return CANDIDATE_GIT_SUBCOMMAND.READ_TREE;
|
|
252
|
+
case CANDIDATE_GIT_SUBCOMMAND.REV_PARSE: return CANDIDATE_GIT_SUBCOMMAND.REV_PARSE;
|
|
253
|
+
case CANDIDATE_GIT_SUBCOMMAND.WORKTREE: return CANDIDATE_GIT_SUBCOMMAND.WORKTREE;
|
|
254
|
+
case CANDIDATE_GIT_SUBCOMMAND.WRITE_TREE: return CANDIDATE_GIT_SUBCOMMAND.WRITE_TREE;
|
|
255
|
+
default: return CANDIDATE_GIT_SUBCOMMAND.OTHER;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function candidateGitDiagnosticMessage(category: CandidateViewGitFailureCategory, subcommand: CandidateGitSubcommand, timeoutMs: number): string {
|
|
260
|
+
if (category === CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT) return `candidate-view Git command ${subcommand} timed out after ${timeoutMs}ms; inspect the candidate state before any new START`;
|
|
261
|
+
if (category === CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT) return `candidate-view Git command ${subcommand} exceeded the ${CANDIDATE_GIT_MAX_BUFFER_BYTES}-byte output limit; inspect the candidate state before any new START`;
|
|
262
|
+
return `candidate-view Git command ${subcommand} failed; inspect the candidate state before any new START`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function candidateGitDiagnostic(category: CandidateViewGitFailureCategory, arguments_: readonly string[], timeoutMs: number): CandidateViewDiagnostic {
|
|
266
|
+
const git_subcommand = candidateGitSubcommand(arguments_);
|
|
267
|
+
return Object.freeze({
|
|
268
|
+
phase: CANDIDATE_VIEW_DIAGNOSTIC_PHASE,
|
|
269
|
+
category,
|
|
270
|
+
git_subcommand,
|
|
271
|
+
timeout_ms: timeoutMs,
|
|
272
|
+
max_buffer_bytes: CANDIDATE_GIT_MAX_BUFFER_BYTES,
|
|
273
|
+
message: candidateGitDiagnosticMessage(category, git_subcommand, timeoutMs),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sanitizeCandidateViewDiagnostic(diagnostics: CandidateViewDiagnostic): CandidateViewDiagnostic | undefined {
|
|
278
|
+
const { phase, category, git_subcommand, timeout_ms, max_buffer_bytes, message } = diagnostics;
|
|
279
|
+
if (
|
|
280
|
+
phase !== CANDIDATE_VIEW_DIAGNOSTIC_PHASE ||
|
|
281
|
+
!Object.values(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY).includes(category) ||
|
|
282
|
+
!Object.values(CANDIDATE_GIT_SUBCOMMAND).includes(git_subcommand) ||
|
|
283
|
+
!isCandidateGitTimeoutMs(timeout_ms) ||
|
|
284
|
+
max_buffer_bytes !== CANDIDATE_GIT_MAX_BUFFER_BYTES ||
|
|
285
|
+
message !== candidateGitDiagnosticMessage(category, git_subcommand, timeout_ms)
|
|
286
|
+
) return undefined;
|
|
287
|
+
return Object.freeze({ phase, category, git_subcommand, timeout_ms, max_buffer_bytes, message });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function candidateGitFailure(category: CandidateViewGitFailureCategory, arguments_: readonly string[], timeoutMs: number): CandidateViewError {
|
|
291
|
+
const diagnostics = candidateGitDiagnostic(category, arguments_, timeoutMs);
|
|
292
|
+
return new CandidateViewError(diagnostics.message, `candidate-view-${category}`, diagnostics);
|
|
293
|
+
}
|
|
294
|
+
|
|
142
295
|
function candidateGit(cwd: string, arguments_: readonly string[], env: NodeJS.ProcessEnv, encoding: "utf8" | "buffer", executor: CandidateGitExecutor): string | Buffer {
|
|
296
|
+
const timeoutMs = resolveCandidateGitTimeoutMs(env);
|
|
143
297
|
try {
|
|
144
|
-
return executor("git", arguments_, {
|
|
298
|
+
return executor("git", arguments_, {
|
|
299
|
+
cwd,
|
|
300
|
+
encoding,
|
|
301
|
+
env,
|
|
302
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
303
|
+
timeout: timeoutMs,
|
|
304
|
+
maxBuffer: CANDIDATE_GIT_MAX_BUFFER_BYTES,
|
|
305
|
+
windowsHide: true,
|
|
306
|
+
});
|
|
145
307
|
} catch (error) {
|
|
146
|
-
const detail = error as NodeJS.ErrnoException & {
|
|
147
|
-
if (detail.code === "
|
|
148
|
-
|
|
308
|
+
const detail = error as NodeJS.ErrnoException & { killed?: boolean };
|
|
309
|
+
if (detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT, arguments_, timeoutMs);
|
|
310
|
+
if (detail.code === "ETIMEDOUT" || detail.killed === true) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT, arguments_, timeoutMs);
|
|
311
|
+
throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, arguments_, timeoutMs);
|
|
149
312
|
}
|
|
150
313
|
}
|
|
151
314
|
|
|
@@ -260,23 +423,30 @@ export interface ChangedPathEntry {
|
|
|
260
423
|
readonly modeOnly: boolean;
|
|
261
424
|
}
|
|
262
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.
|
|
263
433
|
export function deriveChangedPathManifest(cwd: string, baseTree: string, candidateTree: string, executor: CandidateGitExecutor = defaultCandidateGitExecutor): readonly ChangedPathEntry[] {
|
|
264
|
-
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);
|
|
265
435
|
const entries: ChangedPathEntry[] = [];
|
|
266
436
|
for (let index = 0; index < tokens.length;) {
|
|
267
437
|
const header = tokens[index++]?.toString("ascii");
|
|
268
438
|
if (header === undefined) break;
|
|
269
|
-
// `:<old_mode> <new_mode> <old_sha> <new_sha> <status
|
|
270
|
-
|
|
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);
|
|
271
442
|
if (match === null) throw new CandidateViewError("candidate manifest Git output contains an unsafe raw header", "manifest-derivation-invalid");
|
|
272
443
|
const [, oldMode, newMode, oldSha, newSha, status] = match;
|
|
273
|
-
const
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
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);
|
|
277
447
|
entries.push(Object.freeze({
|
|
278
448
|
path,
|
|
279
|
-
status
|
|
449
|
+
status,
|
|
280
450
|
oldMode,
|
|
281
451
|
newMode,
|
|
282
452
|
deleted: status === "D",
|
|
@@ -348,24 +518,20 @@ function assertManifestMatchesGit(descriptor: NativeCandidateProjectionDescripto
|
|
|
348
518
|
}
|
|
349
519
|
}
|
|
350
520
|
|
|
351
|
-
function deriveChangedScope(cwd: string,
|
|
521
|
+
function deriveChangedScope(cwd: string, baseTree: string, candidateTree: string, entries: readonly CandidateTreeEntry[], executor: CandidateGitExecutor): CandidateViewScope {
|
|
352
522
|
const present = new Map(entries.map((entry) => [entry.path, entry]));
|
|
353
523
|
const paths = new Set<string>();
|
|
354
524
|
const deleted = new Set<string>();
|
|
355
|
-
|
|
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);
|
|
356
529
|
for (let index = 0; index < tokens.length;) {
|
|
357
530
|
const status = tokens[index++]?.toString("ascii");
|
|
358
|
-
if (status === undefined || !/^
|
|
359
|
-
const
|
|
360
|
-
if (
|
|
361
|
-
const
|
|
362
|
-
const path = status.startsWith("R")
|
|
363
|
-
? (() => {
|
|
364
|
-
const newPath = tokens[index++];
|
|
365
|
-
if (newPath === undefined) throw new CandidateViewError("candidate scope rename output is incomplete");
|
|
366
|
-
return decodeCanonicalPath(newPath);
|
|
367
|
-
})()
|
|
368
|
-
: 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);
|
|
369
535
|
if (paths.has(path) || deleted.has(path)) throw new CandidateViewError("candidate scope Git output contains duplicate paths");
|
|
370
536
|
if (status === "D") {
|
|
371
537
|
if (present.has(path)) throw new CandidateViewError("candidate scope deletion is present in the candidate tree");
|
|
@@ -468,8 +634,60 @@ function explicitBaseRefCandidates(cwd: string, selector: string, env: NodeJS.Pr
|
|
|
468
634
|
return [...new Set(candidates)].filter((candidate) => refs.has(candidate));
|
|
469
635
|
}
|
|
470
636
|
|
|
637
|
+
// Runs a probe command that may exit nonzero as an expected signal (absent
|
|
638
|
+
// ref, detached HEAD). Returns the exit status and trimmed stdout. Timeout,
|
|
639
|
+
// output-limit, and unexpected Git failures propagate as sanitized
|
|
640
|
+
// CandidateViewError diagnostics, same as candidateGit.
|
|
641
|
+
function probeCandidateGit(cwd: string, arguments_: readonly string[], env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): { status: number; stdout: string } {
|
|
642
|
+
const timeoutMs = resolveCandidateGitTimeoutMs(env);
|
|
643
|
+
try {
|
|
644
|
+
const stdout = executor("git", arguments_, {
|
|
645
|
+
cwd, encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"],
|
|
646
|
+
timeout: timeoutMs, maxBuffer: CANDIDATE_GIT_MAX_BUFFER_BYTES, windowsHide: true,
|
|
647
|
+
}) as string;
|
|
648
|
+
return { status: 0, stdout: stdout.trim() };
|
|
649
|
+
} catch (error) {
|
|
650
|
+
const detail = error as NodeJS.ErrnoException & { killed?: boolean; status?: number; stdout?: string | Buffer };
|
|
651
|
+
if (detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT, arguments_, timeoutMs);
|
|
652
|
+
if (detail.code === "ETIMEDOUT" || detail.killed === true) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT, arguments_, timeoutMs);
|
|
653
|
+
if (typeof detail.status === "number") return { status: detail.status, stdout: typeof detail.stdout === "string" ? detail.stdout.trim() : "" };
|
|
654
|
+
throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, arguments_, timeoutMs);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// An unborn repository's HEAD is a symbolic ref to a branch with no commits.
|
|
659
|
+
// `symbolic-ref --quiet HEAD` exits nonzero for a detached HEAD (not unborn).
|
|
660
|
+
// `rev-parse --verify --quiet <ref>` distinguishes a valid unborn (status 1,
|
|
661
|
+
// ref absent) from a broken symbolic ref (exit 0, ref OID text exists even
|
|
662
|
+
// when the object is missing). Any other status (128, etc.) signals
|
|
663
|
+
// corruption or an I/O failure, so it fails closed instead of masquerading as
|
|
664
|
+
// an unborn repository.
|
|
665
|
+
function isUnbornSymbolicHead(cwd: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): boolean {
|
|
666
|
+
const symbolic = probeCandidateGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], env, executor);
|
|
667
|
+
if (symbolic.status !== 0) return false;
|
|
668
|
+
const refProbeArguments = ["rev-parse", "--verify", "--quiet", symbolic.stdout];
|
|
669
|
+
const refProbe = probeCandidateGit(cwd, refProbeArguments, env, executor);
|
|
670
|
+
if (refProbe.status === 1) return true;
|
|
671
|
+
if (refProbe.status === 0) return false;
|
|
672
|
+
throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, refProbeArguments, resolveCandidateGitTimeoutMs(env));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Derives Git's repository-native empty tree without hardcoding the SHA-1 id,
|
|
676
|
+
// so a sha256 repository derives its own empty-tree object id. `mktree` with
|
|
677
|
+
// ignored stdin reads empty input and writes the empty tree object.
|
|
678
|
+
function resolveEmptyTree(cwd: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): string {
|
|
679
|
+
return git(cwd, ["mktree"], env, executor);
|
|
680
|
+
}
|
|
681
|
+
|
|
471
682
|
function resolveCandidateBase(cwd: string, baseRef: string | undefined, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): ResolvedCandidateBase {
|
|
472
683
|
const selector = baseRef ?? "HEAD";
|
|
684
|
+
// An unborn repository has a symbolic HEAD pointing at a branch with no
|
|
685
|
+
// commits yet. Its review base is Git's repository-native empty tree, not a
|
|
686
|
+
// missing or malformed commit. Only the default/HEAD selector is entitled to
|
|
687
|
+
// the empty-tree base; a detached HEAD over a missing commit stays fail-closed.
|
|
688
|
+
if (selector === "HEAD" && isUnbornSymbolicHead(cwd, env, executor)) {
|
|
689
|
+
return { commit: "HEAD", tree: resolveEmptyTree(cwd, env, executor) };
|
|
690
|
+
}
|
|
473
691
|
try {
|
|
474
692
|
if (baseRef !== undefined) {
|
|
475
693
|
const candidates = explicitBaseRefCandidates(cwd, selector, env, executor);
|
|
@@ -484,7 +702,7 @@ function resolveCandidateBase(cwd: string, baseRef: string | undefined, env: Nod
|
|
|
484
702
|
if (tree !== confirmedTree) throw new CandidateViewError("candidate base tree changed during resolution", "base-ref-moved");
|
|
485
703
|
return { commit: confirmedCommit, tree: confirmedTree };
|
|
486
704
|
} catch (error) {
|
|
487
|
-
if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-moved" || error.reason === "base-ref-unresolvable")) throw error;
|
|
705
|
+
if (error instanceof CandidateViewError && (error.diagnostics !== undefined || error.reason === "base-ref-ambiguous" || error.reason === "base-ref-moved" || error.reason === "base-ref-unresolvable")) throw error;
|
|
488
706
|
throw new CandidateViewError("candidate base reference is unresolvable", "base-ref-unresolvable");
|
|
489
707
|
}
|
|
490
708
|
}
|
|
@@ -519,6 +737,65 @@ function checkoutMaterializedEntries(root: string, entries: readonly CandidateTr
|
|
|
519
737
|
flush();
|
|
520
738
|
}
|
|
521
739
|
|
|
740
|
+
// Creates an unborn worktree (symbolic HEAD pointing at a branch with no
|
|
741
|
+
// commits, no ref written, no phantom commit). Git 2.42+ supports --orphan
|
|
742
|
+
// directly; older Git lacks the flag and reports an unsupported-option usage
|
|
743
|
+
// error (exit status 129). Only that exact status triggers the fallback: a
|
|
744
|
+
// temporary empty-tree commit seeds a detached --no-checkout worktree, then
|
|
745
|
+
// a symbolic-ref rewrite makes HEAD unborn. The temporary commit is never
|
|
746
|
+
// referenced by any ref and is GC-able, so it is not a phantom commit. The
|
|
747
|
+
// fallback uses a deterministic author/committer identity and timestamp in a
|
|
748
|
+
// copied environment so it does not depend on user.name/user.email or the
|
|
749
|
+
// ambient date. Contributor HEAD, branch, refs, and index are never touched.
|
|
750
|
+
function addUnbornWorktree(cwd: string, root: string, branch: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): void {
|
|
751
|
+
const primary = probeCandidateGit(cwd, ["worktree", "add", "--orphan", "-b", branch, root], env, executor);
|
|
752
|
+
if (primary.status === 0) return;
|
|
753
|
+
// Only the unsupported-option usage status (129, pre-2.42 Git lacking --orphan)
|
|
754
|
+
// triggers the fallback. Every other status propagates as a git-failure.
|
|
755
|
+
if (primary.status !== 129 || existsSync(root)) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, ["worktree", "add", "--orphan", "-b", branch, root], resolveCandidateGitTimeoutMs(env));
|
|
756
|
+
const fallbackEnv = {
|
|
757
|
+
...env,
|
|
758
|
+
GIT_AUTHOR_NAME: "gentle-ai-candidate",
|
|
759
|
+
GIT_AUTHOR_EMAIL: "gentle-ai-candidate@example.invalid",
|
|
760
|
+
GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z",
|
|
761
|
+
GIT_COMMITTER_NAME: "gentle-ai-candidate",
|
|
762
|
+
GIT_COMMITTER_EMAIL: "gentle-ai-candidate@example.invalid",
|
|
763
|
+
GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z",
|
|
764
|
+
};
|
|
765
|
+
const emptyTree = git(cwd, ["mktree"], fallbackEnv, executor);
|
|
766
|
+
const tempCommit = git(cwd, ["commit-tree", "-m", "gentle-ai-candidate", emptyTree], fallbackEnv, executor);
|
|
767
|
+
git(cwd, ["worktree", "add", "--no-checkout", "--detach", root, tempCommit], env, executor);
|
|
768
|
+
git(root, ["symbolic-ref", "HEAD", `refs/heads/${branch}`], env, executor);
|
|
769
|
+
}
|
|
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
|
+
|
|
522
799
|
function materializeCandidateView(request: CreateCandidateViewRequest, executor: CandidateGitExecutor): CandidateViewRecord {
|
|
523
800
|
const contributorRoot = realpathSync(request.contributorRoot);
|
|
524
801
|
if (!lstatSync(contributorRoot).isDirectory()) throw new CandidateViewError("contributor root is not a directory");
|
|
@@ -527,6 +804,7 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
527
804
|
const canonicalCommonDir = realpathSync(commonDir);
|
|
528
805
|
const base = resolveCandidateBase(contributorRoot, request.baseRef, process.env, executor);
|
|
529
806
|
const committedOnly = request.committedOnly === true;
|
|
807
|
+
const intendedUntracked = normalizeIntendedUntracked(request.intendedUntracked);
|
|
530
808
|
const candidateCommit = committedOnly
|
|
531
809
|
? resolveCandidateBase(contributorRoot, "HEAD", process.env, executor)
|
|
532
810
|
: base;
|
|
@@ -536,20 +814,45 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
536
814
|
const environment = { ...process.env, GIT_INDEX_FILE: indexPath };
|
|
537
815
|
try {
|
|
538
816
|
const baseCommit = base.commit;
|
|
539
|
-
|
|
540
|
-
|
|
817
|
+
const unborn = baseCommit === "HEAD";
|
|
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
|
+
}
|
|
541
833
|
const candidateTree = git(contributorRoot, ["write-tree"], environment, executor);
|
|
542
834
|
const root = join(parent, randomUUID());
|
|
543
|
-
|
|
835
|
+
// The worktree is created under the same try/catch cleanup boundary as
|
|
836
|
+
// the read-tree materialization that follows. addUnbornWorktree's
|
|
837
|
+
// fallback path can register a worktree with `worktree add` and then
|
|
838
|
+
// fail on a later step (for example `symbolic-ref`); moving creation
|
|
839
|
+
// here ensures any such partial registration is removed by the catch
|
|
840
|
+
// below instead of leaking a registered/admin worktree and directory.
|
|
544
841
|
try {
|
|
842
|
+
// An unborn repository has no commit to detach a worktree at. addUnbornWorktree
|
|
843
|
+
// creates an orphan worktree (unborn branch, no commit, no ref) to host the
|
|
844
|
+
// materialized candidate tree without a phantom commit, with a fallback for
|
|
845
|
+
// Git versions older than 2.42 that do not support --orphan.
|
|
846
|
+
if (unborn) addUnbornWorktree(contributorRoot, root, `gentle-ai-candidate-${randomUUID()}`, process.env, executor);
|
|
847
|
+
else git(contributorRoot, ["worktree", "add", "--detach", "--no-checkout", root, candidateCommit.commit], process.env, executor);
|
|
545
848
|
git(root, ["read-tree", candidateTree], process.env, executor);
|
|
546
849
|
const tree = parseTree(root, candidateTree, executor);
|
|
547
850
|
checkoutMaterializedEntries(root, tree.entries, executor);
|
|
548
851
|
const entries = tree.entries.map((entry) => ({ ...entry, contentHash: entryContentHash(root, entry) }));
|
|
549
|
-
const scope = deriveChangedScope(contributorRoot,
|
|
852
|
+
const scope = deriveChangedScope(contributorRoot, base.tree, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
|
|
550
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");
|
|
551
854
|
makeReadonly(root, entries);
|
|
552
|
-
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 };
|
|
553
856
|
} catch (error) {
|
|
554
857
|
try { git(contributorRoot, ["worktree", "remove", "--force", root], process.env, executor); } catch { rmSync(root, { recursive: true, force: true }); }
|
|
555
858
|
throw error;
|
|
@@ -560,6 +863,20 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
560
863
|
}
|
|
561
864
|
|
|
562
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
|
+
}
|
|
563
880
|
const root = record.root;
|
|
564
881
|
if (!isWithin(record.parent, root) || !existsSync(root)) throw new CandidateViewError("candidate view is missing or moved");
|
|
565
882
|
const rootStat = lstatSync(root);
|
|
@@ -599,22 +916,111 @@ export class CandidateViewRegistry {
|
|
|
599
916
|
constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor) {
|
|
600
917
|
this.gitExecutor = gitExecutor;
|
|
601
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.
|
|
602
922
|
private readonly lineages = new Map<string, string>();
|
|
603
923
|
private readonly projections = new Map<string, FrozenCandidateProjection>();
|
|
604
924
|
private readonly replays = new Map<string, string>();
|
|
605
|
-
private current
|
|
925
|
+
private readonly current = new Map<string, { lineageId: string; token: string }>();
|
|
926
|
+
// The last dispatch-binding hydration that was attempted and failed. A
|
|
927
|
+
// swallowed hydration failure is its own defect (field report 2026-08-16):
|
|
928
|
+
// without it the later dispatch refusal claims no binding was ever
|
|
929
|
+
// available instead of naming the attempt and its typed cause.
|
|
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
|
+
}
|
|
606
1005
|
|
|
607
1006
|
create(request: CreateCandidateViewRequest): CandidateView {
|
|
608
1007
|
return this.createOrReuse(request);
|
|
609
1008
|
}
|
|
610
1009
|
|
|
1010
|
+
cleanupAll(): void {
|
|
1011
|
+
for (const token of [...this.records.keys()]) this.cleanup(token);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
611
1014
|
createOrReuse(request: CreateCandidateViewRequest): CandidateView {
|
|
612
|
-
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);
|
|
613
1019
|
const existing = token === undefined ? undefined : this.records.get(token);
|
|
614
1020
|
if (existing) { assertRecordSafe(existing); return this.expose(existing); }
|
|
615
|
-
const record = materializeCandidateView(
|
|
1021
|
+
const record = materializeCandidateView(normalizedRequest, this.gitExecutor);
|
|
616
1022
|
this.records.set(record.token, record);
|
|
617
|
-
if (
|
|
1023
|
+
if (scopedReplayKey !== undefined) this.replays.set(scopedReplayKey, record.token);
|
|
618
1024
|
return this.expose(record);
|
|
619
1025
|
}
|
|
620
1026
|
|
|
@@ -624,36 +1030,37 @@ export class CandidateViewRegistry {
|
|
|
624
1030
|
|
|
625
1031
|
bindCurrent(request: BindCandidateViewRequest): void {
|
|
626
1032
|
const selectedLenses = this.validateSelectedLenses(request.selectedLenses);
|
|
627
|
-
this.bindRecord(request.token, request.lineageId, selectedLenses);
|
|
628
|
-
this.current
|
|
1033
|
+
const record = this.bindRecord(request.token, request.lineageId, selectedLenses);
|
|
1034
|
+
this.current.set(record.contributorRoot, { lineageId: request.lineageId, token: request.token });
|
|
629
1035
|
}
|
|
630
1036
|
|
|
631
1037
|
retain(token: string, lineageId: string): void {
|
|
632
|
-
this.bindRecord(token, lineageId, []);
|
|
633
|
-
this.current
|
|
1038
|
+
const record = this.bindRecord(token, lineageId, []);
|
|
1039
|
+
this.current.set(record.contributorRoot, { lineageId, token });
|
|
634
1040
|
}
|
|
635
1041
|
|
|
636
1042
|
restoreCurrentFromNativeStart(request: BindCandidateViewRequest): void {
|
|
637
|
-
if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
|
|
638
1043
|
const record = this.records.get(request.token);
|
|
639
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");
|
|
640
1046
|
assertRecordSafe(record);
|
|
641
1047
|
this.assertCurrentBindingMatchesLiveCandidate(record);
|
|
642
1048
|
this.bindCurrent(request);
|
|
643
1049
|
}
|
|
644
1050
|
|
|
645
|
-
hasCurrentBinding(): boolean {
|
|
646
|
-
return this.current
|
|
1051
|
+
hasCurrentBinding(contributorRoot?: string): boolean {
|
|
1052
|
+
return contributorRoot === undefined ? this.current.size > 0 : this.current.has(this.canonicalRoot(contributorRoot));
|
|
647
1053
|
}
|
|
648
1054
|
|
|
649
1055
|
restoreCurrentFromAuthoritativeReviewingStates(
|
|
650
1056
|
contributorRoot: string,
|
|
651
1057
|
states: readonly AuthoritativeReviewingCandidateState[],
|
|
652
1058
|
): void {
|
|
653
|
-
|
|
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");
|
|
654
1061
|
if (states.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing");
|
|
655
1062
|
if (states.length !== 1) throw new CandidateViewError("multiple authoritative reviewing lineages exactly match the live candidate", "authoritative-current-match-ambiguous");
|
|
656
|
-
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);
|
|
657
1064
|
try {
|
|
658
1065
|
const matches = states.filter((state) => this.matchesAuthoritativeState(live, state));
|
|
659
1066
|
if (matches.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing");
|
|
@@ -662,28 +1069,31 @@ export class CandidateViewRegistry {
|
|
|
662
1069
|
const selectedLenses = this.validateSelectedLenses(state.selectedLenses);
|
|
663
1070
|
this.records.set(live.token, live);
|
|
664
1071
|
this.bindRecord(live.token, state.lineageId, selectedLenses);
|
|
665
|
-
this.current
|
|
1072
|
+
this.current.set(root, { lineageId: state.lineageId, token: live.token });
|
|
666
1073
|
} catch (error) {
|
|
667
|
-
|
|
1074
|
+
this.records.delete(live.token);
|
|
1075
|
+
this.remove(live);
|
|
668
1076
|
throw error;
|
|
669
1077
|
}
|
|
670
1078
|
}
|
|
671
1079
|
|
|
672
1080
|
createCorrected(lineageId: string, contributorRoot: string, replayKey: string): CandidateView {
|
|
673
|
-
const
|
|
674
|
-
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);
|
|
675
1085
|
const existing = existingToken === undefined ? undefined : this.records.get(existingToken);
|
|
676
1086
|
if (existing) {
|
|
677
1087
|
if (existing.lineageId !== undefined) throw new CandidateViewError("corrected candidate replay is no longer pending");
|
|
678
1088
|
assertRecordSafe(existing);
|
|
679
1089
|
return this.expose(existing);
|
|
680
1090
|
}
|
|
681
|
-
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);
|
|
682
1092
|
try {
|
|
683
1093
|
if (record.baseCommit !== projection.baseCommit || record.baseTree !== projection.baseTree) throw new CandidateViewError("corrected candidate base does not match the frozen genesis base");
|
|
684
1094
|
if (!record.scope.paths.every((path) => projection.paths.includes(path))) throw new CandidateViewError("corrected candidate scope escapes the frozen genesis paths");
|
|
685
1095
|
this.records.set(record.token, record);
|
|
686
|
-
this.replays.set(
|
|
1096
|
+
this.replays.set(scopedReplayKey, record.token);
|
|
687
1097
|
return this.expose(record);
|
|
688
1098
|
} catch (error) {
|
|
689
1099
|
this.remove(record);
|
|
@@ -691,15 +1101,18 @@ export class CandidateViewRegistry {
|
|
|
691
1101
|
}
|
|
692
1102
|
}
|
|
693
1103
|
|
|
694
|
-
promoteCorrected(lineageId: string, token: string): void {
|
|
1104
|
+
promoteCorrected(lineageId: string, token: string, contributorRoot?: string): void {
|
|
695
1105
|
const replacement = this.records.get(token);
|
|
696
|
-
const
|
|
697
|
-
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);
|
|
698
1110
|
const current = currentToken === undefined ? undefined : this.records.get(currentToken);
|
|
699
|
-
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))) {
|
|
700
1112
|
throw new CandidateViewError("corrected candidate replacement is missing or ambiguous");
|
|
701
1113
|
}
|
|
702
|
-
|
|
1114
|
+
const currentBinding = this.current.get(root);
|
|
1115
|
+
if (currentBinding !== undefined && currentBinding.lineageId !== lineageId) {
|
|
703
1116
|
throw new CandidateViewError("corrected candidate replacement conflicts with the current lineage binding");
|
|
704
1117
|
}
|
|
705
1118
|
assertRecordSafe(replacement);
|
|
@@ -709,26 +1122,28 @@ export class CandidateViewRegistry {
|
|
|
709
1122
|
replacement.baseCommit !== projection.baseCommit ||
|
|
710
1123
|
replacement.baseTree !== projection.baseTree ||
|
|
711
1124
|
replacement.committedOnly !== projection.committedOnly ||
|
|
1125
|
+
JSON.stringify(replacement.intendedUntracked ?? null) !== JSON.stringify(projection.intendedUntracked ?? null) ||
|
|
712
1126
|
!replacement.scope.paths.every((path) => projection.paths.includes(path))
|
|
713
1127
|
) {
|
|
714
1128
|
throw new CandidateViewError("corrected candidate replacement does not preserve its frozen lineage projection");
|
|
715
1129
|
}
|
|
716
1130
|
replacement.lineageId = lineageId;
|
|
717
1131
|
replacement.selectedLenses = [];
|
|
718
|
-
this.lineages.set(
|
|
719
|
-
for (const [
|
|
720
|
-
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, {
|
|
721
1135
|
contributorRoot: replacement.contributorRoot,
|
|
722
1136
|
baseCommit: replacement.baseCommit,
|
|
723
1137
|
baseTree: replacement.baseTree,
|
|
724
1138
|
candidateTree: replacement.candidateTree,
|
|
725
1139
|
committedOnly: replacement.committedOnly,
|
|
1140
|
+
intendedUntracked: replacement.intendedUntracked,
|
|
726
1141
|
paths: replacement.scope.paths,
|
|
727
1142
|
modes: replacement.scope.modes,
|
|
728
1143
|
gitlinks: replacement.scope.gitlinks,
|
|
729
1144
|
deletedPaths: replacement.scope.deletedPaths,
|
|
730
1145
|
});
|
|
731
|
-
this.current
|
|
1146
|
+
this.current.set(root, { lineageId, token });
|
|
732
1147
|
if (current) {
|
|
733
1148
|
this.remove(current);
|
|
734
1149
|
this.forget(current);
|
|
@@ -748,6 +1163,7 @@ export class CandidateViewRegistry {
|
|
|
748
1163
|
state.baseTree === record.baseTree &&
|
|
749
1164
|
state.candidateTree === record.candidateTree &&
|
|
750
1165
|
(state.committedOnly ?? false) === record.committedOnly &&
|
|
1166
|
+
(state.intendedUntracked === undefined || JSON.stringify(state.intendedUntracked) === JSON.stringify(record.intendedUntracked)) &&
|
|
751
1167
|
JSON.stringify(state.paths) === JSON.stringify(record.scope.paths) &&
|
|
752
1168
|
JSON.stringify(state.modes) === JSON.stringify(record.scope.modes) &&
|
|
753
1169
|
gitlinkMapsEqual(state.gitlinks ?? {}, record.scope.gitlinks) &&
|
|
@@ -757,41 +1173,46 @@ export class CandidateViewRegistry {
|
|
|
757
1173
|
}
|
|
758
1174
|
}
|
|
759
1175
|
|
|
760
|
-
private bindRecord(token: string, lineageId: string, selectedLenses: readonly ReviewLens[]):
|
|
1176
|
+
private bindRecord(token: string, lineageId: string, selectedLenses: readonly ReviewLens[]): CandidateViewRecord {
|
|
761
1177
|
const record = this.records.get(token);
|
|
762
|
-
|
|
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");
|
|
763
1180
|
assertRecordSafe(record);
|
|
764
1181
|
record.lineageId = lineageId;
|
|
765
1182
|
record.selectedLenses = selectedLenses;
|
|
766
|
-
this.lineages.set(
|
|
767
|
-
this.projections.set(
|
|
1183
|
+
this.lineages.set(key, record.token);
|
|
1184
|
+
this.projections.set(key, {
|
|
768
1185
|
contributorRoot: record.contributorRoot,
|
|
769
1186
|
baseCommit: record.baseCommit,
|
|
770
1187
|
baseTree: record.baseTree,
|
|
771
1188
|
candidateTree: record.candidateTree,
|
|
772
1189
|
committedOnly: record.committedOnly,
|
|
1190
|
+
intendedUntracked: record.intendedUntracked,
|
|
773
1191
|
paths: record.scope.paths,
|
|
774
1192
|
modes: record.scope.modes,
|
|
775
1193
|
gitlinks: record.scope.gitlinks,
|
|
776
1194
|
deletedPaths: record.scope.deletedPaths,
|
|
777
1195
|
});
|
|
778
|
-
for (const [
|
|
1196
|
+
for (const [replayKey, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(replayKey);
|
|
1197
|
+
return record;
|
|
779
1198
|
}
|
|
780
1199
|
|
|
781
|
-
hasProjection(lineageId: string): boolean {
|
|
782
|
-
return this.projections
|
|
1200
|
+
hasProjection(lineageId: string, contributorRoot?: string): boolean {
|
|
1201
|
+
return this.uniqueKey(this.projections, lineageId, contributorRoot) !== undefined;
|
|
783
1202
|
}
|
|
784
1203
|
|
|
785
1204
|
restoreProjection(lineageId: string, contributorRoot: string, baseCommit: string, baseTree: string, candidateTree: string, paths: readonly string[]): void {
|
|
786
|
-
const root =
|
|
1205
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
1206
|
+
const key = this.lineageKey(root, lineageId);
|
|
787
1207
|
const base = resolveCandidateBase(root, baseCommit, process.env, this.gitExecutor);
|
|
788
|
-
if (!lineageId || this.projections.has(
|
|
789
|
-
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: [] });
|
|
790
1210
|
}
|
|
791
1211
|
|
|
792
1212
|
restoreProjectionFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): void {
|
|
793
|
-
const root =
|
|
794
|
-
|
|
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");
|
|
795
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");
|
|
796
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");
|
|
797
1218
|
const head = resolveCandidateBase(root, "HEAD", process.env, this.gitExecutor);
|
|
@@ -820,12 +1241,13 @@ export class CandidateViewRegistry {
|
|
|
820
1241
|
} else if (JSON.stringify(scope.paths) !== JSON.stringify([...descriptor.paths].sort())) {
|
|
821
1242
|
throw new CandidateViewError("native projection paths do not match Git content");
|
|
822
1243
|
}
|
|
823
|
-
this.projections.set(
|
|
1244
|
+
this.projections.set(key, {
|
|
824
1245
|
contributorRoot: root,
|
|
825
1246
|
baseCommit: base.commit,
|
|
826
1247
|
baseTree: descriptor.baseTree,
|
|
827
1248
|
candidateTree: descriptor.currentCandidateTree,
|
|
828
1249
|
committedOnly,
|
|
1250
|
+
intendedUntracked: Object.freeze([...descriptor.intendedUntracked]),
|
|
829
1251
|
paths: scope.paths,
|
|
830
1252
|
modes: scope.modes,
|
|
831
1253
|
gitlinks: scope.gitlinks,
|
|
@@ -833,54 +1255,177 @@ export class CandidateViewRegistry {
|
|
|
833
1255
|
});
|
|
834
1256
|
}
|
|
835
1257
|
|
|
1258
|
+
/**
|
|
1259
|
+
* Re-derives this lineage's FINALIZE binding from the provider's own
|
|
1260
|
+
* projection, replacing a binding this session is still holding.
|
|
1261
|
+
*
|
|
1262
|
+
* Field defect (Engram #12547): once a bounded correction is admitted, the
|
|
1263
|
+
* candidate identity legitimately moves, and the provider issues its
|
|
1264
|
+
* finalize transition for that corrected target. A session that started the
|
|
1265
|
+
* review still holds the START-time immutable reviewer view, so comparing
|
|
1266
|
+
* it against the corrected projection reads as drift and no receipt is ever
|
|
1267
|
+
* minted — while a fresh process, which restores from the native descriptor,
|
|
1268
|
+
* finalizes the very same lineage successfully. This makes the in-session
|
|
1269
|
+
* path behave like that already-correct fresh-process path.
|
|
1270
|
+
*
|
|
1271
|
+
* This is a re-derivation, not a relaxation: the replacement is
|
|
1272
|
+
* materialized from Git and must match the provider descriptor exactly
|
|
1273
|
+
* (base tree, projection kind, changed-path manifest), and the caller still
|
|
1274
|
+
* asserts the binding afterwards. The immutable reviewer view is retired
|
|
1275
|
+
* here on purpose — the lenses that consumed it finished before the
|
|
1276
|
+
* correction.
|
|
1277
|
+
*/
|
|
1278
|
+
rebindForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
|
|
1279
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
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);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
836
1291
|
restoreForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
|
|
837
|
-
this.
|
|
838
|
-
const
|
|
839
|
-
|
|
1292
|
+
const root = this.canonicalRoot(contributorRoot);
|
|
1293
|
+
const key = this.lineageKey(root, lineageId);
|
|
1294
|
+
let projectionRestored = false;
|
|
1295
|
+
let record: CandidateViewRecord | undefined;
|
|
840
1296
|
try {
|
|
841
|
-
|
|
842
|
-
|
|
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);
|
|
843
1307
|
}
|
|
1308
|
+
if (!matchesProjection(record)) throw new CandidateViewError("live candidate does not match the native frozen projection");
|
|
844
1309
|
this.records.set(record.token, record);
|
|
845
1310
|
this.bindRecord(record.token, lineageId, []);
|
|
846
1311
|
return this.expose(record);
|
|
847
1312
|
} catch (error) {
|
|
848
|
-
this.projections.delete(
|
|
849
|
-
|
|
1313
|
+
if (projectionRestored) this.projections.delete(key);
|
|
1314
|
+
if (record !== undefined) {
|
|
1315
|
+
this.forget(record);
|
|
1316
|
+
this.remove(record);
|
|
1317
|
+
}
|
|
850
1318
|
throw error;
|
|
851
1319
|
}
|
|
852
1320
|
}
|
|
853
1321
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1322
|
+
/**
|
|
1323
|
+
* Mirrors the START-time dispatch registration for a lineage this
|
|
1324
|
+
* controller never started (live defect 2026-08-16: a successor created
|
|
1325
|
+
* by native `review recover` exists only in native authority). The
|
|
1326
|
+
* authoritative STATUS descriptor supplies the frozen projection; the
|
|
1327
|
+
* live candidate is re-materialized and must match it exactly before the
|
|
1328
|
+
* dispatch-facing current binding is established with the provider-named
|
|
1329
|
+
* pending lenses.
|
|
1330
|
+
*/
|
|
1331
|
+
restoreCurrentForDispatchFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor, selectedLenses: readonly string[]): void {
|
|
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;
|
|
1337
|
+
try {
|
|
1338
|
+
const lenses = this.validateSelectedLenses(selectedLenses);
|
|
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");
|
|
1345
|
+
}
|
|
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);
|
|
1350
|
+
} catch (error) {
|
|
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, {
|
|
1357
|
+
lineageId,
|
|
1358
|
+
reason: error instanceof CandidateViewError ? error.reason : "candidate-view-invalid",
|
|
1359
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1360
|
+
});
|
|
1361
|
+
throw error;
|
|
858
1362
|
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
resolveProjection(lineageId: string, contributorRoot: string): FrozenCandidateProjection {
|
|
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);
|
|
859
1370
|
return projection;
|
|
860
1371
|
}
|
|
861
1372
|
|
|
862
|
-
resolveForLens(lineageId: string, lens: string): CandidateView {
|
|
863
|
-
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);
|
|
864
1376
|
const record = token === undefined ? undefined : this.records.get(token);
|
|
865
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");
|
|
866
1378
|
assertRecordSafe(record);
|
|
867
1379
|
return this.expose(record);
|
|
868
1380
|
}
|
|
869
1381
|
|
|
870
|
-
|
|
871
|
-
if (
|
|
872
|
-
|
|
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);
|
|
1388
|
+
if (failure !== undefined) {
|
|
1389
|
+
throw new CandidateViewError(
|
|
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}`,
|
|
1391
|
+
"current-binding-hydration-failed",
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
throw new CandidateViewError("review subagent dispatch has no current controller-owned candidate view lineage binding", "current-binding-missing");
|
|
1395
|
+
}
|
|
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 };
|
|
873
1409
|
}
|
|
874
1410
|
|
|
875
|
-
|
|
876
|
-
return this.
|
|
1411
|
+
currentLineageId(contributorRoot?: string): string {
|
|
1412
|
+
return this.currentBinding(contributorRoot).lineageId;
|
|
877
1413
|
}
|
|
878
1414
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1415
|
+
/** The last failed dispatch-binding hydration, for controller envelopes. */
|
|
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;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
resolveCurrentForLens(lens: string, contributorRoot?: string): CandidateView {
|
|
1422
|
+
return this.resolveCurrentForLenses([lens], contributorRoot)[0]!;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
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");
|
|
884
1429
|
assertRecordSafe(record);
|
|
885
1430
|
this.assertCurrentBindingMatchesLiveCandidate(record);
|
|
886
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");
|
|
@@ -888,7 +1433,7 @@ export class CandidateViewRegistry {
|
|
|
888
1433
|
}
|
|
889
1434
|
|
|
890
1435
|
private assertCurrentBindingMatchesLiveCandidate(record: CandidateViewRecord): void {
|
|
891
|
-
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);
|
|
892
1437
|
try {
|
|
893
1438
|
if (
|
|
894
1439
|
live.baseCommit !== record.baseCommit ||
|
|
@@ -905,8 +1450,9 @@ export class CandidateViewRegistry {
|
|
|
905
1450
|
}
|
|
906
1451
|
}
|
|
907
1452
|
|
|
908
|
-
resolveForFinalize(lineageId: string): CandidateView {
|
|
909
|
-
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);
|
|
910
1456
|
const record = token === undefined ? undefined : this.records.get(token);
|
|
911
1457
|
if (!record || record.lineageId !== lineageId) throw new CandidateViewError("candidate view context is missing or ambiguous for FINALIZE");
|
|
912
1458
|
assertRecordSafe(record);
|
|
@@ -920,40 +1466,47 @@ export class CandidateViewRegistry {
|
|
|
920
1466
|
this.forget(record);
|
|
921
1467
|
}
|
|
922
1468
|
|
|
923
|
-
cleanupTerminal(lineageId: string, state: string): void {
|
|
1469
|
+
cleanupTerminal(lineageId: string, state: string, contributorRoot?: string): void {
|
|
924
1470
|
if (state !== "approved" && state !== "escalated") return;
|
|
925
|
-
const
|
|
926
|
-
|
|
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);
|
|
927
1475
|
}
|
|
928
1476
|
|
|
929
1477
|
private remove(record: CandidateViewRecord): void {
|
|
930
1478
|
if (!isWithin(record.parent, record.root)) throw new CandidateViewError("candidate view cleanup escaped its owned parent");
|
|
931
1479
|
try { makeWritableForCleanup(record.root); } catch {}
|
|
932
|
-
try { git(record.contributorRoot, ["worktree", "remove", "--force", record.root], process.env, record.gitExecutor); } catch {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
}
|
|
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 });
|
|
936
1483
|
}
|
|
937
1484
|
|
|
938
1485
|
private forget(record: CandidateViewRecord): void {
|
|
939
1486
|
this.records.delete(record.token);
|
|
940
|
-
if (record.lineageId
|
|
941
|
-
|
|
942
|
-
|
|
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);
|
|
943
1493
|
}
|
|
944
1494
|
|
|
945
|
-
consumeProjection(lineageId: string): void {
|
|
946
|
-
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);
|
|
947
1498
|
}
|
|
948
1499
|
|
|
949
1500
|
private expose(record: CandidateViewRecord): CandidateView {
|
|
950
1501
|
return {
|
|
951
1502
|
token: record.token,
|
|
952
1503
|
root: record.root,
|
|
1504
|
+
contributorRoot: record.contributorRoot,
|
|
953
1505
|
baseCommit: record.baseCommit,
|
|
954
1506
|
baseTree: record.baseTree,
|
|
955
1507
|
candidateTree: record.candidateTree,
|
|
956
1508
|
committedOnly: record.committedOnly,
|
|
1509
|
+
intendedUntracked: record.intendedUntracked,
|
|
957
1510
|
paths: record.scope.paths,
|
|
958
1511
|
modes: record.scope.modes,
|
|
959
1512
|
gitlinks: record.scope.gitlinks,
|
|
@@ -986,22 +1539,187 @@ function hasCandidateContextConflict(text: string, views: readonly CandidateView
|
|
|
986
1539
|
|| views.some((view) => text.includes(view.root) || text.includes(view.candidateTree));
|
|
987
1540
|
}
|
|
988
1541
|
|
|
989
|
-
function
|
|
1542
|
+
function compareCanonicalStrings(left: string, right: string): number {
|
|
1543
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function canonicalStringMap(value: Readonly<Record<string, string>>): Record<string, string> {
|
|
1547
|
+
// Plain objects enumerate integer-like keys numerically; canonical round trips intentionally preserve that ordering.
|
|
1548
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => compareCanonicalStrings(left, right)));
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function candidateScopeByMode(view: CandidateView): Record<string, string[]> {
|
|
990
1552
|
const grouped = new Map<string, string[]>();
|
|
1553
|
+
const deletedPaths = new Set(view.deletedPaths);
|
|
991
1554
|
for (const path of view.paths) {
|
|
992
|
-
const group =
|
|
1555
|
+
const group = deletedPaths.has(path) ? "deleted" : view.modes[path];
|
|
993
1556
|
if (group === undefined) throw new CandidateViewError("candidate view scope omits a changed path mode");
|
|
994
1557
|
const paths = grouped.get(group) ?? [];
|
|
995
1558
|
paths.push(path);
|
|
996
1559
|
grouped.set(group, paths);
|
|
997
1560
|
}
|
|
998
|
-
|
|
1561
|
+
return Object.fromEntries(
|
|
1562
|
+
[...grouped.entries()]
|
|
1563
|
+
.sort(([left], [right]) => compareCanonicalStrings(left, right))
|
|
1564
|
+
.map(([mode, paths]) => [mode, paths.sort(compareCanonicalStrings)]),
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function isCandidateContextMode(value: string): boolean {
|
|
1569
|
+
return (Object.values(CANDIDATE_CONTEXT_MODE) as readonly string[]).includes(value);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function isCanonicalStringArray(value: unknown): value is readonly string[] {
|
|
1573
|
+
return Array.isArray(value)
|
|
1574
|
+
&& value.length > 0
|
|
1575
|
+
&& value.every((item) => typeof item === "string" && isSafeCandidatePath(item))
|
|
1576
|
+
&& value.every((item, index, items) => index === 0 || compareCanonicalStrings(items[index - 1]!, item) < 0);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function hasCanonicalRecordOrder(value: Readonly<Record<string, unknown>>): boolean {
|
|
1580
|
+
const normalized = Object.fromEntries(Object.entries(value).sort(([left], [right]) => compareCanonicalStrings(left, right)));
|
|
1581
|
+
return JSON.stringify(value) === JSON.stringify(normalized);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function invalidCandidateContextManifest(message: string): never {
|
|
1585
|
+
throw new CandidateViewError(message, "candidate-context-manifest-invalid");
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function validateCandidateContextManifest(value: unknown, bytes: Buffer): CandidateContextManifest {
|
|
1589
|
+
if (!isRecord(value)) return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
|
|
1590
|
+
const keys = Object.keys(value);
|
|
1591
|
+
const expectedKeys = ["version", "scopeByMode", "gitlinks"];
|
|
1592
|
+
if (keys.length !== expectedKeys.length || !expectedKeys.every((key) => keys.includes(key))) {
|
|
1593
|
+
return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
|
|
1594
|
+
}
|
|
1595
|
+
if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
|
|
1596
|
+
if (value.version !== CANDIDATE_CONTEXT_MANIFEST.VERSION || !isRecord(value.scopeByMode) || !isRecord(value.gitlinks)) {
|
|
1597
|
+
return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
|
|
1598
|
+
}
|
|
1599
|
+
const scopeByMode = value.scopeByMode;
|
|
1600
|
+
if (!hasCanonicalRecordOrder(scopeByMode)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
|
|
1601
|
+
const scopePaths = new Set<string>();
|
|
1602
|
+
for (const [mode, paths] of Object.entries(scopeByMode)) {
|
|
1603
|
+
if (!isCandidateContextMode(mode) || !isCanonicalStringArray(paths)) return invalidCandidateContextManifest("candidate context manifest has an invalid scope");
|
|
1604
|
+
for (const path of paths) {
|
|
1605
|
+
if (scopePaths.has(path)) return invalidCandidateContextManifest("candidate context manifest has duplicate scope paths");
|
|
1606
|
+
scopePaths.add(path);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
const gitlinks = value.gitlinks;
|
|
1610
|
+
if (!hasCanonicalRecordOrder(gitlinks)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
|
|
1611
|
+
for (const [path, objectId] of Object.entries(gitlinks)) {
|
|
1612
|
+
if (!isSafeCandidatePath(path) || typeof objectId !== "string" || !isCanonicalObjectId(objectId)) {
|
|
1613
|
+
return invalidCandidateContextManifest("candidate context manifest has an invalid gitlink map");
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
const gitlinkPaths = scopeByMode["160000"];
|
|
1617
|
+
const canonicalGitlinkPaths = Object.keys(gitlinks).sort(compareCanonicalStrings);
|
|
1618
|
+
if (
|
|
1619
|
+
(gitlinkPaths === undefined && canonicalGitlinkPaths.length !== 0)
|
|
1620
|
+
|| (gitlinkPaths !== undefined && JSON.stringify(canonicalGitlinkPaths) !== JSON.stringify(gitlinkPaths))
|
|
1621
|
+
) return invalidCandidateContextManifest("candidate context manifest gitlinks do not match its scope");
|
|
1622
|
+
const manifest: CandidateContextManifest = {
|
|
1623
|
+
version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
|
|
1624
|
+
scopeByMode: scopeByMode as Readonly<Record<string, readonly string[]>>,
|
|
1625
|
+
gitlinks: gitlinks as Readonly<Record<string, string>>,
|
|
1626
|
+
};
|
|
1627
|
+
if (!Buffer.from(JSON.stringify(manifest), "utf8").equals(bytes)) {
|
|
1628
|
+
return invalidCandidateContextManifest("candidate context manifest is not canonical");
|
|
1629
|
+
}
|
|
1630
|
+
return manifest;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
export function decodeCandidateContextManifest(encoded: string, sha256: string): DecodedCandidateContextManifest {
|
|
1634
|
+
if (encoded.length > MAX_CANDIDATE_CONTEXT_LENGTH || !/^[A-Za-z0-9_-]+$/.test(encoded) || !/^[0-9a-f]{64}$/.test(sha256)) {
|
|
1635
|
+
throw new CandidateViewError("candidate context manifest encoding is invalid", "candidate-context-manifest-invalid");
|
|
1636
|
+
}
|
|
1637
|
+
let bytes: Buffer;
|
|
1638
|
+
try {
|
|
1639
|
+
bytes = gunzipSync(Buffer.from(encoded, "base64url"), { maxOutputLength: MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES });
|
|
1640
|
+
} catch {
|
|
1641
|
+
throw new CandidateViewError("candidate context manifest cannot be decompressed", "candidate-context-manifest-invalid");
|
|
1642
|
+
}
|
|
1643
|
+
const actualSha256 = createHash("sha256").update(bytes).digest("hex");
|
|
1644
|
+
if (actualSha256 !== sha256) throw new CandidateViewError("candidate context manifest integrity check failed", "candidate-context-manifest-integrity");
|
|
1645
|
+
const text = bytes.toString("utf8");
|
|
1646
|
+
if (!Buffer.from(text, "utf8").equals(bytes)) return invalidCandidateContextManifest("candidate context manifest is not valid UTF-8");
|
|
1647
|
+
if (gzipSync(bytes, { mtime: 0 }).toString("base64url") !== encoded) {
|
|
1648
|
+
return invalidCandidateContextManifest("candidate context manifest transport is not canonical");
|
|
1649
|
+
}
|
|
1650
|
+
let value: unknown;
|
|
1651
|
+
try {
|
|
1652
|
+
value = JSON.parse(text);
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
if (error instanceof CandidateViewError) throw error;
|
|
1655
|
+
return invalidCandidateContextManifest("candidate context manifest is not valid JSON");
|
|
1656
|
+
}
|
|
1657
|
+
return { manifest: validateCandidateContextManifest(value, bytes), bytes, sha256: actualSha256 };
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
export function readCandidateContextManifestPage(encoded: string, sha256: string, cursor = 0): CandidateContextPage {
|
|
1661
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0) throw new CandidateViewError("candidate context manifest cursor is invalid", "candidate-context-cursor-invalid");
|
|
1662
|
+
const decoded = decodeCandidateContextManifest(encoded, sha256);
|
|
1663
|
+
const entries = Object.entries(decoded.manifest.scopeByMode).flatMap(([mode, paths]) => paths.map((path): CandidateContextPageEntry => ({
|
|
1664
|
+
path,
|
|
1665
|
+
mode: mode as CandidateContextMode,
|
|
1666
|
+
...(mode === CANDIDATE_CONTEXT_MODE.GITLINK ? { gitlinkObjectId: decoded.manifest.gitlinks[path]! } : {}),
|
|
1667
|
+
})));
|
|
1668
|
+
if (cursor > entries.length) throw new CandidateViewError("candidate context manifest cursor exceeds the changed scope", "candidate-context-cursor-invalid");
|
|
1669
|
+
const pageEntries: CandidateContextPageEntry[] = [];
|
|
1670
|
+
for (let index = cursor; index < entries.length && pageEntries.length < MAX_CANDIDATE_SCOPE_PAGE_ENTRIES; index += 1) {
|
|
1671
|
+
const candidateEntries = [...pageEntries, entries[index]!];
|
|
1672
|
+
const candidatePage: CandidateContextPage = {
|
|
1673
|
+
version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
|
|
1674
|
+
sha256: decoded.sha256,
|
|
1675
|
+
cursor,
|
|
1676
|
+
totalPaths: entries.length,
|
|
1677
|
+
entries: candidateEntries,
|
|
1678
|
+
...(cursor + candidateEntries.length < entries.length ? { nextCursor: cursor + candidateEntries.length } : {}),
|
|
1679
|
+
};
|
|
1680
|
+
if (Buffer.byteLength(JSON.stringify(candidatePage), "utf8") > MAX_CANDIDATE_SCOPE_PAGE_BYTES) {
|
|
1681
|
+
if (pageEntries.length === 0) throw new CandidateViewError("candidate context manifest path exceeds the bounded actor response", "candidate-context-page-too-large");
|
|
1682
|
+
break;
|
|
1683
|
+
}
|
|
1684
|
+
pageEntries.push(entries[index]!);
|
|
1685
|
+
}
|
|
1686
|
+
return {
|
|
1687
|
+
version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
|
|
1688
|
+
sha256: decoded.sha256,
|
|
1689
|
+
cursor,
|
|
1690
|
+
totalPaths: entries.length,
|
|
1691
|
+
entries: pageEntries,
|
|
1692
|
+
...(cursor + pageEntries.length < entries.length ? { nextCursor: cursor + pageEntries.length } : {}),
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function candidateContextPreamble(lineageId: string, agents: readonly ReviewLens[], view: CandidateView, scopeSemantics: string): string {
|
|
1697
|
+
return `\n\n${CONTROLLER_CANDIDATE_VIEW_HEADING}\nController-owned review lineage: \`${lineageId}\`.\nAuthorized review actors: ${agents.join(", ")}.\nRead ONLY the absolute frozen candidate view at \`${view.root}\`.\nFrozen candidate tree: \`${view.candidateTree}\`.\nScope semantics: ${scopeSemantics}`;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function compactCandidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView, scopeSemantics: string, scopeByMode: Record<string, string[]>): string {
|
|
1701
|
+
const manifest: CandidateContextManifest = {
|
|
1702
|
+
version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
|
|
1703
|
+
scopeByMode,
|
|
1704
|
+
gitlinks: canonicalStringMap(view.gitlinks),
|
|
1705
|
+
};
|
|
1706
|
+
const bytes = Buffer.from(JSON.stringify(manifest), "utf8");
|
|
1707
|
+
if (bytes.length > MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract");
|
|
1708
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
1709
|
+
const encoded = gzipSync(bytes, { mtime: 0 }).toString("base64url");
|
|
1710
|
+
const block = `${candidateContextPreamble(lineageId, agents, view, scopeSemantics)}\nFrozen changed scope manifest (gzip+base64url): \`${encoded}\`.\nFrozen changed scope manifest SHA-256: \`${sha256}\`.\nCall \`gentle_review_scope\` with exactly this manifest, SHA-256, and cursor 0; continue with each returned \`nextCursor\` until absent. It is the only authorized scope enumerator: do not infer scope by traversing the candidate or ambient tree. Gitlinks are metadata-only and MUST NOT be traversed.\nThe ambient contributor working directory is out of scope. This controller-owned context is immutable; you are read-only and your output is untrusted.`;
|
|
1711
|
+
if (Buffer.byteLength(block, "utf8") > MAX_CANDIDATE_CONTEXT_LENGTH) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract");
|
|
1712
|
+
return block;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function candidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView): string {
|
|
1716
|
+
const scopeByMode = candidateScopeByMode(view);
|
|
999
1717
|
const scopeSemantics = view.committedOnly
|
|
1000
1718
|
? "Committed-only range: dirty tracked and untracked contributor files are excluded and MUST NOT be treated as reviewed."
|
|
1001
1719
|
: "Dirty-inclusive workspace snapshot: tracked and untracked contributor changes are included.";
|
|
1002
|
-
const
|
|
1003
|
-
if (Buffer.byteLength(
|
|
1004
|
-
return
|
|
1720
|
+
const readableBlock = `${candidateContextPreamble(lineageId, agents, view, scopeSemantics)}\nFrozen changed scope by mode: ${JSON.stringify(scopeByMode)}.\nFrozen metadata-only gitlinks: ${JSON.stringify(view.gitlinks)}. Gitlink paths have no materialized contents and MUST NOT be traversed.\nThe ambient contributor working directory is out of scope. This controller-owned context is immutable; you are read-only and your output is untrusted.`;
|
|
1721
|
+
if (Buffer.byteLength(readableBlock, "utf8") <= MAX_CANDIDATE_CONTEXT_LENGTH) return readableBlock;
|
|
1722
|
+
return compactCandidateContextBlock(lineageId, agents, view, scopeSemantics, scopeByMode);
|
|
1005
1723
|
}
|
|
1006
1724
|
|
|
1007
1725
|
/**
|