gentle-pi 4.2.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +336 -137
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/gentle-shell.mjs +170 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +33 -3
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +5 -4
- package/docs/install-wizard.md +8 -1
- package/docs/permission-modes.md +1 -1
- package/docs/readme-reference.md +107 -19
- package/docs/yolo-mode.md +21 -8
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +99 -12
- package/extensions/gentle-ai.ts +284 -52
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +27 -12
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +39 -3
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +145 -11
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-todo.ts +23 -4
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +38 -2
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +143 -9
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/autonomous-guard.test.ts +122 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +1 -1
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +282 -3
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +169 -6
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +79 -0
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +42 -9
- package/tests/gentle-todo.test.ts +179 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +24 -20
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +18 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -67,12 +67,15 @@ export function colorDiff(text: string, theme: ChangesViewTheme): string[] {
|
|
|
67
67
|
return lines;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// New files are two distinct states: `A` is tracked and staged, `U` is still
|
|
71
|
+
// untracked. Git's own `??` reads as a broken value in a status column, so the
|
|
72
|
+
// list names the state instead of echoing the plumbing code.
|
|
70
73
|
const FILE_STATUS = {
|
|
71
74
|
[CHANGE_STATUS.MODIFIED]: "M",
|
|
72
75
|
[CHANGE_STATUS.ADDED]: "A",
|
|
73
76
|
[CHANGE_STATUS.DELETED]: "D",
|
|
74
77
|
[CHANGE_STATUS.RENAMED]: "R",
|
|
75
|
-
[CHANGE_STATUS.UNTRACKED]: "
|
|
78
|
+
[CHANGE_STATUS.UNTRACKED]: "U",
|
|
76
79
|
} as const;
|
|
77
80
|
|
|
78
81
|
function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
|
|
@@ -113,18 +116,41 @@ function fit(text: string, width: number): string {
|
|
|
113
116
|
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
//
|
|
117
|
-
|
|
119
|
+
// One geometry feeds both rendering and pointer hit-testing, so clicks stay
|
|
120
|
+
// aligned with what is painted. Diff-only drops the list column and its
|
|
121
|
+
// separator; its empty list zone receives no pointer events.
|
|
122
|
+
interface PaneLayout {
|
|
123
|
+
inner: number;
|
|
124
|
+
listWidth: number;
|
|
125
|
+
diffWidth: number;
|
|
126
|
+
listStart: number;
|
|
127
|
+
listEnd: number;
|
|
128
|
+
diffStart: number;
|
|
129
|
+
diffEnd: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function paneLayout(width: number, diffOnly: boolean): PaneLayout {
|
|
118
133
|
const inner = Math.max(8, width - 2);
|
|
134
|
+
if (diffOnly) return { inner, listWidth: 0, diffWidth: inner - 2, listStart: 0, listEnd: 0, diffStart: 2, diffEnd: inner };
|
|
119
135
|
const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
|
|
120
136
|
const diffWidth = inner - listWidth - 4;
|
|
137
|
+
return { inner, listWidth, diffWidth, listStart: 2, listEnd: 2 + listWidth, diffStart: 5 + listWidth, diffEnd: 5 + listWidth + diffWidth };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Both standalone files and the worktree accordion use the original frame.
|
|
141
|
+
function renderPanes(width: number, rows: number, theme: ChangesViewTheme, title: string, leftLine: (row: number) => string, diffLines: string[], keys: string, headerFile?: ChangedFile, diffOnly = false): string[] {
|
|
142
|
+
const { inner, listWidth, diffWidth } = paneLayout(width, diffOnly);
|
|
121
143
|
const rightLines = headerFile ? [pathHeader(headerFile, theme, diffWidth), ...diffLines] : diffLines;
|
|
122
144
|
const titleText = truncateToWidth(title, inner - 4, "…");
|
|
123
145
|
const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, titleText) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(titleText) - 3)}╮`);
|
|
124
146
|
const body: string[] = [];
|
|
125
147
|
for (let row = 0; row < rows; row += 1) {
|
|
126
|
-
const left = fit(leftLine(row), listWidth);
|
|
127
148
|
const right = fit(rightLines[row] ?? "", diffWidth);
|
|
149
|
+
if (diffOnly) {
|
|
150
|
+
body.push(`${theme.fg(ROLE.FRAME, "│")} ${right} ${theme.fg(ROLE.FRAME, "│")}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const left = fit(leftLine(row), listWidth);
|
|
128
154
|
body.push(`${theme.fg(ROLE.FRAME, "│")} ${left} ${theme.fg(ROLE.FRAME, "│")} ${right}${theme.fg(ROLE.FRAME, "│")}`);
|
|
129
155
|
}
|
|
130
156
|
const keysLine = `${theme.fg(ROLE.FRAME, "│")} ${fit(keys, inner - 2)} ${theme.fg(ROLE.FRAME, "│")}`;
|
|
@@ -142,11 +168,9 @@ interface PointerLayout {
|
|
|
142
168
|
diffEnd: number;
|
|
143
169
|
}
|
|
144
170
|
|
|
145
|
-
function pointerLayout(width: number, bodyRows: number): PointerLayout {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
const diffWidth = inner - listWidth - 4;
|
|
149
|
-
return { width, height: bodyRows + CHROME_ROWS, bodyRows, listStart: 2, listEnd: 2 + listWidth, diffStart: 5 + listWidth, diffEnd: 5 + listWidth + diffWidth };
|
|
171
|
+
function pointerLayout(width: number, bodyRows: number, diffOnly = false): PointerLayout {
|
|
172
|
+
const { listStart, listEnd, diffStart, diffEnd } = paneLayout(width, diffOnly);
|
|
173
|
+
return { width, height: bodyRows + CHROME_ROWS, bodyRows, listStart, listEnd, diffStart, diffEnd };
|
|
150
174
|
}
|
|
151
175
|
|
|
152
176
|
function displayText(text: string): string {
|
|
@@ -186,6 +210,8 @@ export class WorktreeChangesView {
|
|
|
186
210
|
private disposed = false;
|
|
187
211
|
private leftPressActive = false;
|
|
188
212
|
private hoveredIndex: number | undefined;
|
|
213
|
+
// Not persisted: every opening starts with the file list visible.
|
|
214
|
+
private diffOnly = false;
|
|
189
215
|
private readonly expanded = new Set<string>();
|
|
190
216
|
private readonly previews = new Map<string, { fingerprint: string; view: ChangesView }>();
|
|
191
217
|
|
|
@@ -224,13 +250,27 @@ export class WorktreeChangesView {
|
|
|
224
250
|
handleInput(data: string): void {
|
|
225
251
|
if (this.disposed) return;
|
|
226
252
|
if (data === "r") return this.deps.onRefresh();
|
|
227
|
-
if (
|
|
253
|
+
if (data === "q") return this.deps.onClose();
|
|
254
|
+
if (matchesKey(data, Key.escape)) {
|
|
255
|
+
if (!this.diffOnly) return this.deps.onClose();
|
|
256
|
+
return this.setDiffOnly(false);
|
|
257
|
+
}
|
|
258
|
+
if (data === "F") return this.setDiffOnly(!this.diffOnly);
|
|
228
259
|
const rows = this.visibleRows();
|
|
229
260
|
const row = rows[this.selected];
|
|
230
261
|
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("j")) || matchesKey(data, Key.ctrl("k"))) {
|
|
231
262
|
if (row?.file) this.previews.get(rowKey(row))?.view.handleInput(data);
|
|
232
263
|
return;
|
|
233
264
|
}
|
|
265
|
+
if (this.diffOnly) {
|
|
266
|
+
// Only files have a diff: j/k walk every file, crossing groups.
|
|
267
|
+
if (data === "j" || matchesKey(data, Key.down)) this.stepFile(1);
|
|
268
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.stepFile(-1);
|
|
269
|
+
else if (row?.file && (data === "o" || matchesKey(data, Key.enter))) return this.deps.onOpen(row.tree.root, row.file);
|
|
270
|
+
else return;
|
|
271
|
+
this.deps.requestRender();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
234
274
|
if (data === "j" || matchesKey(data, Key.down)) this.selected = Math.max(0, Math.min(rows.length - 1, this.selected + 1));
|
|
235
275
|
else if (data === "k" || matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
|
|
236
276
|
else if (row && (matchesKey(data, Key.left) || matchesKey(data, Key.backspace))) {
|
|
@@ -315,9 +355,11 @@ export class WorktreeChangesView {
|
|
|
315
355
|
: paintHoverable(theme, `${this.expanded.has(row.tree.root) ? "▾" : "▸"} ${displayText(row.tree.branch ?? "detached")} · ${displayText(basename(row.tree.root))}`, hovered, idleRole);
|
|
316
356
|
return `${marker} ${text}`;
|
|
317
357
|
};
|
|
318
|
-
const keys = theme.fg(ROLE.KEY_TEXT,
|
|
319
|
-
|
|
320
|
-
|
|
358
|
+
const keys = theme.fg(ROLE.KEY_TEXT, this.diffOnly
|
|
359
|
+
? "j/k file ctrl+j/k scroll o open r refresh F files esc files"
|
|
360
|
+
: "j/k select enter toggle/open ← fold ctrl+j/k scroll r refresh F diff only esc close");
|
|
361
|
+
this.pointerLayout = pointerLayout(width, height, this.diffOnly);
|
|
362
|
+
return renderPanes(width, height, theme, `✎ Changes · ${this.trees.length} worktrees`, leftLine, preview, keys, selected?.file, this.diffOnly);
|
|
321
363
|
}
|
|
322
364
|
|
|
323
365
|
invalidate(): void {
|
|
@@ -341,6 +383,31 @@ export class WorktreeChangesView {
|
|
|
341
383
|
return Math.max(MIN_BODY_ROWS, rows - CHROME_ROWS);
|
|
342
384
|
}
|
|
343
385
|
|
|
386
|
+
private setDiffOnly(on: boolean): void {
|
|
387
|
+
this.diffOnly = on;
|
|
388
|
+
this.hoveredIndex = undefined;
|
|
389
|
+
this.pointerLayout = undefined;
|
|
390
|
+
// A group header has no diff; diff-only starts on the next file.
|
|
391
|
+
if (on && !this.visibleRows()[this.selected]?.file) this.stepFile(1);
|
|
392
|
+
this.deps.requestRender();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Move to the next file in the fully expanded order, expanding its group
|
|
396
|
+
// so the file list shows the same selection after diff-only mode.
|
|
397
|
+
private stepFile(direction: 1 | -1): void {
|
|
398
|
+
const all: WorktreeRow[] = this.trees.flatMap((tree) => [{ tree }, ...tree.model.files.map((file) => ({ tree, file }))]);
|
|
399
|
+
const current = this.visibleRows()[this.selected];
|
|
400
|
+
let index = current ? all.findIndex((row) => rowKey(row) === rowKey(current)) : -1;
|
|
401
|
+
do index += direction;
|
|
402
|
+
while (index >= 0 && index < all.length && !all[index]!.file);
|
|
403
|
+
const target = all[index];
|
|
404
|
+
if (!target) return;
|
|
405
|
+
this.expanded.add(target.tree.root);
|
|
406
|
+
this.selected = this.visibleRows().findIndex((row) => rowKey(row) === rowKey(target));
|
|
407
|
+
this.ensureSelectedVisible();
|
|
408
|
+
this.ensurePreview();
|
|
409
|
+
}
|
|
410
|
+
|
|
344
411
|
private scrollList(delta: number, rows: number): boolean {
|
|
345
412
|
const next = Math.max(0, Math.min(Math.max(0, this.visibleRows().length - rows), this.listOffset + Math.trunc(delta)));
|
|
346
413
|
if (next === this.listOffset) return false;
|
package/lib/shell-gauge.ts
CHANGED
|
@@ -20,10 +20,38 @@ const GAUGE_EMPTY_ROLE = "border";
|
|
|
20
20
|
const WARNING_THRESHOLD = 80;
|
|
21
21
|
const ERROR_THRESHOLD = 95;
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// Gauge-specific theme roles (issue #2222). Every field is optional and falls
|
|
24
|
+
// back to the shared role the gauge painted with before gauge roles existed,
|
|
25
|
+
// so themes without a gauge block render byte-identically to today.
|
|
26
|
+
export interface GaugePaint {
|
|
27
|
+
/** Filled cells below the warning threshold; falls back to `accent`. */
|
|
28
|
+
ok?: string;
|
|
29
|
+
/** Filled cells from 80%; falls back to `warning`. */
|
|
30
|
+
warning?: string;
|
|
31
|
+
/** Filled cells from 95%; falls back to `error`. */
|
|
32
|
+
error?: string;
|
|
33
|
+
/** Empty cells; falls back to `border`. */
|
|
34
|
+
empty?: string;
|
|
35
|
+
/** Filled-cell glyph; falls back to `▰`. */
|
|
36
|
+
filled?: string;
|
|
37
|
+
/** Empty-cell glyph; falls back to `▱`. */
|
|
38
|
+
emptyGlyph?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GaugeGlyphs {
|
|
42
|
+
filled: string;
|
|
43
|
+
empty: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function gaugeGlyphs(glyphs?: Partial<GaugeGlyphs>): GaugeGlyphs {
|
|
47
|
+
return { filled: glyphs?.filled ?? GAUGE_FILLED, empty: glyphs?.empty ?? GAUGE_EMPTY };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function renderGauge(percent: number | null, cells: number = GAUGE_CELLS, glyphs?: Partial<GaugeGlyphs>): string {
|
|
24
51
|
const clamped = Math.max(0, Math.min(100, percent ?? 0));
|
|
25
52
|
const filled = Math.round((clamped / 100) * cells);
|
|
26
|
-
|
|
53
|
+
const [filledGlyph, emptyGlyph] = [glyphs?.filled ?? GAUGE_FILLED, glyphs?.empty ?? GAUGE_EMPTY];
|
|
54
|
+
return filledGlyph.repeat(filled) + emptyGlyph.repeat(cells - filled);
|
|
27
55
|
}
|
|
28
56
|
|
|
29
57
|
export function gaugeTone(percent: number | null): GaugeTone {
|
|
@@ -33,8 +61,23 @@ export function gaugeTone(percent: number | null): GaugeTone {
|
|
|
33
61
|
return GAUGE_TONE.ACCENT;
|
|
34
62
|
}
|
|
35
63
|
|
|
36
|
-
export function paintGauge(percent: number | null, theme: GaugeTheme, cells: number = GAUGE_CELLS): string {
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
64
|
+
export function paintGauge(percent: number | null, theme: GaugeTheme, cells: number = GAUGE_CELLS, paint?: GaugePaint): string {
|
|
65
|
+
const glyphs = gaugeGlyphs(paint && { filled: paint.filled, empty: paint.emptyGlyph });
|
|
66
|
+
const gauge = renderGauge(percent, cells, glyphs);
|
|
67
|
+
const filled = stripTrailing(gauge, glyphs.empty);
|
|
68
|
+
// Token names go through theme.fg; raw ANSI prefixes (resolved from direct
|
|
69
|
+
// colors by gauge-theme.ts) paint directly, so the gauges alone recolor.
|
|
70
|
+
const isAnsi = (role: string | undefined): role is string => typeof role === "string" && role.startsWith("\x1b[");
|
|
71
|
+
const paintFg = (role: string | undefined, fallback: string, text: string): string => (isAnsi(role) ? `${role}${text}\x1b[39m` : theme.fg(role ?? fallback, text));
|
|
72
|
+
const tone = gaugeTone(percent);
|
|
73
|
+
const filledRole = tone === GAUGE_TONE.ACCENT ? paint?.ok : tone === GAUGE_TONE.WARNING ? paint?.warning : tone === GAUGE_TONE.ERROR ? paint?.error : undefined;
|
|
74
|
+
return paintFg(filledRole, tone, filled) + paintFg(paint?.empty, GAUGE_EMPTY_ROLE, gauge.slice(filled.length));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Strips the maximal run of trailing empty glyphs without building a RegExp
|
|
78
|
+
// from theme-authored strings, which could contain regex metacharacters.
|
|
79
|
+
function stripTrailing(gauge: string, empty: string): string {
|
|
80
|
+
let end = gauge.length;
|
|
81
|
+
while (end >= empty.length && end > 0 && gauge.endsWith(empty, end)) end -= empty.length;
|
|
82
|
+
return gauge.slice(0, end);
|
|
40
83
|
}
|
package/lib/shell-todo.ts
CHANGED
|
@@ -90,6 +90,21 @@ export interface TodoRenderOptions {
|
|
|
90
90
|
export const TODO_DETAILS_KEY = "gentleTodo";
|
|
91
91
|
export const TODO_TOOL_NAME = "todo";
|
|
92
92
|
export const TODO_GLYPH = "❀";
|
|
93
|
+
/** Emitted on pi.events after each successful todo write; gentle-agents publishes it as session progress. */
|
|
94
|
+
export const TODO_PROGRESS_EVENT = "gentle-pi:todo:progress";
|
|
95
|
+
|
|
96
|
+
export interface TodoProgress {
|
|
97
|
+
sessionId: string;
|
|
98
|
+
done: number;
|
|
99
|
+
total: number;
|
|
100
|
+
/** Title of the first in-progress task, or null when none is in progress. */
|
|
101
|
+
current: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function todoProgress(state: TodoState, sessionId: string): TodoProgress {
|
|
105
|
+
const current = state.tasks.find((task) => task.status === TODO_STATUS.IN_PROGRESS)?.title ?? null;
|
|
106
|
+
return { sessionId, done: countStatus(state, TODO_STATUS.DONE), total: state.tasks.length, current };
|
|
107
|
+
}
|
|
93
108
|
const STATUS_ALIASES: Record<string, TodoStatus> = {
|
|
94
109
|
completed: TODO_STATUS.DONE,
|
|
95
110
|
complete: TODO_STATUS.DONE,
|
|
@@ -261,13 +276,14 @@ export function replayTodo(entries: readonly unknown[]): TodoState {
|
|
|
261
276
|
// gentle-shell#1367, #1662: the system prompt is a cached prefix for every
|
|
262
277
|
// provider, so the block it carries is byte-stable for the whole session:
|
|
263
278
|
// rules only, no task content and no counters. The current list travels as a
|
|
264
|
-
//
|
|
265
|
-
//
|
|
279
|
+
// hidden transcript message appended when it changes (todoStateMessage,
|
|
280
|
+
// gentle-shell#2195), so requests stay append-only and a change costs only
|
|
281
|
+
// the tail of the conversation, never the prefix.
|
|
266
282
|
const TODO_PROMPT_BLOCK = [
|
|
267
283
|
"## Todo list",
|
|
268
284
|
"Keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.",
|
|
269
285
|
"A blocked task waits on something outside the list: do not work on it until its condition holds. When the plan changes, mark the tasks it made obsolete dropped instead of leaving them pending or marking them done.",
|
|
270
|
-
"While tasks stay open, the current list arrives as
|
|
286
|
+
"While tasks stay open, the current list arrives as a hidden conversation message whenever it changes; the latest one is current, and it is not repeated here.",
|
|
271
287
|
].join("\n");
|
|
272
288
|
|
|
273
289
|
/** The byte-stable rules block appended to every system prompt. */
|
|
@@ -275,7 +291,10 @@ export function todoPromptBlock(): string {
|
|
|
275
291
|
return TODO_PROMPT_BLOCK;
|
|
276
292
|
}
|
|
277
293
|
|
|
278
|
-
/**
|
|
294
|
+
/** Persisted once an open list closes, so an earlier snapshot never reads as current work (#2195). */
|
|
295
|
+
export const TODO_CLOSED_STATE = "Current todo list: no open tasks.";
|
|
296
|
+
|
|
297
|
+
/** The open-task snapshot delivered as a hidden transcript message; undefined when nothing is open. */
|
|
279
298
|
export function todoStateMessage(state: TodoState): string | undefined {
|
|
280
299
|
if (todoSummary(state).open === 0) return undefined;
|
|
281
300
|
const lines = state.tasks.map((task, index) => `${index + 1}. [${task.status}] ${task.title}${task.note ? ` — ${task.note}` : ""}`);
|
package/lib/shell-usage-view.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
2
2
|
import { renderUsagePanel, type ActiveProvider, type UsagePanelScope, type UsageSourceRegistry, type UsageStore, type UsageTheme } from "./shell-usage.ts";
|
|
3
|
+
import type { GaugePaint } from "./shell-gauge.ts";
|
|
3
4
|
import { paintHoverable } from "./shell-hover.ts";
|
|
4
5
|
|
|
5
6
|
// Gentle Shell subscriptions overlay: a framed panel over the usage store.
|
|
@@ -16,6 +17,9 @@ export interface UsageViewDeps {
|
|
|
16
17
|
// provider the store holds; with it, exactly the targeted providers, in
|
|
17
18
|
// scope order, explaining the ones without data.
|
|
18
19
|
scope?(): UsagePanelScope | undefined;
|
|
20
|
+
// Optional: gauge-specific theme roles resolved from the installed theme
|
|
21
|
+
// source; without it the panel paints with the shared roles.
|
|
22
|
+
gauge?(): GaugePaint | undefined;
|
|
19
23
|
onRefresh(): Promise<void>;
|
|
20
24
|
onClose(): void;
|
|
21
25
|
requestRender(): void;
|
|
@@ -106,7 +110,7 @@ export class UsageView {
|
|
|
106
110
|
const inner = width - 2;
|
|
107
111
|
const title = this.refreshing ? REFRESHING : TITLE;
|
|
108
112
|
const top = theme.fg(FRAME_ROLE, "╭─ ") + theme.fg(TITLE_ROLE, title) + theme.fg(FRAME_ROLE, ` ${rule(inner - visibleWidth(title) - 3)}╮`);
|
|
109
|
-
const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active(), this.deps.registry?.(), this.deps.scope?.()).map(
|
|
113
|
+
const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active(), this.deps.registry?.(), this.deps.scope?.(), this.deps.gauge?.()).map(
|
|
110
114
|
(line) => `${theme.fg(FRAME_ROLE, "│")} ${fit(line, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`,
|
|
111
115
|
);
|
|
112
116
|
const hints = KEYS.map(([key, label]) => ({ key, label, text: `${key} ${label}`, action: (key === "r" ? "refresh" : "close") as HintAction }));
|
package/lib/shell-usage.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { parseModelRef } from "./agents-config.ts";
|
|
3
|
-
import { paintGauge } from "./shell-gauge.ts";
|
|
3
|
+
import { paintGauge, type GaugePaint } from "./shell-gauge.ts";
|
|
4
4
|
|
|
5
5
|
// Gentle Shell subscription usage: the rate-limit windows each connected
|
|
6
6
|
// provider reports. Codex sends them as SSE headers and through its usage
|
|
@@ -514,16 +514,16 @@ export function accountIdFromToken(token: string): string | undefined {
|
|
|
514
514
|
}
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
-
function paintMeter(percent: number, cells: number, theme: UsageTheme): string {
|
|
518
|
-
return paintGauge(percent, theme, cells);
|
|
517
|
+
function paintMeter(percent: number, cells: number, theme: UsageTheme, paint?: GaugePaint): string {
|
|
518
|
+
return paintGauge(percent, theme, cells, paint);
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
-
export function renderUsageBar(usage: ProviderUsage, theme: UsageTheme, activeModelId?: string): string | undefined {
|
|
521
|
+
export function renderUsageBar(usage: ProviderUsage, theme: UsageTheme, activeModelId?: string, paint?: GaugePaint): string | undefined {
|
|
522
522
|
const main = selectUsageLimit(usage, activeModelId);
|
|
523
523
|
const [first, ...rest] = main?.windows ?? [];
|
|
524
524
|
if (!first) return undefined;
|
|
525
525
|
// An unlabeled window prints as the name, the meter and the percentage.
|
|
526
|
-
const head = [theme.fg(ROLE.LABEL, main.name), ...(first.label.length === 0 ? [] : [theme.fg(ROLE.LABEL, first.label)]), paintMeter(first.usedPercent, 8, theme), theme.fg(ROLE.PERCENT, `${Math.round(first.usedPercent)}%`)].join(" ");
|
|
526
|
+
const head = [theme.fg(ROLE.LABEL, main.name), ...(first.label.length === 0 ? [] : [theme.fg(ROLE.LABEL, first.label)]), paintMeter(first.usedPercent, 8, theme, paint), theme.fg(ROLE.PERCENT, `${Math.round(first.usedPercent)}%`)].join(" ");
|
|
527
527
|
const tail = rest.map((window) => `${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.LABEL, window.label)} ${theme.fg(ROLE.PERCENT, `${Math.round(window.usedPercent)}%`)}`);
|
|
528
528
|
return [head, ...tail].join(" ");
|
|
529
529
|
}
|
|
@@ -574,7 +574,7 @@ export interface UsagePanelScope {
|
|
|
574
574
|
// snapshot keeps that snapshot and says so beside it, and a provider recorded
|
|
575
575
|
// under a previous profile's routing is never presented as current scope after
|
|
576
576
|
// a profile switch.
|
|
577
|
-
export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, width: number, now: number, active?: ActiveProvider, registry?: UsageSourceRegistry, scope?: UsagePanelScope): string[] {
|
|
577
|
+
export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, width: number, now: number, active?: ActiveProvider, registry?: UsageSourceRegistry, scope?: UsagePanelScope, paint?: GaugePaint): string[] {
|
|
578
578
|
const activeUsage = active ? usages.find((usage) => usage.provider === active.provider) : undefined;
|
|
579
579
|
const byProvider = new Map(usages.map((usage) => [usage.provider, usage] as const));
|
|
580
580
|
const rows = scope
|
|
@@ -607,7 +607,7 @@ export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, wid
|
|
|
607
607
|
const percent = `${Math.round(row.window.usedPercent)}%`.padStart(4);
|
|
608
608
|
const reset = formatReset(row.window.resetAt, now);
|
|
609
609
|
const tail = reset.length > 0 ? ` ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, reset)}` : "";
|
|
610
|
-
lines.push(` ${theme.fg(ROLE.LABEL, row.name.padEnd(nameWidth))} ${paintMeter(row.window.usedPercent, PANEL_METER_CELLS, theme)} ${theme.fg(ROLE.PERCENT, percent)}${tail}`);
|
|
610
|
+
lines.push(` ${theme.fg(ROLE.LABEL, row.name.padEnd(nameWidth))} ${paintMeter(row.window.usedPercent, PANEL_METER_CELLS, theme, paint)} ${theme.fg(ROLE.PERCENT, percent)}${tail}`);
|
|
611
611
|
}
|
|
612
612
|
// A failure after a good snapshot never hides either fact: the last good
|
|
613
613
|
// snapshot stays on its rows and the failed refresh says so beside it.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { namesExistingFeatureDocumentTask } from "./bounded-writer-admission.ts";
|
|
2
|
+
import { contextBackstopReached, type MeasuredContext } from "./context-backstop.ts";
|
|
3
|
+
|
|
4
|
+
// odd/tasks/verifier-admission.md: the Verification rule reserves the independent verifier for a change that names a
|
|
5
|
+
// Task Size high-risk item, yet a bench run still launched it on a small, not high-risk task. In the primary session a
|
|
6
|
+
// verifier launch is admitted only when its task or context declares a named high-risk item, carries a task of an
|
|
7
|
+
// existing feature document (the same lines writer admission accepts), or the parent's measured context reaches the
|
|
8
|
+
// context backstop. The agent catalog has no generic verifier alias, so only `gentle-ai-verify` is gated.
|
|
9
|
+
const VERIFIER_NAMES = ["gentle-ai-verify"];
|
|
10
|
+
// A line that starts with `Risk:` (optionally as a list item) whose first entry is `item N` with N in 1-6, for
|
|
11
|
+
// example `Risk: item 3 (shared parser)` or `- Risk: item 2, item 3 (reason)`. `Risk: none` and prose mentions
|
|
12
|
+
// do not start such a line.
|
|
13
|
+
const HIGH_RISK_ITEM_LINE = /^[ \t]*(?:[-*+][ \t]+)?Risk:[ \t]*item[ \t]+[1-6](?!\d)/m;
|
|
14
|
+
export const VERIFIER_RISK_REJECTION =
|
|
15
|
+
"An independent verifier runs only for a change that declares a named high-risk item (`Risk: item N` from the Task Size list in the task). This change declares none: run its focused test and suite inline yourself now; do not retry the launch, and never add a Risk line or create a feature document only to get a verifier admitted.";
|
|
16
|
+
|
|
17
|
+
export function rejectUnriskedVerifierLaunch(
|
|
18
|
+
input: unknown,
|
|
19
|
+
options: { root: string; contextUsage: MeasuredContext | undefined; env?: Readonly<Record<string, string | undefined>> },
|
|
20
|
+
): { block: true; reason: string } | undefined {
|
|
21
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
|
|
22
|
+
const record = input as Record<string, unknown>;
|
|
23
|
+
if (typeof record.agent !== "string" || !VERIFIER_NAMES.includes(record.agent)) return undefined;
|
|
24
|
+
const values = [record.task, record.context];
|
|
25
|
+
if (values.some(value => typeof value === "string" && HIGH_RISK_ITEM_LINE.test(value))) return undefined;
|
|
26
|
+
if (namesExistingFeatureDocumentTask(values, options.root)) return undefined;
|
|
27
|
+
if (contextBackstopReached(options.contextUsage, options.env)) return undefined;
|
|
28
|
+
return { block: true, reason: VERIFIER_RISK_REJECTION };
|
|
29
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// YOLO temp-scratch waiver: a recursive `rm` whose every literal target is the
|
|
2
|
+
// session's own scratch under a temp root needs no data-loss confirmation.
|
|
3
|
+
// Pure over an injectable filesystem view so tests never touch real paths.
|
|
4
|
+
// Callers own the policy gate: primary session with active YOLO only.
|
|
5
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { posix } from "node:path";
|
|
8
|
+
import type { DestructiveCommandMatch } from "./destructive-command-guard.ts";
|
|
9
|
+
|
|
10
|
+
export interface TempScratchStat {
|
|
11
|
+
uid: number;
|
|
12
|
+
isDirectory(): boolean;
|
|
13
|
+
isFile(): boolean;
|
|
14
|
+
isSymbolicLink(): boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TempScratchFs {
|
|
18
|
+
/** Throws an error with code `ENOENT` when the path is missing. */
|
|
19
|
+
lstat(path: string): TempScratchStat;
|
|
20
|
+
/** Current process uid; undefined where POSIX ownership is unavailable. */
|
|
21
|
+
uid: number | undefined;
|
|
22
|
+
/** Resolved `os.tmpdir()`, when resolvable. */
|
|
23
|
+
tmpdir: string | undefined;
|
|
24
|
+
home: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FIXED_TEMP_ROOTS = ["/tmp", "/var/tmp"];
|
|
28
|
+
const MKTEMP_NAME_RE = /^tmp\.[A-Za-z0-9]{6,}$/;
|
|
29
|
+
// A closed character set: expansion, quoting, glob, brace and redirection
|
|
30
|
+
// characters (`$`, backtick, `~`, `*?[{`, quotes, `<>`) make the shell
|
|
31
|
+
// target differ from the text or add a write elsewhere.
|
|
32
|
+
const LITERAL_PATH_RE = /^\/[A-Za-z0-9._@%+=:,/-]*$/;
|
|
33
|
+
|
|
34
|
+
export function defaultTempScratchFs(): TempScratchFs {
|
|
35
|
+
let resolvedTmpdir: string | undefined;
|
|
36
|
+
try { resolvedTmpdir = realpathSync(tmpdir()); } catch { resolvedTmpdir = undefined; }
|
|
37
|
+
return { lstat: (path) => lstatSync(path), uid: process.getuid?.(), tmpdir: resolvedTmpdir, home: homedir() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A temp root equal to, above or below home would expose home entries as scratch. */
|
|
41
|
+
function tempRoots(fs: TempScratchFs): string[] {
|
|
42
|
+
const home = posix.normalize(fs.home);
|
|
43
|
+
return [...FIXED_TEMP_ROOTS, fs.tmpdir].filter((root): root is string =>
|
|
44
|
+
root !== undefined && posix.isAbsolute(root) && posix.normalize(root) === root && root !== "/" &&
|
|
45
|
+
root !== home && !home.startsWith(`${root}/`) && !root.startsWith(`${home}/`));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One literal path component directly under a temp root, proven own scratch by an admission-time lstat. */
|
|
49
|
+
export function isTempScratchTarget(target: string, fs: TempScratchFs): boolean {
|
|
50
|
+
if (fs.uid === undefined || !LITERAL_PATH_RE.test(target)) return false;
|
|
51
|
+
if (target.endsWith("/") || posix.normalize(target) !== target) return false;
|
|
52
|
+
const name = posix.basename(target);
|
|
53
|
+
if (name === "" || name === "." || name === ".." || !tempRoots(fs).includes(posix.dirname(target))) return false;
|
|
54
|
+
let stat: TempScratchStat;
|
|
55
|
+
try {
|
|
56
|
+
stat = fs.lstat(target);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return (error as { code?: unknown }).code === "ENOENT";
|
|
59
|
+
}
|
|
60
|
+
if (stat.isSymbolicLink()) return true;
|
|
61
|
+
if (stat.uid !== fs.uid) return false;
|
|
62
|
+
return stat.isFile() || (stat.isDirectory() && MKTEMP_NAME_RE.test(name));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A recognized effect is waivable temp scratch only when it is a direct recursive `rm` of temp scratch alone. */
|
|
66
|
+
export function isTempScratchRemoval(match: DestructiveCommandMatch, fs: TempScratchFs): boolean {
|
|
67
|
+
if (match.hardDeny || match.kind !== "filesystem" || match.removalTargets === undefined) return false;
|
|
68
|
+
return match.removalTargets.every((target) => isTempScratchTarget(target, fs));
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: an ODD development harness with focused subagents, configured TDD evidence, native review, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -42,7 +42,6 @@
|
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
44
|
"check:provider-contract": "node scripts/check-provider-contract.mjs",
|
|
45
|
-
"mirror:odd-routing": "node scripts/mirror-odd-routing.mjs",
|
|
46
45
|
"postinstall": "node scripts/install-gentle-ai.mjs",
|
|
47
46
|
"test": "node scripts/run-test-suite.mjs",
|
|
48
47
|
"test:harness": "node --experimental-strip-types tests/runtime-harness.mjs",
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
resolveGentleAiReleaseAsset,
|
|
16
16
|
} from "../scripts/gentle-ai-installer.mjs";
|
|
17
17
|
import { engramPinVersion } from "../scripts/installer-downloads.mjs";
|
|
18
|
-
import {
|
|
18
|
+
import { bundledPiConfigDir, bundledPiConfigParent, GENTLE_SHELL_CONFIG_HOME_ENV, resolveGentlePiConfigHome, runningBundledPrefix } from "./gentle-shell-bundled.mjs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
|
|
21
21
|
export const GENTLE_AI_BINARY_MISSING_CODE = "package-local-binary-missing";
|
|
@@ -87,9 +87,11 @@ export function bundledGentleAiPaths(prefix , platform = process.
|
|
|
87
87
|
* (only when the pinned Engram is published), GENTLE_AI_NO_SELF_UPDATE=1 and
|
|
88
88
|
* GENTLE_PI_CONFIG_HOME (the parent of GENTLE_AI_PI_CONFIG_DIR)
|
|
89
89
|
* when this gentle-pi runs from the valid bundled install GENTLE_SHELL_PREFIX
|
|
90
|
-
* names (runningBundledPrefix).
|
|
91
|
-
*
|
|
92
|
-
*
|
|
90
|
+
* names (runningBundledPrefix). In the standalone launcher's isolated home
|
|
91
|
+
* (#2201) only the config pair, for the folder its marker names. Otherwise
|
|
92
|
+
* `env` itself, unchanged: a gentle-pi installed elsewhere (the user's own,
|
|
93
|
+
* started from the agent's shell, which inherits the variable) keeps its own
|
|
94
|
+
* Gentle AI.
|
|
93
95
|
*/
|
|
94
96
|
export function gentleAiIsolationEnv(
|
|
95
97
|
env ,
|
|
@@ -97,7 +99,7 @@ export function gentleAiIsolationEnv(
|
|
|
97
99
|
) {
|
|
98
100
|
const platform = options.platform ?? process.platform;
|
|
99
101
|
const prefix = runningBundledPrefix(env, { packageRoot: options.packageRoot, platform });
|
|
100
|
-
if (prefix === undefined) return env;
|
|
102
|
+
if (prefix === undefined) return standaloneGentleAiEnv(env, platform);
|
|
101
103
|
const paths = bundledGentleAiPaths(prefix, platform);
|
|
102
104
|
let engram = false;
|
|
103
105
|
try {
|
|
@@ -126,6 +128,23 @@ export function gentleAiIsolationEnv(
|
|
|
126
128
|
};
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
// #2201: the same config pair as a bundled install, for the folder the
|
|
132
|
+
// standalone launcher's marker names, when it decides the config home
|
|
133
|
+
// (resolveGentlePiConfigHome; an explicit GENTLE_PI_CONFIG_HOME keeps its
|
|
134
|
+
// existing meaning for Gentle AI). Gentle AI's own state stays where it was.
|
|
135
|
+
// The parent is given only for a folder named gentle-ai, the one Gentle AI
|
|
136
|
+
// 4.0.0 appends to it.
|
|
137
|
+
function standaloneGentleAiEnv(env , platform ) {
|
|
138
|
+
const marker = env[GENTLE_SHELL_CONFIG_HOME_ENV];
|
|
139
|
+
if (marker === undefined || resolveGentlePiConfigHome(env, homedir(), { platform }) !== marker) return env;
|
|
140
|
+
const path = platform === "win32" ? win32 : posix;
|
|
141
|
+
return {
|
|
142
|
+
...env,
|
|
143
|
+
GENTLE_AI_PI_CONFIG_DIR: marker,
|
|
144
|
+
...(path.basename(marker) === "gentle-ai" ? { GENTLE_PI_CONFIG_HOME: path.dirname(marker) } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
129
148
|
function sha256(value ) {
|
|
130
149
|
return createHash("sha256").update(value).digest("hex");
|
|
131
150
|
}
|
|
@@ -137,7 +156,9 @@ function sha256(value ) {
|
|
|
137
156
|
// 1. GENTLE_PI_GENTLE_AI_DEV_BINARY (session override, absolute path), then
|
|
138
157
|
// 2. the persistent registration file at
|
|
139
158
|
// <config home>/dev-binary.json (a bundled prefix's gentle-pi, else
|
|
140
|
-
// GENTLE_PI_CONFIG_HOME, else ~/.pi/gentle-ai
|
|
159
|
+
// GENTLE_PI_CONFIG_HOME, else ~/.pi/gentle-ai, never the standalone
|
|
160
|
+
// launcher's marker: the main channel writes it outside any session,
|
|
161
|
+
// #2201) with the strict
|
|
141
162
|
// shape {"schema":"gentle-pi.dev-binary/v1","path":"<absolute path>"}.
|
|
142
163
|
//
|
|
143
164
|
// The registration deliberately pins no digest: it is the unpinned field-test
|
|
@@ -195,7 +216,7 @@ function ambientDevBinaryEnvironment() {
|
|
|
195
216
|
}
|
|
196
217
|
|
|
197
218
|
export function gentleAiDevBinaryRegistrationPath(environment = ambientDevBinaryEnvironment()) {
|
|
198
|
-
const configHome =
|
|
219
|
+
const configHome = resolveGentlePiConfigHome(environment.env, environment.home, { launcherMarker: false });
|
|
199
220
|
return join(configHome, "dev-binary.json");
|
|
200
221
|
}
|
|
201
222
|
|
|
@@ -176,6 +176,29 @@ export function bundledGentlePiConfigHome(env ) {
|
|
|
176
176
|
return prefix === undefined ? undefined : bundledPiConfigDir(prefix);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// #2201: the standalone launcher gives Pi this variable in its isolated home
|
|
180
|
+
// only, naming the config folder next to the agent dir (standaloneConfigHome:
|
|
181
|
+
// ~/.gentle-shell/gentle-ai by default). It is launcher-owned, never
|
|
182
|
+
// GENTLE_PI_CONFIG_HOME: Gentle AI 4.0.0 reads that one as the parent of its
|
|
183
|
+
// own gentle-ai folder, and the agent's shell would pass it to any gentle-ai.
|
|
184
|
+
export const GENTLE_SHELL_CONFIG_HOME_ENV = "GENTLE_SHELL_CONFIG_HOME";
|
|
185
|
+
|
|
186
|
+
// gentle-pi's config home, the one rule every reader shares: a bundled
|
|
187
|
+
// prefix's folder, then an explicit GENTLE_PI_CONFIG_HOME, then the launcher's
|
|
188
|
+
// marker (absolute only), then ~/.pi/gentle-ai. Empty values count as unset.
|
|
189
|
+
// `launcherMarker: false` is for install-level state written outside a session
|
|
190
|
+
// (the main channel's channel.json, builds and dev-binary.json): the installer
|
|
191
|
+
// and `gentle-shell upgrade` never see the marker, so their readers ignore it.
|
|
192
|
+
export function resolveGentlePiConfigHome(env , home , options = {}) {
|
|
193
|
+
const path = pathFor(options.platform ?? process.platform);
|
|
194
|
+
const bundled = bundledGentlePiConfigHome(env);
|
|
195
|
+
if (bundled !== undefined) return bundled;
|
|
196
|
+
if (env.GENTLE_PI_CONFIG_HOME) return env.GENTLE_PI_CONFIG_HOME;
|
|
197
|
+
const marker = env[GENTLE_SHELL_CONFIG_HOME_ENV];
|
|
198
|
+
if (options.launcherMarker !== false && marker && path.isAbsolute(marker)) return marker;
|
|
199
|
+
return path.join(home, ".pi", "gentle-ai");
|
|
200
|
+
}
|
|
201
|
+
|
|
179
202
|
// The exact Pi of the version (S8): the Pi the version's package.json pins,
|
|
180
203
|
// installed inside the version folder with Pi's own bin entry. Never another Pi.
|
|
181
204
|
export function bundledPi(install , input ) {
|
|
@@ -214,6 +237,8 @@ export function bundledPi(install , input
|
|
|
214
237
|
// memory is allowed locally; otherwise the Shell-owned guard, so no provider,
|
|
215
238
|
// whatever loaded it, runs an Engram binary against an unverified store.
|
|
216
239
|
export const ENGRAM_COMPANION_SOURCE = "npm:gentle-engram@0.4.2";
|
|
240
|
+
export const ENGRAM_PRIVATE_REQUIRED_NOTICE = "Engram memory disabled: session-owned servers require a supporting published bundled core and companion. This bundle does not establish both capabilities; it will not fall back to a detached shared server. Install a supporting published bundle and restart Shell. Existing shared services are not stopped or adopted as session-owned.";
|
|
241
|
+
export const ENGRAM_CORE_CHANGED_NOTICE = "Engram memory disabled: the bundled core changed since Shell recorded this store. Install a published bundle supporting migrate-copy, stop every Engram client, retain a complete backup, then run gentle-shell memory migrate --confirm-quiescent. The command verifies and migrates a copy before updating provenance; older bundles refuse safely. Do not edit coreSha256 or delete the original store. Shell remains usable without memory.";
|
|
217
242
|
export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe store or server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server with verified /compatibility evidence or matching Shell-provisioned store identity. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
|
|
218
243
|
// `gentle-shell setup` publishes the verified core (ensureEngram) on every run.
|
|
219
244
|
export const ENGRAM_PROVISION_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not provision a new Engram store identity, so it cannot vouch for that store. Restart Shell to retry. If Shell keeps reporting unknown compatibility and ~/.engram holds only .instance-id files, back it up and remove it, then restart Shell. Shell remains usable without memory.";
|
|
@@ -237,7 +262,7 @@ function outdatedServerVersion(version )
|
|
|
237
262
|
// The default endpoint gentle-engram uses without ENGRAM_URL or ENGRAM_PORT.
|
|
238
263
|
export const ENGRAM_DEFAULT_PORT = 7437;
|
|
239
264
|
export const ENGRAM_DEFAULT_URL = `http://127.0.0.1:${ENGRAM_DEFAULT_PORT}`;
|
|
240
|
-
const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_DATABASE_URL"];
|
|
265
|
+
const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL"];
|
|
241
266
|
const HEX32 = /^[a-f0-9]{32}$/;
|
|
242
267
|
|
|
243
268
|
|
|
@@ -271,7 +296,7 @@ export function classifyEngramStartup(input
|
|
|
271
296
|
// Shell's own store, created by this exact core: offline, the provider starts
|
|
272
297
|
// that core; a running server must be that store and that exact core.
|
|
273
298
|
if (endpoint.kind === "refused") return { policy: "allow" };
|
|
274
|
-
if (endpoint.kind === "listening" && endpoint.healthInstanceId === own.instanceId && endpoint.healthVersion === input.coreVersion) return { policy: "allow" };
|
|
299
|
+
if (endpoint.kind === "listening" && endpoint.healthInstanceId === own.instanceId && endpoint.healthVersion === input.coreVersion) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
|
|
275
300
|
return unknown;
|
|
276
301
|
}
|
|
277
302
|
|
|
@@ -402,6 +427,11 @@ export function supportsEngramStartupPolicy(metadata )
|
|
|
402
427
|
return pkg?.name === "gentle-engram" && pkg.engram?.startupPolicy === 1;
|
|
403
428
|
}
|
|
404
429
|
|
|
430
|
+
export function supportsEngramSessionOwned(metadata ) {
|
|
431
|
+
const pkg = parseJson(metadata);
|
|
432
|
+
return supportsEngramStartupPolicy(metadata) && pkg?.engram?.sessionOwned === 1;
|
|
433
|
+
}
|
|
434
|
+
|
|
405
435
|
export function bundledChildEnv(base , input ) {
|
|
406
436
|
const env = { ...base, PI_SKIP_VERSION_CHECK: "1", [GENTLE_SHELL_PREFIX_ENV]: input.prefix };
|
|
407
437
|
delete env.GENTLE_SHELL_PI;
|
|
@@ -426,6 +456,12 @@ export function bundledChildEnv(base , input
|
|
|
426
456
|
if (memory.url !== undefined) env.ENGRAM_URL = memory.url;
|
|
427
457
|
const local = memory.policy === "allow" && memory.url === undefined ? input.engram : undefined;
|
|
428
458
|
env.ENGRAM_BIN = local ?? input.guard;
|
|
459
|
+
if (local && memory.owned) {
|
|
460
|
+
env.ENGRAM_SERVER_MODE = "session-owned";
|
|
461
|
+
delete env.ENGRAM_URL;
|
|
462
|
+
delete env.ENGRAM_PORT;
|
|
463
|
+
delete env.ENGRAM_SOCKET;
|
|
464
|
+
}
|
|
429
465
|
return env;
|
|
430
466
|
}
|
|
431
467
|
|