gentle-pi 4.1.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 +352 -128
- 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/engram-guard +1 -1
- package/bin/gentle-shell.mjs +188 -35
- 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/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +40 -8
- 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 +20 -12
- package/docs/install-wizard.md +17 -10
- package/docs/permission-modes.md +51 -6
- package/docs/readme-reference.md +155 -31
- package/docs/session-profile-format.md +39 -0
- package/docs/yolo-mode.md +21 -7
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +146 -28
- package/extensions/gentle-ai.ts +380 -68
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +53 -43
- package/extensions/gentle-todo.ts +62 -18
- 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/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +215 -36
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +251 -35
- package/lib/auto-permission-tool-context.ts +61 -0
- 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 +215 -28
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +62 -8
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +146 -12
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/model-routing-authority.ts +6 -1
- 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/selection-engine.ts +4 -1
- package/lib/session-profile-freeze.ts +40 -16
- package/lib/session-profile-persistence.ts +187 -0
- 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-changes.ts +2 -0
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-todo.ts +39 -8
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/vim-editor-adapter.ts +22 -4
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +5 -6
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +61 -7
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +144 -10
- 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/install-wizard-preview.mjs +2 -2
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/pi-update.mjs +223 -0
- 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/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +23 -5
- package/tests/auto-mode-sdk.test.ts +29 -2
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +353 -2
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +270 -12
- package/tests/autonomous-guard.test.ts +208 -16
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -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 +35 -7
- 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 +443 -8
- 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 +180 -17
- package/tests/gentle-shell-bundled.test.ts +235 -38
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +97 -13
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +113 -14
- package/tests/gentle-todo.test.ts +258 -27
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/install-wizard.test.ts +2 -2
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +3 -3
- package/tests/installer-probes.test.ts +8 -8
- package/tests/installer-runner.test.ts +14 -13
- package/tests/installer-server.test.ts +13 -5
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/main-channel-upgrade.test.ts +7 -1
- 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/odd-runtime-delegation-gate.test.ts +4 -1
- 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 +28 -24
- package/tests/pi-update-launcher.test.ts +106 -0
- package/tests/pi-update.test.ts +186 -0
- 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 +131 -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/selection-engine.test.ts +35 -5
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +37 -1
- package/tests/session-profile-persistence.test.ts +334 -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-changes.test.ts +18 -0
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-todo.test.ts +15 -12
- 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/vim-editor-adapter.test.ts +41 -15
- 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 +24 -1
- 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-changes.ts
CHANGED
|
@@ -110,6 +110,8 @@ export function snapshotChanges(snapshot: ChangesSnapshot): ChangedFile[] {
|
|
|
110
110
|
const counts = new Map(parseNumstat(snapshot.numstat).map((entry) => [entry.path, entry]));
|
|
111
111
|
const files: ChangedFile[] = [];
|
|
112
112
|
for (const [path, status] of parsePorcelain(snapshot.porcelain)) {
|
|
113
|
+
// Dependency backups are local artifacts; tracked files remain visible.
|
|
114
|
+
if (status === CHANGE_STATUS.UNTRACKED && /(?:^|\/)node_modules\.backup-[^/]+\//.test(path)) continue;
|
|
113
115
|
const entry = counts.get(path);
|
|
114
116
|
files.push({ path, added: entry?.added ?? 0, deleted: entry?.deleted ?? 0, status });
|
|
115
117
|
}
|
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-prompt.ts
CHANGED
|
@@ -74,7 +74,9 @@ export const DOUBLE_ESC_CANCEL_HINT = "esc again to cancel";
|
|
|
74
74
|
export const IDLE_ESC_CLEAR_HINT = "esc again to clear";
|
|
75
75
|
const LABEL_ROLE = "muted";
|
|
76
76
|
const HINT_ROLE = "dim";
|
|
77
|
-
|
|
77
|
+
// Audited legacy SGR and Pi 1.2 APC cursors. Do not resolve the latter
|
|
78
|
+
// here: the host TUI owns hardware-cursor suppression after composition.
|
|
79
|
+
const EMPTY_FAKE_CURSORS = ["\x1b[7m \x1b[0m", "\x1b_pi:fc\x07 \x1b_pi:/fc\x07"];
|
|
78
80
|
const SCROLL_INDICATOR = /[↑↓] \d+ more/;
|
|
79
81
|
const STATE_LABEL: Record<PromptState, string | undefined> = {
|
|
80
82
|
[PROMPT_STATE.IDLE]: undefined,
|
|
@@ -228,9 +230,9 @@ export function framePromptLines(lines: string[], width: number, options: Prompt
|
|
|
228
230
|
}
|
|
229
231
|
|
|
230
232
|
export function withPromptHint(line: string, hint: string, fg: PromptFrameOptions["fg"]): string {
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
233
|
-
const afterCursor =
|
|
233
|
+
const cursor = EMPTY_FAKE_CURSORS.find((marker) => line.includes(marker));
|
|
234
|
+
if (!cursor) return line;
|
|
235
|
+
const afterCursor = line.indexOf(cursor) + cursor.length;
|
|
234
236
|
const trailing = line.slice(afterCursor);
|
|
235
237
|
if (trailing.trim() !== "" || trailing.length < hint.length + 1) return line;
|
|
236
238
|
return `${line.slice(0, afterCursor)} ${fg(HINT_ROLE, hint)}${" ".repeat(trailing.length - hint.length - 1)}`;
|
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,
|
|
@@ -258,16 +273,32 @@ export function replayTodo(entries: readonly unknown[]): TodoState {
|
|
|
258
273
|
return state;
|
|
259
274
|
}
|
|
260
275
|
|
|
261
|
-
|
|
276
|
+
// gentle-shell#1367, #1662: the system prompt is a cached prefix for every
|
|
277
|
+
// provider, so the block it carries is byte-stable for the whole session:
|
|
278
|
+
// rules only, no task content and no counters. The current list travels as a
|
|
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.
|
|
282
|
+
const TODO_PROMPT_BLOCK = [
|
|
283
|
+
"## Todo list",
|
|
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.",
|
|
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.",
|
|
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.",
|
|
287
|
+
].join("\n");
|
|
288
|
+
|
|
289
|
+
/** The byte-stable rules block appended to every system prompt. */
|
|
290
|
+
export function todoPromptBlock(): string {
|
|
291
|
+
return TODO_PROMPT_BLOCK;
|
|
292
|
+
}
|
|
293
|
+
|
|
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. */
|
|
298
|
+
export function todoStateMessage(state: TodoState): string | undefined {
|
|
262
299
|
if (todoSummary(state).open === 0) return undefined;
|
|
263
300
|
const lines = state.tasks.map((task, index) => `${index + 1}. [${task.status}] ${task.title}${task.note ? ` — ${task.note}` : ""}`);
|
|
264
|
-
|
|
265
|
-
return [
|
|
266
|
-
"## Todo list",
|
|
267
|
-
"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.",
|
|
268
|
-
"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.",
|
|
269
|
-
...lines,
|
|
270
|
-
].join("\n") + staleLine;
|
|
301
|
+
return ["Current todo list:", ...lines].join("\n");
|
|
271
302
|
}
|
|
272
303
|
|
|
273
304
|
function taskRow(task: TodoTask, theme: TodoTheme, inner: number): string {
|
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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CURSOR_MARKER, Editor, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
|
|
4
|
-
// Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0 and 1.
|
|
4
|
+
// Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0, 1.1.0 and 1.2.0:
|
|
5
5
|
// editor.js state/pastes/history, sticky cursor reset, layoutText/render geometry,
|
|
6
6
|
// autocomplete cancellation and { state, pastes, pasteCounter } undo restoration;
|
|
7
7
|
// undo-stack.js clone-on-push/pop snapshots. The two 0.99 releases ship identical
|
|
@@ -13,6 +13,10 @@ import { createRequire } from "node:module";
|
|
|
13
13
|
// Actual installed bundled/unbundled tests exercise identity, edit/undo, paste,
|
|
14
14
|
// selection, wrapping/scroll and autocomplete; fabricated metadata tests only
|
|
15
15
|
// prove exact-version admission for the older audited releases.
|
|
16
|
+
// 1.2.0 changes only editor cursor rendering to paired pi:fc/pi:/fc APC
|
|
17
|
+
// markers resolved by the host TUI (including hardware-cursor suppression).
|
|
18
|
+
// Keep these markers intact and rearm selection after their reverse-off;
|
|
19
|
+
// state, undo, paste, layout and autocomplete contracts remain unchanged.
|
|
16
20
|
// Never silently adapt another build: undo also owns the paste registry.
|
|
17
21
|
interface Position { line: number; col: number }
|
|
18
22
|
interface State { lines: string[]; cursorLine: number; cursorCol: number }
|
|
@@ -51,7 +55,7 @@ interface PrivateEditor {
|
|
|
51
55
|
|
|
52
56
|
// The single audited-release source for the adapter and Gentle Shell's runtime
|
|
53
57
|
// identity gates. Add a release only after re-auditing the files named above.
|
|
54
|
-
export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0"] as const);
|
|
58
|
+
export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0"] as const);
|
|
55
59
|
export type AuditedPiEditorVersion = (typeof AUDITED_PI_EDITOR_VERSIONS)[number];
|
|
56
60
|
|
|
57
61
|
export function isAuditedPiEditorVersion(version: unknown): version is AuditedPiEditorVersion {
|
|
@@ -83,7 +87,7 @@ function hasEditorIdentity(value: unknown, version: string, editorClass: typeof
|
|
|
83
87
|
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
84
88
|
const marker = /\[paste #(\d+)(?: (?:\+\d+ lines|\d+ chars))?\]/g;
|
|
85
89
|
|
|
86
|
-
// Only CSI SGR and Pi's own zero-width cursor
|
|
90
|
+
// Only CSI SGR and Pi's own zero-width cursor markers are expected in editor
|
|
87
91
|
// text. Unknown escapes leave the unmodified frame intact (fail closed).
|
|
88
92
|
function highlightRenderedRow(row: string, chunk: LayoutLine, left: number, right: number, padding: number): string | undefined {
|
|
89
93
|
const cursor = chunk.hasCursor ? chunk.cursorPos : undefined;
|
|
@@ -114,8 +118,22 @@ function highlightRenderedRow(row: string, chunk: LayoutLine, left: number, righ
|
|
|
114
118
|
result += token;
|
|
115
119
|
if (selected && !inverse) { result += "\x1b[7m"; inverse = true; }
|
|
116
120
|
} else if (token.startsWith("\x1b")) {
|
|
117
|
-
if (token !== CURSOR_MARKER) return undefined;
|
|
121
|
+
if (token !== CURSOR_MARKER && token !== "\x1b_pi:fc\x07" && token !== "\x1b_pi:/fc\x07") return undefined;
|
|
122
|
+
// Close selection *before* the cursor pair, never inside its cell:
|
|
123
|
+
// otherwise a later host SGR 7 would immediately meet our SGR 27.
|
|
124
|
+
// Closing before CURSOR_MARKER also keeps it adjacent to pi:fc so
|
|
125
|
+
// hardware-cursor suppression can still recognize the focused pair.
|
|
126
|
+
const local = col - padding;
|
|
127
|
+
if ((token === CURSOR_MARKER || token === "\x1b_pi:fc\x07") && selected &&
|
|
128
|
+
!(local >= left && local < right && local < chunk.text.length)) {
|
|
129
|
+
if (!nativeInverse && inverse) result += "\x1b[27m";
|
|
130
|
+
selected = false;
|
|
131
|
+
inverse = nativeInverse;
|
|
132
|
+
}
|
|
118
133
|
result += token;
|
|
134
|
+
// The host resolves pi:/fc to SGR 27, or drops the pair when the
|
|
135
|
+
// hardware cursor owns this cell. Selection must survive either path.
|
|
136
|
+
if (token === "\x1b_pi:/fc\x07" && selected) result += "\x1b[7m";
|
|
119
137
|
} else {
|
|
120
138
|
const local = col - padding;
|
|
121
139
|
const shouldSelect = local >= left && local < right && local < chunk.text.length;
|
|
@@ -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",
|
|
@@ -75,7 +74,7 @@
|
|
|
75
74
|
"@heyhuynhgiabuu/pi-pretty": "0.6.27"
|
|
76
75
|
},
|
|
77
76
|
"peerDependencies": {
|
|
78
|
-
"@earendil-works/pi-coding-agent": ">=
|
|
77
|
+
"@earendil-works/pi-coding-agent": ">=1.2.0",
|
|
79
78
|
"@earendil-works/pi-ai": "*",
|
|
80
79
|
"@earendil-works/pi-tui": "*",
|
|
81
80
|
"typebox": "*"
|
|
@@ -92,9 +91,9 @@
|
|
|
92
91
|
}
|
|
93
92
|
},
|
|
94
93
|
"devDependencies": {
|
|
95
|
-
"@earendil-works/pi-coding-agent": ">=1.
|
|
96
|
-
"@earendil-works/pi-ai": ">=1.
|
|
97
|
-
"@earendil-works/pi-tui": ">=1.
|
|
94
|
+
"@earendil-works/pi-coding-agent": ">=1.2.0",
|
|
95
|
+
"@earendil-works/pi-ai": ">=1.2.0",
|
|
96
|
+
"@earendil-works/pi-tui": ">=1.2.0",
|
|
98
97
|
"@types/node": "^24.13.3",
|
|
99
98
|
"typescript": "^5.9.3"
|
|
100
99
|
},
|