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
package/extensions/sdd-init.ts
CHANGED
|
@@ -775,11 +775,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
775
775
|
description:
|
|
776
776
|
"Auto-detect project stack and bootstrap openspec/config.yaml for SDD.",
|
|
777
777
|
handler: async (_args: unknown, ctx: any) => {
|
|
778
|
-
await ensureSddPreflight(ctx, {
|
|
778
|
+
const prefs = await ensureSddPreflight(ctx, {
|
|
779
779
|
pi,
|
|
780
780
|
installAssets: (cwd) => installSddAssets(cwd, false),
|
|
781
781
|
applyModelConfig: () => applySavedModelConfig(ctx),
|
|
782
782
|
});
|
|
783
|
+
|
|
784
|
+
const detection = detectProject(ctx.cwd);
|
|
785
|
+
const testSummary = detection.testCommand
|
|
786
|
+
? `strict TDD enabled with \`${detection.testCommand}\``
|
|
787
|
+
: "strict TDD disabled because no test runner was detected";
|
|
788
|
+
const layerSummary = `unit: ${detection.commands.unit.length}, integration: ${detection.commands.integration.length}, e2e: ${detection.commands.e2e.length}`;
|
|
789
|
+
|
|
790
|
+
const shouldCreateOpenSpec =
|
|
791
|
+
prefs.artifactStore === "openspec" ||
|
|
792
|
+
prefs.artifactStore === "both";
|
|
793
|
+
if (!shouldCreateOpenSpec) {
|
|
794
|
+
ctx.ui.notify(
|
|
795
|
+
`SDD initialized for ${prefs.artifactStore}: detected ${detection.stack.join(", ") || "project"}; ${testSummary}; tests found: ${layerSummary}.`,
|
|
796
|
+
"info",
|
|
797
|
+
);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
783
801
|
const configPath = join(ctx.cwd, CONFIG_REL_PATH);
|
|
784
802
|
if (existsSync(configPath)) {
|
|
785
803
|
ctx.ui.notify(
|
|
@@ -789,15 +807,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
789
807
|
return;
|
|
790
808
|
}
|
|
791
809
|
|
|
792
|
-
const detection = detectProject(ctx.cwd);
|
|
793
810
|
ensureOpenSpecDirs(ctx.cwd);
|
|
794
811
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
795
812
|
writeFileSync(configPath, renderConfig(detection));
|
|
796
813
|
|
|
797
|
-
const testSummary = detection.testCommand
|
|
798
|
-
? `strict TDD enabled with \`${detection.testCommand}\``
|
|
799
|
-
: "strict TDD disabled because no test runner was detected";
|
|
800
|
-
const layerSummary = `unit: ${detection.commands.unit.length}, integration: ${detection.commands.integration.length}, e2e: ${detection.commands.e2e.length}`;
|
|
801
814
|
ctx.ui.notify(
|
|
802
815
|
`Wrote ${CONFIG_REL_PATH}: detected ${detection.stack.join(", ") || "project"}; ${testSummary}; tests found: ${layerSummary}.`,
|
|
803
816
|
"info",
|
|
@@ -21,7 +21,7 @@ const EXCLUDE_NAMES = new Set(["_shared", "skill-registry"]);
|
|
|
21
21
|
const EXCLUDE_PREFIXES = ["sdd-"];
|
|
22
22
|
const ATL_IGNORE_ENTRY = ".atl/";
|
|
23
23
|
const WATCH_DEBOUNCE_MS = 500;
|
|
24
|
-
const REGISTRY_SCHEMA_VERSION =
|
|
24
|
+
const REGISTRY_SCHEMA_VERSION = 7;
|
|
25
25
|
const NO_SKILL_REGISTRY_FLAG = "no-skill-registry";
|
|
26
26
|
const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
|
|
27
27
|
const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
|
|
@@ -253,7 +253,14 @@ async function fingerprint(files: string[]): Promise<string> {
|
|
|
253
253
|
for (const file of files) {
|
|
254
254
|
try {
|
|
255
255
|
const info = await stat(file);
|
|
256
|
-
|
|
256
|
+
let contentHash: string;
|
|
257
|
+
try {
|
|
258
|
+
contentHash = createHash("sha1").update(await readFile(file)).digest("hex");
|
|
259
|
+
} catch {
|
|
260
|
+
lines.push(`${file}:unreadable`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
lines.push(`${file}:${info.mtimeMs}:${info.size}:${contentHash}`);
|
|
257
264
|
} catch {
|
|
258
265
|
lines.push(`${file}:missing`);
|
|
259
266
|
}
|
|
@@ -526,6 +533,7 @@ export const __testing = {
|
|
|
526
533
|
normalizeSkillDescription,
|
|
527
534
|
parseFrontmatter,
|
|
528
535
|
renderRegistry,
|
|
536
|
+
regenerateRegistry,
|
|
529
537
|
shouldSkipSkillRegistryStartup,
|
|
530
538
|
shouldSkipDuplicateExtensionLoad,
|
|
531
539
|
startSkillRegistryWatcher,
|
|
@@ -553,7 +553,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
553
553
|
if (selected.startsWith("Rose:")) config.showRose = !config.showRose;
|
|
554
554
|
else if (selected.startsWith("Text logo:")) config.showTextLogo = !config.showTextLogo;
|
|
555
555
|
else if (selected.startsWith("Color:")) {
|
|
556
|
-
|
|
556
|
+
const color = await ctx.ui.select("Startup banner color", [...BANNER_COLORS]);
|
|
557
|
+
if (!color) return;
|
|
558
|
+
config.color = color as BannerColor;
|
|
557
559
|
}
|
|
558
560
|
await writeBannerConfig(config);
|
|
559
561
|
notifyBannerConfig(ctx, config);
|
|
@@ -577,9 +579,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
577
579
|
handler: async (args, ctx) => {
|
|
578
580
|
const config = await readBannerConfig();
|
|
579
581
|
const requested = String(args ?? "").trim() as BannerColor;
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
582
|
+
if (BANNER_COLORS.includes(requested)) {
|
|
583
|
+
config.color = requested;
|
|
584
|
+
} else {
|
|
585
|
+
const selected = await ctx.ui.select("Startup banner color", [...BANNER_COLORS]);
|
|
586
|
+
if (!selected) return;
|
|
587
|
+
config.color = selected as BannerColor;
|
|
588
|
+
}
|
|
583
589
|
await writeBannerConfig(config);
|
|
584
590
|
notifyBannerConfig(ctx, config);
|
|
585
591
|
},
|
package/lib/gentle-ai-binary.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
5
|
import {
|
|
5
6
|
GENTLE_AI_INSTALL_METHOD,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|
|
8
9
|
GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
|
|
9
10
|
GENTLE_AI_WINDOWS_SOURCE_TAG,
|
|
11
|
+
INSTALLER_VERSION,
|
|
10
12
|
isGentleAiWindowsGoVersionSupported,
|
|
11
13
|
isWindowsGoSumdbSourceTarget,
|
|
12
14
|
resolveGentleAiReleaseAsset,
|
|
@@ -14,7 +16,10 @@ import {
|
|
|
14
16
|
import { fileURLToPath } from "node:url";
|
|
15
17
|
|
|
16
18
|
export const GENTLE_AI_BINARY_MISSING_CODE = "package-local-binary-missing";
|
|
17
|
-
|
|
19
|
+
// Derived from the one authoritative pinned version in
|
|
20
|
+
// scripts/gentle-ai-installer.mjs rather than repeating the literal here, so
|
|
21
|
+
// the two can never independently drift apart the way they once did.
|
|
22
|
+
export const GENTLE_AI_VERSION = INSTALLER_VERSION;
|
|
18
23
|
|
|
19
24
|
export class PackageLocalGentleAiBinaryMissingError extends Error {
|
|
20
25
|
readonly code = GENTLE_AI_BINARY_MISSING_CODE;
|
|
@@ -42,6 +47,165 @@ function sha256(value: Buffer): string {
|
|
|
42
47
|
return createHash("sha256").update(value).digest("hex");
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Dev-binary override — the maintainer field-test lane.
|
|
52
|
+
//
|
|
53
|
+
// Two explicit activation paths, in precedence order:
|
|
54
|
+
// 1. GENTLE_PI_GENTLE_AI_DEV_BINARY (session override, absolute path), then
|
|
55
|
+
// 2. the persistent registration file at
|
|
56
|
+
// <GENTLE_PI_CONFIG_HOME|~/.pi/gentle-ai>/dev-binary.json with the strict
|
|
57
|
+
// shape {"schema":"gentle-pi.dev-binary/v1","path":"<absolute path>"}.
|
|
58
|
+
//
|
|
59
|
+
// The registration deliberately pins no digest: it is the unpinned field-test
|
|
60
|
+
// mode, and the binary at that path changes on every rebuild. Every resolution
|
|
61
|
+
// re-validates the file and recomputes the sha256, so a rebuilt binary is
|
|
62
|
+
// followed automatically with a fresh digest and no re-registration.
|
|
63
|
+
//
|
|
64
|
+
// Guardrails per resolution: absolute path, regular non-symlink file, POSIX
|
|
65
|
+
// executable. Any failure — including a malformed registration document or a
|
|
66
|
+
// registered-but-missing binary — is a typed error naming its origin, never a
|
|
67
|
+
// silent fallback to the pinned binary: silently running the pin while the
|
|
68
|
+
// maintainer believes he is field-testing main is the worst possible outcome.
|
|
69
|
+
// With neither activation path present, the pinned supply-chain resolution
|
|
70
|
+
// below stays byte-identical.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export const GENTLE_AI_DEV_BINARY_ENV = "GENTLE_PI_GENTLE_AI_DEV_BINARY";
|
|
74
|
+
export const GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA = "gentle-pi.dev-binary/v1";
|
|
75
|
+
export const GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE = "dev-binary-override-invalid";
|
|
76
|
+
|
|
77
|
+
export interface GentleAiDevBinaryEnvironment {
|
|
78
|
+
env: Record<string, string | undefined>;
|
|
79
|
+
home: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface GentleAiDevBinaryOverride {
|
|
83
|
+
source: "env" | "registration";
|
|
84
|
+
/** The env var name or registration file path that selected this binary. */
|
|
85
|
+
origin: string;
|
|
86
|
+
path: string;
|
|
87
|
+
sha256: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class GentleAiDevBinaryOverrideError extends Error {
|
|
91
|
+
readonly code = GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE;
|
|
92
|
+
readonly source: "env" | "registration";
|
|
93
|
+
readonly origin: string;
|
|
94
|
+
constructor(source: "env" | "registration", origin: string, reason: string) {
|
|
95
|
+
super(`${GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE}: ${origin} ${reason}. Fix or remove the override; the pinned binary is never used silently while an override is declared.`);
|
|
96
|
+
this.name = "GentleAiDevBinaryOverrideError";
|
|
97
|
+
this.source = source;
|
|
98
|
+
this.origin = origin;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let devBinaryEnvironmentTestingOverlay: GentleAiDevBinaryEnvironment | undefined;
|
|
103
|
+
|
|
104
|
+
/** Testing-only environment overlay; production code never calls this. */
|
|
105
|
+
export function setGentleAiDevBinaryEnvironmentForTesting(environment: GentleAiDevBinaryEnvironment | undefined): void {
|
|
106
|
+
devBinaryEnvironmentTestingOverlay = environment;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function ambientDevBinaryEnvironment(): GentleAiDevBinaryEnvironment {
|
|
110
|
+
return devBinaryEnvironmentTestingOverlay ?? { env: process.env, home: homedir() };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function gentleAiDevBinaryRegistrationPath(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): string {
|
|
114
|
+
const configHome = environment.env.GENTLE_PI_CONFIG_HOME ?? join(environment.home, ".pi", "gentle-ai");
|
|
115
|
+
return join(configHome, "dev-binary.json");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validateDevBinary(source: "env" | "registration", origin: string, path: string, platform: string): GentleAiDevBinaryOverride {
|
|
119
|
+
if (typeof path !== "string" || path.length === 0) throw new GentleAiDevBinaryOverrideError(source, origin, "declares an empty dev binary path");
|
|
120
|
+
if (!isAbsolute(path)) throw new GentleAiDevBinaryOverrideError(source, origin, `must name an absolute path, received "${path}"`);
|
|
121
|
+
let details: ReturnType<typeof lstatSync>;
|
|
122
|
+
try {
|
|
123
|
+
details = lstatSync(path);
|
|
124
|
+
} catch {
|
|
125
|
+
throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which does not exist`);
|
|
126
|
+
}
|
|
127
|
+
if (!details.isFile() || details.isSymbolicLink()) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a regular non-symlink file`);
|
|
128
|
+
if (platform !== "win32" && (details.mode & 0o111) === 0) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a POSIX executable`);
|
|
129
|
+
let digest: string;
|
|
130
|
+
try {
|
|
131
|
+
digest = sha256(readFileSync(path));
|
|
132
|
+
} catch {
|
|
133
|
+
throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which could not be read`);
|
|
134
|
+
}
|
|
135
|
+
return { source, origin, path, sha256: digest };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readDevBinaryRegistration(registrationPath: string): string {
|
|
139
|
+
let contents: string;
|
|
140
|
+
try {
|
|
141
|
+
contents = readFileSync(registrationPath, "utf8");
|
|
142
|
+
} catch {
|
|
143
|
+
throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "could not be read");
|
|
144
|
+
}
|
|
145
|
+
let parsed: unknown;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(contents);
|
|
148
|
+
} catch {
|
|
149
|
+
throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "is not valid JSON");
|
|
150
|
+
}
|
|
151
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must be a JSON object");
|
|
152
|
+
const record = parsed as Record<string, unknown>;
|
|
153
|
+
const keys = Object.keys(record).sort();
|
|
154
|
+
if (keys.length !== 2 || keys[0] !== "path" || keys[1] !== "schema") throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must carry exactly the keys "schema" and "path"`);
|
|
155
|
+
if (record.schema !== GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must declare schema ${GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA}`);
|
|
156
|
+
if (typeof record.path !== "string" || record.path.length === 0) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must declare a non-empty string path");
|
|
157
|
+
return record.path;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolves the active dev-binary override, if any. Returns undefined only when
|
|
162
|
+
* neither activation path is present; a present-but-invalid override always
|
|
163
|
+
* throws a typed GentleAiDevBinaryOverrideError naming its origin.
|
|
164
|
+
*/
|
|
165
|
+
export function resolveGentleAiDevBinaryOverride(
|
|
166
|
+
environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(),
|
|
167
|
+
platform = process.platform,
|
|
168
|
+
): GentleAiDevBinaryOverride | undefined {
|
|
169
|
+
const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV];
|
|
170
|
+
if (envValue !== undefined && envValue.length > 0) return validateDevBinary("env", GENTLE_AI_DEV_BINARY_ENV, envValue, platform);
|
|
171
|
+
const registrationPath = gentleAiDevBinaryRegistrationPath(environment);
|
|
172
|
+
if (!existsSync(registrationPath)) return undefined;
|
|
173
|
+
return validateDevBinary("registration", registrationPath, readDevBinaryRegistration(registrationPath), platform);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Cheap presence probe: is a dev-binary override declared at all? Used by the
|
|
178
|
+
* native CLI to select the unpinned version gate without hashing the binary.
|
|
179
|
+
* Declared-but-invalid still counts as configured — the resolution path will
|
|
180
|
+
* fail loudly with the typed error instead of quietly using the pin.
|
|
181
|
+
*/
|
|
182
|
+
export function gentleAiDevBinaryOverrideConfigured(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): boolean {
|
|
183
|
+
const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV];
|
|
184
|
+
if (envValue !== undefined && envValue.length > 0) return true;
|
|
185
|
+
return existsSync(gentleAiDevBinaryRegistrationPath(environment));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Validates and persistently registers a dev binary; returns the fresh override. */
|
|
189
|
+
export function registerGentleAiDevBinary(
|
|
190
|
+
path: string,
|
|
191
|
+
environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(),
|
|
192
|
+
platform = process.platform,
|
|
193
|
+
): { registrationPath: string; override: GentleAiDevBinaryOverride } {
|
|
194
|
+
const registrationPath = gentleAiDevBinaryRegistrationPath(environment);
|
|
195
|
+
const validated = validateDevBinary("registration", registrationPath, path, platform);
|
|
196
|
+
mkdirSync(dirname(registrationPath), { recursive: true });
|
|
197
|
+
writeFileSync(registrationPath, `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path })}\n`);
|
|
198
|
+
return { registrationPath, override: validated };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Deletes the persistent registration; returns whether one existed. */
|
|
202
|
+
export function unregisterGentleAiDevBinary(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): boolean {
|
|
203
|
+
const registrationPath = gentleAiDevBinaryRegistrationPath(environment);
|
|
204
|
+
if (!existsSync(registrationPath)) return false;
|
|
205
|
+
rmSync(registrationPath);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
45
209
|
function isConfined(path: string, directory: string): boolean {
|
|
46
210
|
const relativePath = relative(directory, path);
|
|
47
211
|
return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
|
|
@@ -106,7 +270,14 @@ export function resolveGentleAiBinary(
|
|
|
106
270
|
packageRoot = dirname(dirname(fileURLToPath(import.meta.url))),
|
|
107
271
|
platform = process.platform,
|
|
108
272
|
readBinary: (path: string) => Buffer = readFileSync,
|
|
273
|
+
environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(),
|
|
109
274
|
): string {
|
|
275
|
+
// The explicit dev-binary override wins over the pinned supply-chain path.
|
|
276
|
+
// Its typed errors propagate: a declared override never falls back to the
|
|
277
|
+
// pin. Without a declared override this call returns undefined and the
|
|
278
|
+
// pinned resolution below is byte-identical to the pre-override behavior.
|
|
279
|
+
const override = resolveGentleAiDevBinaryOverride(environment, platform);
|
|
280
|
+
if (override !== undefined) return override.path;
|
|
110
281
|
const binaryPath = gentleAiBinaryPath(packageRoot, platform);
|
|
111
282
|
const versionDirectory = dirname(binaryPath);
|
|
112
283
|
const manifestPath = join(versionDirectory, "integrity.json");
|
|
@@ -77,8 +77,8 @@ interface CommitTransactionRecordBody {
|
|
|
77
77
|
command: string;
|
|
78
78
|
command_hash: string;
|
|
79
79
|
arguments: readonly string[];
|
|
80
|
-
original_head
|
|
81
|
-
original_head_tree
|
|
80
|
+
original_head?: string;
|
|
81
|
+
original_head_tree?: string;
|
|
82
82
|
original_index_tree: string;
|
|
83
83
|
original_index_hash: string;
|
|
84
84
|
authorized_pre_hook_tree: string;
|
|
@@ -180,13 +180,68 @@ function absoluteGitPath(cwd: string, name: string): string {
|
|
|
180
180
|
return isAbsolute(value) ? value : resolve(cwd, value);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// Runs a probe that may exit nonzero as an expected signal (absent ref, unborn
|
|
184
|
+
// HEAD). Returns the exit status and trimmed stdout. Timeout and I/O failures
|
|
185
|
+
// propagate instead of being masked as a status, so callers fail closed.
|
|
186
|
+
function probeGit(cwd: string, args: readonly string[]): { status: number; stdout: string } {
|
|
187
|
+
try {
|
|
188
|
+
const stdout = execFileSync("git", args, {
|
|
189
|
+
cwd,
|
|
190
|
+
encoding: "utf8",
|
|
191
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
192
|
+
timeout: GIT_TIMEOUT_MS,
|
|
193
|
+
windowsHide: true,
|
|
194
|
+
});
|
|
195
|
+
return { status: 0, stdout: stdout.trim() };
|
|
196
|
+
} catch (error) {
|
|
197
|
+
const detail = error as NodeJS.ErrnoException & { killed?: boolean; status?: number; stdout?: string | Buffer };
|
|
198
|
+
if (detail.code === "ETIMEDOUT" || detail.killed === true) throw error;
|
|
199
|
+
if (typeof detail.status === "number") return { status: detail.status, stdout: typeof detail.stdout === "string" ? detail.stdout.trim() : "" };
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Resolves HEAD to a commit SHA, or undefined only for a valid unborn symbolic
|
|
205
|
+
// HEAD (symbolic HEAD pointing at a branch with no commits). Timeout, I/O,
|
|
206
|
+
// corruption, and all other failures propagate (fail closed on uncertain HEAD
|
|
207
|
+
// state). Classification uses status-based probes, not localized stderr text.
|
|
208
|
+
function resolveHead(cwd: string): string | undefined {
|
|
209
|
+
try {
|
|
210
|
+
return git(cwd, ["rev-parse", "--verify", "HEAD"]);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const detail = error as NodeJS.ErrnoException & { killed?: boolean };
|
|
213
|
+
if (detail.code === "ETIMEDOUT" || detail.killed === true) throw error;
|
|
214
|
+
if (typeof detail.status !== "number") throw error;
|
|
215
|
+
const symbolic = probeGit(cwd, ["symbolic-ref", "--quiet", "HEAD"]);
|
|
216
|
+
if (symbolic.status !== 0) throw error;
|
|
217
|
+
// show-ref --verify --quiet distinguishes: status 1 = ref absent (valid
|
|
218
|
+
// unborn), status 0 = ref exists and valid (rethrow original HEAD error),
|
|
219
|
+
// any other status (128, etc.) = corruption/missing object (fail closed).
|
|
220
|
+
const refProbe = probeGit(cwd, ["show-ref", "--verify", "--quiet", symbolic.stdout]);
|
|
221
|
+
if (refProbe.status === 1) return undefined;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
183
226
|
function repositoryBinding(cwd: string): RepositoryBinding {
|
|
184
227
|
const root = realpathSync(git(cwd, ["rev-parse", "--show-toplevel"]));
|
|
185
228
|
const commonDirValue = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
186
229
|
const gitDirValue = git(root, ["rev-parse", "--path-format=absolute", "--git-dir"]);
|
|
187
230
|
const commonDir = realpathSync(commonDirValue);
|
|
188
231
|
const gitDir = realpathSync(gitDirValue);
|
|
189
|
-
|
|
232
|
+
// Preserve the durable repository identity across the unborn-handling
|
|
233
|
+
// upgrade: a born repository keeps the byte-for-byte previous formula
|
|
234
|
+
// `sha256(canonicalJson({ common_directory: commonDir, roots }))` with
|
|
235
|
+
// `roots` the sorted root commits reachable from HEAD. An unborn
|
|
236
|
+
// repository has no HEAD, so `rev-list HEAD` cannot run; resolveHead
|
|
237
|
+
// already classifies HEAD state and propagates timeout/corruption/I/O
|
|
238
|
+
// failures rather than masking them, so the unborn branch gets a
|
|
239
|
+
// deterministic safe roots representation (the empty set) without
|
|
240
|
+
// hiding real errors.
|
|
241
|
+
const head = resolveHead(root);
|
|
242
|
+
const roots = head === undefined
|
|
243
|
+
? []
|
|
244
|
+
: git(root, ["rev-list", "--max-parents=0", "HEAD"]).split(/\r?\n/).filter(Boolean).sort();
|
|
190
245
|
const repositoryId = sha256(canonicalJson({ common_directory: commonDir, roots }));
|
|
191
246
|
const worktreeKey = sha256(gitDir).slice("sha256:".length, "sha256:".length + 24);
|
|
192
247
|
const stateDir = join(commonDir, "gentle-pi", "commit-transactions", worktreeKey);
|
|
@@ -241,11 +296,11 @@ function decodeRecord(value: unknown): CommitTransactionRecord {
|
|
|
241
296
|
if (record.schema !== TRANSACTION_SCHEMA) throw new Error("commit transaction record schema is incompatible");
|
|
242
297
|
for (const field of [
|
|
243
298
|
"transaction_id", "repository_id", "repository_root", "common_directory", "git_directory",
|
|
244
|
-
"command", "command_hash", "
|
|
299
|
+
"command", "command_hash", "original_index_tree",
|
|
245
300
|
"original_index_hash", "authorized_pre_hook_tree", "state", "created_at", "updated_at", "record_hash",
|
|
246
301
|
]) if (typeof record[field] !== "string" || (record[field] as string).length === 0) throw new Error(`commit transaction record ${field} is invalid`);
|
|
247
302
|
if (!isStringArray(record.arguments) || !isStringArray(record.invocation_ids) || !isStringArray(record.lineage_history)) throw new Error("commit transaction record arrays are invalid");
|
|
248
|
-
for (const field of ["post_hook_tree", "post_hook_index_hash", "authorized_tree", "authority_revision", "gate_context_hash", "committed_head", "committed_tree", "git_created_head", "git_created_tree", "error"] as const) {
|
|
303
|
+
for (const field of ["original_head", "original_head_tree", "post_hook_tree", "post_hook_index_hash", "authorized_tree", "authority_revision", "gate_context_hash", "committed_head", "committed_tree", "git_created_head", "git_created_tree", "error"] as const) {
|
|
249
304
|
if (record[field] !== undefined && typeof record[field] !== "string") throw new Error(`commit transaction record ${field} is invalid`);
|
|
250
305
|
}
|
|
251
306
|
if (!Number.isSafeInteger(record.hook_runs) || (record.hook_runs as number) < 0) throw new Error("commit transaction hook count is invalid");
|
|
@@ -437,7 +492,7 @@ export function prepareCommitTransactionInvocation(input: {
|
|
|
437
492
|
}): CommitTransactionInvocation {
|
|
438
493
|
assertSafeCommitArguments(input.arguments);
|
|
439
494
|
const binding = repositoryBinding(input.cwd);
|
|
440
|
-
const head =
|
|
495
|
+
const head = resolveHead(binding.root);
|
|
441
496
|
const currentTree = git(binding.root, ["write-tree"]);
|
|
442
497
|
if (currentTree !== input.authorization.intendedTree) throw new Error("commit transaction pre-hook index no longer matches its controller authorization");
|
|
443
498
|
let transactionId = randomUUID();
|
|
@@ -540,7 +595,7 @@ function validateNativeTree(result: NativeValidateResult, lineageId: string, tre
|
|
|
540
595
|
}
|
|
541
596
|
|
|
542
597
|
function createRecord(binding: RepositoryBinding, invocation: CommitTransactionInvocation, now: () => Date): CommitTransactionRecord {
|
|
543
|
-
const head =
|
|
598
|
+
const head = resolveHead(binding.root);
|
|
544
599
|
const tree = git(binding.root, ["write-tree"]);
|
|
545
600
|
if (tree !== invocation.authorization.intendedTree) throw new Error("commit transaction index changed after controller authorization");
|
|
546
601
|
const timestamp = now().toISOString();
|
|
@@ -555,7 +610,7 @@ function createRecord(binding: RepositoryBinding, invocation: CommitTransactionI
|
|
|
555
610
|
command_hash: invocation.commandHash,
|
|
556
611
|
arguments: [...invocation.arguments],
|
|
557
612
|
original_head: head,
|
|
558
|
-
original_head_tree: git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]),
|
|
613
|
+
original_head_tree: head === undefined ? undefined : git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]),
|
|
559
614
|
original_index_tree: tree,
|
|
560
615
|
original_index_hash: indexFingerprint(binding.root),
|
|
561
616
|
authorized_pre_hook_tree: invocation.authorization.intendedTree,
|
|
@@ -571,13 +626,17 @@ function createRecord(binding: RepositoryBinding, invocation: CommitTransactionI
|
|
|
571
626
|
function assertInvocationMatches(binding: RepositoryBinding, record: CommitTransactionRecord, invocation: CommitTransactionInvocation): void {
|
|
572
627
|
if (record.repository_id !== binding.repositoryId || record.repository_root !== binding.root || record.common_directory !== binding.commonDir || record.git_directory !== binding.gitDir) throw new Error("commit transaction repository identity changed");
|
|
573
628
|
if (record.transaction_id !== invocation.transactionId || record.command_hash !== invocation.commandHash || record.command !== invocation.command || canonicalJson(record.arguments) !== canonicalJson(invocation.arguments)) throw new Error("commit transaction exact retry does not match the durable command intent");
|
|
574
|
-
if (
|
|
629
|
+
if (resolveHead(binding.root) !== record.original_head) throw new Error("commit transaction HEAD changed before reconciliation");
|
|
575
630
|
}
|
|
576
631
|
|
|
577
632
|
function recoverCompletedCommit(binding: RepositoryBinding, record: CommitTransactionRecord, now: () => Date): CommitTransactionResult | undefined {
|
|
578
633
|
if (record.state !== COMMIT_TRANSACTION_STATE.COMMIT_RUNNING && record.state !== COMMIT_TRANSACTION_STATE.COMMITTED) return undefined;
|
|
579
|
-
const head =
|
|
634
|
+
const head = resolveHead(binding.root);
|
|
580
635
|
if (head === record.original_head) return undefined;
|
|
636
|
+
if (head === undefined) {
|
|
637
|
+
transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { error: "HEAD disappeared during commit transaction" }, now);
|
|
638
|
+
throw new Error("commit transaction incident: HEAD disappeared during commit; publication remains blocked");
|
|
639
|
+
}
|
|
581
640
|
const tree = git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
|
|
582
641
|
if (record.git_created_head === undefined || record.git_created_tree === undefined || head !== record.git_created_head || tree !== record.git_created_tree || tree !== record.authorized_tree) {
|
|
583
642
|
transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { committed_head: head, committed_tree: tree, error: "HEAD identity differs from the exact Git-created authorized commit" }, now);
|
|
@@ -684,14 +743,15 @@ export async function runGitCommitTransaction(
|
|
|
684
743
|
const commit = await runProcess("git", ["-c", `core.hooksPath=${proxy}`, "commit", ...invocation.arguments], binding.root);
|
|
685
744
|
if (dependencies.failpoint === "after-commit-before-proof") throw new Error("commit transaction test interruption after Git returned");
|
|
686
745
|
record = readRecord(binding.activePath) ?? record;
|
|
687
|
-
const head =
|
|
688
|
-
const headTree = git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
|
|
689
|
-
|
|
746
|
+
const head = resolveHead(binding.root);
|
|
747
|
+
const headTree = head === undefined ? undefined : git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
|
|
748
|
+
const headChanged = head !== record.original_head;
|
|
749
|
+
if (headChanged && head === record.git_created_head && headTree === record.git_created_tree && headTree === record.authorized_tree) {
|
|
690
750
|
const committed = transition(binding, record, COMMIT_TRANSACTION_STATE.COMMITTED, { committed_head: head, committed_tree: headTree, ...(commit.code === 0 && commit.signal === null ? {} : { error: `Git returned ${commit.signal ?? `exit ${commit.code}`} after creating the authorized commit` }) }, now);
|
|
691
751
|
archive(binding, committed);
|
|
692
|
-
return { transactionId: record.transaction_id, status: "committed", head
|
|
752
|
+
return { transactionId: record.transaction_id, status: "committed", head: head!, tree: headTree! };
|
|
693
753
|
}
|
|
694
|
-
if (
|
|
754
|
+
if (headChanged) {
|
|
695
755
|
transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { committed_head: head, committed_tree: headTree, error: "HEAD identity differs from the exact Git-created authorized commit" }, now);
|
|
696
756
|
throw new Error("commit transaction incident: HEAD identity changed after Git created the authorized commit; publication remains blocked");
|
|
697
757
|
}
|
|
@@ -701,7 +761,7 @@ export async function runGitCommitTransaction(
|
|
|
701
761
|
if (record !== undefined && dependencies.signal?.aborted === true && existsSync(binding.activePath)) {
|
|
702
762
|
try {
|
|
703
763
|
const active = readRecord(binding.activePath) ?? record;
|
|
704
|
-
if (
|
|
764
|
+
if (resolveHead(binding.root) === active.original_head) transition(binding, active, COMMIT_TRANSACTION_STATE.INTERRUPTED, { error: "commit transaction was cancelled" }, now);
|
|
705
765
|
} catch { /* retain the earlier durable state */ }
|
|
706
766
|
}
|
|
707
767
|
throw error;
|
|
@@ -782,7 +842,7 @@ export function abandonCommitTransaction(cwd: string): CommitTransactionRecord {
|
|
|
782
842
|
try {
|
|
783
843
|
const record = readRecord(binding.activePath);
|
|
784
844
|
if (record === undefined) throw new Error("active commit transaction disappeared during recovery");
|
|
785
|
-
if (
|
|
845
|
+
if (resolveHead(binding.root) !== record.original_head) throw new Error("cannot abandon a commit transaction after HEAD changed; reconcile the committed tree instead");
|
|
786
846
|
const abandoned = transition(binding, record, COMMIT_TRANSACTION_STATE.ABANDONED, { error: "explicitly abandoned without changing HEAD or index" }, now);
|
|
787
847
|
return archive(binding, abandoned);
|
|
788
848
|
} finally { releaseLock(); }
|