gentle-pi 0.3.7 → 0.3.9
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 +7 -2
- package/extensions/gentle-ai.ts +101 -36
- package/package.json +1 -1
- package/skills/release/SKILL.md +108 -0
- package/tests/runtime-harness.mjs +99 -1
package/README.md
CHANGED
|
@@ -462,12 +462,17 @@ node --experimental-strip-types --check extensions/startup-banner.ts
|
|
|
462
462
|
npm pack --dry-run
|
|
463
463
|
```
|
|
464
464
|
|
|
465
|
-
Publish:
|
|
465
|
+
Publish npm through GitHub Actions only:
|
|
466
466
|
|
|
467
467
|
```bash
|
|
468
|
-
|
|
468
|
+
gh workflow run publish.yml --repo Gentleman-Programming/gentle-pi --ref main -f dist-tag=latest
|
|
469
|
+
gh run watch <run-id> --repo Gentleman-Programming/gentle-pi --exit-status
|
|
470
|
+
npm view gentle-pi@<version> version --registry=https://registry.npmjs.org/
|
|
471
|
+
npm dist-tag ls gentle-pi --registry=https://registry.npmjs.org/
|
|
469
472
|
```
|
|
470
473
|
|
|
474
|
+
Do not run `npm publish` locally for `gentle-pi`; the GitHub workflow provides provenance, environment protection, and registry credentials.
|
|
475
|
+
|
|
471
476
|
## Principles
|
|
472
477
|
|
|
473
478
|
- Human control over agent momentum.
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -233,6 +233,9 @@ const MODEL_CONTROL_OPTIONS = [
|
|
|
233
233
|
INHERIT_MODEL,
|
|
234
234
|
CUSTOM_MODEL,
|
|
235
235
|
] as const;
|
|
236
|
+
const MODEL_PANEL_MAX_RENDER_ROWS = 20;
|
|
237
|
+
const AGENT_LIST_MAX_VISIBLE_ROWS = MODEL_PANEL_MAX_RENDER_ROWS - 13;
|
|
238
|
+
const MODEL_LIST_MAX_VISIBLE_ROWS = 12;
|
|
236
239
|
|
|
237
240
|
function readStringPath(value: unknown, path: string[]): string | undefined {
|
|
238
241
|
let current = value;
|
|
@@ -367,16 +370,32 @@ function isThinkingLevel(value: unknown): value is ThinkingLevel {
|
|
|
367
370
|
);
|
|
368
371
|
}
|
|
369
372
|
|
|
373
|
+
const ANSI_ESCAPE_PATTERN =
|
|
374
|
+
/[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
|
|
375
|
+
const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
|
|
376
|
+
const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/;
|
|
377
|
+
|
|
378
|
+
function sanitizeTerminalText(value: string): string {
|
|
379
|
+
return value
|
|
380
|
+
.replace(ANSI_ESCAPE_PATTERN, "")
|
|
381
|
+
.replace(CONTROL_CHAR_PATTERN, "");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function normalizeModelId(value: unknown): string | undefined {
|
|
385
|
+
if (typeof value !== "string") return undefined;
|
|
386
|
+
const model = value.trim();
|
|
387
|
+
if (model.length === 0) return undefined;
|
|
388
|
+
if (!SAFE_MODEL_ID_PATTERN.test(model)) return undefined;
|
|
389
|
+
return model;
|
|
390
|
+
}
|
|
391
|
+
|
|
370
392
|
function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
|
|
371
393
|
if (typeof value === "string") {
|
|
372
|
-
const model = value
|
|
373
|
-
return model
|
|
394
|
+
const model = normalizeModelId(value);
|
|
395
|
+
return model ? { model } : undefined;
|
|
374
396
|
}
|
|
375
397
|
if (!isRecord(value)) return undefined;
|
|
376
|
-
const model =
|
|
377
|
-
typeof value.model === "string" && value.model.trim().length > 0
|
|
378
|
-
? value.model.trim()
|
|
379
|
-
: undefined;
|
|
398
|
+
const model = normalizeModelId(value.model);
|
|
380
399
|
const thinking = isThinkingLevel(value.thinking) ? value.thinking : undefined;
|
|
381
400
|
if (!model && !thinking) return undefined;
|
|
382
401
|
return { model, thinking };
|
|
@@ -804,14 +823,15 @@ function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
|
|
|
804
823
|
const entry = config[agent.name];
|
|
805
824
|
const model = entry?.model ?? "inherit";
|
|
806
825
|
const thinking = entry?.thinking ?? "inherit";
|
|
807
|
-
return `${agent.name}: model=${model}, effort=${thinking}`;
|
|
826
|
+
return `${sanitizeTerminalText(agent.name)}: model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(thinking)}`;
|
|
808
827
|
});
|
|
809
828
|
}
|
|
810
829
|
|
|
811
830
|
async function getPiModelOptions(ctx: ExtensionContext): Promise<string[]> {
|
|
812
831
|
const models = await ctx.modelRegistry.getAvailable();
|
|
813
832
|
const modelIds = models
|
|
814
|
-
.map((model) => `${model.provider}/${model.id}`)
|
|
833
|
+
.map((model) => normalizeModelId(`${model.provider}/${model.id}`))
|
|
834
|
+
.filter((model): model is string => model !== undefined)
|
|
815
835
|
.sort((left, right) => left.localeCompare(right));
|
|
816
836
|
return [...MODEL_CONTROL_OPTIONS, ...modelIds];
|
|
817
837
|
}
|
|
@@ -868,9 +888,25 @@ class SddModelPanel implements OverlayComponent {
|
|
|
868
888
|
}
|
|
869
889
|
|
|
870
890
|
render(width: number): string[] {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
891
|
+
const innerWidth = Math.max(1, width - 4);
|
|
892
|
+
const lines =
|
|
893
|
+
this.mode === "models"
|
|
894
|
+
? this.renderModelPicker(innerWidth)
|
|
895
|
+
: this.mode === "effort"
|
|
896
|
+
? this.renderEffortPicker(innerWidth)
|
|
897
|
+
: this.renderAgentList(innerWidth);
|
|
898
|
+
return this.renderCard(lines, width);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private renderCard(lines: string[], width: number): string[] {
|
|
902
|
+
const innerWidth = Math.max(1, width - 4);
|
|
903
|
+
const fit = (text = "") =>
|
|
904
|
+
truncateToWidth(sanitizeTerminalText(text), innerWidth, "…", true).padEnd(innerWidth);
|
|
905
|
+
return [
|
|
906
|
+
`╭${"─".repeat(innerWidth + 2)}╮`,
|
|
907
|
+
...lines.map((line) => `│ ${fit(line)} │`),
|
|
908
|
+
`╰${"─".repeat(innerWidth + 2)}╯`,
|
|
909
|
+
];
|
|
874
910
|
}
|
|
875
911
|
|
|
876
912
|
private handleAgentInput(data: string): void {
|
|
@@ -883,25 +919,33 @@ class SddModelPanel implements OverlayComponent {
|
|
|
883
919
|
this.done({ type: "save", config: this.draft });
|
|
884
920
|
return;
|
|
885
921
|
}
|
|
886
|
-
if (matchesKey(data, "down") || data
|
|
922
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
887
923
|
this.cursor = Math.min(maxCursor, this.cursor + 1);
|
|
888
924
|
return;
|
|
889
925
|
}
|
|
890
|
-
if (matchesKey(data, "up") || data
|
|
926
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
891
927
|
this.cursor = Math.max(0, this.cursor - 1);
|
|
892
928
|
return;
|
|
893
929
|
}
|
|
894
|
-
if (data
|
|
930
|
+
if (matchesKey(data, "g")) {
|
|
931
|
+
this.cursor = 0;
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (data === "G") {
|
|
935
|
+
this.cursor = maxCursor;
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (matchesKey(data, "i")) {
|
|
895
939
|
this.applyInherit();
|
|
896
940
|
return;
|
|
897
941
|
}
|
|
898
|
-
if (data
|
|
942
|
+
if (matchesKey(data, "e")) {
|
|
899
943
|
this.selectedRow = this.rows[this.cursor] ?? SET_ALL_AGENTS;
|
|
900
944
|
this.mode = "effort";
|
|
901
945
|
this.effortCursor = 0;
|
|
902
946
|
return;
|
|
903
947
|
}
|
|
904
|
-
if (data
|
|
948
|
+
if (matchesKey(data, "c")) {
|
|
905
949
|
const row = this.rows[this.cursor];
|
|
906
950
|
if (row === SET_ALL_AGENTS)
|
|
907
951
|
this.done({ type: "custom", agent: "all", config: this.draft });
|
|
@@ -943,14 +987,14 @@ class SddModelPanel implements OverlayComponent {
|
|
|
943
987
|
);
|
|
944
988
|
return;
|
|
945
989
|
}
|
|
946
|
-
if (matchesKey(data, "down") || data
|
|
990
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
947
991
|
this.modelCursor = Math.min(
|
|
948
992
|
Math.max(0, options.length - 1),
|
|
949
993
|
this.modelCursor + 1,
|
|
950
994
|
);
|
|
951
995
|
return;
|
|
952
996
|
}
|
|
953
|
-
if (matchesKey(data, "up") || data
|
|
997
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
954
998
|
this.modelCursor = Math.max(0, this.modelCursor - 1);
|
|
955
999
|
return;
|
|
956
1000
|
}
|
|
@@ -1041,11 +1085,22 @@ class SddModelPanel implements OverlayComponent {
|
|
|
1041
1085
|
const lines: string[] = [];
|
|
1042
1086
|
const line = (text = "") =>
|
|
1043
1087
|
truncateToWidth(text, Math.max(1, width), "…", true);
|
|
1044
|
-
lines.push(line("Assign Models to Agents"));
|
|
1088
|
+
lines.push(line("Assign Models and Effort to Agents"));
|
|
1045
1089
|
lines.push("");
|
|
1046
1090
|
lines.push(line("Current assignments:"));
|
|
1047
1091
|
lines.push("");
|
|
1048
|
-
|
|
1092
|
+
const visibleRows = Math.min(AGENT_LIST_MAX_VISIBLE_ROWS, this.rows.length);
|
|
1093
|
+
const listCursor = Math.min(this.cursor, this.rows.length - 1);
|
|
1094
|
+
const start = Math.max(
|
|
1095
|
+
0,
|
|
1096
|
+
Math.min(
|
|
1097
|
+
listCursor - Math.floor(visibleRows / 2),
|
|
1098
|
+
Math.max(0, this.rows.length - visibleRows),
|
|
1099
|
+
),
|
|
1100
|
+
);
|
|
1101
|
+
const end = Math.min(this.rows.length, start + visibleRows);
|
|
1102
|
+
if (start > 0) lines.push(line(` ↑ ${start} more agent(s)`));
|
|
1103
|
+
for (let i = start; i < end; i++) {
|
|
1049
1104
|
const row = this.rows[i] ?? SET_ALL_AGENTS;
|
|
1050
1105
|
const focused = i === this.cursor;
|
|
1051
1106
|
const label =
|
|
@@ -1054,6 +1109,8 @@ class SddModelPanel implements OverlayComponent {
|
|
|
1054
1109
|
: this.renderAgentLabel(row);
|
|
1055
1110
|
lines.push(line(`${focused ? "▸" : " "} ${label}`));
|
|
1056
1111
|
}
|
|
1112
|
+
if (end < this.rows.length)
|
|
1113
|
+
lines.push(line(` ↓ ${this.rows.length - end} more agent(s)`));
|
|
1057
1114
|
lines.push("");
|
|
1058
1115
|
lines.push(
|
|
1059
1116
|
line(`${this.cursor === this.rows.length ? "▸" : " "} Continue`),
|
|
@@ -1064,7 +1121,7 @@ class SddModelPanel implements OverlayComponent {
|
|
|
1064
1121
|
lines.push("");
|
|
1065
1122
|
lines.push(
|
|
1066
1123
|
line(
|
|
1067
|
-
"j/k:
|
|
1124
|
+
"j/k: scroll • g/G: top/bottom • enter: change model / confirm • e: effort • i: inherit • c: custom • ctrl+s: save • esc: back",
|
|
1068
1125
|
),
|
|
1069
1126
|
);
|
|
1070
1127
|
return lines;
|
|
@@ -1075,22 +1132,21 @@ class SddModelPanel implements OverlayComponent {
|
|
|
1075
1132
|
const options = this.filteredModelOptions();
|
|
1076
1133
|
const line = (text = "") =>
|
|
1077
1134
|
truncateToWidth(text, Math.max(1, width), "…", true);
|
|
1078
|
-
lines.push(line(`Select model for ${this.selectedRow}`));
|
|
1135
|
+
lines.push(line(`Select model for ${sanitizeTerminalText(this.selectedRow)}`));
|
|
1079
1136
|
lines.push("");
|
|
1080
1137
|
lines.push(line(`◎ ${this.query || "search..."}`));
|
|
1081
1138
|
lines.push("");
|
|
1082
|
-
const maxVisible = 12;
|
|
1083
1139
|
const start = Math.max(
|
|
1084
1140
|
0,
|
|
1085
1141
|
Math.min(
|
|
1086
|
-
this.modelCursor - Math.floor(
|
|
1087
|
-
Math.max(0, options.length -
|
|
1142
|
+
this.modelCursor - Math.floor(MODEL_LIST_MAX_VISIBLE_ROWS / 2),
|
|
1143
|
+
Math.max(0, options.length - MODEL_LIST_MAX_VISIBLE_ROWS),
|
|
1088
1144
|
),
|
|
1089
1145
|
);
|
|
1090
|
-
const end = Math.min(options.length, start +
|
|
1146
|
+
const end = Math.min(options.length, start + MODEL_LIST_MAX_VISIBLE_ROWS);
|
|
1091
1147
|
for (let i = start; i < end; i++) {
|
|
1092
1148
|
const focused = i === this.modelCursor;
|
|
1093
|
-
lines.push(line(`${focused ? "▸" : " "} ${options[i]}`));
|
|
1149
|
+
lines.push(line(`${focused ? "▸" : " "} ${sanitizeTerminalText(options[i] ?? "")}`));
|
|
1094
1150
|
}
|
|
1095
1151
|
if (options.length === 0) lines.push(line(" No matching models"));
|
|
1096
1152
|
lines.push("");
|
|
@@ -1109,14 +1165,14 @@ class SddModelPanel implements OverlayComponent {
|
|
|
1109
1165
|
this.mode = "agents";
|
|
1110
1166
|
return;
|
|
1111
1167
|
}
|
|
1112
|
-
if (matchesKey(data, "down") || data
|
|
1168
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
1113
1169
|
this.effortCursor = Math.min(
|
|
1114
1170
|
Math.max(0, THINKING_OPTIONS.length - 1),
|
|
1115
1171
|
this.effortCursor + 1,
|
|
1116
1172
|
);
|
|
1117
1173
|
return;
|
|
1118
1174
|
}
|
|
1119
|
-
if (matchesKey(data, "up") || data
|
|
1175
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
1120
1176
|
this.effortCursor = Math.max(0, this.effortCursor - 1);
|
|
1121
1177
|
return;
|
|
1122
1178
|
}
|
|
@@ -1131,7 +1187,7 @@ class SddModelPanel implements OverlayComponent {
|
|
|
1131
1187
|
const lines: string[] = [];
|
|
1132
1188
|
const line = (text = "") =>
|
|
1133
1189
|
truncateToWidth(text, Math.max(1, width), "…", true);
|
|
1134
|
-
lines.push(line(`Select effort for ${this.selectedRow}`));
|
|
1190
|
+
lines.push(line(`Select effort for ${sanitizeTerminalText(this.selectedRow)}`));
|
|
1135
1191
|
lines.push("");
|
|
1136
1192
|
for (let i = 0; i < THINKING_OPTIONS.length; i++) {
|
|
1137
1193
|
const focused = i === this.effortCursor;
|
|
@@ -1157,13 +1213,13 @@ class SddModelPanel implements OverlayComponent {
|
|
|
1157
1213
|
const effortLabel = efforts.every((value) => value === firstEffort)
|
|
1158
1214
|
? firstEffort
|
|
1159
1215
|
: "mixed";
|
|
1160
|
-
return `${row.padEnd(20)} model=${modelLabel}, effort=${effortLabel}`;
|
|
1216
|
+
return `${sanitizeTerminalText(row).padEnd(20)} model=${sanitizeTerminalText(modelLabel)}, effort=${sanitizeTerminalText(effortLabel)}`;
|
|
1161
1217
|
}
|
|
1162
1218
|
|
|
1163
1219
|
private renderAgentLabel(row: string): string {
|
|
1164
1220
|
const model = this.draft[row]?.model ?? "inherit";
|
|
1165
1221
|
const effort = this.draft[row]?.thinking ?? "inherit";
|
|
1166
|
-
return `${row.padEnd(20)} model=${model}, effort=${effort}`;
|
|
1222
|
+
return `${sanitizeTerminalText(row).padEnd(20)} model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(effort)}`;
|
|
1167
1223
|
}
|
|
1168
1224
|
}
|
|
1169
1225
|
|
|
@@ -1206,18 +1262,27 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
1206
1262
|
? "inherit"
|
|
1207
1263
|
: (config[result.agent]?.model ?? "inherit");
|
|
1208
1264
|
const custom = await ctx.ui.input(
|
|
1209
|
-
`${result.agent === "all" ? "all agents" : result.agent} custom model id`,
|
|
1210
|
-
current === "inherit" ? "provider/model" : current,
|
|
1265
|
+
`${result.agent === "all" ? "all agents" : sanitizeTerminalText(result.agent)} custom model id`,
|
|
1266
|
+
current === "inherit" ? "provider/model" : sanitizeTerminalText(current),
|
|
1211
1267
|
);
|
|
1212
1268
|
if (custom === undefined) return;
|
|
1213
1269
|
const trimmed = custom.trim();
|
|
1214
1270
|
if (trimmed.length > 0) {
|
|
1271
|
+
const model = normalizeModelId(trimmed);
|
|
1272
|
+
if (!model) {
|
|
1273
|
+
ctx.ui.notify(
|
|
1274
|
+
"Custom model id must be a single-line provider/model identifier using letters, numbers, '.', '-', '_', '~', ':', '@', '/', '+', '%' only.",
|
|
1275
|
+
"warning",
|
|
1276
|
+
);
|
|
1277
|
+
result = await showSddModelPanel(ctx, config);
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1215
1280
|
if (result.agent === "all") {
|
|
1216
1281
|
const next: AgentModelConfig = { ...config };
|
|
1217
1282
|
for (const agent of listDiscoverableAgents(ctx.cwd)) {
|
|
1218
1283
|
next[agent.name] = {
|
|
1219
1284
|
...(next[agent.name] ?? {}),
|
|
1220
|
-
model
|
|
1285
|
+
model,
|
|
1221
1286
|
};
|
|
1222
1287
|
}
|
|
1223
1288
|
config = next;
|
|
@@ -1226,7 +1291,7 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
1226
1291
|
...config,
|
|
1227
1292
|
[result.agent]: {
|
|
1228
1293
|
...(config[result.agent] ?? {}),
|
|
1229
|
-
model
|
|
1294
|
+
model,
|
|
1230
1295
|
},
|
|
1231
1296
|
};
|
|
1232
1297
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: release
|
|
3
|
+
description: "Release gentle-pi through GitHub and npm. Trigger: release, publish, npm publish, GitHub release, version bump."
|
|
4
|
+
license: Apache-2.0
|
|
5
|
+
metadata:
|
|
6
|
+
author: gentleman-programming
|
|
7
|
+
version: "1.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## When to Use
|
|
11
|
+
|
|
12
|
+
Use this skill when preparing, publishing, or verifying a `gentle-pi` release.
|
|
13
|
+
|
|
14
|
+
## Hard Rules
|
|
15
|
+
|
|
16
|
+
- Do not publish `gentle-pi` to npm from a local machine.
|
|
17
|
+
- npm publishing MUST go through the GitHub Actions workflow `.github/workflows/publish.yml` so provenance, environment protection, and registry credentials are controlled by GitHub.
|
|
18
|
+
- Use a clean worktree for release commits. Do not package unrelated local files or scratch artifacts.
|
|
19
|
+
- Run a fresh review before pushing a code release unless the change is trivial docs-only.
|
|
20
|
+
- Never skip package verification. The publish workflow runs verification again, but local validation should still pass before tagging.
|
|
21
|
+
|
|
22
|
+
## Release Procedure
|
|
23
|
+
|
|
24
|
+
1. **Inspect state**
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git status --short
|
|
28
|
+
git fetch origin main --tags
|
|
29
|
+
git log --oneline --decorate --max-count=5 origin/main
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
2. **Prepare the release commit**
|
|
33
|
+
|
|
34
|
+
- Apply only intended changes.
|
|
35
|
+
- Bump `package.json` to the next semver version.
|
|
36
|
+
- Keep lockfile changes out unless dependency resolution actually changed.
|
|
37
|
+
|
|
38
|
+
3. **Verify locally**
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pnpm test
|
|
42
|
+
pnpm publish --dry-run --no-git-checks
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The dry run is allowed because it does not publish. It verifies package contents and lifecycle scripts.
|
|
46
|
+
|
|
47
|
+
4. **Commit and push**
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git add <intended-files>
|
|
51
|
+
git commit -m "<type(scope): release-ready change>"
|
|
52
|
+
git push origin HEAD:main
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
5. **Create the GitHub release**
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
git tag -a v<version> -m "gentle-pi v<version>"
|
|
59
|
+
git push origin v<version>
|
|
60
|
+
gh release create v<version> \
|
|
61
|
+
--repo Gentleman-Programming/gentle-pi \
|
|
62
|
+
--title "gentle-pi v<version>" \
|
|
63
|
+
--notes "<release notes>"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
6. **Publish npm through GitHub Actions**
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
gh workflow run publish.yml \
|
|
70
|
+
--repo Gentleman-Programming/gentle-pi \
|
|
71
|
+
--ref main \
|
|
72
|
+
-f dist-tag=latest
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Watch the run and fail the release if it fails:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
gh run list --repo Gentleman-Programming/gentle-pi --workflow publish.yml --limit 3
|
|
79
|
+
gh run watch <run-id> --repo Gentleman-Programming/gentle-pi --exit-status
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
7. **Verify npm**
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm view gentle-pi@<version> version --registry=https://registry.npmjs.org/
|
|
86
|
+
npm dist-tag ls gentle-pi --registry=https://registry.npmjs.org/
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Failure Handling
|
|
90
|
+
|
|
91
|
+
- If a local `npm publish` fails, do not retry locally. Use the GitHub workflow instead.
|
|
92
|
+
- If the workflow fails, inspect logs with:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
gh run view <run-id> --repo Gentleman-Programming/gentle-pi --log
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- If npm verification is briefly stale after a successful workflow, check the exact version first (`npm view gentle-pi@<version> version`) before assuming publish failed.
|
|
99
|
+
|
|
100
|
+
## Output Contract
|
|
101
|
+
|
|
102
|
+
Report:
|
|
103
|
+
|
|
104
|
+
- Commit SHA pushed to `main`.
|
|
105
|
+
- GitHub release URL.
|
|
106
|
+
- Publish workflow run URL and conclusion.
|
|
107
|
+
- npm exact version and `latest` dist-tag.
|
|
108
|
+
- Any remaining follow-up or warnings.
|
|
@@ -5,6 +5,7 @@ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promis
|
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
8
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
10
|
|
|
10
11
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -640,12 +641,75 @@ async function run() {
|
|
|
640
641
|
join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
641
642
|
`---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
|
|
642
643
|
);
|
|
644
|
+
for (let i = 0; i < 25; i++) {
|
|
645
|
+
const name = `large-agent-${String(i).padStart(2, "0")}`;
|
|
646
|
+
await writeFile(
|
|
647
|
+
join(modelsCwd, ".pi", "agents", `${name}.md`),
|
|
648
|
+
`---\nname: ${name}\ndescription: Scroll fixture\n---\n`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
await writeFile(
|
|
652
|
+
join(modelsCwd, ".pi", "agents", "escape-agent.md"),
|
|
653
|
+
`---\nname: evil\u001b]52;c;Zm9v\u0007-agent\ndescription: Escape fixture\n---\n`,
|
|
654
|
+
);
|
|
643
655
|
await writeFile(
|
|
644
656
|
globalModelsPath,
|
|
645
657
|
JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
|
|
646
658
|
);
|
|
647
659
|
|
|
648
660
|
const ctx = createCtx(modelsCwd, true);
|
|
661
|
+
ctx.modelRegistry.getAvailable = async () => [
|
|
662
|
+
{ provider: "safe", id: "model" },
|
|
663
|
+
{ provider: "evil\u001b]52;c;Zm9v\u0007", id: "model" },
|
|
664
|
+
];
|
|
665
|
+
ctx.ui.custom = (factory) => {
|
|
666
|
+
const panel = factory(null, null, null, () => undefined);
|
|
667
|
+
const initialLines = panel.render(120);
|
|
668
|
+
assert.ok(
|
|
669
|
+
initialLines[0].startsWith("╭") && initialLines.at(-1).startsWith("╰"),
|
|
670
|
+
"model panel should render inside a bordered card",
|
|
671
|
+
);
|
|
672
|
+
assert.ok(
|
|
673
|
+
initialLines.length <= 20,
|
|
674
|
+
"long model agent list should fit within a 24-row terminal 85% overlay budget",
|
|
675
|
+
);
|
|
676
|
+
assert.ok(
|
|
677
|
+
initialLines.some((line) => /↓ \d+ more agent\(s\)/.test(line)),
|
|
678
|
+
"long model agent list should render a down-scroll indicator",
|
|
679
|
+
);
|
|
680
|
+
assert.ok(
|
|
681
|
+
initialLines.some((line) => line.includes("Continue")),
|
|
682
|
+
"long model agent list should keep Continue visible",
|
|
683
|
+
);
|
|
684
|
+
assert.doesNotMatch(
|
|
685
|
+
initialLines.join("\n"),
|
|
686
|
+
/[\u001b\u0007]/,
|
|
687
|
+
"model panel must strip terminal control sequences from agent labels",
|
|
688
|
+
);
|
|
689
|
+
for (let i = 0; i < 20; i++) panel.handleInput("j");
|
|
690
|
+
const scrolledLines = panel.render(120);
|
|
691
|
+
assert.ok(
|
|
692
|
+
scrolledLines.length <= 20,
|
|
693
|
+
"scrolled model agent list should stay within the overlay height budget",
|
|
694
|
+
);
|
|
695
|
+
assert.ok(
|
|
696
|
+
scrolledLines.some((line) => /↑ \d+ more agent\(s\)/.test(line)),
|
|
697
|
+
"long model agent list should render an up-scroll indicator after navigation",
|
|
698
|
+
);
|
|
699
|
+
panel.handleInput("G");
|
|
700
|
+
const bottomLines = panel.render(120);
|
|
701
|
+
assert.ok(
|
|
702
|
+
bottomLines.length <= 20,
|
|
703
|
+
"bottom model agent list should stay within the overlay height budget",
|
|
704
|
+
);
|
|
705
|
+
assert.ok(
|
|
706
|
+
bottomLines.some((line) => line.includes("▸ ← Back")),
|
|
707
|
+
"G should jump to the Back action",
|
|
708
|
+
);
|
|
709
|
+
return Promise.resolve({ type: "cancel" });
|
|
710
|
+
};
|
|
711
|
+
await commands.get("gentle:models").handler("", ctx);
|
|
712
|
+
|
|
649
713
|
await hooks.get("session_start")[0]({ reason: "startup" }, ctx);
|
|
650
714
|
const legacyAppliedAgent = await readFile(
|
|
651
715
|
join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
@@ -663,6 +727,11 @@ async function run() {
|
|
|
663
727
|
},
|
|
664
728
|
});
|
|
665
729
|
await commands.get("gentle:models").handler("", ctx);
|
|
730
|
+
assert.doesNotMatch(
|
|
731
|
+
ctx.ui.notifications.at(-1).message,
|
|
732
|
+
/[\u001b\u0007]/,
|
|
733
|
+
"model save notification must strip terminal control sequences from discovered agent names",
|
|
734
|
+
);
|
|
666
735
|
|
|
667
736
|
const savedConfig = JSON.parse(
|
|
668
737
|
await readFile(globalModelsPath, "utf8"),
|
|
@@ -693,6 +762,10 @@ async function run() {
|
|
|
693
762
|
);
|
|
694
763
|
assert.equal(settings.subagents.agentOverrides.worker.thinking, "low");
|
|
695
764
|
|
|
765
|
+
const kittyE = "\x1b[101u";
|
|
766
|
+
assert.notEqual(kittyE, "e");
|
|
767
|
+
assert.equal(matchesKey(kittyE, "e"), true);
|
|
768
|
+
|
|
696
769
|
let customPanelCalls = 0;
|
|
697
770
|
ctx.ui.input = async () => "custom/provider-model";
|
|
698
771
|
ctx.ui.custom = (factory) =>
|
|
@@ -700,7 +773,7 @@ async function run() {
|
|
|
700
773
|
customPanelCalls += 1;
|
|
701
774
|
const panel = factory(null, null, null, resolve);
|
|
702
775
|
if (customPanelCalls === 1) {
|
|
703
|
-
panel.handleInput(
|
|
776
|
+
panel.handleInput(kittyE); // effort picker for all agents
|
|
704
777
|
for (let i = 0; i < 4; i++) panel.handleInput("j"); // medium
|
|
705
778
|
panel.handleInput("\r");
|
|
706
779
|
panel.handleInput("c"); // custom model from the same unsaved draft
|
|
@@ -717,6 +790,31 @@ async function run() {
|
|
|
717
790
|
model: "custom/provider-model",
|
|
718
791
|
thinking: "medium",
|
|
719
792
|
});
|
|
793
|
+
|
|
794
|
+
let invalidCustomCalls = 0;
|
|
795
|
+
ctx.ui.input = async () => "bad\nmodel: injected";
|
|
796
|
+
ctx.ui.custom = (factory) =>
|
|
797
|
+
new Promise((resolve) => {
|
|
798
|
+
invalidCustomCalls += 1;
|
|
799
|
+
const panel = factory(null, null, null, resolve);
|
|
800
|
+
if (invalidCustomCalls === 1) {
|
|
801
|
+
panel.handleInput("c");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
panel.handleInput("\u001b");
|
|
805
|
+
});
|
|
806
|
+
await commands.get("gentle:models").handler("", ctx);
|
|
807
|
+
assert.match(
|
|
808
|
+
ctx.ui.notifications.at(-1).message,
|
|
809
|
+
/Custom model id must be a single-line/,
|
|
810
|
+
);
|
|
811
|
+
const rejectedCustomConfig = JSON.parse(
|
|
812
|
+
await readFile(globalModelsPath, "utf8"),
|
|
813
|
+
);
|
|
814
|
+
assert.deepEqual(rejectedCustomConfig["sdd-apply"], {
|
|
815
|
+
model: "custom/provider-model",
|
|
816
|
+
thinking: "medium",
|
|
817
|
+
});
|
|
720
818
|
} finally {
|
|
721
819
|
await rm(modelsCwd, { recursive: true, force: true });
|
|
722
820
|
await rm(globalModelsPath, { force: true });
|