gentle-pi 2.6.4 → 2.7.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 +1 -1
- package/docs/gentle-shell.md +30 -18
- package/docs/readme-reference.md +5 -5
- package/extensions/gentle-agents.ts +8 -0
- package/extensions/gentle-shell.ts +52 -70
- package/lib/agents-runner.ts +7 -2
- package/lib/native-review-cli.ts +9 -0
- package/lib/session-change-capture.ts +87 -0
- package/lib/session-changes.ts +140 -0
- package/lib/shell-bar.ts +6 -1
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-changes.ts +4 -2
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +9 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -2
- package/tests/agents-runner.test.ts +14 -0
- package/tests/gentle-agents.test.ts +34 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-shell.test.ts +136 -196
- package/tests/native-review-capability-contract.test.ts +15 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/session-change-capture.test.ts +68 -0
- package/tests/session-changes-shell.test.ts +38 -0
- package/tests/session-changes.test.ts +103 -0
- package/tests/shell-bar.test.ts +14 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute } from "node:path";
|
|
5
|
+
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { changesModel, type ChangedFile, type WorktreeChanges } from "./shell-changes.ts";
|
|
7
|
+
|
|
8
|
+
export const SESSION_CHANGE_ENTRY = "gentle-pi.session-change/v1";
|
|
9
|
+
export const SESSION_CHANGE_EVENT = "gentle-pi:session-change";
|
|
10
|
+
export const SESSION_CHANGE_RELAY = "gentle-pi:child-session-change";
|
|
11
|
+
export const MAX_CHANGE_BYTES = 64 * 1024;
|
|
12
|
+
const MAX_RECORDS = 256;
|
|
13
|
+
const MAX_SESSION_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
const MAX_LINES = 2000;
|
|
15
|
+
export type ChangeSnapshot = { kind: "text"; text: string } | { kind: "absent" } | { kind: "unavailable"; reason: string };
|
|
16
|
+
export interface SessionChangeEvidence {
|
|
17
|
+
id: string;
|
|
18
|
+
root: string;
|
|
19
|
+
path: string;
|
|
20
|
+
before: ChangeSnapshot;
|
|
21
|
+
after: ChangeSnapshot;
|
|
22
|
+
}
|
|
23
|
+
type Entry = { type?: string; customType?: string; data?: unknown };
|
|
24
|
+
interface FileState { before: ChangeSnapshot; after: ChangeSnapshot; unavailable?: string; diff?: string }
|
|
25
|
+
|
|
26
|
+
const unavailable = (reason: string): ChangeSnapshot => ({ kind: "unavailable", reason });
|
|
27
|
+
export function textSnapshot(text: string): ChangeSnapshot {
|
|
28
|
+
if (Buffer.byteLength(text) > MAX_CHANGE_BYTES || text.split("\n", MAX_LINES + 1).length > MAX_LINES) return unavailable("File exceeds the session diff limit.");
|
|
29
|
+
if (text.includes("\0")) return unavailable("Binary file; line counts unavailable.");
|
|
30
|
+
return { kind: "text", text };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read only the named mutation target. Never traverse, follow symlinks, or open a FIFO for blocking I/O. */
|
|
34
|
+
export async function readChangeSnapshot(path: string): Promise<ChangeSnapshot> {
|
|
35
|
+
let file: Awaited<ReturnType<typeof open>> | undefined;
|
|
36
|
+
try {
|
|
37
|
+
file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
|
|
38
|
+
const stat = await file.stat();
|
|
39
|
+
if (!stat.isFile() || stat.size > MAX_CHANGE_BYTES) return unavailable("Nonregular or large file; diff unavailable.");
|
|
40
|
+
const buffer = Buffer.alloc(MAX_CHANGE_BYTES + 1);
|
|
41
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
42
|
+
if (bytesRead !== stat.size) return unavailable("File changed while its snapshot was being read.");
|
|
43
|
+
return textSnapshot(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "absent" } : unavailable("Snapshot unavailable.");
|
|
46
|
+
} finally { await file?.close().catch(() => {}); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validSnapshot(value: unknown): value is ChangeSnapshot {
|
|
50
|
+
if (!value || typeof value !== "object") return false;
|
|
51
|
+
const v = value as ChangeSnapshot;
|
|
52
|
+
return v.kind === "absent" || (v.kind === "text" && typeof v.text === "string" && textSnapshot(v.text).kind === "text") ||
|
|
53
|
+
(v.kind === "unavailable" && typeof v.reason === "string" && v.reason.length <= 200);
|
|
54
|
+
}
|
|
55
|
+
export function isSessionChangeEvidence(value: unknown): value is SessionChangeEvidence {
|
|
56
|
+
if (!value || typeof value !== "object") return false;
|
|
57
|
+
const v = value as SessionChangeEvidence;
|
|
58
|
+
return typeof v.id === "string" && v.id.length > 0 && v.id.length <= 256 &&
|
|
59
|
+
typeof v.root === "string" && isAbsolute(v.root) && v.root.length <= 4096 &&
|
|
60
|
+
typeof v.path === "string" && v.path.length > 0 && v.path.length <= 4096 &&
|
|
61
|
+
!isAbsolute(v.path) && !/[\0\r\n]/.test(v.root + v.path) &&
|
|
62
|
+
!v.path.split(/[\\/]/).some(part => part === ".." || part === ".git") &&
|
|
63
|
+
validSnapshot(v.before) && validSnapshot(v.after);
|
|
64
|
+
}
|
|
65
|
+
export const sameSnapshot = (a: ChangeSnapshot, b: ChangeSnapshot): boolean =>
|
|
66
|
+
a.kind === b.kind && (a.kind === "absent" || (a.kind === "text" && b.kind === "text" && a.text === b.text));
|
|
67
|
+
const snapshotText = (value: ChangeSnapshot) => value.kind === "text" ? value.text : "";
|
|
68
|
+
|
|
69
|
+
export class SessionChanges {
|
|
70
|
+
private readonly seen = new Set<string>();
|
|
71
|
+
private readonly files = new Map<string, Map<string, FileState>>();
|
|
72
|
+
private bytes = 0;
|
|
73
|
+
notice: string | undefined;
|
|
74
|
+
readonly sessionId: string;
|
|
75
|
+
private cached: WorktreeChanges[] | undefined;
|
|
76
|
+
constructor(sessionId: string, entries: readonly Entry[] = []) { this.sessionId = sessionId; this.restore(entries); }
|
|
77
|
+
|
|
78
|
+
restore(entries: readonly Entry[]): void {
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (entry.type !== "custom" || entry.customType !== SESSION_CHANGE_ENTRY) continue;
|
|
81
|
+
const data = entry.data as { sessionId?: string; evidence?: unknown } | undefined;
|
|
82
|
+
if (data?.sessionId === this.sessionId && isSessionChangeEvidence(data.evidence)) this.record(data.evidence);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
record(evidence: SessionChangeEvidence): boolean {
|
|
86
|
+
if (!isSessionChangeEvidence(evidence) || this.seen.has(evidence.id)) return false;
|
|
87
|
+
const bytes = Buffer.byteLength(JSON.stringify(evidence));
|
|
88
|
+
if (this.seen.size >= MAX_RECORDS || this.bytes + bytes > MAX_SESSION_BYTES) {
|
|
89
|
+
this.notice = "Session change capture limit reached; additional changes are not displayed.";
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
this.seen.add(evidence.id);
|
|
93
|
+
this.bytes += bytes;
|
|
94
|
+
if (sameSnapshot(evidence.before, evidence.after)) return false;
|
|
95
|
+
let files = this.files.get(evidence.root);
|
|
96
|
+
if (!files) this.files.set(evidence.root, files = new Map());
|
|
97
|
+
const previous = files.get(evidence.path);
|
|
98
|
+
const state: FileState = { before: previous?.before ?? structuredClone(evidence.before), after: structuredClone(evidence.after) };
|
|
99
|
+
state.unavailable = previous?.unavailable;
|
|
100
|
+
if (previous && !sameSnapshot(previous.after, evidence.before)) state.unavailable = "Snapshot continuity lost (external or unobserved edit); session diff unavailable.";
|
|
101
|
+
if (evidence.before.kind === "unavailable") state.unavailable ??= evidence.before.reason;
|
|
102
|
+
if (evidence.after.kind === "unavailable") state.unavailable ??= evidence.after.reason;
|
|
103
|
+
// Keep the final state even after an own revert, to detect later external edits.
|
|
104
|
+
this.patch(evidence.path, state);
|
|
105
|
+
files.set(evidence.path, state);
|
|
106
|
+
this.cached = undefined;
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
private changed(root: string): ChangedFile[] {
|
|
110
|
+
return [...(this.files.get(root) ?? [])].flatMap(([path, state]) => {
|
|
111
|
+
if (!state.unavailable && sameSnapshot(state.before, state.after)) return [];
|
|
112
|
+
const patch = this.patch(path, state);
|
|
113
|
+
const patchLines = patch.split("\n");
|
|
114
|
+
const firstHunk = patchLines.findIndex(line => line.startsWith("@@"));
|
|
115
|
+
const lines = firstHunk < 0 ? [] : patchLines.slice(firstHunk + 1);
|
|
116
|
+
return [{
|
|
117
|
+
path, diffRevision: createHash("sha256").update(patch).digest("hex"), status: state.before.kind === "absent" ? "added" as const : state.after.kind === "absent" ? "deleted" as const : "modified" as const,
|
|
118
|
+
added: state.unavailable ? 0 : lines.filter(line => line.startsWith("+")).length,
|
|
119
|
+
deleted: state.unavailable ? 0 : lines.filter(line => line.startsWith("-")).length,
|
|
120
|
+
...(state.unavailable ? { countsUnavailable: state.unavailable } : {}),
|
|
121
|
+
}];
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
private patch(path: string, state: FileState): string {
|
|
125
|
+
if (state.unavailable) return state.unavailable;
|
|
126
|
+
return state.diff ??= generateUnifiedPatch(path, snapshotText(state.before), snapshotText(state.after));
|
|
127
|
+
}
|
|
128
|
+
get worktrees(): WorktreeChanges[] {
|
|
129
|
+
return this.cached ??= [...this.files.keys()].map(root => ({ root, model: changesModel(this.changed(root)) })).filter(tree => tree.model.files.length > 0);
|
|
130
|
+
}
|
|
131
|
+
get model() {
|
|
132
|
+
const trees = this.worktrees;
|
|
133
|
+
return changesModel(trees.flatMap(tree => tree.model.files.map(file => ({ ...file, path: trees.length === 1 ? file.path : tree.root + "/" + file.path }))));
|
|
134
|
+
}
|
|
135
|
+
async refresh() { return this.model; }
|
|
136
|
+
loadDiff(root: string, file: ChangedFile): string {
|
|
137
|
+
const state = this.files.get(root)?.get(file.path);
|
|
138
|
+
return state ? this.patch(file.path, state) : "No captured agent diff for this file.";
|
|
139
|
+
}
|
|
140
|
+
}
|
package/lib/shell-bar.ts
CHANGED
|
@@ -11,6 +11,7 @@ export { gaugeTone, renderGauge, type GaugeTone };
|
|
|
11
11
|
// verified without a live TUI.
|
|
12
12
|
|
|
13
13
|
export interface ShellBarModel {
|
|
14
|
+
profile?: string;
|
|
14
15
|
cwd: string;
|
|
15
16
|
branch: string | null;
|
|
16
17
|
dirty: number | undefined;
|
|
@@ -138,7 +139,11 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
138
139
|
},
|
|
139
140
|
{
|
|
140
141
|
title: "Model",
|
|
141
|
-
lines: [
|
|
142
|
+
lines: [
|
|
143
|
+
value(model.modelId),
|
|
144
|
+
...(model.effort ? [`${label("Effort")} ${theme.fg(ROLE.EFFORT, model.effort)}`] : []),
|
|
145
|
+
...(model.profile ? [`${label("Profile")} ${value(sanitizeStatus(model.profile))}`] : []),
|
|
146
|
+
],
|
|
142
147
|
},
|
|
143
148
|
{
|
|
144
149
|
title: "Context",
|
|
@@ -74,6 +74,7 @@ const FILE_STATUS = {
|
|
|
74
74
|
} as const;
|
|
75
75
|
|
|
76
76
|
function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
|
|
77
|
+
if (file.countsUnavailable) return theme.fg("dim", "counts unavailable");
|
|
77
78
|
return `${theme.fg(ROLE.ADDED, `+${file.added}`)} ${theme.fg(ROLE.REMOVED, `-${file.deleted}`)}`;
|
|
78
79
|
}
|
|
79
80
|
|
|
@@ -126,7 +127,7 @@ function displayText(text: string): string {
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
function fingerprint(file: ChangedFile): string {
|
|
129
|
-
return `${file.status}:${file.added}:${file.deleted}`;
|
|
130
|
+
return `${file.status}:${file.added}:${file.deleted}:${file.diffRevision ?? ""}:${file.countsUnavailable ?? ""}`;
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
export interface WorktreeChangesViewDeps {
|
package/lib/shell-changes.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface ChangedFile {
|
|
|
19
19
|
added: number;
|
|
20
20
|
deleted: number;
|
|
21
21
|
status: ChangeStatus;
|
|
22
|
+
countsUnavailable?: string;
|
|
23
|
+
diffRevision?: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface ChangesModel {
|
|
@@ -115,7 +117,7 @@ export function changesModel(changed: ChangedFile[]): ChangesModel {
|
|
|
115
117
|
|
|
116
118
|
export function changesSummary(model: ChangesModel): string {
|
|
117
119
|
const noun = model.files.length === 1 ? "file" : "files";
|
|
118
|
-
return `${model.files.length} ${noun} · +${model.added} −${model.deleted}`;
|
|
120
|
+
return `${model.files.length} ${noun} · +${model.added} −${model.deleted}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
// One line: summary, the files joined by dots, and the command pushed to
|
|
@@ -124,7 +126,7 @@ export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, wi
|
|
|
124
126
|
if (model.files.length === 0) return [];
|
|
125
127
|
const noun = model.files.length === 1 ? "file" : "files";
|
|
126
128
|
const dot = theme.fg("muted", "·");
|
|
127
|
-
const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}`;
|
|
129
|
+
const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
|
|
128
130
|
const hint = theme.fg("dim", CHANGES_COMMAND);
|
|
129
131
|
const list = model.files.map((file) => theme.fg("muted", file.path)).join(` ${dot} `);
|
|
130
132
|
const left = `${head} ${dot} ${list}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -994,6 +994,15 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
|
|
|
994
994
|
// remain dark because neither is proven to reach the negotiated START
|
|
995
995
|
// path Pi consumes.
|
|
996
996
|
"2.9.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 }),
|
|
997
|
+
// v2.9.1 shipped restoring compatible OpenCode review consent (#4584) and
|
|
998
|
+
// deriving Claude Code SDD dispatch authority from the session transcript
|
|
999
|
+
// (#4575, #4551). Ground-truthed by diffing contracts/review-integration/v2
|
|
1000
|
+
// and contracts/review-provider-contract between the v2.9.0 and v2.9.1 tags
|
|
1001
|
+
// in the gentle-ai source tree: zero bytes changed. Neither change touches
|
|
1002
|
+
// the closed START/STATUS fields this row negotiates, so it repeats 2.9.0
|
|
1003
|
+
// exactly. riskEvidence and hint remain dark because neither is proven to
|
|
1004
|
+
// reach the negotiated START path Pi consumes.
|
|
1005
|
+
"2.9.1": 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 }),
|
|
997
1006
|
});
|
|
998
1007
|
|
|
999
1008
|
|
|
@@ -36,7 +36,7 @@ const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
|
|
|
36
36
|
// version check below) derives from this constant instead of repeating the
|
|
37
37
|
// literal, so a pin bump cannot leave a stale copy behind. See
|
|
38
38
|
// scripts/install-gentle-ai.mjs for the incident that motivated this.
|
|
39
|
-
export const INSTALLER_VERSION = "2.9.
|
|
39
|
+
export const INSTALLER_VERSION = "2.9.1";
|
|
40
40
|
export const RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-ai/releases/download/v${INSTALLER_VERSION}/`;
|
|
41
41
|
export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
|
|
42
42
|
SIGNED_RELEASE_ASSET: "signed-release-asset",
|
|
@@ -45,10 +45,10 @@ export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
|
|
|
45
45
|
export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v2/cmd/gentle-ai";
|
|
46
46
|
export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v2";
|
|
47
47
|
export const GENTLE_AI_WINDOWS_SOURCE_TAG = `v${INSTALLER_VERSION}`;
|
|
48
|
-
// `go mod download -json github.com/gentleman-programming/gentle-ai/v2@v2.9.
|
|
48
|
+
// `go mod download -json github.com/gentleman-programming/gentle-ai/v2@v2.9.1`
|
|
49
49
|
// with GOSUMDB=sum.golang.org reports this exact module SumDB checksum, and the
|
|
50
|
-
// tag resolves to commit
|
|
51
|
-
export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:
|
|
50
|
+
// tag resolves to commit 9ec0cf44, the published v2.9.1 release head.
|
|
51
|
+
export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:roCuxlF+L4YOb1X0los4RpjcLfxAmwmHxteSQMcfThY=";
|
|
52
52
|
export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
|
|
53
53
|
export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
|
|
54
54
|
export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
|
|
@@ -67,7 +67,7 @@ export class GentleAiInstallerError extends Error {
|
|
|
67
67
|
// Sentinel used while a re-pinned gentle-ai release is not yet published. A
|
|
68
68
|
// sentinel digest can never match a real SHA-256, so installation fails closed,
|
|
69
69
|
// and verify-package-files.mjs refuses to pack/publish while any digest below
|
|
70
|
-
// still holds it. The v2.9.
|
|
70
|
+
// still holds it. The v2.9.1 digests are pinned from the published release:
|
|
71
71
|
// archive sha256 values verified against the minisign-signed checksums.txt and
|
|
72
72
|
// freshly computed hashes; binary sha256 values computed from the extracted
|
|
73
73
|
// executables.
|
|
@@ -109,15 +109,15 @@ async function downloadPinnedGentleAiAsset(asset, destination, options) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
// Windows is absent from signed release archives on purpose. gentle-ai stopped
|
|
112
|
-
// distributing unsigned Windows builds in c4b764d0, so v2.9.
|
|
112
|
+
// distributing unsigned Windows builds in c4b764d0, so v2.9.1 publishes signed
|
|
113
113
|
// Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
|
|
114
114
|
// exact-tag Go SumDB source-build path below; restore archive rows only when
|
|
115
115
|
// upstream ships signed Windows assets.
|
|
116
116
|
export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
|
|
117
|
-
"darwin/amd64": asset("gentle-ai_2.9.
|
|
118
|
-
"darwin/arm64": asset("gentle-ai_2.9.
|
|
119
|
-
"linux/amd64": asset("gentle-ai_2.9.
|
|
120
|
-
"linux/arm64": asset("gentle-ai_2.9.
|
|
117
|
+
"darwin/amd64": asset("gentle-ai_2.9.1_darwin_amd64.tar.gz", "9e4b10c8e0ff36e51f39fad4da082f37b408234253cbbb7f8a8f710a5ee7b7f7", "96b2739365aa7513997e9ea5435cf38c019810dc3db0fb51c4c8f2fdb910de58", "gentle-ai"),
|
|
118
|
+
"darwin/arm64": asset("gentle-ai_2.9.1_darwin_arm64.tar.gz", "d876c237e4559bea6849e9f9be33c63cea053154149df4a3e1e5983c9a6232ff", "5a675f666f52e4c070ad703da01a5e3ab6b1a4a002e3ac08ca1ef2871d21ac59", "gentle-ai"),
|
|
119
|
+
"linux/amd64": asset("gentle-ai_2.9.1_linux_amd64.tar.gz", "07efddecd29fd1c3f8c8df19a3b6613c1948927d410b8502bf82b39b84d9481b", "8bd0161c51ed07e77801a92d13bdcb82ff483c273d4a182904534057d4054d97", "gentle-ai"),
|
|
120
|
+
"linux/arm64": asset("gentle-ai_2.9.1_linux_arm64.tar.gz", "c3e5a173fa1b19be0abc0c6e5de18709cd1985cff61cc140546191e809324ed2", "a311c9ae6bc8d59931eee7c686be5d51b35d60b5d2b591e17e9b432968926e4e", "gentle-ai"),
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
// A pinned asset is either a signed archive or, for a prerelease pin only,
|
|
@@ -340,7 +340,7 @@ async function main() {
|
|
|
340
340
|
});
|
|
341
341
|
|
|
342
342
|
if (driftedContracts.length > 0) {
|
|
343
|
-
console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v2.9.
|
|
343
|
+
console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v2.9.1 runtime's vendored Gentle AI contract artifacts:");
|
|
344
344
|
for (const drift of driftedContracts) console.error(`- ${drift.relativePath}: expected ${drift.expected}, got ${drift.actual}`);
|
|
345
345
|
process.exit(1);
|
|
346
346
|
}
|
|
@@ -385,7 +385,7 @@ async function main() {
|
|
|
385
385
|
process.exit(1);
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v2.9.
|
|
388
|
+
console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v2.9.1 runtime).`);
|
|
389
389
|
}
|
|
390
390
|
|
|
391
391
|
const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
@@ -321,6 +321,20 @@ test("child observation guard is never consulted when collection is default-off"
|
|
|
321
321
|
assert.equal(calls, 0);
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
+
test("child session diff evidence travels only with a paired successful tool outcome", async () => {
|
|
325
|
+
const observed: any[] = [];
|
|
326
|
+
const h = harness({ onSuccessfulMutation: (_task, tool) => { observed.push(tool); } });
|
|
327
|
+
const task = h.runner.run(request()); await tick();
|
|
328
|
+
const child = h.children[0];
|
|
329
|
+
const evidence = {id:"w",root:"/repo",path:"src/file.ts",before:{kind:"absent"},after:{kind:"text",text:"agent\n"}};
|
|
330
|
+
child.emit({type:"tool_execution_start",toolCallId:"w",toolName:"write",args:{path:"src/file.ts"}});
|
|
331
|
+
child.emit({type:"tool_execution_end",toolCallId:"w",isError:false,result:{content:[],details:{gentleSessionChange:evidence}}});
|
|
332
|
+
assert.deepEqual(observed[0].evidence,evidence);
|
|
333
|
+
child.emit({type:"tool_execution_end",toolCallId:"w",isError:false,result:{content:[],details:{gentleSessionChange:evidence}}});
|
|
334
|
+
assert.equal(observed.length,1);
|
|
335
|
+
h.runner.cancel(task.id); await tick();
|
|
336
|
+
});
|
|
337
|
+
|
|
324
338
|
for (const ending of ["cancel", "failure", "hook-error", "hook-async-error"] as const) {
|
|
325
339
|
test(`successful child mutations require paired RPC events and survive ${ending}`, async () => {
|
|
326
340
|
const mutations: unknown[] = [];
|
|
@@ -832,6 +832,40 @@ async function shutdownAndRestoreNativeSpawn(
|
|
|
832
832
|
}
|
|
833
833
|
}
|
|
834
834
|
|
|
835
|
+
for (const matching of [true, false]) {
|
|
836
|
+
test("owned child diff relay validates the exact file independently of review bookkeeping: "+matching, async () => {
|
|
837
|
+
const h=fakePi(), d=deps(), {ctx}=fakeContext();
|
|
838
|
+
const target=realpathSync(cwd);
|
|
839
|
+
(ctx as any).cwd=target;
|
|
840
|
+
ctx.sessionManager.getCwd=()=>target;
|
|
841
|
+
ctx.sessionManager.getEntries=(()=>h.entries) as any;
|
|
842
|
+
ctx.sessionManager.getBranch=(()=>h.entries) as any;
|
|
843
|
+
d.deps.resolveWorktree=(path,base)=>{
|
|
844
|
+
const full=resolve(base,path);
|
|
845
|
+
return full===target||full.startsWith(target+"/")?{root:target,commonDir:"/fixture/common"}:undefined;
|
|
846
|
+
};
|
|
847
|
+
const spawn=d.deps.spawn!;
|
|
848
|
+
d.deps.spawn=(...args)=>{
|
|
849
|
+
const child=spawn(...args),on=child.on.bind(child);
|
|
850
|
+
child.on=((event,listener)=>{if(event==="spawn")queueMicrotask(listener);return on(event,listener);}) as any;
|
|
851
|
+
return child;
|
|
852
|
+
};
|
|
853
|
+
gentleAgents(h.pi,{},d.deps);
|
|
854
|
+
await h.fire("session_start",ctx);
|
|
855
|
+
await h.tools.get("subagent_run")!.execute("diff",{agent:"explore",task:"Write",mode:"background",workspace_root:target},undefined,undefined,ctx);
|
|
856
|
+
await tick();
|
|
857
|
+
writeFileSync(join(target,"session-diff-test.ts"),"agent\n");
|
|
858
|
+
const evidence={id:"write",root:target,path:matching?"session-diff-test.ts":"different.ts",before:{kind:"text",text:"original\n"},after:{kind:"text",text:"agent\n"}};
|
|
859
|
+
d.children[0].emit({type:"tool_execution_start",toolCallId:"write",toolName:"write",args:{path:"session-diff-test.ts"}});
|
|
860
|
+
d.children[0].emit({type:"tool_execution_end",toolCallId:"write",isError:false,result:{content:[],details:{gentleSessionChange:evidence}}});
|
|
861
|
+
const relays=h.events.filter(event=>event.name==="gentle-pi:child-session-change");
|
|
862
|
+
assert.equal(relays.length,matching?1:0);
|
|
863
|
+
if(matching) assert.match((relays[0].data as any).evidence.id,/:write$/);
|
|
864
|
+
assert.equal(h.entries.filter(entry=>entry.customType===REVIEW_REMINDER_RECEIPT).length,1);
|
|
865
|
+
await h.fire("session_shutdown",ctx); await tick();
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
835
869
|
for (const scenario of ["own", "other-root", "escaped", "sibling", "session-switch", "shutdown", "unregistered"] as const) {
|
|
836
870
|
test(`child mutation attribution through registered subagent_run: ${scenario}`, async () => {
|
|
837
871
|
const h = fakePi();
|
|
@@ -97,7 +97,7 @@ async function writeWindowsSourceBinary(packageRoot: string): Promise<{ binaryPa
|
|
|
97
97
|
method: "go-sumdb-source-build",
|
|
98
98
|
package: "github.com/gentleman-programming/gentle-ai/v2/cmd/gentle-ai",
|
|
99
99
|
module: "github.com/gentleman-programming/gentle-ai/v2",
|
|
100
|
-
tag: "v2.9.
|
|
100
|
+
tag: "v2.9.1",
|
|
101
101
|
architecture: process.arch === "x64" ? "x64" : "arm64",
|
|
102
102
|
binarySha256: createHash("sha256").update(binary).digest("hex"),
|
|
103
103
|
moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|