gentle-pi 2.1.2 → 2.2.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 +70 -12
- 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 -1
- package/assets/agents/review-reliability.md +4 -1
- package/assets/agents/review-resilience.md +4 -1
- package/assets/agents/review-risk.md +4 -1
- package/assets/agents/sdd-apply.md +6 -1
- 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 +6 -1
- package/assets/agents/sdd-spec.md +6 -1
- package/assets/agents/sdd-status.md +6 -1
- package/assets/agents/sdd-sync.md +6 -1
- package/assets/agents/sdd-tasks.md +6 -1
- package/assets/agents/sdd-verify.md +6 -1
- package/assets/chains/4r-review.chain.md +2 -0
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-plan.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +246 -67
- package/assets/orchestrator.md +7 -14
- package/assets/sdd-orchestrator-workflow.md +154 -9
- package/assets/support/sdd-status-contract.md +19 -1
- 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/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/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 +5 -5
- package/docs/review-integration.md +22 -2
- package/extensions/gentle-ai.ts +1595 -201
- package/extensions/sdd-init.ts +19 -6
- package/extensions/skill-registry.ts +10 -2
- package/extensions/startup-banner.ts +10 -4
- package/lib/gentle-ai-binary.ts +173 -2
- package/lib/git-commit-transaction.ts +77 -17
- package/lib/native-review-cli.ts +528 -65
- package/lib/provider-contract-bundle.ts +704 -0
- package/lib/review-candidate-view.ts +527 -18
- package/lib/review-compact-contract.ts +59 -248
- package/lib/review-host-relay.ts +436 -0
- package/lib/review-integration-v2.ts +537 -36
- package/lib/review-relay-contract.ts +16 -0
- package/lib/sdd-preflight.ts +53 -1
- package/package.json +5 -2
- package/runtime/gentle-ai-binary.mjs +173 -2
- package/runtime/git-commit-transaction.mjs +75 -15
- package/runtime/native-review-cli.mjs +524 -61
- package/runtime/review-integration-v2.mjs +536 -35
- package/runtime/review-relay-contract.mjs +17 -0
- package/scripts/build-git-commit-transaction-runner.mjs +1 -0
- package/scripts/check-provider-contract.mjs +138 -0
- package/scripts/gentle-ai-installer.mjs +23 -13
- package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
- package/scripts/mirror-provider-contract.mjs +143 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/verify-package-files.mjs +110 -33
- package/skills/_shared/review-ledger-contract.md +4 -6
- package/skills/gentle-ai/SKILL.md +4 -4
- package/skills/issue-creation/SKILL.md +94 -168
- package/skills/judgment-day/SKILL.md +7 -1
- package/skills/judgment-day/references/prompts-and-formats.md +2 -0
- package/skills/rdd-defect-workflow/SKILL.md +54 -0
- package/tests/background-subagents.test.ts +771 -0
- package/tests/crosslane/cross-lane.mjs +1169 -0
- package/tests/delegated-key-learnings-contract.test.ts +238 -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/result-artifact-v2-path.captured.json +12 -0
- package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -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/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/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 +1 -1
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
- package/tests/gentle-ai-dev-binary.test.ts +336 -0
- package/tests/gentle-ai-installer.test.ts +46 -46
- package/tests/git-commit-transaction.test.ts +229 -1
- package/tests/maintainer/provider-relay.maintest.ts +265 -0
- package/tests/native-review-capability-contract.test.ts +48 -2
- package/tests/native-review-cli.test.ts +56 -0
- package/tests/native-review-consent.test.ts +164 -3
- package/tests/native-review-parity-runtime.test.ts +37 -0
- package/tests/native-review-parity.test.ts +218 -15
- package/tests/native-sdd-attempt-authority.test.ts +235 -0
- package/tests/orchestrator-budget.test.ts +30 -5
- package/tests/package-manifest.test.ts +98 -72
- 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 +355 -0
- package/tests/review-actor-tool-deny.test.ts +12 -13
- package/tests/review-candidate-view.test.ts +489 -9
- package/tests/review-compact-contract.test.ts +52 -119
- package/tests/review-controller-native-recovery.test.ts +643 -47
- package/tests/review-controller-native-routing.test.ts +1667 -222
- package/tests/review-controller-workspace-root.test.ts +17 -2
- package/tests/review-corrected-finalize-binding.test.ts +175 -0
- package/tests/review-dispatch-hydration-gap.test.ts +197 -0
- package/tests/review-host-relay-routing.test.ts +317 -0
- package/tests/review-host-relay.test.ts +520 -0
- package/tests/review-integration-v2-forward.test.ts +631 -0
- package/tests/review-integration-v2.test.ts +114 -0
- package/tests/review-ledger-contract.test.ts +12 -28
- package/tests/review-recovered-lineage-routing.test.ts +246 -0
- package/tests/review-relay-transport-agent.test.ts +249 -0
- package/tests/runtime-harness.mjs +242 -14
- package/tests/sdd-agent-tools.test.ts +18 -33
- package/tests/skill-collision-prefixes.test.ts +1 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/verify-package-files.test.ts +62 -0
- package/assets/agents/review-refuter.md +0 -40
- package/assets/agents/review-validator.md +0 -23
- 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/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/review-compact.test.ts +0 -243
- package/tests/review-refuter-adapter.test.ts +0 -89
|
@@ -4,10 +4,51 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync,
|
|
|
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 {
|
|
@@ -71,6 +126,33 @@ export interface CandidateView {
|
|
|
71
126
|
cleanup(): void;
|
|
72
127
|
}
|
|
73
128
|
|
|
129
|
+
export interface CandidateContextManifest {
|
|
130
|
+
version: typeof CANDIDATE_CONTEXT_MANIFEST.VERSION;
|
|
131
|
+
scopeByMode: Readonly<Record<string, readonly string[]>>;
|
|
132
|
+
gitlinks: Readonly<Record<string, string>>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface DecodedCandidateContextManifest {
|
|
136
|
+
manifest: CandidateContextManifest;
|
|
137
|
+
bytes: Buffer;
|
|
138
|
+
sha256: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface CandidateContextPageEntry {
|
|
142
|
+
path: string;
|
|
143
|
+
mode: CandidateContextMode;
|
|
144
|
+
gitlinkObjectId?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface CandidateContextPage {
|
|
148
|
+
version: typeof CANDIDATE_CONTEXT_MANIFEST.VERSION;
|
|
149
|
+
sha256: string;
|
|
150
|
+
cursor: number;
|
|
151
|
+
totalPaths: number;
|
|
152
|
+
entries: readonly CandidateContextPageEntry[];
|
|
153
|
+
nextCursor?: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
74
156
|
export interface FrozenCandidateProjection {
|
|
75
157
|
contributorRoot: string;
|
|
76
158
|
baseCommit: string;
|
|
@@ -130,22 +212,96 @@ export interface NativeCandidateProjectionDescriptor {
|
|
|
130
212
|
providerManifestHashVerified?: true;
|
|
131
213
|
}
|
|
132
214
|
|
|
215
|
+
export interface CandidateViewDiagnostic {
|
|
216
|
+
phase: typeof CANDIDATE_VIEW_DIAGNOSTIC_PHASE;
|
|
217
|
+
category: CandidateViewGitFailureCategory;
|
|
218
|
+
git_subcommand: CandidateGitSubcommand;
|
|
219
|
+
timeout_ms: number;
|
|
220
|
+
max_buffer_bytes: number;
|
|
221
|
+
message: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
133
224
|
export class CandidateViewError extends Error {
|
|
134
225
|
readonly reason: string;
|
|
135
|
-
|
|
226
|
+
readonly diagnostics?: CandidateViewDiagnostic;
|
|
227
|
+
constructor(message: string, reason = "candidate-view-invalid", diagnostics?: CandidateViewDiagnostic) {
|
|
136
228
|
super(message);
|
|
137
229
|
this.name = "CandidateViewError";
|
|
138
230
|
this.reason = reason;
|
|
231
|
+
this.diagnostics = diagnostics === undefined ? undefined : sanitizeCandidateViewDiagnostic(diagnostics);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function candidateGitSubcommand(arguments_: readonly string[]): CandidateGitSubcommand {
|
|
236
|
+
switch (arguments_[0]) {
|
|
237
|
+
case CANDIDATE_GIT_SUBCOMMAND.ADD: return CANDIDATE_GIT_SUBCOMMAND.ADD;
|
|
238
|
+
case CANDIDATE_GIT_SUBCOMMAND.CHECKOUT_INDEX: return CANDIDATE_GIT_SUBCOMMAND.CHECKOUT_INDEX;
|
|
239
|
+
case CANDIDATE_GIT_SUBCOMMAND.DIFF: return CANDIDATE_GIT_SUBCOMMAND.DIFF;
|
|
240
|
+
case CANDIDATE_GIT_SUBCOMMAND.FOR_EACH_REF: return CANDIDATE_GIT_SUBCOMMAND.FOR_EACH_REF;
|
|
241
|
+
case CANDIDATE_GIT_SUBCOMMAND.LOG: return CANDIDATE_GIT_SUBCOMMAND.LOG;
|
|
242
|
+
case CANDIDATE_GIT_SUBCOMMAND.LS_FILES: return CANDIDATE_GIT_SUBCOMMAND.LS_FILES;
|
|
243
|
+
case CANDIDATE_GIT_SUBCOMMAND.LS_TREE: return CANDIDATE_GIT_SUBCOMMAND.LS_TREE;
|
|
244
|
+
case CANDIDATE_GIT_SUBCOMMAND.READ_TREE: return CANDIDATE_GIT_SUBCOMMAND.READ_TREE;
|
|
245
|
+
case CANDIDATE_GIT_SUBCOMMAND.REV_PARSE: return CANDIDATE_GIT_SUBCOMMAND.REV_PARSE;
|
|
246
|
+
case CANDIDATE_GIT_SUBCOMMAND.WORKTREE: return CANDIDATE_GIT_SUBCOMMAND.WORKTREE;
|
|
247
|
+
case CANDIDATE_GIT_SUBCOMMAND.WRITE_TREE: return CANDIDATE_GIT_SUBCOMMAND.WRITE_TREE;
|
|
248
|
+
default: return CANDIDATE_GIT_SUBCOMMAND.OTHER;
|
|
139
249
|
}
|
|
140
250
|
}
|
|
141
251
|
|
|
252
|
+
function candidateGitDiagnosticMessage(category: CandidateViewGitFailureCategory, subcommand: CandidateGitSubcommand, timeoutMs: number): string {
|
|
253
|
+
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`;
|
|
254
|
+
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`;
|
|
255
|
+
return `candidate-view Git command ${subcommand} failed; inspect the candidate state before any new START`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function candidateGitDiagnostic(category: CandidateViewGitFailureCategory, arguments_: readonly string[], timeoutMs: number): CandidateViewDiagnostic {
|
|
259
|
+
const git_subcommand = candidateGitSubcommand(arguments_);
|
|
260
|
+
return Object.freeze({
|
|
261
|
+
phase: CANDIDATE_VIEW_DIAGNOSTIC_PHASE,
|
|
262
|
+
category,
|
|
263
|
+
git_subcommand,
|
|
264
|
+
timeout_ms: timeoutMs,
|
|
265
|
+
max_buffer_bytes: CANDIDATE_GIT_MAX_BUFFER_BYTES,
|
|
266
|
+
message: candidateGitDiagnosticMessage(category, git_subcommand, timeoutMs),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function sanitizeCandidateViewDiagnostic(diagnostics: CandidateViewDiagnostic): CandidateViewDiagnostic | undefined {
|
|
271
|
+
const { phase, category, git_subcommand, timeout_ms, max_buffer_bytes, message } = diagnostics;
|
|
272
|
+
if (
|
|
273
|
+
phase !== CANDIDATE_VIEW_DIAGNOSTIC_PHASE ||
|
|
274
|
+
!Object.values(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY).includes(category) ||
|
|
275
|
+
!Object.values(CANDIDATE_GIT_SUBCOMMAND).includes(git_subcommand) ||
|
|
276
|
+
!isCandidateGitTimeoutMs(timeout_ms) ||
|
|
277
|
+
max_buffer_bytes !== CANDIDATE_GIT_MAX_BUFFER_BYTES ||
|
|
278
|
+
message !== candidateGitDiagnosticMessage(category, git_subcommand, timeout_ms)
|
|
279
|
+
) return undefined;
|
|
280
|
+
return Object.freeze({ phase, category, git_subcommand, timeout_ms, max_buffer_bytes, message });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function candidateGitFailure(category: CandidateViewGitFailureCategory, arguments_: readonly string[], timeoutMs: number): CandidateViewError {
|
|
284
|
+
const diagnostics = candidateGitDiagnostic(category, arguments_, timeoutMs);
|
|
285
|
+
return new CandidateViewError(diagnostics.message, `candidate-view-${category}`, diagnostics);
|
|
286
|
+
}
|
|
287
|
+
|
|
142
288
|
function candidateGit(cwd: string, arguments_: readonly string[], env: NodeJS.ProcessEnv, encoding: "utf8" | "buffer", executor: CandidateGitExecutor): string | Buffer {
|
|
289
|
+
const timeoutMs = resolveCandidateGitTimeoutMs(env);
|
|
143
290
|
try {
|
|
144
|
-
return executor("git", arguments_, {
|
|
291
|
+
return executor("git", arguments_, {
|
|
292
|
+
cwd,
|
|
293
|
+
encoding,
|
|
294
|
+
env,
|
|
295
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
296
|
+
timeout: timeoutMs,
|
|
297
|
+
maxBuffer: CANDIDATE_GIT_MAX_BUFFER_BYTES,
|
|
298
|
+
windowsHide: true,
|
|
299
|
+
});
|
|
145
300
|
} catch (error) {
|
|
146
|
-
const detail = error as NodeJS.ErrnoException & {
|
|
147
|
-
if (detail.code === "
|
|
148
|
-
|
|
301
|
+
const detail = error as NodeJS.ErrnoException & { killed?: boolean };
|
|
302
|
+
if (detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT, arguments_, timeoutMs);
|
|
303
|
+
if (detail.code === "ETIMEDOUT" || detail.killed === true) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT, arguments_, timeoutMs);
|
|
304
|
+
throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, arguments_, timeoutMs);
|
|
149
305
|
}
|
|
150
306
|
}
|
|
151
307
|
|
|
@@ -348,11 +504,11 @@ function assertManifestMatchesGit(descriptor: NativeCandidateProjectionDescripto
|
|
|
348
504
|
}
|
|
349
505
|
}
|
|
350
506
|
|
|
351
|
-
function deriveChangedScope(cwd: string,
|
|
507
|
+
function deriveChangedScope(cwd: string, baseTree: string, candidateTree: string, entries: readonly CandidateTreeEntry[], executor: CandidateGitExecutor): CandidateViewScope {
|
|
352
508
|
const present = new Map(entries.map((entry) => [entry.path, entry]));
|
|
353
509
|
const paths = new Set<string>();
|
|
354
510
|
const deleted = new Set<string>();
|
|
355
|
-
const tokens = gitPathTokens(cwd, ["diff", "--name-status", "-z", "--no-ext-diff", "--find-renames=100%",
|
|
511
|
+
const tokens = gitPathTokens(cwd, ["diff", "--name-status", "-z", "--no-ext-diff", "--find-renames=100%", baseTree, candidateTree], executor);
|
|
356
512
|
for (let index = 0; index < tokens.length;) {
|
|
357
513
|
const status = tokens[index++]?.toString("ascii");
|
|
358
514
|
if (status === undefined || !/^(?:[AMDT]|R[0-9]{3})$/.test(status)) throw new CandidateViewError("candidate scope Git output contains an unsafe status");
|
|
@@ -468,8 +624,60 @@ function explicitBaseRefCandidates(cwd: string, selector: string, env: NodeJS.Pr
|
|
|
468
624
|
return [...new Set(candidates)].filter((candidate) => refs.has(candidate));
|
|
469
625
|
}
|
|
470
626
|
|
|
627
|
+
// Runs a probe command that may exit nonzero as an expected signal (absent
|
|
628
|
+
// ref, detached HEAD). Returns the exit status and trimmed stdout. Timeout,
|
|
629
|
+
// output-limit, and unexpected Git failures propagate as sanitized
|
|
630
|
+
// CandidateViewError diagnostics, same as candidateGit.
|
|
631
|
+
function probeCandidateGit(cwd: string, arguments_: readonly string[], env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): { status: number; stdout: string } {
|
|
632
|
+
const timeoutMs = resolveCandidateGitTimeoutMs(env);
|
|
633
|
+
try {
|
|
634
|
+
const stdout = executor("git", arguments_, {
|
|
635
|
+
cwd, encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"],
|
|
636
|
+
timeout: timeoutMs, maxBuffer: CANDIDATE_GIT_MAX_BUFFER_BYTES, windowsHide: true,
|
|
637
|
+
}) as string;
|
|
638
|
+
return { status: 0, stdout: stdout.trim() };
|
|
639
|
+
} catch (error) {
|
|
640
|
+
const detail = error as NodeJS.ErrnoException & { killed?: boolean; status?: number; stdout?: string | Buffer };
|
|
641
|
+
if (detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT, arguments_, timeoutMs);
|
|
642
|
+
if (detail.code === "ETIMEDOUT" || detail.killed === true) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT, arguments_, timeoutMs);
|
|
643
|
+
if (typeof detail.status === "number") return { status: detail.status, stdout: typeof detail.stdout === "string" ? detail.stdout.trim() : "" };
|
|
644
|
+
throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, arguments_, timeoutMs);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// An unborn repository's HEAD is a symbolic ref to a branch with no commits.
|
|
649
|
+
// `symbolic-ref --quiet HEAD` exits nonzero for a detached HEAD (not unborn).
|
|
650
|
+
// `rev-parse --verify --quiet <ref>` distinguishes a valid unborn (status 1,
|
|
651
|
+
// ref absent) from a broken symbolic ref (exit 0, ref OID text exists even
|
|
652
|
+
// when the object is missing). Any other status (128, etc.) signals
|
|
653
|
+
// corruption or an I/O failure, so it fails closed instead of masquerading as
|
|
654
|
+
// an unborn repository.
|
|
655
|
+
function isUnbornSymbolicHead(cwd: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): boolean {
|
|
656
|
+
const symbolic = probeCandidateGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], env, executor);
|
|
657
|
+
if (symbolic.status !== 0) return false;
|
|
658
|
+
const refProbeArguments = ["rev-parse", "--verify", "--quiet", symbolic.stdout];
|
|
659
|
+
const refProbe = probeCandidateGit(cwd, refProbeArguments, env, executor);
|
|
660
|
+
if (refProbe.status === 1) return true;
|
|
661
|
+
if (refProbe.status === 0) return false;
|
|
662
|
+
throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, refProbeArguments, resolveCandidateGitTimeoutMs(env));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Derives Git's repository-native empty tree without hardcoding the SHA-1 id,
|
|
666
|
+
// so a sha256 repository derives its own empty-tree object id. `mktree` with
|
|
667
|
+
// ignored stdin reads empty input and writes the empty tree object.
|
|
668
|
+
function resolveEmptyTree(cwd: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): string {
|
|
669
|
+
return git(cwd, ["mktree"], env, executor);
|
|
670
|
+
}
|
|
671
|
+
|
|
471
672
|
function resolveCandidateBase(cwd: string, baseRef: string | undefined, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): ResolvedCandidateBase {
|
|
472
673
|
const selector = baseRef ?? "HEAD";
|
|
674
|
+
// An unborn repository has a symbolic HEAD pointing at a branch with no
|
|
675
|
+
// commits yet. Its review base is Git's repository-native empty tree, not a
|
|
676
|
+
// missing or malformed commit. Only the default/HEAD selector is entitled to
|
|
677
|
+
// the empty-tree base; a detached HEAD over a missing commit stays fail-closed.
|
|
678
|
+
if (selector === "HEAD" && isUnbornSymbolicHead(cwd, env, executor)) {
|
|
679
|
+
return { commit: "HEAD", tree: resolveEmptyTree(cwd, env, executor) };
|
|
680
|
+
}
|
|
473
681
|
try {
|
|
474
682
|
if (baseRef !== undefined) {
|
|
475
683
|
const candidates = explicitBaseRefCandidates(cwd, selector, env, executor);
|
|
@@ -484,7 +692,7 @@ function resolveCandidateBase(cwd: string, baseRef: string | undefined, env: Nod
|
|
|
484
692
|
if (tree !== confirmedTree) throw new CandidateViewError("candidate base tree changed during resolution", "base-ref-moved");
|
|
485
693
|
return { commit: confirmedCommit, tree: confirmedTree };
|
|
486
694
|
} catch (error) {
|
|
487
|
-
if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-moved" || error.reason === "base-ref-unresolvable")) throw error;
|
|
695
|
+
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
696
|
throw new CandidateViewError("candidate base reference is unresolvable", "base-ref-unresolvable");
|
|
489
697
|
}
|
|
490
698
|
}
|
|
@@ -519,6 +727,37 @@ function checkoutMaterializedEntries(root: string, entries: readonly CandidateTr
|
|
|
519
727
|
flush();
|
|
520
728
|
}
|
|
521
729
|
|
|
730
|
+
// Creates an unborn worktree (symbolic HEAD pointing at a branch with no
|
|
731
|
+
// commits, no ref written, no phantom commit). Git 2.42+ supports --orphan
|
|
732
|
+
// directly; older Git lacks the flag and reports an unsupported-option usage
|
|
733
|
+
// error (exit status 129). Only that exact status triggers the fallback: a
|
|
734
|
+
// temporary empty-tree commit seeds a detached --no-checkout worktree, then
|
|
735
|
+
// a symbolic-ref rewrite makes HEAD unborn. The temporary commit is never
|
|
736
|
+
// referenced by any ref and is GC-able, so it is not a phantom commit. The
|
|
737
|
+
// fallback uses a deterministic author/committer identity and timestamp in a
|
|
738
|
+
// copied environment so it does not depend on user.name/user.email or the
|
|
739
|
+
// ambient date. Contributor HEAD, branch, refs, and index are never touched.
|
|
740
|
+
function addUnbornWorktree(cwd: string, root: string, branch: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): void {
|
|
741
|
+
const primary = probeCandidateGit(cwd, ["worktree", "add", "--orphan", "-b", branch, root], env, executor);
|
|
742
|
+
if (primary.status === 0) return;
|
|
743
|
+
// Only the unsupported-option usage status (129, pre-2.42 Git lacking --orphan)
|
|
744
|
+
// triggers the fallback. Every other status propagates as a git-failure.
|
|
745
|
+
if (primary.status !== 129 || existsSync(root)) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, ["worktree", "add", "--orphan", "-b", branch, root], resolveCandidateGitTimeoutMs(env));
|
|
746
|
+
const fallbackEnv = {
|
|
747
|
+
...env,
|
|
748
|
+
GIT_AUTHOR_NAME: "gentle-ai-candidate",
|
|
749
|
+
GIT_AUTHOR_EMAIL: "gentle-ai-candidate@example.invalid",
|
|
750
|
+
GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z",
|
|
751
|
+
GIT_COMMITTER_NAME: "gentle-ai-candidate",
|
|
752
|
+
GIT_COMMITTER_EMAIL: "gentle-ai-candidate@example.invalid",
|
|
753
|
+
GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z",
|
|
754
|
+
};
|
|
755
|
+
const emptyTree = git(cwd, ["mktree"], fallbackEnv, executor);
|
|
756
|
+
const tempCommit = git(cwd, ["commit-tree", "-m", "gentle-ai-candidate", emptyTree], fallbackEnv, executor);
|
|
757
|
+
git(cwd, ["worktree", "add", "--no-checkout", "--detach", root, tempCommit], env, executor);
|
|
758
|
+
git(root, ["symbolic-ref", "HEAD", `refs/heads/${branch}`], env, executor);
|
|
759
|
+
}
|
|
760
|
+
|
|
522
761
|
function materializeCandidateView(request: CreateCandidateViewRequest, executor: CandidateGitExecutor): CandidateViewRecord {
|
|
523
762
|
const contributorRoot = realpathSync(request.contributorRoot);
|
|
524
763
|
if (!lstatSync(contributorRoot).isDirectory()) throw new CandidateViewError("contributor root is not a directory");
|
|
@@ -536,17 +775,32 @@ function materializeCandidateView(request: CreateCandidateViewRequest, executor:
|
|
|
536
775
|
const environment = { ...process.env, GIT_INDEX_FILE: indexPath };
|
|
537
776
|
try {
|
|
538
777
|
const baseCommit = base.commit;
|
|
539
|
-
|
|
778
|
+
const unborn = baseCommit === "HEAD";
|
|
779
|
+
// For an unborn repository the base tree is Git's empty tree, so seed the
|
|
780
|
+
// private candidate index from `--empty` instead of a non-existent commit.
|
|
781
|
+
if (unborn) git(contributorRoot, ["read-tree", "--empty"], environment, executor);
|
|
782
|
+
else git(contributorRoot, ["read-tree", candidateCommit.commit], environment, executor);
|
|
540
783
|
if (!committedOnly) git(contributorRoot, ["add", "-A"], environment, executor);
|
|
541
784
|
const candidateTree = git(contributorRoot, ["write-tree"], environment, executor);
|
|
542
785
|
const root = join(parent, randomUUID());
|
|
543
|
-
|
|
786
|
+
// The worktree is created under the same try/catch cleanup boundary as
|
|
787
|
+
// the read-tree materialization that follows. addUnbornWorktree's
|
|
788
|
+
// fallback path can register a worktree with `worktree add` and then
|
|
789
|
+
// fail on a later step (for example `symbolic-ref`); moving creation
|
|
790
|
+
// here ensures any such partial registration is removed by the catch
|
|
791
|
+
// below instead of leaking a registered/admin worktree and directory.
|
|
544
792
|
try {
|
|
793
|
+
// An unborn repository has no commit to detach a worktree at. addUnbornWorktree
|
|
794
|
+
// creates an orphan worktree (unborn branch, no commit, no ref) to host the
|
|
795
|
+
// materialized candidate tree without a phantom commit, with a fallback for
|
|
796
|
+
// Git versions older than 2.42 that do not support --orphan.
|
|
797
|
+
if (unborn) addUnbornWorktree(contributorRoot, root, `gentle-ai-candidate-${randomUUID()}`, process.env, executor);
|
|
798
|
+
else git(contributorRoot, ["worktree", "add", "--detach", "--no-checkout", root, candidateCommit.commit], process.env, executor);
|
|
545
799
|
git(root, ["read-tree", candidateTree], process.env, executor);
|
|
546
800
|
const tree = parseTree(root, candidateTree, executor);
|
|
547
801
|
checkoutMaterializedEntries(root, tree.entries, executor);
|
|
548
802
|
const entries = tree.entries.map((entry) => ({ ...entry, contentHash: entryContentHash(root, entry) }));
|
|
549
|
-
const scope = deriveChangedScope(contributorRoot,
|
|
803
|
+
const scope = deriveChangedScope(contributorRoot, base.tree, candidateTree, [...tree.entries, ...tree.gitlinks], executor);
|
|
550
804
|
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
805
|
makeReadonly(root, entries);
|
|
552
806
|
return { token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor };
|
|
@@ -603,6 +857,11 @@ export class CandidateViewRegistry {
|
|
|
603
857
|
private readonly projections = new Map<string, FrozenCandidateProjection>();
|
|
604
858
|
private readonly replays = new Map<string, string>();
|
|
605
859
|
private current: { lineageId: string; token: string } | undefined;
|
|
860
|
+
// The last dispatch-binding hydration that was attempted and failed. A
|
|
861
|
+
// swallowed hydration failure is its own defect (field report 2026-08-16):
|
|
862
|
+
// without it the later dispatch refusal claims no binding was ever
|
|
863
|
+
// available instead of naming the attempt and its typed cause.
|
|
864
|
+
private lastHydrationFailure: { lineageId: string; reason: string; message: string } | undefined;
|
|
606
865
|
|
|
607
866
|
create(request: CreateCandidateViewRequest): CandidateView {
|
|
608
867
|
return this.createOrReuse(request);
|
|
@@ -833,6 +1092,37 @@ export class CandidateViewRegistry {
|
|
|
833
1092
|
});
|
|
834
1093
|
}
|
|
835
1094
|
|
|
1095
|
+
/**
|
|
1096
|
+
* Re-derives this lineage's FINALIZE binding from the provider's own
|
|
1097
|
+
* projection, replacing a binding this session is still holding.
|
|
1098
|
+
*
|
|
1099
|
+
* Field defect (Engram #12547): once a bounded correction is admitted, the
|
|
1100
|
+
* candidate identity legitimately moves, and the provider issues its
|
|
1101
|
+
* finalize transition for that corrected target. A session that started the
|
|
1102
|
+
* review still holds the START-time immutable reviewer view, so comparing
|
|
1103
|
+
* it against the corrected projection reads as drift and no receipt is ever
|
|
1104
|
+
* minted — while a fresh process, which restores from the native descriptor,
|
|
1105
|
+
* finalizes the very same lineage successfully. This makes the in-session
|
|
1106
|
+
* path behave like that already-correct fresh-process path.
|
|
1107
|
+
*
|
|
1108
|
+
* This is a re-derivation, not a relaxation: the replacement is
|
|
1109
|
+
* materialized from Git and must match the provider descriptor exactly
|
|
1110
|
+
* (base tree, projection kind, changed-path manifest), and the caller still
|
|
1111
|
+
* asserts the binding afterwards. The immutable reviewer view is retired
|
|
1112
|
+
* here on purpose — the lenses that consumed it finished before the
|
|
1113
|
+
* correction.
|
|
1114
|
+
*/
|
|
1115
|
+
rebindForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
|
|
1116
|
+
const staleToken = this.lineages.get(lineageId);
|
|
1117
|
+
const stale = staleToken === undefined ? undefined : this.records.get(staleToken);
|
|
1118
|
+
this.projections.delete(lineageId);
|
|
1119
|
+
if (stale !== undefined) {
|
|
1120
|
+
this.remove(stale);
|
|
1121
|
+
this.forget(stale);
|
|
1122
|
+
}
|
|
1123
|
+
return this.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
836
1126
|
restoreForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView {
|
|
837
1127
|
this.restoreProjectionFromNative(lineageId, contributorRoot, descriptor);
|
|
838
1128
|
const projection = this.resolveProjection(lineageId, contributorRoot);
|
|
@@ -851,6 +1141,46 @@ export class CandidateViewRegistry {
|
|
|
851
1141
|
}
|
|
852
1142
|
}
|
|
853
1143
|
|
|
1144
|
+
/**
|
|
1145
|
+
* Mirrors the START-time dispatch registration for a lineage this
|
|
1146
|
+
* controller never started (live defect 2026-08-16: a successor created
|
|
1147
|
+
* by native `review recover` exists only in native authority). The
|
|
1148
|
+
* authoritative STATUS descriptor supplies the frozen projection; the
|
|
1149
|
+
* live candidate is re-materialized and must match it exactly before the
|
|
1150
|
+
* dispatch-facing current binding is established with the provider-named
|
|
1151
|
+
* pending lenses.
|
|
1152
|
+
*/
|
|
1153
|
+
restoreCurrentForDispatchFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor, selectedLenses: readonly string[]): void {
|
|
1154
|
+
if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established");
|
|
1155
|
+
try {
|
|
1156
|
+
const lenses = this.validateSelectedLenses(selectedLenses);
|
|
1157
|
+
this.restoreProjectionFromNative(lineageId, contributorRoot, descriptor);
|
|
1158
|
+
const projection = this.resolveProjection(lineageId, contributorRoot);
|
|
1159
|
+
const record = materializeCandidateView({ contributorRoot, baseRef: projection.baseCommit, committedOnly: projection.committedOnly }, this.gitExecutor);
|
|
1160
|
+
try {
|
|
1161
|
+
if (record.baseTree !== projection.baseTree || record.candidateTree !== projection.candidateTree || JSON.stringify(record.scope.paths) !== JSON.stringify(projection.paths)) {
|
|
1162
|
+
throw new CandidateViewError("live candidate does not match the native frozen projection");
|
|
1163
|
+
}
|
|
1164
|
+
this.records.set(record.token, record);
|
|
1165
|
+
this.bindRecord(record.token, lineageId, lenses);
|
|
1166
|
+
this.current = { lineageId, token: record.token };
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
this.projections.delete(lineageId);
|
|
1169
|
+
this.records.delete(record.token);
|
|
1170
|
+
this.remove(record);
|
|
1171
|
+
throw error;
|
|
1172
|
+
}
|
|
1173
|
+
this.lastHydrationFailure = undefined;
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
this.lastHydrationFailure = {
|
|
1176
|
+
lineageId,
|
|
1177
|
+
reason: error instanceof CandidateViewError ? error.reason : "candidate-view-invalid",
|
|
1178
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1179
|
+
};
|
|
1180
|
+
throw error;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
854
1184
|
resolveProjection(lineageId: string, contributorRoot: string): FrozenCandidateProjection {
|
|
855
1185
|
const projection = this.projections.get(lineageId);
|
|
856
1186
|
if (!projection || realpathSync(contributorRoot) !== projection.contributorRoot) {
|
|
@@ -868,10 +1198,24 @@ export class CandidateViewRegistry {
|
|
|
868
1198
|
}
|
|
869
1199
|
|
|
870
1200
|
currentLineageId(): string {
|
|
871
|
-
if (this.current === undefined)
|
|
1201
|
+
if (this.current === undefined) {
|
|
1202
|
+
const failure = this.lastHydrationFailure;
|
|
1203
|
+
if (failure !== undefined) {
|
|
1204
|
+
throw new CandidateViewError(
|
|
1205
|
+
`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}`,
|
|
1206
|
+
"current-binding-hydration-failed",
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
throw new CandidateViewError("review subagent dispatch has no current controller-owned candidate view lineage binding", "current-binding-missing");
|
|
1210
|
+
}
|
|
872
1211
|
return this.current.lineageId;
|
|
873
1212
|
}
|
|
874
1213
|
|
|
1214
|
+
/** The last failed dispatch-binding hydration, for controller envelopes. */
|
|
1215
|
+
lastDispatchHydrationFailure(): Readonly<{ lineageId: string; reason: string; message: string }> | undefined {
|
|
1216
|
+
return this.lastHydrationFailure;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
875
1219
|
resolveCurrentForLens(lens: string): CandidateView {
|
|
876
1220
|
return this.resolveCurrentForLenses([lens])[0]!;
|
|
877
1221
|
}
|
|
@@ -986,22 +1330,187 @@ function hasCandidateContextConflict(text: string, views: readonly CandidateView
|
|
|
986
1330
|
|| views.some((view) => text.includes(view.root) || text.includes(view.candidateTree));
|
|
987
1331
|
}
|
|
988
1332
|
|
|
989
|
-
function
|
|
1333
|
+
function compareCanonicalStrings(left: string, right: string): number {
|
|
1334
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function canonicalStringMap(value: Readonly<Record<string, string>>): Record<string, string> {
|
|
1338
|
+
// Plain objects enumerate integer-like keys numerically; canonical round trips intentionally preserve that ordering.
|
|
1339
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => compareCanonicalStrings(left, right)));
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function candidateScopeByMode(view: CandidateView): Record<string, string[]> {
|
|
990
1343
|
const grouped = new Map<string, string[]>();
|
|
1344
|
+
const deletedPaths = new Set(view.deletedPaths);
|
|
991
1345
|
for (const path of view.paths) {
|
|
992
|
-
const group =
|
|
1346
|
+
const group = deletedPaths.has(path) ? "deleted" : view.modes[path];
|
|
993
1347
|
if (group === undefined) throw new CandidateViewError("candidate view scope omits a changed path mode");
|
|
994
1348
|
const paths = grouped.get(group) ?? [];
|
|
995
1349
|
paths.push(path);
|
|
996
1350
|
grouped.set(group, paths);
|
|
997
1351
|
}
|
|
998
|
-
|
|
1352
|
+
return Object.fromEntries(
|
|
1353
|
+
[...grouped.entries()]
|
|
1354
|
+
.sort(([left], [right]) => compareCanonicalStrings(left, right))
|
|
1355
|
+
.map(([mode, paths]) => [mode, paths.sort(compareCanonicalStrings)]),
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function isCandidateContextMode(value: string): boolean {
|
|
1360
|
+
return (Object.values(CANDIDATE_CONTEXT_MODE) as readonly string[]).includes(value);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function isCanonicalStringArray(value: unknown): value is readonly string[] {
|
|
1364
|
+
return Array.isArray(value)
|
|
1365
|
+
&& value.length > 0
|
|
1366
|
+
&& value.every((item) => typeof item === "string" && isSafeCandidatePath(item))
|
|
1367
|
+
&& value.every((item, index, items) => index === 0 || compareCanonicalStrings(items[index - 1]!, item) < 0);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function hasCanonicalRecordOrder(value: Readonly<Record<string, unknown>>): boolean {
|
|
1371
|
+
const normalized = Object.fromEntries(Object.entries(value).sort(([left], [right]) => compareCanonicalStrings(left, right)));
|
|
1372
|
+
return JSON.stringify(value) === JSON.stringify(normalized);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function invalidCandidateContextManifest(message: string): never {
|
|
1376
|
+
throw new CandidateViewError(message, "candidate-context-manifest-invalid");
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function validateCandidateContextManifest(value: unknown, bytes: Buffer): CandidateContextManifest {
|
|
1380
|
+
if (!isRecord(value)) return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
|
|
1381
|
+
const keys = Object.keys(value);
|
|
1382
|
+
const expectedKeys = ["version", "scopeByMode", "gitlinks"];
|
|
1383
|
+
if (keys.length !== expectedKeys.length || !expectedKeys.every((key) => keys.includes(key))) {
|
|
1384
|
+
return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
|
|
1385
|
+
}
|
|
1386
|
+
if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
|
|
1387
|
+
if (value.version !== CANDIDATE_CONTEXT_MANIFEST.VERSION || !isRecord(value.scopeByMode) || !isRecord(value.gitlinks)) {
|
|
1388
|
+
return invalidCandidateContextManifest("candidate context manifest has an invalid structure");
|
|
1389
|
+
}
|
|
1390
|
+
const scopeByMode = value.scopeByMode;
|
|
1391
|
+
if (!hasCanonicalRecordOrder(scopeByMode)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
|
|
1392
|
+
const scopePaths = new Set<string>();
|
|
1393
|
+
for (const [mode, paths] of Object.entries(scopeByMode)) {
|
|
1394
|
+
if (!isCandidateContextMode(mode) || !isCanonicalStringArray(paths)) return invalidCandidateContextManifest("candidate context manifest has an invalid scope");
|
|
1395
|
+
for (const path of paths) {
|
|
1396
|
+
if (scopePaths.has(path)) return invalidCandidateContextManifest("candidate context manifest has duplicate scope paths");
|
|
1397
|
+
scopePaths.add(path);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
const gitlinks = value.gitlinks;
|
|
1401
|
+
if (!hasCanonicalRecordOrder(gitlinks)) return invalidCandidateContextManifest("candidate context manifest is not canonical");
|
|
1402
|
+
for (const [path, objectId] of Object.entries(gitlinks)) {
|
|
1403
|
+
if (!isSafeCandidatePath(path) || typeof objectId !== "string" || !isCanonicalObjectId(objectId)) {
|
|
1404
|
+
return invalidCandidateContextManifest("candidate context manifest has an invalid gitlink map");
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
const gitlinkPaths = scopeByMode["160000"];
|
|
1408
|
+
const canonicalGitlinkPaths = Object.keys(gitlinks).sort(compareCanonicalStrings);
|
|
1409
|
+
if (
|
|
1410
|
+
(gitlinkPaths === undefined && canonicalGitlinkPaths.length !== 0)
|
|
1411
|
+
|| (gitlinkPaths !== undefined && JSON.stringify(canonicalGitlinkPaths) !== JSON.stringify(gitlinkPaths))
|
|
1412
|
+
) return invalidCandidateContextManifest("candidate context manifest gitlinks do not match its scope");
|
|
1413
|
+
const manifest: CandidateContextManifest = {
|
|
1414
|
+
version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
|
|
1415
|
+
scopeByMode: scopeByMode as Readonly<Record<string, readonly string[]>>,
|
|
1416
|
+
gitlinks: gitlinks as Readonly<Record<string, string>>,
|
|
1417
|
+
};
|
|
1418
|
+
if (!Buffer.from(JSON.stringify(manifest), "utf8").equals(bytes)) {
|
|
1419
|
+
return invalidCandidateContextManifest("candidate context manifest is not canonical");
|
|
1420
|
+
}
|
|
1421
|
+
return manifest;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
export function decodeCandidateContextManifest(encoded: string, sha256: string): DecodedCandidateContextManifest {
|
|
1425
|
+
if (encoded.length > MAX_CANDIDATE_CONTEXT_LENGTH || !/^[A-Za-z0-9_-]+$/.test(encoded) || !/^[0-9a-f]{64}$/.test(sha256)) {
|
|
1426
|
+
throw new CandidateViewError("candidate context manifest encoding is invalid", "candidate-context-manifest-invalid");
|
|
1427
|
+
}
|
|
1428
|
+
let bytes: Buffer;
|
|
1429
|
+
try {
|
|
1430
|
+
bytes = gunzipSync(Buffer.from(encoded, "base64url"), { maxOutputLength: MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES });
|
|
1431
|
+
} catch {
|
|
1432
|
+
throw new CandidateViewError("candidate context manifest cannot be decompressed", "candidate-context-manifest-invalid");
|
|
1433
|
+
}
|
|
1434
|
+
const actualSha256 = createHash("sha256").update(bytes).digest("hex");
|
|
1435
|
+
if (actualSha256 !== sha256) throw new CandidateViewError("candidate context manifest integrity check failed", "candidate-context-manifest-integrity");
|
|
1436
|
+
const text = bytes.toString("utf8");
|
|
1437
|
+
if (!Buffer.from(text, "utf8").equals(bytes)) return invalidCandidateContextManifest("candidate context manifest is not valid UTF-8");
|
|
1438
|
+
if (gzipSync(bytes, { mtime: 0 }).toString("base64url") !== encoded) {
|
|
1439
|
+
return invalidCandidateContextManifest("candidate context manifest transport is not canonical");
|
|
1440
|
+
}
|
|
1441
|
+
let value: unknown;
|
|
1442
|
+
try {
|
|
1443
|
+
value = JSON.parse(text);
|
|
1444
|
+
} catch (error) {
|
|
1445
|
+
if (error instanceof CandidateViewError) throw error;
|
|
1446
|
+
return invalidCandidateContextManifest("candidate context manifest is not valid JSON");
|
|
1447
|
+
}
|
|
1448
|
+
return { manifest: validateCandidateContextManifest(value, bytes), bytes, sha256: actualSha256 };
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
export function readCandidateContextManifestPage(encoded: string, sha256: string, cursor = 0): CandidateContextPage {
|
|
1452
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0) throw new CandidateViewError("candidate context manifest cursor is invalid", "candidate-context-cursor-invalid");
|
|
1453
|
+
const decoded = decodeCandidateContextManifest(encoded, sha256);
|
|
1454
|
+
const entries = Object.entries(decoded.manifest.scopeByMode).flatMap(([mode, paths]) => paths.map((path): CandidateContextPageEntry => ({
|
|
1455
|
+
path,
|
|
1456
|
+
mode: mode as CandidateContextMode,
|
|
1457
|
+
...(mode === CANDIDATE_CONTEXT_MODE.GITLINK ? { gitlinkObjectId: decoded.manifest.gitlinks[path]! } : {}),
|
|
1458
|
+
})));
|
|
1459
|
+
if (cursor > entries.length) throw new CandidateViewError("candidate context manifest cursor exceeds the changed scope", "candidate-context-cursor-invalid");
|
|
1460
|
+
const pageEntries: CandidateContextPageEntry[] = [];
|
|
1461
|
+
for (let index = cursor; index < entries.length && pageEntries.length < MAX_CANDIDATE_SCOPE_PAGE_ENTRIES; index += 1) {
|
|
1462
|
+
const candidateEntries = [...pageEntries, entries[index]!];
|
|
1463
|
+
const candidatePage: CandidateContextPage = {
|
|
1464
|
+
version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
|
|
1465
|
+
sha256: decoded.sha256,
|
|
1466
|
+
cursor,
|
|
1467
|
+
totalPaths: entries.length,
|
|
1468
|
+
entries: candidateEntries,
|
|
1469
|
+
...(cursor + candidateEntries.length < entries.length ? { nextCursor: cursor + candidateEntries.length } : {}),
|
|
1470
|
+
};
|
|
1471
|
+
if (Buffer.byteLength(JSON.stringify(candidatePage), "utf8") > MAX_CANDIDATE_SCOPE_PAGE_BYTES) {
|
|
1472
|
+
if (pageEntries.length === 0) throw new CandidateViewError("candidate context manifest path exceeds the bounded actor response", "candidate-context-page-too-large");
|
|
1473
|
+
break;
|
|
1474
|
+
}
|
|
1475
|
+
pageEntries.push(entries[index]!);
|
|
1476
|
+
}
|
|
1477
|
+
return {
|
|
1478
|
+
version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
|
|
1479
|
+
sha256: decoded.sha256,
|
|
1480
|
+
cursor,
|
|
1481
|
+
totalPaths: entries.length,
|
|
1482
|
+
entries: pageEntries,
|
|
1483
|
+
...(cursor + pageEntries.length < entries.length ? { nextCursor: cursor + pageEntries.length } : {}),
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function candidateContextPreamble(lineageId: string, agents: readonly ReviewLens[], view: CandidateView, scopeSemantics: string): string {
|
|
1488
|
+
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}`;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function compactCandidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView, scopeSemantics: string, scopeByMode: Record<string, string[]>): string {
|
|
1492
|
+
const manifest: CandidateContextManifest = {
|
|
1493
|
+
version: CANDIDATE_CONTEXT_MANIFEST.VERSION,
|
|
1494
|
+
scopeByMode,
|
|
1495
|
+
gitlinks: canonicalStringMap(view.gitlinks),
|
|
1496
|
+
};
|
|
1497
|
+
const bytes = Buffer.from(JSON.stringify(manifest), "utf8");
|
|
1498
|
+
if (bytes.length > MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract");
|
|
1499
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
1500
|
+
const encoded = gzipSync(bytes, { mtime: 0 }).toString("base64url");
|
|
1501
|
+
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.`;
|
|
1502
|
+
if (Buffer.byteLength(block, "utf8") > MAX_CANDIDATE_CONTEXT_LENGTH) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract");
|
|
1503
|
+
return block;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function candidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView): string {
|
|
1507
|
+
const scopeByMode = candidateScopeByMode(view);
|
|
999
1508
|
const scopeSemantics = view.committedOnly
|
|
1000
1509
|
? "Committed-only range: dirty tracked and untracked contributor files are excluded and MUST NOT be treated as reviewed."
|
|
1001
1510
|
: "Dirty-inclusive workspace snapshot: tracked and untracked contributor changes are included.";
|
|
1002
|
-
const
|
|
1003
|
-
if (Buffer.byteLength(
|
|
1004
|
-
return
|
|
1511
|
+
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.`;
|
|
1512
|
+
if (Buffer.byteLength(readableBlock, "utf8") <= MAX_CANDIDATE_CONTEXT_LENGTH) return readableBlock;
|
|
1513
|
+
return compactCandidateContextBlock(lineageId, agents, view, scopeSemantics, scopeByMode);
|
|
1005
1514
|
}
|
|
1006
1515
|
|
|
1007
1516
|
/**
|