gentle-pi 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -12
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/orchestrator-delegation.md +19 -9
- package/assets/orchestrator.md +5 -5
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +898 -79
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +88 -12
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-integration-v2.ts +58 -8
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +87 -11
- package/runtime/review-integration-v2.mjs +58 -8
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +6 -2
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +28 -12
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +17 -6
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +3 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +25 -0
- package/tests/review-host-relay-routing.test.ts +20 -4
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MouseRegion,
|
|
3
|
+
type Component,
|
|
4
|
+
type TuiMouseEvent,
|
|
5
|
+
type TuiMouseEventResult,
|
|
6
|
+
} from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
export type NativePointerCallback = (event: TuiMouseEvent) => TuiMouseEventResult | undefined;
|
|
9
|
+
|
|
10
|
+
export interface NativePointerRegionCallbacks {
|
|
11
|
+
onHover?: NativePointerCallback;
|
|
12
|
+
onLeave?: (event?: TuiMouseEvent) => void;
|
|
13
|
+
onPress?: NativePointerCallback;
|
|
14
|
+
onClick?: NativePointerCallback;
|
|
15
|
+
onWheel?: NativePointerCallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NativePointerMouseObserver {
|
|
19
|
+
beforeMouse(event: TuiMouseEvent): void;
|
|
20
|
+
afterMouse(event: TuiMouseEvent): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A composable adapter around the public MouseRegion component.
|
|
25
|
+
* Child dispatch stays first, so an already handled child result is unchanged.
|
|
26
|
+
*/
|
|
27
|
+
export class NativePointerRegion implements Component {
|
|
28
|
+
private readonly adapter: MouseRegion;
|
|
29
|
+
private readonly callbacks: NativePointerRegionCallbacks;
|
|
30
|
+
private readonly scope: NativePointerScope | undefined;
|
|
31
|
+
private hovered = false;
|
|
32
|
+
private disabled = false;
|
|
33
|
+
private disposed = false;
|
|
34
|
+
private renderedWidth: number | undefined;
|
|
35
|
+
|
|
36
|
+
constructor(child: Component, callbacks: NativePointerRegionCallbacks, scope?: NativePointerScope) {
|
|
37
|
+
this.callbacks = callbacks;
|
|
38
|
+
this.scope = scope;
|
|
39
|
+
this.adapter = new MouseRegion(child, (event) => this.handlePointer(event));
|
|
40
|
+
this.scope?.register(this);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render(width: number): string[] {
|
|
44
|
+
if (this.renderedWidth !== undefined && this.renderedWidth !== width) this.clearHover();
|
|
45
|
+
this.renderedWidth = width;
|
|
46
|
+
return this.adapter.render(width);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
50
|
+
if (this.disposed) return undefined;
|
|
51
|
+
return this.adapter.handleMouse(event);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
invalidate(): void {
|
|
55
|
+
this.renderedWidth = undefined;
|
|
56
|
+
this.clearHover();
|
|
57
|
+
this.adapter.invalidate();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setDisabled(disabled: boolean): void {
|
|
61
|
+
if (this.disabled === disabled) return;
|
|
62
|
+
this.disabled = disabled;
|
|
63
|
+
if (disabled) this.clearHover();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
clearHover(event?: TuiMouseEvent): boolean {
|
|
67
|
+
if (!this.hovered) return false;
|
|
68
|
+
this.hovered = false;
|
|
69
|
+
this.callbacks.onLeave?.(event);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
dispose(): void {
|
|
74
|
+
if (this.disposed) return;
|
|
75
|
+
this.disposed = true;
|
|
76
|
+
this.clearHover();
|
|
77
|
+
this.scope?.unregister(this);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private handlePointer(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
81
|
+
if (this.disabled || this.disposed) return undefined;
|
|
82
|
+
if (event.type === "move" && event.button === "none") {
|
|
83
|
+
this.scope?.markMove(this, event);
|
|
84
|
+
this.hovered = true;
|
|
85
|
+
return this.callbacks.onHover?.(event);
|
|
86
|
+
}
|
|
87
|
+
if (event.type === "press") return this.callbacks.onPress?.(event);
|
|
88
|
+
if (event.type === "click") return this.callbacks.onClick?.(event);
|
|
89
|
+
if (event.type === "wheel") return this.callbacks.onWheel?.(event);
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Coordinates pointer regions with a root-compatible native mouse observer. */
|
|
95
|
+
export class NativePointerScope {
|
|
96
|
+
private readonly regions = new Set<NativePointerRegion>();
|
|
97
|
+
private moved = false;
|
|
98
|
+
private disabled = false;
|
|
99
|
+
private disposed = false;
|
|
100
|
+
|
|
101
|
+
wrap(child: Component, callbacks: NativePointerRegionCallbacks): NativePointerRegion {
|
|
102
|
+
const region = new NativePointerRegion(child, callbacks, this);
|
|
103
|
+
region.setDisabled(this.disabled);
|
|
104
|
+
if (this.disposed) region.dispose();
|
|
105
|
+
return region;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
createMouseObserver(requestRender?: () => void): NativePointerMouseObserver {
|
|
109
|
+
return {
|
|
110
|
+
beforeMouse: (event) => {
|
|
111
|
+
if (!this.disposed && event.type === "move" && event.button === "none") this.moved = false;
|
|
112
|
+
},
|
|
113
|
+
afterMouse: (event) => {
|
|
114
|
+
if (!this.isInRoot(event) || this.moved) return;
|
|
115
|
+
if (this.clearHover(event)) requestRender?.();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setDisabled(disabled: boolean): void {
|
|
121
|
+
this.disabled = disabled;
|
|
122
|
+
for (const region of this.regions) region.setDisabled(disabled);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
invalidate(): void {
|
|
126
|
+
this.clearHover();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
dispose(): void {
|
|
130
|
+
if (this.disposed) return;
|
|
131
|
+
this.disposed = true;
|
|
132
|
+
for (const region of [...this.regions]) region.dispose();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
register(region: NativePointerRegion): void {
|
|
136
|
+
if (!this.disposed) this.regions.add(region);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
unregister(region: NativePointerRegion): void {
|
|
140
|
+
this.regions.delete(region);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
markMove(region: NativePointerRegion, event: TuiMouseEvent): void {
|
|
144
|
+
this.moved = true;
|
|
145
|
+
for (const candidate of this.regions) {
|
|
146
|
+
if (candidate !== region) candidate.clearHover(event);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private clearHover(event?: TuiMouseEvent): boolean {
|
|
151
|
+
let changed = false;
|
|
152
|
+
for (const region of this.regions) changed = region.clearHover(event) || changed;
|
|
153
|
+
return changed;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private isInRoot(event: TuiMouseEvent): boolean {
|
|
157
|
+
return !this.disposed && event.type === "move" && event.button === "none" &&
|
|
158
|
+
event.x >= 0 && event.x < event.width && event.y >= 0 && event.y < event.height;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createNativePointerScope(): NativePointerScope {
|
|
163
|
+
return new NativePointerScope();
|
|
164
|
+
}
|
package/lib/native-review-cli.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, isAbsolute, join, posix, win32 } from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { PackageLocalGentleAiBinaryMissingError, resolveGentleAiBinary } from "./gentle-ai-binary.ts";
|
|
7
7
|
import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
|
|
8
|
+
import { decodeReviewAssessmentV1, type ReviewAssessmentV1 } from "./review-risk-assessment.ts";
|
|
8
9
|
import {
|
|
9
10
|
REVIEW_INTEGRATION_CONTRACT,
|
|
10
11
|
decodeReviewAcknowledgedV1,
|
|
@@ -59,6 +60,7 @@ export const NATIVE_REVIEW_OPERATION = {
|
|
|
59
60
|
RECONCILE_AUTHORITY: "review/reconcile-authority",
|
|
60
61
|
REPAIR_LEGACY_ALIAS: "review/repair-legacy-alias",
|
|
61
62
|
MODE: "review/mode",
|
|
63
|
+
ASSESS: "review/assess",
|
|
62
64
|
REPAIR: "review/repair",
|
|
63
65
|
CAPTURE_RESULT: "review/capture-result",
|
|
64
66
|
CAPTURE_CORRECTION_PLAN: "review/capture-correction-plan",
|
|
@@ -109,6 +111,11 @@ export interface NativeReviewCli {
|
|
|
109
111
|
// outside the negotiated review-integration protocol — same shape as
|
|
110
112
|
// reviewStatus/reclaim above.
|
|
111
113
|
reviewMode?(request: NativeReviewModeRequest): Promise<NativeReviewModeResult>;
|
|
114
|
+
// Read-only risk assessment (gentle-ai#4295, landing in parallel with
|
|
115
|
+
// gentle-pi#662). Same plain-versioned shape as reviewMode/reviewStatus:
|
|
116
|
+
// an older binary without the verb, or any other process/decode failure,
|
|
117
|
+
// rejects the returned promise -- callers fail closed to `high` risk.
|
|
118
|
+
assess?(request: NativeReviewAssessRequest): Promise<ReviewAssessmentV1>;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
export const NATIVE_REVIEW_MODE_OPERATION = {
|
|
@@ -151,6 +158,17 @@ export interface NativeReviewModeRequest {
|
|
|
151
158
|
signal?: AbortSignal;
|
|
152
159
|
}
|
|
153
160
|
|
|
161
|
+
// Read-only risk assessment request (gentle-pi#662). `baseRef` requires
|
|
162
|
+
// explicit `committedOnly` acknowledgement, exactly like Native START's
|
|
163
|
+
// baseRef/committedOnly pairing, because both select a committed range
|
|
164
|
+
// instead of the ambient working tree.
|
|
165
|
+
export interface NativeReviewAssessRequest {
|
|
166
|
+
cwd: string;
|
|
167
|
+
baseRef?: string;
|
|
168
|
+
committedOnly?: boolean;
|
|
169
|
+
signal?: AbortSignal;
|
|
170
|
+
}
|
|
171
|
+
|
|
154
172
|
export interface NativeReviewModeStatus {
|
|
155
173
|
global: NativeReviewModeValue;
|
|
156
174
|
cloneLocal: NativeReviewModeValue;
|
|
@@ -261,7 +279,6 @@ export interface NativeReviewAbandonRequest {
|
|
|
261
279
|
snapshotIdentity: string;
|
|
262
280
|
capturedLensResults: readonly string[];
|
|
263
281
|
findingsPresent: boolean;
|
|
264
|
-
evidenceRecordsPresent: boolean;
|
|
265
282
|
actor: string;
|
|
266
283
|
reason: string;
|
|
267
284
|
maintainerAuthorization: string;
|
|
@@ -498,8 +515,13 @@ export const NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION = {
|
|
|
498
515
|
} as const;
|
|
499
516
|
export type NativeReviewAuthorityEntryVersion = (typeof NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION)[keyof typeof NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION];
|
|
500
517
|
|
|
501
|
-
export const NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS =
|
|
502
|
-
|
|
518
|
+
export const NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS = {
|
|
519
|
+
...NATIVE_REVIEW_AUTHORITY_STATUS,
|
|
520
|
+
INCOMPLETE_STORE_ENTRY: "incomplete-store-entry",
|
|
521
|
+
HISTORICAL_PRE_RECEIPT: "historical-pre-receipt",
|
|
522
|
+
INVALIDATED: "invalidated",
|
|
523
|
+
} as const;
|
|
524
|
+
export type NativeReviewAuthorityEntryStatus = (typeof NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS)[keyof typeof NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS];
|
|
503
525
|
|
|
504
526
|
export const NATIVE_REVIEW_LOCK_STATUS = {
|
|
505
527
|
OWNED: "owned",
|
|
@@ -541,6 +563,10 @@ export interface NativeReviewRecovery {
|
|
|
541
563
|
recoveredAt: string;
|
|
542
564
|
maintainerAuthorization?: string;
|
|
543
565
|
}
|
|
566
|
+
export interface NativeReviewDiscardedWorkSummary {
|
|
567
|
+
capturedLensResults: readonly string[];
|
|
568
|
+
findingsPresent: boolean;
|
|
569
|
+
}
|
|
544
570
|
export interface NativeReviewAuthorityEntry {
|
|
545
571
|
version: NativeReviewAuthorityEntryVersion;
|
|
546
572
|
lineageId?: string;
|
|
@@ -551,6 +577,7 @@ export interface NativeReviewAuthorityEntry {
|
|
|
551
577
|
snapshotIdentity?: string;
|
|
552
578
|
chainIdentity?: string;
|
|
553
579
|
recovery?: NativeReviewRecovery;
|
|
580
|
+
discardedWork?: NativeReviewDiscardedWorkSummary;
|
|
554
581
|
problems: readonly string[];
|
|
555
582
|
}
|
|
556
583
|
export interface NativeReviewAuthorityLock {
|
|
@@ -804,6 +831,18 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
|
|
|
804
831
|
// consumes did not change between 2.5.0 and 2.6.0, so the columns match
|
|
805
832
|
// the 2.5.0 row.
|
|
806
833
|
"2.6.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
834
|
+
// Ground-truthed against the published v2.7.0 binary installed from its
|
|
835
|
+
// signed archive: `review capabilities` on the v2 lane still advertises
|
|
836
|
+
// capabilities/v2.5 (protocol minor 5) and answers status/v7, consent/v3,
|
|
837
|
+
// and the `start/v4` continuation, all of which the decoders already read.
|
|
838
|
+
// `review mode status --json` still answers `gentle-ai.rdd-mode-status/v1`
|
|
839
|
+
// and `review validate --gate pre-commit` still answers
|
|
840
|
+
// `gentle-ai.review-gate-result/v1` carrying `delivery`, both verified
|
|
841
|
+
// against the binary. riskEvidence and hint stay dark for the same reason
|
|
842
|
+
// they have on every row since 2.2.0: still not proven to reach the
|
|
843
|
+
// negotiated START path Pi reads. The closed fields Pi consumes did not
|
|
844
|
+
// change between 2.6.0 and 2.7.0, so the columns match the 2.6.0 row.
|
|
845
|
+
"2.7.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
|
|
807
846
|
});
|
|
808
847
|
|
|
809
848
|
export interface NativeReviewProcessDiagnostics {
|
|
@@ -1046,8 +1085,15 @@ function decodeNativeReviewRecovery(value: unknown): NativeReviewRecovery {
|
|
|
1046
1085
|
...(recovery.maintainer_authorization === undefined ? {} : { maintainerAuthorization: requiredString(recovery.maintainer_authorization) }),
|
|
1047
1086
|
};
|
|
1048
1087
|
}
|
|
1088
|
+
function decodeNativeReviewDiscardedWorkSummary(value: unknown): NativeReviewDiscardedWorkSummary {
|
|
1089
|
+
const discardedWork = exactObject(value, ["captured_lens_results", "findings_present"]);
|
|
1090
|
+
return {
|
|
1091
|
+
capturedLensResults: stringArray(discardedWork.captured_lens_results),
|
|
1092
|
+
findingsPresent: booleanValue(discardedWork.findings_present),
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1049
1095
|
function decodeNativeReviewStatusEntry(value: unknown): NativeReviewAuthorityEntry {
|
|
1050
|
-
const entry = exactObject(value, ["version", "path", "status", "problems"], ["lineage_id", "state", "revision", "snapshot_identity", "chain_identity", "recovery"]);
|
|
1096
|
+
const entry = exactObject(value, ["version", "path", "status", "problems"], ["lineage_id", "state", "revision", "snapshot_identity", "chain_identity", "recovery", "discarded_work"]);
|
|
1051
1097
|
return {
|
|
1052
1098
|
version: enumString(entry.version, Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION)) as NativeReviewAuthorityEntryVersion,
|
|
1053
1099
|
...(entry.lineage_id === undefined ? {} : { lineageId: requiredString(entry.lineage_id) }),
|
|
@@ -1058,6 +1104,7 @@ function decodeNativeReviewStatusEntry(value: unknown): NativeReviewAuthorityEnt
|
|
|
1058
1104
|
...(entry.snapshot_identity === undefined ? {} : { snapshotIdentity: sha256Identity(entry.snapshot_identity) }),
|
|
1059
1105
|
...(entry.chain_identity === undefined ? {} : { chainIdentity: requiredString(entry.chain_identity) }),
|
|
1060
1106
|
...(entry.recovery === undefined ? {} : { recovery: decodeNativeReviewRecovery(entry.recovery) }),
|
|
1107
|
+
...(entry.discarded_work === undefined ? {} : { discardedWork: decodeNativeReviewDiscardedWorkSummary(entry.discarded_work) }),
|
|
1061
1108
|
problems: stringArray(entry.problems),
|
|
1062
1109
|
};
|
|
1063
1110
|
}
|
|
@@ -1372,9 +1419,7 @@ class NativeReviewPlainCli {
|
|
|
1372
1419
|
if (!isCanonicalProcessString(value)) throw new TypeError(`Native ABANDON ${name} must be a non-empty, trimmed, NUL-free string`);
|
|
1373
1420
|
}
|
|
1374
1421
|
if (!Array.isArray(request.capturedLensResults) || request.capturedLensResults.some((entry) => !isCanonicalProcessString(entry))) throw new TypeError("Native ABANDON capturedLensResults must be an array of non-empty, trimmed, NUL-free strings");
|
|
1375
|
-
|
|
1376
|
-
if (typeof value !== "boolean") throw new TypeError(`Native ABANDON ${name} must be a boolean`);
|
|
1377
|
-
}
|
|
1422
|
+
if (typeof request.findingsPresent !== "boolean") throw new TypeError("Native ABANDON findingsPresent must be a boolean");
|
|
1378
1423
|
if (request.maintainerAuthorization !== nativeReviewAbandonAuthorization(request)) throw new TypeError("Native ABANDON maintainerAuthorization must match the exact lineage, revision, snapshot, reason, discarded-work, and actor binding");
|
|
1379
1424
|
const execution = await this.execute(NATIVE_REVIEW_OPERATION.ABANDON, request.cwd, [
|
|
1380
1425
|
"review", "abandon", "--cwd", request.cwd,
|
|
@@ -1463,7 +1508,11 @@ class NativeReviewPlainCli {
|
|
|
1463
1508
|
}
|
|
1464
1509
|
}
|
|
1465
1510
|
|
|
1466
|
-
export function nativeReviewAbandonAuthorization(request: Pick<NativeReviewAbandonRequest, "lineage" | "expectedRevision" | "snapshotIdentity" | "capturedLensResults" | "findingsPresent" | "
|
|
1511
|
+
export function nativeReviewAbandonAuthorization(request: Pick<NativeReviewAbandonRequest, "lineage" | "expectedRevision" | "snapshotIdentity" | "capturedLensResults" | "findingsPresent" | "actor" | "reason">): string {
|
|
1512
|
+
// gentle-ai 0ed9225f removed evidence records from the discarded-work summary,
|
|
1513
|
+
// so the native v2 gate verifies an exact eight-line binding (schema, lineage,
|
|
1514
|
+
// revision, snapshot_identity, reason, captured_lens_results, findings_present,
|
|
1515
|
+
// actor) — there is no evidence_records_present line to derive or relay.
|
|
1467
1516
|
return [
|
|
1468
1517
|
"gentle-ai.review-abandon-authorization/v2",
|
|
1469
1518
|
`lineage=${request.lineage}`,
|
|
@@ -1472,7 +1521,6 @@ export function nativeReviewAbandonAuthorization(request: Pick<NativeReviewAband
|
|
|
1472
1521
|
`reason=${request.reason}`,
|
|
1473
1522
|
`captured_lens_results=${request.capturedLensResults.join(",")}`,
|
|
1474
1523
|
`findings_present=${request.findingsPresent}`,
|
|
1475
|
-
`evidence_records_present=${request.evidenceRecordsPresent}`,
|
|
1476
1524
|
`actor=${request.actor.trim()}`,
|
|
1477
1525
|
].join("\n");
|
|
1478
1526
|
}
|
|
@@ -1595,14 +1643,20 @@ function splitNativeConsentInvocation(invocation: string): readonly string[] {
|
|
|
1595
1643
|
let quote: "'" | '"' | undefined;
|
|
1596
1644
|
let escaping = false;
|
|
1597
1645
|
let started = false;
|
|
1598
|
-
|
|
1646
|
+
const source = invocation.trim();
|
|
1647
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
1648
|
+
const character = source[index]!;
|
|
1599
1649
|
if (escaping) {
|
|
1600
1650
|
current += character;
|
|
1601
1651
|
escaping = false;
|
|
1602
1652
|
started = true;
|
|
1603
1653
|
continue;
|
|
1604
1654
|
}
|
|
1605
|
-
|
|
1655
|
+
// Provider invocations are not shell commands. Keep every path backslash
|
|
1656
|
+
// verbatim, including quoted UNC and drive-root paths. Outside quotes,
|
|
1657
|
+
// only a backslash before whitespace joins a space-containing token.
|
|
1658
|
+
const next = source[index + 1];
|
|
1659
|
+
if (character === "\\" && quote === undefined && next !== undefined && /\s/.test(next)) {
|
|
1606
1660
|
escaping = true;
|
|
1607
1661
|
started = true;
|
|
1608
1662
|
continue;
|
|
@@ -1693,7 +1747,9 @@ function consentInvocationArguments(request: NativeReviewConsentAnswerRequest):
|
|
|
1693
1747
|
if (words[0] !== "gentle-ai" || words[1] !== "review" || words[2] !== "start") throw new NativeReviewConsentBindingError("consent-invocation-not-start", "Native consent invocation is not a provider review START");
|
|
1694
1748
|
const arguments_ = words.slice(1);
|
|
1695
1749
|
if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new NativeReviewConsentBindingError("consent-invocation-contract-changed", "Native consent invocation contract changed");
|
|
1696
|
-
|
|
1750
|
+
const providerCwd = exactConsentOption(arguments_, "--cwd");
|
|
1751
|
+
const requestedCwd = normalizeNativeReviewCwd(request.cwd);
|
|
1752
|
+
if (normalizeNativeReviewCwd(providerCwd) !== requestedCwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
|
|
1697
1753
|
if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new NativeReviewConsentBindingError("consent-invocation-target-changed", "Native consent invocation target binding changed");
|
|
1698
1754
|
if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new NativeReviewConsentBindingError("consent-invocation-projection-changed", "Native consent invocation projection binding changed");
|
|
1699
1755
|
const lineageId = optionalConsentLineageOption(arguments_);
|
|
@@ -2193,6 +2249,26 @@ export class NativeReviewCliV216 implements NativeReviewCli {
|
|
|
2193
2249
|
return decode(NATIVE_REVIEW_OPERATION.MODE, mutating, () => decodeNativeReviewMode(execution.body, request.operation));
|
|
2194
2250
|
}
|
|
2195
2251
|
|
|
2252
|
+
// Read-only risk assessment (gentle-ai#4295, gentle-pi#662). Never mutates;
|
|
2253
|
+
// an older binary without the verb, or any other process/decode failure,
|
|
2254
|
+
// rejects -- callers (the `gentle_review` tool's `assess` operation) fail
|
|
2255
|
+
// closed to `high`.
|
|
2256
|
+
async assess(request: NativeReviewAssessRequest): Promise<ReviewAssessmentV1> {
|
|
2257
|
+
if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native ASSESS baseRef must be a non-empty, trimmed, NUL-free string");
|
|
2258
|
+
if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native ASSESS baseRef requires explicit committedOnly acknowledgement");
|
|
2259
|
+
if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native ASSESS committedOnly requires an explicit baseRef");
|
|
2260
|
+
const cwd = await canonicalNativeReviewCwd(request.cwd);
|
|
2261
|
+
const execution = await this.invoke(
|
|
2262
|
+
NATIVE_REVIEW_OPERATION.ASSESS,
|
|
2263
|
+
cwd,
|
|
2264
|
+
["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), "--json"],
|
|
2265
|
+
false,
|
|
2266
|
+
request.signal,
|
|
2267
|
+
this.executablePath(NATIVE_REVIEW_OPERATION.ASSESS, false),
|
|
2268
|
+
);
|
|
2269
|
+
return decode(NATIVE_REVIEW_OPERATION.ASSESS, false, () => decodeReviewAssessmentV1(execution.body));
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2196
2272
|
// Recovery commands are version-gated plain CLI operations outside the
|
|
2197
2273
|
// negotiated integration-v1 contract, exactly like reviewStatus.
|
|
2198
2274
|
reclaim(request: NativeReviewReclaimRequest): Promise<NativeReviewRecoveryResult> {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, fsyncSync, lstatSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { conservativeOwnerDeathProofV1 } from "./review-lock.ts";
|
|
6
|
+
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
8
|
+
const BOOT_UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
9
|
+
export interface CandidateViewOwner {
|
|
10
|
+
version: 1;
|
|
11
|
+
uuid: string;
|
|
12
|
+
token: string;
|
|
13
|
+
pid: number;
|
|
14
|
+
host: string | null;
|
|
15
|
+
root: string;
|
|
16
|
+
commonDir: string;
|
|
17
|
+
}
|
|
18
|
+
type Git = (args: readonly string[]) => string;
|
|
19
|
+
|
|
20
|
+
// A hostname or a repository-local nonce cannot prove that a PID is local.
|
|
21
|
+
// Bind to the kernel boot and, on Linux, its PID namespace. Unsupported or
|
|
22
|
+
// unavailable provenance disables reclamation (including across reboots).
|
|
23
|
+
function localHost(): string | null {
|
|
24
|
+
try {
|
|
25
|
+
if (process.platform === "darwin") {
|
|
26
|
+
const boot = execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf8", timeout: 1000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
27
|
+
if (BOOT_UUID.test(boot)) return `darwin:${boot.toLowerCase()}`;
|
|
28
|
+
}
|
|
29
|
+
if (process.platform === "linux") {
|
|
30
|
+
const boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
31
|
+
const namespace = readlinkSync("/proc/self/ns/pid");
|
|
32
|
+
if (BOOT_UUID.test(boot) && /^pid:\[\d+\]$/.test(namespace)) return `linux:${boot}:${namespace}`;
|
|
33
|
+
}
|
|
34
|
+
} catch { /* No remote/PID-only fallback. */ }
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function directory(path: string, privateMode = false): string {
|
|
39
|
+
const stat = lstatSync(path);
|
|
40
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync(path) !== path ||
|
|
41
|
+
(privateMode && (stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0))) throw new Error("Unsafe candidate owner directory");
|
|
42
|
+
return `${stat.dev}:${stat.ino}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function assertCandidateOwnerParent(commonDir: string): string {
|
|
46
|
+
directory(commonDir);
|
|
47
|
+
directory(join(commonDir, "gentle-ai"));
|
|
48
|
+
const parent = join(commonDir, "gentle-ai", "candidate-views");
|
|
49
|
+
directory(parent, true);
|
|
50
|
+
return parent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function regular(path: string, privateMode = false): string {
|
|
54
|
+
const stat = lstatSync(path);
|
|
55
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || realpathSync(path) !== path || stat.size > 16384 ||
|
|
56
|
+
(privateMode && (stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600))) throw new Error("Unsafe candidate owner file");
|
|
57
|
+
return `${stat.dev}:${stat.ino}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function syncDirectory(path: string): void {
|
|
61
|
+
const fd = openSync(path, "r");
|
|
62
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function exclusiveFile(path: string, content: string): void {
|
|
66
|
+
const fd = openSync(path, "wx", 0o600);
|
|
67
|
+
try { writeFileSync(fd, content); fsyncSync(fd); } finally { closeSync(fd); }
|
|
68
|
+
syncDirectory(dirname(path));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function markerPath(root: string): string { return `${root}.owner.json`; }
|
|
72
|
+
|
|
73
|
+
export function createCandidateOwner(commonDir: string, root: string): CandidateViewOwner {
|
|
74
|
+
const parent = assertCandidateOwnerParent(commonDir);
|
|
75
|
+
const uuid = basename(root);
|
|
76
|
+
if (!UUID.test(uuid) || root !== join(parent, uuid) || lstatSync(root, { throwIfNoEntry: false })) throw new Error("Unsafe candidate owner root");
|
|
77
|
+
const owner: CandidateViewOwner = { version: 1, uuid, token: randomUUID(), pid: process.pid, host: localHost(), root, commonDir };
|
|
78
|
+
// Any write/fsync failure aborts creation BEFORE Git can register the view.
|
|
79
|
+
// An incomplete marker is retained, never guessed into ownership.
|
|
80
|
+
exclusiveFile(markerPath(root), JSON.stringify(owner));
|
|
81
|
+
syncDirectory(dirname(parent));
|
|
82
|
+
syncDirectory(commonDir);
|
|
83
|
+
return Object.freeze(owner);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readOwner(commonDir: string, root: string): CandidateViewOwner {
|
|
87
|
+
const parent = assertCandidateOwnerParent(commonDir);
|
|
88
|
+
if (!UUID.test(basename(root)) || root !== join(parent, basename(root))) throw new Error("Candidate owner escaped parent");
|
|
89
|
+
regular(markerPath(root), true);
|
|
90
|
+
const owner = JSON.parse(readFileSync(markerPath(root), "utf8")) as CandidateViewOwner;
|
|
91
|
+
if (!owner || Object.keys(owner).sort().join(",") !== "commonDir,host,pid,root,token,uuid,version" || owner.version !== 1 ||
|
|
92
|
+
owner.uuid !== basename(root) || !UUID.test(owner.token) || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 ||
|
|
93
|
+
owner.root !== root || owner.commonDir !== commonDir || !(owner.host === null || typeof owner.host === "string")) throw new Error("Malformed candidate owner");
|
|
94
|
+
return owner;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function dead(owner: CandidateViewOwner): boolean {
|
|
98
|
+
return owner.host !== null && owner.host === localHost() && conservativeOwnerDeathProofV1({
|
|
99
|
+
pid: owner.pid, token: owner.token, owner_hash: "", repository_id: owner.commonDir, authority_id: owner.uuid,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function registration(root: string, commonDir: string, git: Git): string {
|
|
104
|
+
const rows = git(["worktree", "list", "--porcelain", "-z"]).split("\0\0");
|
|
105
|
+
const matching = rows.filter((row) => row.split("\0")[0] === `worktree ${root}`);
|
|
106
|
+
if (matching.length !== 1 || matching[0]!.split("\0").some((field) => /^(locked|prunable)( |$)/.test(field))) throw new Error("Candidate registration is ambiguous");
|
|
107
|
+
const gitFile = join(root, ".git");
|
|
108
|
+
regular(gitFile);
|
|
109
|
+
const pointer = readFileSync(gitFile, "utf8");
|
|
110
|
+
if (!pointer.startsWith("gitdir: ") || !pointer.endsWith("\n")) throw new Error("Unsafe candidate Git pointer");
|
|
111
|
+
const admin = pointer.slice(8, -1);
|
|
112
|
+
if (dirname(admin) !== join(commonDir, "worktrees")) throw new Error("Candidate admin escaped common directory");
|
|
113
|
+
directory(join(commonDir, "worktrees"));
|
|
114
|
+
directory(admin);
|
|
115
|
+
regular(join(admin, "gitdir"));
|
|
116
|
+
regular(join(admin, "commondir"));
|
|
117
|
+
if (readFileSync(join(admin, "gitdir"), "utf8") !== `${gitFile}\n` ||
|
|
118
|
+
resolve(admin, readFileSync(join(admin, "commondir"), "utf8").trim()) !== commonDir) throw new Error("Candidate Git backlinks changed");
|
|
119
|
+
return matching[0]! + pointer;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function removeCandidateOwner(owner: CandidateViewOwner, git: Git, makeWritable: (root: string) => void, orphan = false): void {
|
|
123
|
+
const { root, commonDir } = owner;
|
|
124
|
+
const parent = assertCandidateOwnerParent(commonDir);
|
|
125
|
+
const expected = JSON.stringify(owner);
|
|
126
|
+
const parentIdentity = directory(parent, true);
|
|
127
|
+
const markerIdentity = regular(markerPath(root), true);
|
|
128
|
+
const checkOwner = (): void => {
|
|
129
|
+
if (directory(parent, true) !== parentIdentity || regular(markerPath(root), true) !== markerIdentity ||
|
|
130
|
+
JSON.stringify(readOwner(commonDir, root)) !== expected || (orphan ? !dead(owner) : owner.pid !== process.pid)) throw new Error("Candidate ownership changed");
|
|
131
|
+
};
|
|
132
|
+
checkOwner();
|
|
133
|
+
const lock = `${root}.reaper-lock`;
|
|
134
|
+
const token = randomUUID();
|
|
135
|
+
exclusiveFile(lock, token); // EEXIST is final; unknown/stale locks are never stolen.
|
|
136
|
+
const lockIdentity = regular(lock, true);
|
|
137
|
+
const checkLock = (): void => {
|
|
138
|
+
if (regular(lock, true) !== lockIdentity || readFileSync(lock, "utf8") !== token) throw new Error("Candidate reaper lock changed");
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
checkOwner();
|
|
142
|
+
const identity = [directory(parent, true), directory(root), regular(markerPath(root), true)];
|
|
143
|
+
const registered = registration(root, commonDir, git);
|
|
144
|
+
checkOwner();
|
|
145
|
+
checkLock();
|
|
146
|
+
makeWritable(root);
|
|
147
|
+
// Git and chmod are race boundaries: repeat path, owner, lock, and exact
|
|
148
|
+
// registration proofs immediately before asking Git to remove this root.
|
|
149
|
+
if (registration(root, commonDir, git) !== registered ||
|
|
150
|
+
JSON.stringify([directory(parent, true), directory(root), regular(markerPath(root), true)]) !== JSON.stringify(identity)) throw new Error("Candidate cleanup identity changed");
|
|
151
|
+
checkOwner();
|
|
152
|
+
checkLock();
|
|
153
|
+
git(["worktree", "remove", "--force", root]);
|
|
154
|
+
// Git failure or incomplete removal must never trigger recursive rm.
|
|
155
|
+
if (lstatSync(root, { throwIfNoEntry: false }) || git(["worktree", "list", "--porcelain", "-z"]).split("\0").includes(`worktree ${root}`)) throw new Error("Candidate removal is incomplete");
|
|
156
|
+
checkOwner();
|
|
157
|
+
checkLock();
|
|
158
|
+
unlinkSync(markerPath(root));
|
|
159
|
+
syncDirectory(parent);
|
|
160
|
+
} finally {
|
|
161
|
+
// Only release this exact lock, even when the deletion itself failed.
|
|
162
|
+
try { assertCandidateOwnerParent(commonDir); checkLock(); unlinkSync(lock); syncDirectory(parent); } catch {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function sweepCandidateOwners(commonDir: string, git: Git, makeWritable: (root: string) => void): void {
|
|
167
|
+
try {
|
|
168
|
+
const parent = assertCandidateOwnerParent(commonDir);
|
|
169
|
+
for (const name of readdirSync(parent)) {
|
|
170
|
+
if (!name.endsWith(".owner.json")) continue;
|
|
171
|
+
try {
|
|
172
|
+
const owner = readOwner(commonDir, join(parent, name.slice(0, -11)));
|
|
173
|
+
if (dead(owner)) removeCandidateOwner(owner, git, makeWritable, true);
|
|
174
|
+
} catch { /* Unknown, legacy, unsafe, unregistered, and contended entries survive. */ }
|
|
175
|
+
}
|
|
176
|
+
} catch { /* Startup/materialization sweeps are best-effort; never create a store. */ }
|
|
177
|
+
}
|