squad-kit 0.2.2 → 0.4.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/CHANGELOG.md +92 -1
- package/README.md +21 -7
- package/dist/cli.js +1303 -410
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/agents/claude-code/squad-plan.md +1 -0
- package/templates/agents/copilot/squad-plan.prompt.md +2 -0
- package/templates/agents/cursor/squad-plan.md +1 -0
- package/templates/agents/gemini/squad-plan.toml +2 -0
- package/templates/prompts/generate-plan.md +3 -0
- package/templates/prompts/intake.md +11 -8
- package/templates/prompts/story-skeleton.md +2 -0
package/dist/cli.js
CHANGED
|
@@ -169,10 +169,23 @@ function renderError(err) {
|
|
|
169
169
|
`);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// src/core/cli-exit.ts
|
|
173
|
+
var SquadExit = class extends Error {
|
|
174
|
+
constructor(exitCode, message) {
|
|
175
|
+
super(message ?? "");
|
|
176
|
+
this.exitCode = exitCode;
|
|
177
|
+
}
|
|
178
|
+
exitCode;
|
|
179
|
+
name = "SquadExit";
|
|
180
|
+
};
|
|
181
|
+
function isSquadExit(err) {
|
|
182
|
+
return err instanceof SquadExit;
|
|
183
|
+
}
|
|
184
|
+
|
|
172
185
|
// src/commands/init.ts
|
|
173
186
|
import fs14 from "fs";
|
|
174
187
|
import path6 from "path";
|
|
175
|
-
import { input as
|
|
188
|
+
import { input as input2, select as select3, checkbox, confirm as confirm5, password as password3 } from "@inquirer/prompts";
|
|
176
189
|
|
|
177
190
|
// src/core/paths.ts
|
|
178
191
|
import path from "path";
|
|
@@ -212,8 +225,8 @@ function requireSquadRoot(startDir = process.cwd()) {
|
|
|
212
225
|
}
|
|
213
226
|
return root;
|
|
214
227
|
}
|
|
215
|
-
function slugify(
|
|
216
|
-
return
|
|
228
|
+
function slugify(input5) {
|
|
229
|
+
return input5.toLowerCase().trim().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
|
217
230
|
}
|
|
218
231
|
|
|
219
232
|
// src/core/gitignore.ts
|
|
@@ -222,42 +235,38 @@ import path2 from "path";
|
|
|
222
235
|
var SQUAD_SECRETS_PATTERN = ".squad/secrets.yaml";
|
|
223
236
|
var SQUAD_ATTACHMENTS_PATTERN = ".squad/stories/**/attachments/";
|
|
224
237
|
var SQUAD_TRASH_PATTERN = ".squad/.trash/";
|
|
238
|
+
var SQUAD_LAST_RUN_PATTERN = ".squad/.last-run.json";
|
|
239
|
+
var SQUAD_LAST_COPY_PROMPT_PATTERN = ".squad/.last-copy-prompt.md";
|
|
225
240
|
var MANAGED_BLOCK_HEADER = "# Managed by squad-kit \u2014 do not edit this block";
|
|
226
241
|
var MANAGED_BLOCK_FOOTER = "# End squad-kit block";
|
|
227
242
|
function ensureGitignore(repoRoot) {
|
|
228
243
|
const file = path2.join(repoRoot, ".gitignore");
|
|
229
244
|
const text = fs2.existsSync(file) ? fs2.readFileSync(file, "utf8") : "";
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
245
|
+
const requiredPatterns = [
|
|
246
|
+
SQUAD_SECRETS_PATTERN,
|
|
247
|
+
SQUAD_ATTACHMENTS_PATTERN,
|
|
248
|
+
SQUAD_TRASH_PATTERN,
|
|
249
|
+
SQUAD_LAST_RUN_PATTERN,
|
|
250
|
+
SQUAD_LAST_COPY_PROMPT_PATTERN
|
|
251
|
+
];
|
|
252
|
+
const allPresent = requiredPatterns.every((p) => text.includes(p));
|
|
253
|
+
if (allPresent) return false;
|
|
254
|
+
if (!text.includes(SQUAD_SECRETS_PATTERN)) {
|
|
255
|
+
const block = ["", MANAGED_BLOCK_HEADER, ...requiredPatterns, MANAGED_BLOCK_FOOTER, ""].join("\n");
|
|
256
|
+
const next2 = text.length === 0 ? block.trimStart() : text.endsWith("\n") ? text + block : text + "\n" + block;
|
|
257
|
+
fs2.writeFileSync(file, next2, "utf8");
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
let next = text;
|
|
261
|
+
for (const pattern of requiredPatterns) {
|
|
262
|
+
if (next.includes(pattern)) continue;
|
|
263
|
+
if (next.includes(MANAGED_BLOCK_FOOTER)) {
|
|
264
|
+
next = next.replace(MANAGED_BLOCK_FOOTER, pattern + "\n" + MANAGED_BLOCK_FOOTER);
|
|
242
265
|
} else {
|
|
243
|
-
next = next.replace(/\n?$/, (m) => m +
|
|
244
|
-
}
|
|
245
|
-
if (next !== text) {
|
|
246
|
-
fs2.writeFileSync(file, next, "utf8");
|
|
247
|
-
return true;
|
|
266
|
+
next = next.replace(/\n?$/, (m) => m + pattern + "\n");
|
|
248
267
|
}
|
|
249
268
|
}
|
|
250
|
-
if (
|
|
251
|
-
const block = [
|
|
252
|
-
"",
|
|
253
|
-
MANAGED_BLOCK_HEADER,
|
|
254
|
-
SQUAD_SECRETS_PATTERN,
|
|
255
|
-
SQUAD_ATTACHMENTS_PATTERN,
|
|
256
|
-
SQUAD_TRASH_PATTERN,
|
|
257
|
-
MANAGED_BLOCK_FOOTER,
|
|
258
|
-
""
|
|
259
|
-
].join("\n");
|
|
260
|
-
const next = text.length === 0 ? block.trimStart() : text.endsWith("\n") ? text + block : text + "\n" + block;
|
|
269
|
+
if (next !== text) {
|
|
261
270
|
fs2.writeFileSync(file, next, "utf8");
|
|
262
271
|
return true;
|
|
263
272
|
}
|
|
@@ -280,15 +289,27 @@ var DEFAULT_CONFIG = {
|
|
|
280
289
|
planner: void 0
|
|
281
290
|
};
|
|
282
291
|
var FORBIDDEN_KEYS = ["apikey", "api_key", "token", "secret", "credential", "credentials"];
|
|
283
|
-
|
|
292
|
+
var DEFAULT_PLANNER_MAX_OUTPUT_TOKENS = 16384;
|
|
293
|
+
var MIN_PLANNER_MAX_OUTPUT_TOKENS = 1024;
|
|
294
|
+
var MAX_PLANNER_MAX_OUTPUT_TOKENS = 128e3;
|
|
295
|
+
function clampPlannerMaxOut(raw) {
|
|
296
|
+
if (raw === void 0 || raw === null) return DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
|
|
297
|
+
const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseInt(raw, 10) : NaN;
|
|
298
|
+
if (!Number.isFinite(n) || n < 1) return DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
|
|
299
|
+
return Math.min(
|
|
300
|
+
MAX_PLANNER_MAX_OUTPUT_TOKENS,
|
|
301
|
+
Math.max(MIN_PLANNER_MAX_OUTPUT_TOKENS, Math.round(n))
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
function rejectSecretsInYaml(node, configFile, path26 = []) {
|
|
284
305
|
if (node === null || typeof node !== "object") return;
|
|
285
306
|
for (const [key, value] of Object.entries(node)) {
|
|
286
307
|
if (FORBIDDEN_KEYS.includes(key.toLowerCase())) {
|
|
287
308
|
throw new Error(
|
|
288
|
-
`Refusing to load ${configFile}: key "${[...
|
|
309
|
+
`Refusing to load ${configFile}: key "${[...path26, key].join(".")}" looks like a secret. Run \`squad config set planner\` or \`squad config set tracker\` to save it to .squad/secrets.yaml, then remove the key from config.yaml. Provider keys also work via ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY.`
|
|
289
310
|
);
|
|
290
311
|
}
|
|
291
|
-
if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...
|
|
312
|
+
if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...path26, key]);
|
|
292
313
|
}
|
|
293
314
|
}
|
|
294
315
|
function mergePlanner(base, override) {
|
|
@@ -306,7 +327,11 @@ function mergePlanner(base, override) {
|
|
|
306
327
|
modelOverride: {
|
|
307
328
|
...base?.modelOverride ?? {},
|
|
308
329
|
...override.modelOverride ?? {}
|
|
309
|
-
}
|
|
330
|
+
},
|
|
331
|
+
cache: {
|
|
332
|
+
enabled: override.cache?.enabled ?? base?.cache?.enabled ?? true
|
|
333
|
+
},
|
|
334
|
+
maxOutputTokens: clampPlannerMaxOut(override.maxOutputTokens ?? base?.maxOutputTokens)
|
|
310
335
|
};
|
|
311
336
|
if (merged.modelOverride) {
|
|
312
337
|
const pruned = Object.fromEntries(
|
|
@@ -350,6 +375,20 @@ function loadConfig(configFile) {
|
|
|
350
375
|
rejectSecretsInYaml(parsed, configFile);
|
|
351
376
|
const merged = mergeConfig(DEFAULT_CONFIG, parsed);
|
|
352
377
|
validateModelOverride(merged.planner?.modelOverride, configFile);
|
|
378
|
+
if (merged.planner?.cache !== void 0) {
|
|
379
|
+
const c = merged.planner.cache;
|
|
380
|
+
if (typeof c !== "object" || c === null || Array.isArray(c)) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`planner.cache in ${configFile} must be an object with \`enabled: true\` or \`false\`. Run \`squad config set planner\` to set prompt caching.`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
const ce = c.enabled;
|
|
386
|
+
if (typeof ce !== "boolean") {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`planner.cache.enabled in ${configFile} must be a boolean (not a quoted string). Run \`squad config set planner\` to set prompt caching, or use \`true\` or \`false\` unquoted in YAML.`
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
353
392
|
if (merged.planner?.enabled) {
|
|
354
393
|
if (!["anthropic", "openai", "google"].includes(merged.planner.provider)) {
|
|
355
394
|
throw new Error(
|
|
@@ -571,6 +610,9 @@ function render(template, vars) {
|
|
|
571
610
|
return value === void 0 || value === null ? "" : String(value);
|
|
572
611
|
});
|
|
573
612
|
}
|
|
613
|
+
function escapeTemplateValue(s) {
|
|
614
|
+
return s.replace(/\{\{/g, "{ {");
|
|
615
|
+
}
|
|
574
616
|
|
|
575
617
|
// src/commands/config/show.ts
|
|
576
618
|
import fs7 from "fs";
|
|
@@ -696,7 +738,7 @@ async function runConfigShow(opts) {
|
|
|
696
738
|
|
|
697
739
|
// src/commands/config/set-planner.ts
|
|
698
740
|
import fs9 from "fs";
|
|
699
|
-
import { confirm as confirm2,
|
|
741
|
+
import { confirm as confirm2, password as password2, select } from "@inquirer/prompts";
|
|
700
742
|
|
|
701
743
|
// src/tracker/env-overlay.ts
|
|
702
744
|
function overlayTrackerEnv(base) {
|
|
@@ -842,7 +884,9 @@ function newPlannerBlock(provider) {
|
|
|
842
884
|
maxFileReads: 25,
|
|
843
885
|
maxContextBytes: 5e4,
|
|
844
886
|
maxDurationSeconds: 180
|
|
845
|
-
}
|
|
887
|
+
},
|
|
888
|
+
cache: { enabled: true },
|
|
889
|
+
maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
|
|
846
890
|
};
|
|
847
891
|
}
|
|
848
892
|
function mergePlannerKeyIntoSecrets(base, provider, key) {
|
|
@@ -993,22 +1037,11 @@ async function runConfigSetPlanner(opts = {}) {
|
|
|
993
1037
|
success("Planner key saved to .squad/secrets.yaml");
|
|
994
1038
|
info(".squad/secrets.yaml updated (chmod 0600 on POSIX)");
|
|
995
1039
|
}
|
|
996
|
-
const
|
|
997
|
-
message: "
|
|
998
|
-
default:
|
|
1040
|
+
const cacheEnabled = await confirm2({
|
|
1041
|
+
message: "Enable prompt caching? (Recommended \u2014 reduces billed tokens by ~70% on most providers.)",
|
|
1042
|
+
default: nextPlanner.cache?.enabled ?? true
|
|
999
1043
|
});
|
|
1000
|
-
|
|
1001
|
-
const modelId = await input2({
|
|
1002
|
-
message: "Plan model id (leave empty to use the default for this provider):",
|
|
1003
|
-
default: ""
|
|
1004
|
-
});
|
|
1005
|
-
if (modelId.trim().length > 0 && nextPlanner) {
|
|
1006
|
-
nextPlanner = {
|
|
1007
|
-
...nextPlanner,
|
|
1008
|
-
modelOverride: { ...nextPlanner.modelOverride ?? {}, [provider]: modelId.trim() }
|
|
1009
|
-
};
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1044
|
+
nextPlanner = { ...nextPlanner, cache: { enabled: cacheEnabled } };
|
|
1012
1045
|
}
|
|
1013
1046
|
const next = { ...config2, planner: nextPlanner };
|
|
1014
1047
|
saveConfig(paths.configFile, next);
|
|
@@ -1029,6 +1062,7 @@ async function runConfigSetPlanner(opts = {}) {
|
|
|
1029
1062
|
kv("credential", `${cred.source} (${sourceText})`, 10);
|
|
1030
1063
|
}
|
|
1031
1064
|
if (process.env.CI === "true" || !cred) {
|
|
1065
|
+
printPlannerNextSteps(Boolean(cred));
|
|
1032
1066
|
return;
|
|
1033
1067
|
}
|
|
1034
1068
|
const listed = await fetchProviderModelIds(provider, cred.value);
|
|
@@ -1042,6 +1076,23 @@ async function runConfigSetPlanner(opts = {}) {
|
|
|
1042
1076
|
} else {
|
|
1043
1077
|
info("Credential check: models API responded OK for this key.");
|
|
1044
1078
|
}
|
|
1079
|
+
printPlannerNextSteps(true);
|
|
1080
|
+
}
|
|
1081
|
+
function printPlannerNextSteps(credentialReady) {
|
|
1082
|
+
blank();
|
|
1083
|
+
step("Next:");
|
|
1084
|
+
if (!credentialReady) {
|
|
1085
|
+
info("1) Save a planner key: re-run `squad config set planner` and paste the key when prompted.");
|
|
1086
|
+
info("2) Verify with `squad doctor` \u2014 all planner checks should turn green.");
|
|
1087
|
+
info("3) Then run `squad new-story <slug>` and `squad new-plan --api` to generate your first plan.");
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
info("1) Verify with `squad doctor` \u2014 every planner check should be green.");
|
|
1091
|
+
info("2) Create a story: squad new-story <feature-slug> (or --no-tracker for a manual story).");
|
|
1092
|
+
info("3) Fill the generated intake.md, then run `squad new-plan --api` to generate the plan.");
|
|
1093
|
+
info(
|
|
1094
|
+
"4) To change provider, key, or disable caching later: re-run `squad config set planner`. Model overrides are edited directly in .squad/config.yaml (planner.modelOverride)."
|
|
1095
|
+
);
|
|
1045
1096
|
}
|
|
1046
1097
|
|
|
1047
1098
|
// src/commands/config/set-tracker.ts
|
|
@@ -1196,6 +1247,7 @@ var AzureDevOpsClient = class {
|
|
|
1196
1247
|
id: String(body.id ?? id),
|
|
1197
1248
|
title: fields["System.Title"] ?? "(no title)",
|
|
1198
1249
|
description: stripHtml(fields["System.Description"] ?? ""),
|
|
1250
|
+
acceptanceCriteria: stripHtml(fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? ""),
|
|
1199
1251
|
url: `${this.webBase}/${body.id ?? id}`,
|
|
1200
1252
|
labels: parseTags(fields["System.Tags"]),
|
|
1201
1253
|
type: fields["System.WorkItemType"],
|
|
@@ -1319,6 +1371,7 @@ var JiraClient = class {
|
|
|
1319
1371
|
id: body.key ?? id,
|
|
1320
1372
|
title: fields.summary ?? "(no title)",
|
|
1321
1373
|
description,
|
|
1374
|
+
acceptanceCriteria: "",
|
|
1322
1375
|
url: `${this.baseUrl}/browse/${body.key ?? id}`,
|
|
1323
1376
|
labels: Array.isArray(fields.labels) ? fields.labels : [],
|
|
1324
1377
|
type: fields.issuetype?.name,
|
|
@@ -1526,6 +1579,7 @@ async function runConfigSetTracker(opts = {}) {
|
|
|
1526
1579
|
kv("project", next.tracker.project, 10);
|
|
1527
1580
|
}
|
|
1528
1581
|
if (process.env.CI === "true") {
|
|
1582
|
+
printTrackerNextSteps(next.tracker.type);
|
|
1529
1583
|
return;
|
|
1530
1584
|
}
|
|
1531
1585
|
const reloaded = loadConfig(paths.configFile);
|
|
@@ -1549,6 +1603,20 @@ async function runConfigSetTracker(opts = {}) {
|
|
|
1549
1603
|
);
|
|
1550
1604
|
}
|
|
1551
1605
|
}
|
|
1606
|
+
printTrackerNextSteps(next.tracker.type);
|
|
1607
|
+
}
|
|
1608
|
+
function printTrackerNextSteps(type) {
|
|
1609
|
+
blank();
|
|
1610
|
+
step("Next:");
|
|
1611
|
+
if (type === "none") {
|
|
1612
|
+
info("1) Tracker is off. Create stories manually with `squad new-story <slug> --no-tracker`.");
|
|
1613
|
+
info("2) Re-enable any time with `squad config set tracker`.");
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
info("1) Verify with `squad doctor` \u2014 tracker checks should be green.");
|
|
1617
|
+
info(`2) Create a story: squad new-story <feature-slug> --id <${type === "jira" ? "JIRA-123" : "azure-work-item-id"}>`);
|
|
1618
|
+
info(" squad-kit auto-fetches the title, description, and attachments into the intake.");
|
|
1619
|
+
info("3) Review the generated intake.md, then run `squad new-plan --api` to generate the plan.");
|
|
1552
1620
|
}
|
|
1553
1621
|
|
|
1554
1622
|
// src/commands/config/unset-tracker.ts
|
|
@@ -1671,8 +1739,8 @@ async function runInit(opts) {
|
|
|
1671
1739
|
agents: parseAgentsFlag(opts.agents)
|
|
1672
1740
|
};
|
|
1673
1741
|
const answers = opts.yes ? defaults : {
|
|
1674
|
-
name: await
|
|
1675
|
-
language: await
|
|
1742
|
+
name: await input2({ message: "Project name", default: defaults.name }),
|
|
1743
|
+
language: await input2({ message: "Primary language", default: defaults.language }),
|
|
1676
1744
|
tracker: await select3({
|
|
1677
1745
|
message: "Issue tracker",
|
|
1678
1746
|
choices: [
|
|
@@ -1736,7 +1804,9 @@ async function runInit(opts) {
|
|
|
1736
1804
|
maxFileReads: 25,
|
|
1737
1805
|
maxContextBytes: 5e4,
|
|
1738
1806
|
maxDurationSeconds: 180
|
|
1739
|
-
}
|
|
1807
|
+
},
|
|
1808
|
+
cache: { enabled: true },
|
|
1809
|
+
maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
|
|
1740
1810
|
};
|
|
1741
1811
|
}
|
|
1742
1812
|
} else {
|
|
@@ -1762,7 +1832,9 @@ async function runInit(opts) {
|
|
|
1762
1832
|
maxFileReads: 25,
|
|
1763
1833
|
maxContextBytes: 5e4,
|
|
1764
1834
|
maxDurationSeconds: 180
|
|
1765
|
-
}
|
|
1835
|
+
},
|
|
1836
|
+
cache: { enabled: true },
|
|
1837
|
+
maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
|
|
1766
1838
|
};
|
|
1767
1839
|
}
|
|
1768
1840
|
}
|
|
@@ -1893,7 +1965,7 @@ Plan meta-prompts (\`generate-plan.md\`, \`story-skeleton.md\`) ship inside the
|
|
|
1893
1965
|
// src/commands/new-story.ts
|
|
1894
1966
|
import fs16 from "fs";
|
|
1895
1967
|
import path8 from "path";
|
|
1896
|
-
import { input as
|
|
1968
|
+
import { input as input3, select as select4 } from "@inquirer/prompts";
|
|
1897
1969
|
|
|
1898
1970
|
// src/core/stories.ts
|
|
1899
1971
|
import fs15 from "fs";
|
|
@@ -1933,11 +2005,14 @@ function listStories(paths, opts = {}) {
|
|
|
1933
2005
|
function findPlanFor(plansDir, feature, storyId) {
|
|
1934
2006
|
const featureDir = path7.join(plansDir, feature);
|
|
1935
2007
|
if (!fs15.existsSync(featureDir)) return void 0;
|
|
2008
|
+
const candidates = [];
|
|
1936
2009
|
for (const entry of fs15.readdirSync(featureDir)) {
|
|
1937
2010
|
if (!STORY_FILE.test(entry)) continue;
|
|
1938
|
-
if (entry.includes(storyId))
|
|
2011
|
+
if (entry.includes(storyId)) candidates.push(entry);
|
|
1939
2012
|
}
|
|
1940
|
-
return void 0;
|
|
2013
|
+
if (candidates.length === 0) return void 0;
|
|
2014
|
+
const complete = candidates.find((e) => !e.endsWith(".partial.md"));
|
|
2015
|
+
return complete ?? candidates[0];
|
|
1941
2016
|
}
|
|
1942
2017
|
function findStoryByIntake(records, intakePath) {
|
|
1943
2018
|
const target = normalizeIntakePath(intakePath.trim());
|
|
@@ -1953,21 +2028,53 @@ function normalizeIntakePath(p) {
|
|
|
1953
2028
|
}
|
|
1954
2029
|
function readTitleHint(intakePath) {
|
|
1955
2030
|
try {
|
|
1956
|
-
const
|
|
1957
|
-
const
|
|
1958
|
-
|
|
2031
|
+
const raw = fs15.readFileSync(intakePath, "utf8");
|
|
2032
|
+
const head = raw.split("\n").slice(0, 30).join("\n");
|
|
2033
|
+
const hintLine = head.match(/>\s*\*\*Title hint \(from CLI\):\*\*\s*(.+?)\s*$/m);
|
|
2034
|
+
const fromHint = hintLine?.[1]?.trim();
|
|
2035
|
+
if (fromHint) return fromHint;
|
|
2036
|
+
const titleFence = raw.match(/(?:^|\n)## Title\b[\s\S]*?\n```(?:[\w-]*)?\s*\n([\s\S]*?)\n```/);
|
|
2037
|
+
const fromFence = titleFence?.[1]?.trim();
|
|
2038
|
+
if (fromFence) return fromFence;
|
|
2039
|
+
return void 0;
|
|
1959
2040
|
} catch {
|
|
1960
2041
|
return void 0;
|
|
1961
2042
|
}
|
|
1962
2043
|
}
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
2044
|
+
var PLAN_GENERATED_BY = /^<!--\s*squad-kit:\s*generated by\s+([^\s,]+)/;
|
|
2045
|
+
var PLAN_ANY_SQUAD = /^<!--\s*squad-kit:\s*(.+?)\s*-->$/;
|
|
2046
|
+
var MAX_FREEFORM_META_LEN = 96;
|
|
2047
|
+
function parsePlanMetadataLine(line2) {
|
|
2048
|
+
const strict = line2.match(PLAN_GENERATED_BY);
|
|
2049
|
+
if (strict) {
|
|
2050
|
+
const generatedBy = strict[1];
|
|
2051
|
+
const [provider, model] = generatedBy.split("/");
|
|
2052
|
+
return { provider, model, generatedBy };
|
|
2053
|
+
}
|
|
2054
|
+
const any = line2.match(PLAN_ANY_SQUAD);
|
|
2055
|
+
if (!any) return null;
|
|
2056
|
+
const inner = any[1].trim();
|
|
2057
|
+
const flex = inner.match(/^generated\s+by\s+([^\s,]+)/i);
|
|
2058
|
+
if (flex) {
|
|
2059
|
+
const generatedBy = flex[1];
|
|
1969
2060
|
const [provider, model] = generatedBy.split("/");
|
|
1970
2061
|
return { provider, model, generatedBy };
|
|
2062
|
+
}
|
|
2063
|
+
const short = inner.length > MAX_FREEFORM_META_LEN ? `${inner.slice(0, MAX_FREEFORM_META_LEN - 1)}\u2026` : inner;
|
|
2064
|
+
return { generatedBy: short };
|
|
2065
|
+
}
|
|
2066
|
+
function readPlanMetadata(planFileAbsPath) {
|
|
2067
|
+
try {
|
|
2068
|
+
const raw = fs15.readFileSync(planFileAbsPath, "utf8").replace(/^\uFEFF/, "");
|
|
2069
|
+
const lines = raw.split(/\n/);
|
|
2070
|
+
const scan = Math.min(lines.length, 40);
|
|
2071
|
+
for (let i = 0; i < scan; i++) {
|
|
2072
|
+
const line2 = (lines[i] ?? "").replace(/\r$/, "").trim();
|
|
2073
|
+
if (!line2) continue;
|
|
2074
|
+
const parsed = parsePlanMetadataLine(line2);
|
|
2075
|
+
if (parsed) return parsed;
|
|
2076
|
+
}
|
|
2077
|
+
return {};
|
|
1971
2078
|
} catch {
|
|
1972
2079
|
return {};
|
|
1973
2080
|
}
|
|
@@ -2024,7 +2131,7 @@ async function runNewStory(featureSlug, opts) {
|
|
|
2024
2131
|
}
|
|
2025
2132
|
}
|
|
2026
2133
|
if (!featureSlugInput) {
|
|
2027
|
-
featureSlugInput = (await
|
|
2134
|
+
featureSlugInput = (await input3({
|
|
2028
2135
|
message: "New feature slug (kebab-case):",
|
|
2029
2136
|
validate: validateFeatureSlugForPrompt
|
|
2030
2137
|
})).trim();
|
|
@@ -2063,7 +2170,7 @@ async function runNewStory(featureSlug, opts) {
|
|
|
2063
2170
|
noTracker = true;
|
|
2064
2171
|
trackerNeedsId = false;
|
|
2065
2172
|
} else {
|
|
2066
|
-
id = (await
|
|
2173
|
+
id = (await input3({
|
|
2067
2174
|
message: `${config2.tracker.type} work-item id:`,
|
|
2068
2175
|
validate: (v) => validateTrackerId(config2.tracker.type, v.trim()) ? true : `Invalid ${config2.tracker.type} id format.`
|
|
2069
2176
|
})).trim();
|
|
@@ -2082,12 +2189,12 @@ async function runNewStory(featureSlug, opts) {
|
|
|
2082
2189
|
);
|
|
2083
2190
|
}
|
|
2084
2191
|
} else {
|
|
2085
|
-
title = (await
|
|
2192
|
+
title = (await input3({ message: "Short story title:" })).trim();
|
|
2086
2193
|
if (!title) throw new Error("Title cannot be empty. Run `squad new-story` again and enter a short title, or add `--yes` to fail fast in scripts.");
|
|
2087
2194
|
}
|
|
2088
2195
|
}
|
|
2089
2196
|
if (interactive && !title) {
|
|
2090
|
-
const maybe = (await
|
|
2197
|
+
const maybe = (await input3({ message: "Short title (optional, press Enter to skip):", default: "" })).trim();
|
|
2091
2198
|
if (maybe) title = maybe;
|
|
2092
2199
|
}
|
|
2093
2200
|
const storyFolderName = id ? trackerIdForFilename(config2.tracker.type, id) : slugify(title ?? "");
|
|
@@ -2107,10 +2214,12 @@ async function runNewStory(featureSlug, opts) {
|
|
|
2107
2214
|
fs16.mkdirSync(attachmentsDir, { recursive: true });
|
|
2108
2215
|
let sourceBlock = "";
|
|
2109
2216
|
let fetchedSummary;
|
|
2217
|
+
let fetchedIssue;
|
|
2110
2218
|
const shouldFetch = !noTracker && opts.fetch !== false && Boolean(id) && config2.tracker.type !== "none";
|
|
2111
2219
|
if (shouldFetch) {
|
|
2112
2220
|
const outcome = await tryFetchIssue(config2, id, opts, paths, attachmentsDir);
|
|
2113
2221
|
if (outcome.outcome === "ok") {
|
|
2222
|
+
fetchedIssue = outcome.issue;
|
|
2114
2223
|
sourceBlock = buildSourcePreamble(outcome.issue, outcome.downloads, config2.tracker.type);
|
|
2115
2224
|
if (!title) title = outcome.issue.title;
|
|
2116
2225
|
const written = outcome.downloads.filter((d) => d.outcome === "written").length;
|
|
@@ -2122,18 +2231,19 @@ async function runNewStory(featureSlug, opts) {
|
|
|
2122
2231
|
}
|
|
2123
2232
|
}
|
|
2124
2233
|
const template = readBundledPrompt("intake.md");
|
|
2125
|
-
const rendered = render(
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2234
|
+
const rendered = render(
|
|
2235
|
+
template,
|
|
2236
|
+
buildIntakeTemplateVars({
|
|
2237
|
+
config: config2,
|
|
2238
|
+
featureSlug: slug,
|
|
2239
|
+
storyFolderName,
|
|
2240
|
+
trackerId: id,
|
|
2241
|
+
title,
|
|
2242
|
+
fetchedIssue
|
|
2243
|
+
})
|
|
2244
|
+
);
|
|
2132
2245
|
const skippedPreamble = noTracker && config2.tracker.type !== "none" && !sourceBlock ? buildSkippedTrackerPreamble(config2.tracker.type) : "";
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
` : "";
|
|
2136
|
-
writeFileSafe(intakeFile, skippedPreamble + sourceBlock + titleHint + rendered, false);
|
|
2246
|
+
writeFileSafe(intakeFile, skippedPreamble + sourceBlock + rendered, false);
|
|
2137
2247
|
ensureFeatureOverview(path8.join(paths.plansDir, slug), slug);
|
|
2138
2248
|
const relativePath = path8.relative(root, intakeFile);
|
|
2139
2249
|
const relAttachDir = path8.relative(root, attachmentsDir);
|
|
@@ -2206,6 +2316,25 @@ function explainTrackerErrorHint(err) {
|
|
|
2206
2316
|
function truncate(s, n) {
|
|
2207
2317
|
return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
|
|
2208
2318
|
}
|
|
2319
|
+
function buildIntakeTemplateVars(args) {
|
|
2320
|
+
const e = escapeTemplateValue;
|
|
2321
|
+
const { config: config2, featureSlug, storyFolderName, trackerId, title, fetchedIssue } = args;
|
|
2322
|
+
return {
|
|
2323
|
+
featureSlug,
|
|
2324
|
+
storyId: storyFolderName,
|
|
2325
|
+
trackerType: config2.tracker.type,
|
|
2326
|
+
trackerWorkItemId: e((fetchedIssue?.id ?? trackerId ?? "").trim()),
|
|
2327
|
+
trackerWorkItemType: e((fetchedIssue?.type ?? "").trim()),
|
|
2328
|
+
trackerStatus: e((fetchedIssue?.status ?? "").trim()),
|
|
2329
|
+
trackerAssignee: e((fetchedIssue?.assignee ?? "").trim()),
|
|
2330
|
+
trackerLabels: e((fetchedIssue?.labels ?? []).join(", ").trim()),
|
|
2331
|
+
trackerTitle: e((title ?? "").trim()),
|
|
2332
|
+
trackerDescription: e((fetchedIssue?.description ?? "").trim()),
|
|
2333
|
+
trackerAcceptanceCriteria: e((fetchedIssue?.acceptanceCriteria ?? "").trim()),
|
|
2334
|
+
projectRoots: (config2.project.projectRoots ?? ["."]).join(", "),
|
|
2335
|
+
primaryLanguage: config2.project.primaryLanguage ?? ""
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2209
2338
|
function buildSourcePreamble(issue, downloads, trackerType) {
|
|
2210
2339
|
const header = `> **Fetched from ${trackerType}:** [${issue.id}](${issue.url})
|
|
2211
2340
|
> *Fetched ${issue.fetchedAt}. Edit the sections below as needed; the planner reads this file verbatim.*
|
|
@@ -2279,29 +2408,96 @@ function ensureFeatureOverview(featurePlanDir, slug) {
|
|
|
2279
2408
|
}
|
|
2280
2409
|
|
|
2281
2410
|
// src/commands/new-plan.ts
|
|
2282
|
-
import
|
|
2283
|
-
import
|
|
2411
|
+
import fs22 from "fs";
|
|
2412
|
+
import path15 from "path";
|
|
2284
2413
|
import { confirm as confirm6, select as select5 } from "@inquirer/prompts";
|
|
2285
2414
|
|
|
2286
2415
|
// src/utils/clipboard.ts
|
|
2287
|
-
import { spawn } from "child_process";
|
|
2416
|
+
import { spawn, spawnSync } from "child_process";
|
|
2288
2417
|
async function copyToClipboard(text) {
|
|
2289
|
-
const
|
|
2290
|
-
if (
|
|
2418
|
+
const candidates = clipboardCandidates();
|
|
2419
|
+
if (candidates.length === 0) {
|
|
2420
|
+
return { ok: false, reason: noCandidateReason() };
|
|
2421
|
+
}
|
|
2422
|
+
let lastReason;
|
|
2423
|
+
for (const cmd of candidates) {
|
|
2424
|
+
const reason = cmd.unavailableReason?.();
|
|
2425
|
+
if (reason) {
|
|
2426
|
+
lastReason = reason;
|
|
2427
|
+
continue;
|
|
2428
|
+
}
|
|
2429
|
+
const res = await tryCopy(cmd, text);
|
|
2430
|
+
if (res.ok) return res;
|
|
2431
|
+
lastReason = res.reason;
|
|
2432
|
+
}
|
|
2433
|
+
return { ok: false, reason: lastReason ?? "no working clipboard tool found" };
|
|
2434
|
+
}
|
|
2435
|
+
function tryCopy(cmd, text) {
|
|
2291
2436
|
return new Promise((resolve) => {
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2437
|
+
let child;
|
|
2438
|
+
try {
|
|
2439
|
+
child = spawn(cmd.command, cmd.args, { stdio: ["pipe", "ignore", "pipe"] });
|
|
2440
|
+
} catch (err) {
|
|
2441
|
+
resolve({ ok: false, reason: `${cmd.command} failed to spawn: ${err.message}` });
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
let stderr = "";
|
|
2445
|
+
child.stderr?.on("data", (chunk) => {
|
|
2446
|
+
stderr += chunk.toString();
|
|
2447
|
+
});
|
|
2448
|
+
child.on("error", (err) => {
|
|
2449
|
+
resolve({ ok: false, reason: `${cmd.command} spawn error: ${err.message}` });
|
|
2450
|
+
});
|
|
2451
|
+
child.on("close", (code) => {
|
|
2452
|
+
if (code === 0) {
|
|
2453
|
+
resolve({ ok: true, tool: cmd.command });
|
|
2454
|
+
} else {
|
|
2455
|
+
resolve({
|
|
2456
|
+
ok: false,
|
|
2457
|
+
reason: `${cmd.command} exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 200)}` : ""}`
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
});
|
|
2461
|
+
child.stdin?.write(text);
|
|
2462
|
+
child.stdin?.end();
|
|
2297
2463
|
});
|
|
2298
2464
|
}
|
|
2299
|
-
function
|
|
2300
|
-
if (process.platform === "darwin")
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
if (process.
|
|
2304
|
-
|
|
2465
|
+
function clipboardCandidates() {
|
|
2466
|
+
if (process.platform === "darwin") {
|
|
2467
|
+
return [{ command: "pbcopy", args: [] }];
|
|
2468
|
+
}
|
|
2469
|
+
if (process.platform === "win32") {
|
|
2470
|
+
return [{ command: "clip", args: [] }];
|
|
2471
|
+
}
|
|
2472
|
+
const out = [];
|
|
2473
|
+
if (process.env.WAYLAND_DISPLAY) {
|
|
2474
|
+
out.push({ command: "wl-copy", args: [], unavailableReason: hasBinary("wl-copy") });
|
|
2475
|
+
}
|
|
2476
|
+
if (process.env.DISPLAY) {
|
|
2477
|
+
out.push({ command: "xclip", args: ["-selection", "clipboard"], unavailableReason: hasBinary("xclip") });
|
|
2478
|
+
out.push({ command: "xsel", args: ["--clipboard", "--input"], unavailableReason: hasBinary("xsel") });
|
|
2479
|
+
}
|
|
2480
|
+
if (process.env.TERMUX_VERSION) {
|
|
2481
|
+
out.push({ command: "termux-clipboard-set", args: [], unavailableReason: hasBinary("termux-clipboard-set") });
|
|
2482
|
+
}
|
|
2483
|
+
return out;
|
|
2484
|
+
}
|
|
2485
|
+
function hasBinary(bin) {
|
|
2486
|
+
return () => {
|
|
2487
|
+
const which = process.platform === "win32" ? "where" : "which";
|
|
2488
|
+
const res = spawnSync(which, [bin], { stdio: "ignore" });
|
|
2489
|
+
if (res.status === 0) return void 0;
|
|
2490
|
+
return `${bin} not found on PATH`;
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
function noCandidateReason() {
|
|
2494
|
+
if (process.platform === "linux") {
|
|
2495
|
+
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && !process.env.TERMUX_VERSION) {
|
|
2496
|
+
return "no display server detected (headless / SSH) \u2014 install xclip or xsel, or use the saved file";
|
|
2497
|
+
}
|
|
2498
|
+
return "no clipboard tool detected \u2014 install xclip, xsel, or wl-copy";
|
|
2499
|
+
}
|
|
2500
|
+
return `no clipboard tool detected on ${process.platform}`;
|
|
2305
2501
|
}
|
|
2306
2502
|
|
|
2307
2503
|
// src/planner/loop.ts
|
|
@@ -2323,8 +2519,8 @@ var READ_FILE_TOOL = {
|
|
|
2323
2519
|
}
|
|
2324
2520
|
};
|
|
2325
2521
|
var MAX_BYTES_PER_READ = 32e3;
|
|
2326
|
-
function readFileTool(root, budget,
|
|
2327
|
-
const raw =
|
|
2522
|
+
function readFileTool(root, budget, input5) {
|
|
2523
|
+
const raw = input5?.path;
|
|
2328
2524
|
if (typeof raw !== "string" || raw.length === 0) {
|
|
2329
2525
|
return error('read_file: missing or invalid "path" argument.');
|
|
2330
2526
|
}
|
|
@@ -2455,117 +2651,16 @@ function rateLimitMessage(err) {
|
|
|
2455
2651
|
].join("\n");
|
|
2456
2652
|
}
|
|
2457
2653
|
|
|
2458
|
-
// src/planner/
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
const
|
|
2463
|
-
const
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
let budgetExhausted = false;
|
|
2467
|
-
let finishedNormally = false;
|
|
2468
|
-
while (iterations < maxIter) {
|
|
2469
|
-
iterations += 1;
|
|
2470
|
-
if (input6.budget.timedOut()) {
|
|
2471
|
-
return { planText: accumulatedText, budgetExhausted, timedOut: true, finishedNormally: false, iterations };
|
|
2472
|
-
}
|
|
2473
|
-
if (input6.budget.overCost()) {
|
|
2474
|
-
return { planText: accumulatedText, budgetExhausted: true, timedOut: false, finishedNormally: false, iterations };
|
|
2475
|
-
}
|
|
2476
|
-
const request = {
|
|
2477
|
-
systemPrompt: input6.systemPrompt,
|
|
2478
|
-
model: input6.model,
|
|
2479
|
-
tools: [READ_FILE_TOOL],
|
|
2480
|
-
turns,
|
|
2481
|
-
apiKey: input6.apiKey,
|
|
2482
|
-
maxOutputTokens: 8192
|
|
2483
|
-
};
|
|
2484
|
-
let response = await input6.provider.send(request);
|
|
2485
|
-
if (response.usage) {
|
|
2486
|
-
input6.budget.recordUsage(response.usage);
|
|
2487
|
-
input6.onUsage?.(response.usage);
|
|
2488
|
-
}
|
|
2489
|
-
let retriedRateLimit = false;
|
|
2490
|
-
let retrySkippedReason;
|
|
2491
|
-
if (response.stopReason === "error" && response.errorKind === "rate_limit") {
|
|
2492
|
-
const asked = response.retryAfterSec;
|
|
2493
|
-
if (asked !== void 0 && asked > MAX_RATE_LIMIT_RETRY_SEC) {
|
|
2494
|
-
retrySkippedReason = "retry_after_too_long";
|
|
2495
|
-
} else {
|
|
2496
|
-
const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
|
|
2497
|
-
input6.onRateLimit?.(waitSec);
|
|
2498
|
-
await (input6.sleep ?? defaultSleep)(waitSec * 1e3);
|
|
2499
|
-
response = await input6.provider.send(request);
|
|
2500
|
-
if (response.usage) {
|
|
2501
|
-
input6.budget.recordUsage(response.usage);
|
|
2502
|
-
input6.onUsage?.(response.usage);
|
|
2503
|
-
}
|
|
2504
|
-
retriedRateLimit = true;
|
|
2505
|
-
}
|
|
2506
|
-
}
|
|
2507
|
-
if (response.stopReason === "error") {
|
|
2508
|
-
throw composePlannerError(input6.provider.name, response, retriedRateLimit, retrySkippedReason);
|
|
2509
|
-
}
|
|
2510
|
-
if (response.text) {
|
|
2511
|
-
accumulatedText += response.text;
|
|
2512
|
-
input6.onAssistantText?.(response.text);
|
|
2513
|
-
}
|
|
2514
|
-
if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
|
|
2515
|
-
return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally: false, iterations };
|
|
2516
|
-
}
|
|
2517
|
-
if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
|
|
2518
|
-
finishedNormally = true;
|
|
2519
|
-
return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally, iterations };
|
|
2520
|
-
}
|
|
2521
|
-
turns.push({
|
|
2522
|
-
role: "assistant",
|
|
2523
|
-
text: response.text,
|
|
2524
|
-
toolCalls: response.toolCalls
|
|
2525
|
-
});
|
|
2526
|
-
const toolResults = [];
|
|
2527
|
-
for (const tc of response.toolCalls) {
|
|
2528
|
-
if (tc.name !== READ_FILE_TOOL.name) {
|
|
2529
|
-
toolResults.push({ toolCallId: tc.id, content: `unknown tool "${tc.name}"`, isError: true });
|
|
2530
|
-
continue;
|
|
2531
|
-
}
|
|
2532
|
-
const before = input6.budget.snapshot().bytes;
|
|
2533
|
-
const result = readFileTool(input6.root, input6.budget, tc.input);
|
|
2534
|
-
const after = input6.budget.snapshot().bytes;
|
|
2535
|
-
input6.onToolCall?.(tc, after - before, after);
|
|
2536
|
-
if (result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content))) {
|
|
2537
|
-
budgetExhausted = true;
|
|
2538
|
-
}
|
|
2539
|
-
toolResults.push({ toolCallId: tc.id, content: result.content, isError: result.isError });
|
|
2540
|
-
}
|
|
2541
|
-
turns.push({ role: "user", toolResults });
|
|
2542
|
-
if (budgetExhausted) {
|
|
2543
|
-
turns.push({
|
|
2544
|
-
role: "user",
|
|
2545
|
-
text: "Budget is exhausted. Finalise the plan with the information you already have. Do not call any more tools. Output the complete plan markdown now."
|
|
2546
|
-
});
|
|
2547
|
-
}
|
|
2548
|
-
}
|
|
2549
|
-
return { planText: accumulatedText, budgetExhausted, timedOut: false, finishedNormally: false, iterations };
|
|
2550
|
-
}
|
|
2551
|
-
function composePlannerError(providerName, response, retriedRateLimit, retrySkippedReason) {
|
|
2552
|
-
if (response.errorKind === "rate_limit") {
|
|
2553
|
-
return new Error(
|
|
2554
|
-
rateLimitMessage({
|
|
2555
|
-
provider: providerName,
|
|
2556
|
-
retryAfterSec: response.retryAfterSec,
|
|
2557
|
-
rawBody: response.rawError ?? "",
|
|
2558
|
-
retryAlreadyAttempted: retriedRateLimit,
|
|
2559
|
-
retrySkippedReason,
|
|
2560
|
-
maxRetrySec: MAX_RATE_LIMIT_RETRY_SEC
|
|
2561
|
-
})
|
|
2562
|
-
);
|
|
2563
|
-
}
|
|
2564
|
-
if (response.errorKind === "model_not_found") {
|
|
2565
|
-
return new Error(response.rawError ?? "planner: model not found");
|
|
2654
|
+
// src/planner/stable-json.ts
|
|
2655
|
+
function sortRecordKeys(v) {
|
|
2656
|
+
if (v === null || typeof v !== "object") return v;
|
|
2657
|
+
if (Array.isArray(v)) return v.map((x) => sortRecordKeys(x));
|
|
2658
|
+
const o = v;
|
|
2659
|
+
const out = {};
|
|
2660
|
+
for (const k of Object.keys(o).sort()) {
|
|
2661
|
+
out[k] = sortRecordKeys(o[k]);
|
|
2566
2662
|
}
|
|
2567
|
-
|
|
2568
|
-
return new Error(`${base} Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`);
|
|
2663
|
+
return out;
|
|
2569
2664
|
}
|
|
2570
2665
|
|
|
2571
2666
|
// src/planner/providers/anthropic.ts
|
|
@@ -2577,14 +2672,39 @@ var anthropicProvider = {
|
|
|
2577
2672
|
return callAnthropic(req);
|
|
2578
2673
|
}
|
|
2579
2674
|
};
|
|
2580
|
-
|
|
2581
|
-
|
|
2675
|
+
function buildAnthropicBody(req) {
|
|
2676
|
+
if (req.cacheEnabled === false) {
|
|
2677
|
+
const base2 = {
|
|
2678
|
+
model: req.model,
|
|
2679
|
+
max_tokens: req.maxOutputTokens ?? 4096,
|
|
2680
|
+
system: req.systemPrompt,
|
|
2681
|
+
tools: req.tools.map(toAnthropicTool)
|
|
2682
|
+
};
|
|
2683
|
+
if (req.turns.length === 0) {
|
|
2684
|
+
return base2;
|
|
2685
|
+
}
|
|
2686
|
+
return { ...base2, messages: req.turns.map((t) => toAnthropicMessage(t)) };
|
|
2687
|
+
}
|
|
2688
|
+
const base = {
|
|
2582
2689
|
model: req.model,
|
|
2583
2690
|
max_tokens: req.maxOutputTokens ?? 4096,
|
|
2584
|
-
system:
|
|
2585
|
-
|
|
2586
|
-
|
|
2691
|
+
system: [
|
|
2692
|
+
{
|
|
2693
|
+
type: "text",
|
|
2694
|
+
text: req.systemPrompt,
|
|
2695
|
+
cache_control: { type: "ephemeral" }
|
|
2696
|
+
}
|
|
2697
|
+
],
|
|
2698
|
+
tools: req.tools.map(toAnthropicTool)
|
|
2587
2699
|
};
|
|
2700
|
+
if (req.turns.length === 0) {
|
|
2701
|
+
return base;
|
|
2702
|
+
}
|
|
2703
|
+
return { ...base, messages: toMessagesWithCacheMarkers(req.turns) };
|
|
2704
|
+
}
|
|
2705
|
+
async function callAnthropic(req) {
|
|
2706
|
+
const built = buildAnthropicBody(req);
|
|
2707
|
+
const body = { ...built, messages: built.messages ?? [] };
|
|
2588
2708
|
const res = await fetch(API_URL, {
|
|
2589
2709
|
method: "POST",
|
|
2590
2710
|
headers: {
|
|
@@ -2624,26 +2744,73 @@ async function callAnthropic(req) {
|
|
|
2624
2744
|
stopReason: mapStop(json.stop_reason),
|
|
2625
2745
|
usage: {
|
|
2626
2746
|
inputTokens: json.usage?.input_tokens ?? 0,
|
|
2627
|
-
outputTokens: json.usage?.output_tokens ?? 0
|
|
2747
|
+
outputTokens: json.usage?.output_tokens ?? 0,
|
|
2748
|
+
cacheCreationTokens: json.usage?.cache_creation_input_tokens ?? 0,
|
|
2749
|
+
cacheReadTokens: json.usage?.cache_read_input_tokens ?? 0
|
|
2628
2750
|
}
|
|
2629
2751
|
};
|
|
2630
2752
|
}
|
|
2753
|
+
function toMessagesWithCacheMarkers(turns) {
|
|
2754
|
+
let lastUserWithToolResultIdx = -1;
|
|
2755
|
+
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
|
2756
|
+
const t = turns[i];
|
|
2757
|
+
if (t.role === "user" && hasToolResultBlock(t)) {
|
|
2758
|
+
lastUserWithToolResultIdx = i;
|
|
2759
|
+
break;
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
return turns.map((turn, idx) => {
|
|
2763
|
+
const message = toAnthropicMessage(turn);
|
|
2764
|
+
if (idx === lastUserWithToolResultIdx) {
|
|
2765
|
+
return attachCacheControlToLastToolResult(message);
|
|
2766
|
+
}
|
|
2767
|
+
return message;
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
function hasToolResultBlock(turn) {
|
|
2771
|
+
return (turn.toolResults?.length ?? 0) > 0;
|
|
2772
|
+
}
|
|
2773
|
+
function attachCacheControlToLastToolResult(msg) {
|
|
2774
|
+
const content = [...msg.content];
|
|
2775
|
+
for (let i = content.length - 1; i >= 0; i -= 1) {
|
|
2776
|
+
const block = content[i];
|
|
2777
|
+
if (block.type === "tool_result") {
|
|
2778
|
+
content[i] = { ...block, cache_control: { type: "ephemeral" } };
|
|
2779
|
+
break;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
return { ...msg, content };
|
|
2783
|
+
}
|
|
2631
2784
|
function toAnthropicTool(t) {
|
|
2632
|
-
return {
|
|
2785
|
+
return {
|
|
2786
|
+
name: t.name,
|
|
2787
|
+
description: t.description,
|
|
2788
|
+
input_schema: sortRecordKeys(t.inputSchema)
|
|
2789
|
+
};
|
|
2633
2790
|
}
|
|
2634
2791
|
function toAnthropicMessage(turn) {
|
|
2635
2792
|
const blocks = [];
|
|
2636
2793
|
if (turn.text) blocks.push({ type: "text", text: turn.text });
|
|
2637
|
-
if (turn.toolCalls)
|
|
2638
|
-
for (const tc of turn.toolCalls)
|
|
2639
|
-
|
|
2640
|
-
|
|
2794
|
+
if (turn.toolCalls) {
|
|
2795
|
+
for (const tc of turn.toolCalls) {
|
|
2796
|
+
blocks.push({
|
|
2797
|
+
type: "tool_use",
|
|
2798
|
+
id: tc.id,
|
|
2799
|
+
name: tc.name,
|
|
2800
|
+
input: sortRecordKeys(tc.input)
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
if (turn.toolResults) {
|
|
2805
|
+
for (const tr of turn.toolResults) {
|
|
2641
2806
|
blocks.push({
|
|
2642
2807
|
type: "tool_result",
|
|
2643
2808
|
tool_use_id: tr.toolCallId,
|
|
2644
2809
|
content: tr.content,
|
|
2645
2810
|
is_error: tr.isError ?? false
|
|
2646
2811
|
});
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2647
2814
|
return { role: turn.role === "assistant" ? "assistant" : "user", content: blocks };
|
|
2648
2815
|
}
|
|
2649
2816
|
function mapStop(r) {
|
|
@@ -2660,15 +2827,23 @@ var openaiProvider = {
|
|
|
2660
2827
|
return callOpenAI(req);
|
|
2661
2828
|
}
|
|
2662
2829
|
};
|
|
2663
|
-
|
|
2664
|
-
const
|
|
2665
|
-
for (const turn of req.turns) messages.push(...toOpenAIMessages(turn));
|
|
2666
|
-
const body = {
|
|
2830
|
+
function buildOpenAIBody(req) {
|
|
2831
|
+
const base = {
|
|
2667
2832
|
model: req.model,
|
|
2668
|
-
|
|
2669
|
-
tools: req.tools.map(toOpenAITool)
|
|
2670
|
-
max_completion_tokens: req.maxOutputTokens ?? 4096
|
|
2833
|
+
max_completion_tokens: req.maxOutputTokens ?? 4096,
|
|
2834
|
+
tools: req.tools.map(toOpenAITool)
|
|
2671
2835
|
};
|
|
2836
|
+
if (req.turns.length === 0) {
|
|
2837
|
+
return base;
|
|
2838
|
+
}
|
|
2839
|
+
const messages = [{ role: "system", content: req.systemPrompt }];
|
|
2840
|
+
for (const turn of req.turns) messages.push(...toOpenAIMessages(turn));
|
|
2841
|
+
return { ...base, messages };
|
|
2842
|
+
}
|
|
2843
|
+
async function callOpenAI(req) {
|
|
2844
|
+
const built = buildOpenAIBody(req);
|
|
2845
|
+
const messages = built.messages && built.messages.length > 0 ? built.messages : [{ role: "system", content: req.systemPrompt }];
|
|
2846
|
+
const body = { ...built, messages };
|
|
2672
2847
|
const res = await fetch(API_URL2, {
|
|
2673
2848
|
method: "POST",
|
|
2674
2849
|
headers: {
|
|
@@ -2718,7 +2893,14 @@ async function callOpenAI(req) {
|
|
|
2718
2893
|
};
|
|
2719
2894
|
}
|
|
2720
2895
|
function toOpenAITool(t) {
|
|
2721
|
-
return {
|
|
2896
|
+
return {
|
|
2897
|
+
type: "function",
|
|
2898
|
+
function: {
|
|
2899
|
+
name: t.name,
|
|
2900
|
+
description: t.description,
|
|
2901
|
+
parameters: sortRecordKeys(t.inputSchema)
|
|
2902
|
+
}
|
|
2903
|
+
};
|
|
2722
2904
|
}
|
|
2723
2905
|
function toOpenAIMessages(turn) {
|
|
2724
2906
|
const out = [];
|
|
@@ -2729,7 +2911,10 @@ function toOpenAIMessages(turn) {
|
|
|
2729
2911
|
tool_calls: turn.toolCalls?.map((tc) => ({
|
|
2730
2912
|
id: tc.id,
|
|
2731
2913
|
type: "function",
|
|
2732
|
-
function: {
|
|
2914
|
+
function: {
|
|
2915
|
+
name: tc.name,
|
|
2916
|
+
arguments: JSON.stringify(sortRecordKeys(tc.input))
|
|
2917
|
+
}
|
|
2733
2918
|
}))
|
|
2734
2919
|
});
|
|
2735
2920
|
} else {
|
|
@@ -2749,7 +2934,7 @@ function mapStop2(r) {
|
|
|
2749
2934
|
}
|
|
2750
2935
|
function safeJson(raw) {
|
|
2751
2936
|
try {
|
|
2752
|
-
return JSON.parse(raw);
|
|
2937
|
+
return sortRecordKeys(JSON.parse(raw));
|
|
2753
2938
|
} catch {
|
|
2754
2939
|
return {};
|
|
2755
2940
|
}
|
|
@@ -2763,14 +2948,23 @@ var googleProvider = {
|
|
|
2763
2948
|
return callGoogle(req);
|
|
2764
2949
|
}
|
|
2765
2950
|
};
|
|
2766
|
-
|
|
2767
|
-
const
|
|
2768
|
-
const body = {
|
|
2951
|
+
function buildGoogleBody(req) {
|
|
2952
|
+
const base = {
|
|
2769
2953
|
system_instruction: { parts: [{ text: req.systemPrompt }] },
|
|
2770
|
-
contents: req.turns.map(toGoogleContent),
|
|
2771
|
-
tools: req.tools.length ? [{ functionDeclarations: req.tools.map(toGoogleTool) }] : void 0,
|
|
2772
2954
|
generationConfig: { maxOutputTokens: req.maxOutputTokens ?? 4096 }
|
|
2773
2955
|
};
|
|
2956
|
+
if (req.tools.length) {
|
|
2957
|
+
base.tools = [{ functionDeclarations: req.tools.map(toGoogleTool) }];
|
|
2958
|
+
}
|
|
2959
|
+
if (req.turns.length === 0) {
|
|
2960
|
+
return base;
|
|
2961
|
+
}
|
|
2962
|
+
return { ...base, contents: req.turns.map(toGoogleContent) };
|
|
2963
|
+
}
|
|
2964
|
+
async function callGoogle(req) {
|
|
2965
|
+
const url = `${API_BASE}/${encodeURIComponent(req.model)}:generateContent?key=${encodeURIComponent(req.apiKey)}`;
|
|
2966
|
+
const built = buildGoogleBody(req);
|
|
2967
|
+
const body = { ...built, contents: built.contents ?? [] };
|
|
2774
2968
|
const res = await fetch(url, {
|
|
2775
2969
|
method: "POST",
|
|
2776
2970
|
headers: { "content-type": "application/json" },
|
|
@@ -2805,7 +2999,7 @@ async function callGoogle(req) {
|
|
|
2805
2999
|
const toolCalls = parts.filter((p) => "functionCall" in p && !!p.functionCall).map((p, i) => ({
|
|
2806
3000
|
id: `gcall_${i}_${p.functionCall.name}`,
|
|
2807
3001
|
name: p.functionCall.name,
|
|
2808
|
-
input: p.functionCall.args
|
|
3002
|
+
input: p.functionCall.args ? sortRecordKeys(p.functionCall.args) : {}
|
|
2809
3003
|
}));
|
|
2810
3004
|
return {
|
|
2811
3005
|
text: textOut || void 0,
|
|
@@ -2818,14 +3012,17 @@ async function callGoogle(req) {
|
|
|
2818
3012
|
};
|
|
2819
3013
|
}
|
|
2820
3014
|
function toGoogleTool(t) {
|
|
2821
|
-
return { name: t.name, description: t.description, parameters: t.inputSchema };
|
|
3015
|
+
return { name: t.name, description: t.description, parameters: sortRecordKeys(t.inputSchema) };
|
|
2822
3016
|
}
|
|
2823
3017
|
function toGoogleContent(turn) {
|
|
2824
3018
|
const role = turn.role === "assistant" ? "model" : "user";
|
|
2825
3019
|
const parts = [];
|
|
2826
3020
|
if (turn.text) parts.push({ text: turn.text });
|
|
2827
|
-
if (turn.toolCalls)
|
|
2828
|
-
for (const tc of turn.toolCalls)
|
|
3021
|
+
if (turn.toolCalls) {
|
|
3022
|
+
for (const tc of turn.toolCalls) {
|
|
3023
|
+
parts.push({ functionCall: { name: tc.name, args: sortRecordKeys(tc.input) } });
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
2829
3026
|
if (turn.toolResults) {
|
|
2830
3027
|
for (const tr of turn.toolResults) {
|
|
2831
3028
|
parts.push({
|
|
@@ -2845,6 +3042,416 @@ function mapStop3(r) {
|
|
|
2845
3042
|
return "tool_use";
|
|
2846
3043
|
}
|
|
2847
3044
|
|
|
3045
|
+
// src/planner/providers/prefix.ts
|
|
3046
|
+
function prefixOf(provider, req) {
|
|
3047
|
+
const trimmed = { ...req, turns: req.turns.slice(0, -1) };
|
|
3048
|
+
const s = (() => {
|
|
3049
|
+
switch (provider) {
|
|
3050
|
+
case "anthropic":
|
|
3051
|
+
return JSON.stringify(buildAnthropicBody(trimmed));
|
|
3052
|
+
case "openai":
|
|
3053
|
+
return JSON.stringify(buildOpenAIBody(trimmed));
|
|
3054
|
+
case "google":
|
|
3055
|
+
return JSON.stringify(buildGoogleBody(trimmed));
|
|
3056
|
+
}
|
|
3057
|
+
})();
|
|
3058
|
+
return s.length > 0 ? s.slice(0, -1) : s;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// src/planner/session-limits.ts
|
|
3062
|
+
var DEFAULT_PLANNER_MAX_ITERATIONS = 40;
|
|
3063
|
+
var PLANNER_MARKDOWN_CONTINUATION_USER = "Your previous reply was cut off because it hit the per-response output token limit for this request. Continue the Markdown plan from the exact cut-off only: write the remainder, do not repeat headings or paragraphs already sent. When the plan is complete, use end_turn without unnecessary tool calls.";
|
|
3064
|
+
|
|
3065
|
+
// src/planner/loop.ts
|
|
3066
|
+
var MAX_RATE_LIMIT_RETRY_SEC = 90;
|
|
3067
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3068
|
+
function buildPlannerRunStats(budget, turns, runStartedAt) {
|
|
3069
|
+
const u = budget.snapshot().usage;
|
|
3070
|
+
const cacheRead = u.cacheReadTokens ?? 0;
|
|
3071
|
+
const cacheCreate = u.cacheCreationTokens ?? 0;
|
|
3072
|
+
const inTok = u.inputTokens;
|
|
3073
|
+
const outTok = u.outputTokens;
|
|
3074
|
+
const totalInput = inTok + cacheRead;
|
|
3075
|
+
const cacheHitRatio = totalInput === 0 ? 0 : Math.round(cacheRead / totalInput * 100) / 100;
|
|
3076
|
+
return {
|
|
3077
|
+
turns,
|
|
3078
|
+
inputTokens: inTok,
|
|
3079
|
+
outputTokens: outTok,
|
|
3080
|
+
cacheCreationTokens: cacheCreate,
|
|
3081
|
+
cacheReadTokens: cacheRead,
|
|
3082
|
+
cacheHitRatio,
|
|
3083
|
+
durationMs: Date.now() - runStartedAt
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
function readBudgetishError(result) {
|
|
3087
|
+
return result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content));
|
|
3088
|
+
}
|
|
3089
|
+
function extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut) {
|
|
3090
|
+
input5.budget.extendSession();
|
|
3091
|
+
maxIter.current += baseMaxIter;
|
|
3092
|
+
maxOut.current += baseMaxOut;
|
|
3093
|
+
}
|
|
3094
|
+
async function runPlanner(input5) {
|
|
3095
|
+
const runStartedAt = Date.now();
|
|
3096
|
+
const baseMaxIter = input5.maxIterations ?? DEFAULT_PLANNER_MAX_ITERATIONS;
|
|
3097
|
+
const maxIter = { current: baseMaxIter };
|
|
3098
|
+
const baseMaxOut = input5.maxOutputTokens ?? DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
|
|
3099
|
+
const maxOut = { current: baseMaxOut };
|
|
3100
|
+
const turns = [{ role: "user", text: input5.userPrompt }];
|
|
3101
|
+
let accumulatedText = "";
|
|
3102
|
+
let iterations = 0;
|
|
3103
|
+
let budgetExhausted = false;
|
|
3104
|
+
let finishedNormally = false;
|
|
3105
|
+
let prevRequestPrefix;
|
|
3106
|
+
const decide = input5.decideOnLimit;
|
|
3107
|
+
const limitCtx = (kind) => ({
|
|
3108
|
+
kind,
|
|
3109
|
+
budgetSnapshot: input5.budget.snapshot(),
|
|
3110
|
+
iterations,
|
|
3111
|
+
maxIterations: maxIter.current,
|
|
3112
|
+
maxOutputTokens: maxOut.current
|
|
3113
|
+
});
|
|
3114
|
+
for (; ; ) {
|
|
3115
|
+
if (iterations >= maxIter.current) {
|
|
3116
|
+
if (decide) {
|
|
3117
|
+
const d = await decide(limitCtx("max_iterations"));
|
|
3118
|
+
if (d === "cancel") {
|
|
3119
|
+
return {
|
|
3120
|
+
planText: accumulatedText,
|
|
3121
|
+
budgetExhausted,
|
|
3122
|
+
timedOut: false,
|
|
3123
|
+
finishedNormally: false,
|
|
3124
|
+
iterations,
|
|
3125
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3126
|
+
incompleteKind: "max_iterations",
|
|
3127
|
+
userCancelled: true
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
|
|
3131
|
+
continue;
|
|
3132
|
+
}
|
|
3133
|
+
return {
|
|
3134
|
+
planText: accumulatedText,
|
|
3135
|
+
budgetExhausted,
|
|
3136
|
+
timedOut: false,
|
|
3137
|
+
finishedNormally: false,
|
|
3138
|
+
iterations,
|
|
3139
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3140
|
+
incompleteKind: "max_iterations"
|
|
3141
|
+
};
|
|
3142
|
+
}
|
|
3143
|
+
iterations += 1;
|
|
3144
|
+
if (input5.budget.timedOut()) {
|
|
3145
|
+
if (decide) {
|
|
3146
|
+
const d = await decide(limitCtx("wall_clock"));
|
|
3147
|
+
if (d === "cancel") {
|
|
3148
|
+
return {
|
|
3149
|
+
planText: accumulatedText,
|
|
3150
|
+
budgetExhausted,
|
|
3151
|
+
timedOut: true,
|
|
3152
|
+
finishedNormally: false,
|
|
3153
|
+
iterations,
|
|
3154
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3155
|
+
incompleteKind: "wall_clock",
|
|
3156
|
+
userCancelled: true
|
|
3157
|
+
};
|
|
3158
|
+
}
|
|
3159
|
+
extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
|
|
3160
|
+
iterations -= 1;
|
|
3161
|
+
continue;
|
|
3162
|
+
}
|
|
3163
|
+
return {
|
|
3164
|
+
planText: accumulatedText,
|
|
3165
|
+
budgetExhausted,
|
|
3166
|
+
timedOut: true,
|
|
3167
|
+
finishedNormally: false,
|
|
3168
|
+
iterations,
|
|
3169
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
if (input5.budget.overCost()) {
|
|
3173
|
+
if (decide) {
|
|
3174
|
+
const d = await decide(limitCtx("cost_cap"));
|
|
3175
|
+
if (d === "cancel") {
|
|
3176
|
+
return {
|
|
3177
|
+
planText: accumulatedText,
|
|
3178
|
+
budgetExhausted,
|
|
3179
|
+
timedOut: false,
|
|
3180
|
+
finishedNormally: false,
|
|
3181
|
+
iterations,
|
|
3182
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3183
|
+
incompleteKind: "cost_cap",
|
|
3184
|
+
userCancelled: true
|
|
3185
|
+
};
|
|
3186
|
+
}
|
|
3187
|
+
extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
|
|
3188
|
+
iterations -= 1;
|
|
3189
|
+
continue;
|
|
3190
|
+
}
|
|
3191
|
+
return {
|
|
3192
|
+
planText: accumulatedText,
|
|
3193
|
+
budgetExhausted: true,
|
|
3194
|
+
timedOut: false,
|
|
3195
|
+
finishedNormally: false,
|
|
3196
|
+
iterations,
|
|
3197
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
const request = {
|
|
3201
|
+
systemPrompt: input5.systemPrompt,
|
|
3202
|
+
model: input5.model,
|
|
3203
|
+
tools: [READ_FILE_TOOL],
|
|
3204
|
+
turns,
|
|
3205
|
+
apiKey: input5.apiKey,
|
|
3206
|
+
maxOutputTokens: maxOut.current,
|
|
3207
|
+
cacheEnabled: input5.cacheEnabled ?? true
|
|
3208
|
+
};
|
|
3209
|
+
if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test" && prevRequestPrefix !== void 0) {
|
|
3210
|
+
const currentPrefix = prefixOf(input5.provider.name, request);
|
|
3211
|
+
if (!currentPrefix.startsWith(prevRequestPrefix)) {
|
|
3212
|
+
const byte = firstByteDiff(prevRequestPrefix, currentPrefix);
|
|
3213
|
+
warning(
|
|
3214
|
+
`planner: prefix mutation at turn ${iterations}, byte ${byte} \u2014 caching will not hit.`
|
|
3215
|
+
);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
let response = await input5.provider.send(request);
|
|
3219
|
+
if (response.usage) {
|
|
3220
|
+
input5.budget.recordUsage(response.usage);
|
|
3221
|
+
input5.onUsage?.(response.usage);
|
|
3222
|
+
}
|
|
3223
|
+
let retriedRateLimit = false;
|
|
3224
|
+
let retrySkippedReason;
|
|
3225
|
+
if (response.stopReason === "error" && response.errorKind === "rate_limit") {
|
|
3226
|
+
const asked = response.retryAfterSec;
|
|
3227
|
+
if (asked !== void 0 && asked > MAX_RATE_LIMIT_RETRY_SEC) {
|
|
3228
|
+
retrySkippedReason = "retry_after_too_long";
|
|
3229
|
+
} else {
|
|
3230
|
+
const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
|
|
3231
|
+
input5.onRateLimit?.(waitSec);
|
|
3232
|
+
await (input5.sleep ?? defaultSleep)(waitSec * 1e3);
|
|
3233
|
+
response = await input5.provider.send(request);
|
|
3234
|
+
if (response.usage) {
|
|
3235
|
+
input5.budget.recordUsage(response.usage);
|
|
3236
|
+
input5.onUsage?.(response.usage);
|
|
3237
|
+
}
|
|
3238
|
+
retriedRateLimit = true;
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
if (response.stopReason === "error") {
|
|
3242
|
+
throw composePlannerError(input5.provider.name, response, retriedRateLimit, retrySkippedReason);
|
|
3243
|
+
}
|
|
3244
|
+
prevRequestPrefix = prefixOf(input5.provider.name, request);
|
|
3245
|
+
if (response.text) {
|
|
3246
|
+
accumulatedText += response.text;
|
|
3247
|
+
input5.onAssistantText?.(response.text);
|
|
3248
|
+
}
|
|
3249
|
+
if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
|
|
3250
|
+
if (decide) {
|
|
3251
|
+
const d = await decide(limitCtx("max_output_tokens"));
|
|
3252
|
+
if (d === "cancel") {
|
|
3253
|
+
return {
|
|
3254
|
+
planText: accumulatedText,
|
|
3255
|
+
budgetExhausted,
|
|
3256
|
+
timedOut: false,
|
|
3257
|
+
finishedNormally: false,
|
|
3258
|
+
iterations,
|
|
3259
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3260
|
+
incompleteKind: "max_output_tokens",
|
|
3261
|
+
userCancelled: true
|
|
3262
|
+
};
|
|
3263
|
+
}
|
|
3264
|
+
extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
|
|
3265
|
+
turns.push({ role: "assistant", text: response.text ?? "" });
|
|
3266
|
+
turns.push({ role: "user", text: PLANNER_MARKDOWN_CONTINUATION_USER });
|
|
3267
|
+
iterations -= 1;
|
|
3268
|
+
continue;
|
|
3269
|
+
}
|
|
3270
|
+
return {
|
|
3271
|
+
planText: accumulatedText,
|
|
3272
|
+
budgetExhausted,
|
|
3273
|
+
timedOut: false,
|
|
3274
|
+
finishedNormally: false,
|
|
3275
|
+
iterations,
|
|
3276
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3277
|
+
incompleteKind: "max_output_tokens"
|
|
3278
|
+
};
|
|
3279
|
+
}
|
|
3280
|
+
if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
|
|
3281
|
+
finishedNormally = true;
|
|
3282
|
+
return {
|
|
3283
|
+
planText: accumulatedText,
|
|
3284
|
+
budgetExhausted,
|
|
3285
|
+
timedOut: false,
|
|
3286
|
+
finishedNormally,
|
|
3287
|
+
iterations,
|
|
3288
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
|
|
3289
|
+
};
|
|
3290
|
+
}
|
|
3291
|
+
turns.push({
|
|
3292
|
+
role: "assistant",
|
|
3293
|
+
text: response.text,
|
|
3294
|
+
toolCalls: response.toolCalls
|
|
3295
|
+
});
|
|
3296
|
+
const toolResults = [];
|
|
3297
|
+
const toolCalls = response.toolCalls;
|
|
3298
|
+
for (let i = 0; i < toolCalls.length; i += 1) {
|
|
3299
|
+
const tc = toolCalls[i];
|
|
3300
|
+
if (tc.name !== READ_FILE_TOOL.name) {
|
|
3301
|
+
toolResults.push({ toolCallId: tc.id, content: `unknown tool "${tc.name}"`, isError: true });
|
|
3302
|
+
continue;
|
|
3303
|
+
}
|
|
3304
|
+
const before = input5.budget.snapshot().bytes;
|
|
3305
|
+
let result = readFileTool(input5.root, input5.budget, tc.input);
|
|
3306
|
+
const after = input5.budget.snapshot().bytes;
|
|
3307
|
+
input5.onToolCall?.(tc, after - before, after);
|
|
3308
|
+
if (readBudgetishError(result)) {
|
|
3309
|
+
if (decide) {
|
|
3310
|
+
const d = await decide(limitCtx("file_or_context_reads"));
|
|
3311
|
+
if (d === "cancel") {
|
|
3312
|
+
toolResults.push({ toolCallId: tc.id, content: result.content, isError: true });
|
|
3313
|
+
for (let j = i + 1; j < toolCalls.length; j += 1) {
|
|
3314
|
+
const t2 = toolCalls[j];
|
|
3315
|
+
toolResults.push({
|
|
3316
|
+
toolCallId: t2.id,
|
|
3317
|
+
content: t2.name === READ_FILE_TOOL.name ? "read_file: not executed (planning session stopped after file/context budget)." : `unknown tool "${t2.name}"`,
|
|
3318
|
+
isError: true
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
turns.push({ role: "user", toolResults });
|
|
3322
|
+
return {
|
|
3323
|
+
planText: accumulatedText,
|
|
3324
|
+
budgetExhausted: false,
|
|
3325
|
+
timedOut: false,
|
|
3326
|
+
finishedNormally: false,
|
|
3327
|
+
iterations,
|
|
3328
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3329
|
+
incompleteKind: "budget_reads",
|
|
3330
|
+
userCancelled: true
|
|
3331
|
+
};
|
|
3332
|
+
}
|
|
3333
|
+
extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
|
|
3334
|
+
result = readFileTool(input5.root, input5.budget, tc.input);
|
|
3335
|
+
} else {
|
|
3336
|
+
budgetExhausted = true;
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
toolResults.push({ toolCallId: tc.id, content: result.content, isError: result.isError });
|
|
3340
|
+
}
|
|
3341
|
+
turns.push({ role: "user", toolResults });
|
|
3342
|
+
if (budgetExhausted) {
|
|
3343
|
+
turns.push({
|
|
3344
|
+
role: "user",
|
|
3345
|
+
text: "Budget is exhausted. Finalise the plan with the information you already have. Do not call any more tools. Output the complete plan markdown now."
|
|
3346
|
+
});
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
function firstByteDiff(a, b) {
|
|
3351
|
+
const n = Math.min(a.length, b.length);
|
|
3352
|
+
for (let i = 0; i < n; i += 1) {
|
|
3353
|
+
if (a.charCodeAt(i) !== b.charCodeAt(i)) return i;
|
|
3354
|
+
}
|
|
3355
|
+
return n;
|
|
3356
|
+
}
|
|
3357
|
+
function composePlannerError(providerName, response, retriedRateLimit, retrySkippedReason) {
|
|
3358
|
+
if (response.errorKind === "rate_limit") {
|
|
3359
|
+
return new Error(
|
|
3360
|
+
rateLimitMessage({
|
|
3361
|
+
provider: providerName,
|
|
3362
|
+
retryAfterSec: response.retryAfterSec,
|
|
3363
|
+
rawBody: response.rawError ?? "",
|
|
3364
|
+
retryAlreadyAttempted: retriedRateLimit,
|
|
3365
|
+
retrySkippedReason,
|
|
3366
|
+
maxRetrySec: MAX_RATE_LIMIT_RETRY_SEC
|
|
3367
|
+
})
|
|
3368
|
+
);
|
|
3369
|
+
}
|
|
3370
|
+
if (response.errorKind === "model_not_found") {
|
|
3371
|
+
return new Error(response.rawError ?? "planner: model not found");
|
|
3372
|
+
}
|
|
3373
|
+
const base = response.rawError ?? "planner: provider error";
|
|
3374
|
+
return new Error(`${base} Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`);
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3377
|
+
// src/planner/planner-limit-messages.ts
|
|
3378
|
+
function printPlannerApiCostNotice() {
|
|
3379
|
+
blank();
|
|
3380
|
+
step("Billing");
|
|
3381
|
+
info(
|
|
3382
|
+
"`squad new-plan --api` sends your intake and repo context to the configured provider. Each model round is billed like any other API usage (input tokens, output tokens, and cache-related tokens when applicable). Choosing \u201CContinue\u201D after a limit runs more rounds and usually increases cost."
|
|
3383
|
+
);
|
|
3384
|
+
blank();
|
|
3385
|
+
}
|
|
3386
|
+
function kindTitle(kind) {
|
|
3387
|
+
switch (kind) {
|
|
3388
|
+
case "max_output_tokens":
|
|
3389
|
+
return "Output length limit (single response)";
|
|
3390
|
+
case "max_iterations":
|
|
3391
|
+
return "Model round limit";
|
|
3392
|
+
case "wall_clock":
|
|
3393
|
+
return "Wall-clock time limit";
|
|
3394
|
+
case "cost_cap":
|
|
3395
|
+
return "Estimated cost cap";
|
|
3396
|
+
case "file_or_context_reads":
|
|
3397
|
+
return "File read or context size limit";
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
function kindDetail(ctx) {
|
|
3401
|
+
const snap = ctx.budgetSnapshot;
|
|
3402
|
+
const cost = snap.usage.costUsd !== void 0 ? `Estimated spend so far: about $${snap.usage.costUsd.toFixed(2)} (provider-reported when available).` : "Estimated spend so far: not reported by the provider for this run.";
|
|
3403
|
+
switch (ctx.kind) {
|
|
3404
|
+
case "max_output_tokens":
|
|
3405
|
+
return [
|
|
3406
|
+
`The model hit the per-request output cap (${ctx.maxOutputTokens} completion tokens). Long plans can stop mid-markdown even though the session is otherwise healthy.`,
|
|
3407
|
+
"You can continue: squad-kit will ask the model to append the rest of the plan in a follow-up request (extra tokens apply).",
|
|
3408
|
+
cost
|
|
3409
|
+
];
|
|
3410
|
+
case "max_iterations":
|
|
3411
|
+
return [
|
|
3412
|
+
`The planner reached its round cap (${ctx.maxIterations} model turns) without a clean stop.`,
|
|
3413
|
+
"Continuing raises the round cap and file/time budgets for this run by another full slice from your config.",
|
|
3414
|
+
cost
|
|
3415
|
+
];
|
|
3416
|
+
case "wall_clock":
|
|
3417
|
+
return [
|
|
3418
|
+
"The configured wall-clock budget for this planning session elapsed before the model finished.",
|
|
3419
|
+
"Continuing adds another slice of time (and read/output/round limits) from your planner budget.",
|
|
3420
|
+
cost
|
|
3421
|
+
];
|
|
3422
|
+
case "cost_cap":
|
|
3423
|
+
return [
|
|
3424
|
+
"Spending crossed the optional `planner.budget.maxCostUsd` ceiling for this session.",
|
|
3425
|
+
"Continuing raises that ceiling for this run by another slice from config (if a cap is set).",
|
|
3426
|
+
cost
|
|
3427
|
+
];
|
|
3428
|
+
case "file_or_context_reads":
|
|
3429
|
+
return [
|
|
3430
|
+
"A `read_file` call could not run because the file-read count or total read-bytes budget was exhausted.",
|
|
3431
|
+
"Continuing extends read, context, time, round, and output limits for this session so the model can read again or finish without reads.",
|
|
3432
|
+
cost
|
|
3433
|
+
];
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
function printPlannerLimitExplanation(ctx) {
|
|
3437
|
+
warning(kindTitle(ctx.kind));
|
|
3438
|
+
for (const line2 of kindDetail(ctx)) {
|
|
3439
|
+
info(line2);
|
|
3440
|
+
}
|
|
3441
|
+
kv("session so far", `${snapSummary(ctx)}`, 18);
|
|
3442
|
+
}
|
|
3443
|
+
function snapSummary(ctx) {
|
|
3444
|
+
const s = ctx.budgetSnapshot;
|
|
3445
|
+
return `${s.reads} reads \xB7 ${(s.bytes / 1024).toFixed(1)} KB read context \xB7 ${s.usage.inputTokens} in / ${s.usage.outputTokens} out tokens`;
|
|
3446
|
+
}
|
|
3447
|
+
function printPlannerLimitNextSteps() {
|
|
3448
|
+
blank();
|
|
3449
|
+
step("If you stop here");
|
|
3450
|
+
info("An incomplete plan is saved as `*.partial.md` with front matter `squad-kit-plan-status: partial`.");
|
|
3451
|
+
info("Fix limits in `.squad/config.yaml` (`planner.budget`, `planner.maxOutputTokens`) or run again when ready.");
|
|
3452
|
+
blank();
|
|
3453
|
+
}
|
|
3454
|
+
|
|
2848
3455
|
// src/planner/providers/index.ts
|
|
2849
3456
|
function providerFor(name) {
|
|
2850
3457
|
switch (name) {
|
|
@@ -2861,18 +3468,35 @@ function providerFor(name) {
|
|
|
2861
3468
|
var Budget = class {
|
|
2862
3469
|
constructor(cfg) {
|
|
2863
3470
|
this.cfg = cfg;
|
|
3471
|
+
this.maxFileReadsAllowed = cfg.maxFileReads;
|
|
3472
|
+
this.maxContextBytesAllowed = cfg.maxContextBytes;
|
|
3473
|
+
this.maxWallSeconds = cfg.maxDurationSeconds;
|
|
3474
|
+
this.maxCostUsdCeiling = cfg.maxCostUsd;
|
|
2864
3475
|
}
|
|
2865
3476
|
cfg;
|
|
2866
3477
|
reads = 0;
|
|
2867
3478
|
bytes = 0;
|
|
2868
3479
|
startedAt = Date.now();
|
|
2869
|
-
totalUsage = { inputTokens: 0, outputTokens: 0 };
|
|
3480
|
+
totalUsage = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
|
|
3481
|
+
maxFileReadsAllowed;
|
|
3482
|
+
maxContextBytesAllowed;
|
|
3483
|
+
maxWallSeconds;
|
|
3484
|
+
maxCostUsdCeiling;
|
|
3485
|
+
/** Add another slice of the original config limits (used when the user chooses to continue a planner session). */
|
|
3486
|
+
extendSession() {
|
|
3487
|
+
this.maxFileReadsAllowed += this.cfg.maxFileReads;
|
|
3488
|
+
this.maxContextBytesAllowed += this.cfg.maxContextBytes;
|
|
3489
|
+
this.maxWallSeconds += this.cfg.maxDurationSeconds;
|
|
3490
|
+
if (this.cfg.maxCostUsd !== void 0) {
|
|
3491
|
+
this.maxCostUsdCeiling = (this.maxCostUsdCeiling ?? 0) + this.cfg.maxCostUsd;
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
2870
3494
|
canRead(nextFileBytes) {
|
|
2871
|
-
if (this.reads >= this.
|
|
2872
|
-
return { ok: false, reason: `max file reads (${this.
|
|
3495
|
+
if (this.reads >= this.maxFileReadsAllowed) {
|
|
3496
|
+
return { ok: false, reason: `max file reads (${this.maxFileReadsAllowed}) reached` };
|
|
2873
3497
|
}
|
|
2874
|
-
if (this.bytes + nextFileBytes > this.
|
|
2875
|
-
return { ok: false, reason: `context budget (${this.
|
|
3498
|
+
if (this.bytes + nextFileBytes > this.maxContextBytesAllowed) {
|
|
3499
|
+
return { ok: false, reason: `context budget (${this.maxContextBytesAllowed} bytes) would be exceeded` };
|
|
2876
3500
|
}
|
|
2877
3501
|
return { ok: true };
|
|
2878
3502
|
}
|
|
@@ -2884,13 +3508,15 @@ var Budget = class {
|
|
|
2884
3508
|
this.totalUsage.inputTokens += u.inputTokens;
|
|
2885
3509
|
this.totalUsage.outputTokens += u.outputTokens;
|
|
2886
3510
|
if (u.costUsd !== void 0) this.totalUsage.costUsd = (this.totalUsage.costUsd ?? 0) + u.costUsd;
|
|
3511
|
+
this.totalUsage.cacheCreationTokens = (this.totalUsage.cacheCreationTokens ?? 0) + (u.cacheCreationTokens ?? 0);
|
|
3512
|
+
this.totalUsage.cacheReadTokens = (this.totalUsage.cacheReadTokens ?? 0) + (u.cacheReadTokens ?? 0);
|
|
2887
3513
|
}
|
|
2888
3514
|
timedOut() {
|
|
2889
|
-
return (Date.now() - this.startedAt) / 1e3 > this.
|
|
3515
|
+
return (Date.now() - this.startedAt) / 1e3 > this.maxWallSeconds;
|
|
2890
3516
|
}
|
|
2891
3517
|
overCost() {
|
|
2892
|
-
if (this.
|
|
2893
|
-
return (this.totalUsage.costUsd ?? 0) > this.
|
|
3518
|
+
if (this.maxCostUsdCeiling === void 0) return false;
|
|
3519
|
+
return (this.totalUsage.costUsd ?? 0) > this.maxCostUsdCeiling;
|
|
2894
3520
|
}
|
|
2895
3521
|
snapshot() {
|
|
2896
3522
|
return {
|
|
@@ -3029,10 +3655,19 @@ function formatSequence(n) {
|
|
|
3029
3655
|
}
|
|
3030
3656
|
|
|
3031
3657
|
// src/planner/writer.ts
|
|
3032
|
-
function
|
|
3033
|
-
const
|
|
3658
|
+
function planFileSlugParts(config2, story) {
|
|
3659
|
+
const baseSlug = slugify(story.id);
|
|
3660
|
+
const trackerId = config2.naming.includeTrackerId && config2.tracker.type !== "none" ? trackerIdForFilename(config2.tracker.type, story.id) : "";
|
|
3661
|
+
const trackerPart = trackerId && trackerId.toLowerCase() !== baseSlug ? `-${trackerId}` : "";
|
|
3662
|
+
return { baseSlug, trackerPart };
|
|
3663
|
+
}
|
|
3664
|
+
function writePlanFile(input5) {
|
|
3665
|
+
const { paths, config: config2, story } = input5;
|
|
3034
3666
|
const featurePlanDir = path13.join(paths.plansDir, story.feature);
|
|
3035
3667
|
fs20.mkdirSync(featurePlanDir, { recursive: true });
|
|
3668
|
+
if (input5.partial) {
|
|
3669
|
+
return writePartialPlanFile(input5, featurePlanDir, paths, config2, story);
|
|
3670
|
+
}
|
|
3036
3671
|
const existing = findExistingPlan(featurePlanDir, story.id);
|
|
3037
3672
|
let targetPath;
|
|
3038
3673
|
let nn;
|
|
@@ -3043,23 +3678,53 @@ function writePlanFile(input6) {
|
|
|
3043
3678
|
overwrote = true;
|
|
3044
3679
|
} else {
|
|
3045
3680
|
nn = scanPlans(paths.plansDir).nextGlobal;
|
|
3046
|
-
const
|
|
3047
|
-
const baseSlug = titleHintSlug || slugify(story.id);
|
|
3048
|
-
const trackerId = config2.naming.includeTrackerId && config2.tracker.type !== "none" ? trackerIdForFilename(config2.tracker.type, story.id) : "";
|
|
3049
|
-
const trackerPart = trackerId && trackerId !== baseSlug ? `-${trackerId}` : "";
|
|
3681
|
+
const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
|
|
3050
3682
|
targetPath = path13.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
|
|
3051
3683
|
}
|
|
3052
|
-
const body = `${
|
|
3684
|
+
const body = `${input5.metadataHeader.trimEnd()}
|
|
3685
|
+
|
|
3686
|
+
${input5.planBodyMarkdown.trimStart()}
|
|
3687
|
+
`;
|
|
3688
|
+
fs20.writeFileSync(targetPath, body, "utf8");
|
|
3689
|
+
upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story, false);
|
|
3690
|
+
return { planFile: targetPath, sequenceNumber: nn, overwrote };
|
|
3691
|
+
}
|
|
3692
|
+
function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
|
|
3693
|
+
const existingPartial = findExistingPartialPlan(featurePlanDir, story.id);
|
|
3694
|
+
let targetPath;
|
|
3695
|
+
let nn;
|
|
3696
|
+
let overwrote = false;
|
|
3697
|
+
if (existingPartial) {
|
|
3698
|
+
targetPath = existingPartial.absPath;
|
|
3699
|
+
nn = existingPartial.nn;
|
|
3700
|
+
overwrote = true;
|
|
3701
|
+
} else {
|
|
3702
|
+
nn = scanPlans(paths.plansDir).nextGlobal;
|
|
3703
|
+
const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
|
|
3704
|
+
targetPath = path13.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.partial.md`);
|
|
3705
|
+
}
|
|
3706
|
+
const body = `${input5.metadataHeader.trimEnd()}
|
|
3053
3707
|
|
|
3054
|
-
${
|
|
3708
|
+
${input5.planBodyMarkdown.trimStart()}
|
|
3055
3709
|
`;
|
|
3056
3710
|
fs20.writeFileSync(targetPath, body, "utf8");
|
|
3057
|
-
upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story);
|
|
3711
|
+
upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story, true);
|
|
3058
3712
|
return { planFile: targetPath, sequenceNumber: nn, overwrote };
|
|
3059
3713
|
}
|
|
3060
3714
|
function findExistingPlan(featureDir, storyId) {
|
|
3061
3715
|
if (!fs20.existsSync(featureDir)) return void 0;
|
|
3062
3716
|
const pattern = /^(\d{2,})-story-.+\.md$/;
|
|
3717
|
+
for (const entry of fs20.readdirSync(featureDir)) {
|
|
3718
|
+
if (entry.endsWith(".partial.md")) continue;
|
|
3719
|
+
const m = entry.match(pattern);
|
|
3720
|
+
if (!m) continue;
|
|
3721
|
+
if (entry.includes(storyId)) return { absPath: path13.join(featureDir, entry), nn: parseInt(m[1], 10) };
|
|
3722
|
+
}
|
|
3723
|
+
return void 0;
|
|
3724
|
+
}
|
|
3725
|
+
function findExistingPartialPlan(featureDir, storyId) {
|
|
3726
|
+
if (!fs20.existsSync(featureDir)) return void 0;
|
|
3727
|
+
const pattern = /^(\d{2,})-story-.+\.partial\.md$/;
|
|
3063
3728
|
for (const entry of fs20.readdirSync(featureDir)) {
|
|
3064
3729
|
const m = entry.match(pattern);
|
|
3065
3730
|
if (!m) continue;
|
|
@@ -3067,9 +3732,10 @@ function findExistingPlan(featureDir, storyId) {
|
|
|
3067
3732
|
}
|
|
3068
3733
|
return void 0;
|
|
3069
3734
|
}
|
|
3070
|
-
function upsertOverviewRow(featureDir, feature, nn, filename, story) {
|
|
3735
|
+
function upsertOverviewRow(featureDir, feature, nn, filename, story, isPartial) {
|
|
3071
3736
|
const overviewPath = path13.join(featureDir, "00-overview.md");
|
|
3072
|
-
const
|
|
3737
|
+
const titleCell = isPartial ? `${story.titleHint ?? story.id} (partial)` : story.titleHint ?? story.id;
|
|
3738
|
+
const row = `| ${formatSequence(nn)} | \`${filename}\` | ${titleCell} | ${story.id} | \u2014 |`;
|
|
3073
3739
|
if (!fs20.existsSync(overviewPath)) {
|
|
3074
3740
|
const content = `# ${feature} \u2014 plan overview
|
|
3075
3741
|
|
|
@@ -3115,7 +3781,54 @@ function buildMetadataHeader(args) {
|
|
|
3115
3781
|
`${args.inputTokens} in / ${args.outputTokens} out tokens`,
|
|
3116
3782
|
args.costUsd !== void 0 ? `~$${args.costUsd.toFixed(2)}` : null
|
|
3117
3783
|
].filter(Boolean);
|
|
3118
|
-
|
|
3784
|
+
const line1 = `<!-- squad-kit: ${parts.join(", ")} -->`;
|
|
3785
|
+
if (args.planStatus === "partial") {
|
|
3786
|
+
return `${line1}
|
|
3787
|
+
|
|
3788
|
+
---
|
|
3789
|
+
squad-kit-plan-status: partial
|
|
3790
|
+
---`;
|
|
3791
|
+
}
|
|
3792
|
+
return line1;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
// src/core/last-run.ts
|
|
3796
|
+
import fs21 from "fs/promises";
|
|
3797
|
+
import path14 from "path";
|
|
3798
|
+
async function writeLastRun(paths, rec) {
|
|
3799
|
+
const file = path14.join(paths.squadDir, ".last-run.json");
|
|
3800
|
+
await fs21.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
|
|
3801
|
+
}
|
|
3802
|
+
async function readLastRun(paths) {
|
|
3803
|
+
const file = path14.join(paths.squadDir, ".last-run.json");
|
|
3804
|
+
try {
|
|
3805
|
+
const raw = await fs21.readFile(file, "utf8");
|
|
3806
|
+
const parsed = JSON.parse(raw);
|
|
3807
|
+
if (parsed.version !== 1) return void 0;
|
|
3808
|
+
return parsed;
|
|
3809
|
+
} catch {
|
|
3810
|
+
return void 0;
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
// src/ui/planner-cache-summary.ts
|
|
3815
|
+
function formatTokenK(n) {
|
|
3816
|
+
return `${(n / 1e3).toFixed(1)}k`;
|
|
3817
|
+
}
|
|
3818
|
+
function formatPlannerCacheLine(opts) {
|
|
3819
|
+
if (!opts.cacheEnabled) {
|
|
3820
|
+
return "cache disabled";
|
|
3821
|
+
}
|
|
3822
|
+
const { stats } = opts;
|
|
3823
|
+
if (stats.cacheReadTokens > 0) {
|
|
3824
|
+
const pct = Math.round(stats.cacheHitRatio * 100);
|
|
3825
|
+
const total = stats.inputTokens + stats.cacheReadTokens;
|
|
3826
|
+
const read = formatTokenK(stats.cacheReadTokens);
|
|
3827
|
+
const tot = formatTokenK(total);
|
|
3828
|
+
const written = stats.cacheCreationTokens > 0 ? ` \xB7 ${formatTokenK(stats.cacheCreationTokens)} written` : "";
|
|
3829
|
+
return `cache hit ${pct}% (${read} read / ${tot} total${written})`;
|
|
3830
|
+
}
|
|
3831
|
+
return "cache miss 0% \u2014 run `squad doctor` to diagnose";
|
|
3119
3832
|
}
|
|
3120
3833
|
|
|
3121
3834
|
// src/commands/new-plan.ts
|
|
@@ -3149,8 +3862,8 @@ async function runNewPlan(intakePath, opts) {
|
|
|
3149
3862
|
}
|
|
3150
3863
|
}
|
|
3151
3864
|
function resolveFromPath(intakePath, stories, root) {
|
|
3152
|
-
const resolved =
|
|
3153
|
-
if (!
|
|
3865
|
+
const resolved = path15.resolve(intakePath.trim());
|
|
3866
|
+
if (!fs22.existsSync(resolved)) {
|
|
3154
3867
|
throw new Error(
|
|
3155
3868
|
`Intake not found: ${resolved}. Run \`squad list\` to see intakes, or \`squad new-story\` to create one first.`
|
|
3156
3869
|
);
|
|
@@ -3162,7 +3875,7 @@ function resolveFromPath(intakePath, stories, root) {
|
|
|
3162
3875
|
);
|
|
3163
3876
|
}
|
|
3164
3877
|
function relativeStoriesDir(root) {
|
|
3165
|
-
return
|
|
3878
|
+
return path15.relative(root, path15.join(root, ".squad", "stories"));
|
|
3166
3879
|
}
|
|
3167
3880
|
async function pickStory(stories, opts) {
|
|
3168
3881
|
const candidates = opts.all ? stories : stories.filter((s) => !s.planFile);
|
|
@@ -3193,7 +3906,7 @@ async function pickStory(stories, opts) {
|
|
|
3193
3906
|
value: s.intakePath
|
|
3194
3907
|
}))
|
|
3195
3908
|
});
|
|
3196
|
-
return candidates.find((s) =>
|
|
3909
|
+
return candidates.find((s) => path15.resolve(s.intakePath) === path15.resolve(pick));
|
|
3197
3910
|
}
|
|
3198
3911
|
function formatLabel(s) {
|
|
3199
3912
|
const head = `${s.feature} / ${s.id}`;
|
|
@@ -3214,7 +3927,19 @@ async function confirmOverwrite(story, interactive, yes) {
|
|
|
3214
3927
|
const go = await confirm6({ message: "Proceed and regenerate?", default: false });
|
|
3215
3928
|
return go;
|
|
3216
3929
|
}
|
|
3217
|
-
|
|
3930
|
+
function printPlannerRunIncompleteSummary(result, relPlanFile) {
|
|
3931
|
+
blank();
|
|
3932
|
+
warning("Planning stopped before the model reported a clean completion.");
|
|
3933
|
+
kv("saved", relPlanFile, 8);
|
|
3934
|
+
if (result.userCancelled) kv("stopped", "you chose not to continue after a limit", 8);
|
|
3935
|
+
if (result.timedOut) kv("timed out", "yes", 8);
|
|
3936
|
+
if (result.budgetExhausted) kv("read budget", "exhausted (model was asked to finalise)", 8);
|
|
3937
|
+
if (result.incompleteKind) kv("detail", result.incompleteKind, 8);
|
|
3938
|
+
blank();
|
|
3939
|
+
info("The file uses the `.partial.md` suffix and YAML `squad-kit-plan-status: partial`.");
|
|
3940
|
+
info("Raise limits in `.squad/config.yaml` (`planner.budget`, `planner.maxOutputTokens`) or re-run when ready.");
|
|
3941
|
+
}
|
|
3942
|
+
async function emitViaApi(story, paths, config2, opts) {
|
|
3218
3943
|
const planner = config2.planner;
|
|
3219
3944
|
if (!planner?.enabled) {
|
|
3220
3945
|
throw new Error(
|
|
@@ -3239,6 +3964,10 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3239
3964
|
`${planner.budget.maxFileReads} reads \xB7 ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB \xB7 ${planner.budget.maxDurationSeconds}s`
|
|
3240
3965
|
);
|
|
3241
3966
|
blank();
|
|
3967
|
+
const interactive = !opts.yes && Boolean(process.stdin.isTTY);
|
|
3968
|
+
if (interactive) {
|
|
3969
|
+
printPlannerApiCostNotice();
|
|
3970
|
+
}
|
|
3242
3971
|
const mapSpinner = spinner("building repo map\u2026");
|
|
3243
3972
|
const repoMap = buildRepoMap(paths.root);
|
|
3244
3973
|
mapSpinner.succeed(
|
|
@@ -3254,6 +3983,19 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3254
3983
|
const userPrompt = composeUserPrompt({ intakeContent: readFile(story.intakePath) });
|
|
3255
3984
|
const sessionSpinner = { current: null };
|
|
3256
3985
|
const startedAt = Date.now();
|
|
3986
|
+
const cacheEnabled = planner.cache?.enabled ?? true;
|
|
3987
|
+
const decideOnLimit = interactive ? async (ctx) => {
|
|
3988
|
+
printPlannerLimitExplanation(ctx);
|
|
3989
|
+
const ans = await select5({
|
|
3990
|
+
message: "Continue this planning session? (Continuing sends more API requests and is billed.)",
|
|
3991
|
+
choices: [
|
|
3992
|
+
{ name: "Continue \u2014 extend limits for this run", value: "continue" },
|
|
3993
|
+
{ name: "Stop \u2014 save partial plan and exit", value: "cancel" }
|
|
3994
|
+
]
|
|
3995
|
+
});
|
|
3996
|
+
if (ans === "cancel") printPlannerLimitNextSteps();
|
|
3997
|
+
return ans;
|
|
3998
|
+
} : void 0;
|
|
3257
3999
|
const result = await runPlanner({
|
|
3258
4000
|
root: paths.root,
|
|
3259
4001
|
provider,
|
|
@@ -3262,6 +4004,9 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3262
4004
|
systemPrompt,
|
|
3263
4005
|
userPrompt,
|
|
3264
4006
|
budget,
|
|
4007
|
+
maxOutputTokens: planner.maxOutputTokens,
|
|
4008
|
+
cacheEnabled,
|
|
4009
|
+
decideOnLimit,
|
|
3265
4010
|
onToolCall: (tc, bytes, total) => {
|
|
3266
4011
|
const p = tc.input.path ?? "<unknown>";
|
|
3267
4012
|
sessionSpinner.current?.succeed(
|
|
@@ -3287,6 +4032,7 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3287
4032
|
}
|
|
3288
4033
|
const snap = budget.snapshot();
|
|
3289
4034
|
const elapsedMs = Date.now() - startedAt;
|
|
4035
|
+
const success2 = result.finishedNormally && !result.timedOut && !result.userCancelled;
|
|
3290
4036
|
const header = buildMetadataHeader({
|
|
3291
4037
|
provider: planner.provider,
|
|
3292
4038
|
model,
|
|
@@ -3295,18 +4041,30 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3295
4041
|
inputTokens: snap.usage.inputTokens,
|
|
3296
4042
|
outputTokens: snap.usage.outputTokens,
|
|
3297
4043
|
durationMs: elapsedMs,
|
|
3298
|
-
costUsd: snap.usage.costUsd
|
|
4044
|
+
costUsd: snap.usage.costUsd,
|
|
4045
|
+
planStatus: success2 ? void 0 : "partial"
|
|
3299
4046
|
});
|
|
3300
4047
|
const { planFile, sequenceNumber, overwrote } = writePlanFile({
|
|
3301
4048
|
paths,
|
|
3302
4049
|
config: config2,
|
|
3303
4050
|
story,
|
|
3304
4051
|
planBodyMarkdown: result.planText,
|
|
3305
|
-
metadataHeader: header
|
|
4052
|
+
metadataHeader: header,
|
|
4053
|
+
partial: !success2
|
|
3306
4054
|
});
|
|
4055
|
+
try {
|
|
4056
|
+
await writeLastRun(paths, {
|
|
4057
|
+
stats: result.stats,
|
|
4058
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4059
|
+
provider: planner.provider,
|
|
4060
|
+
model
|
|
4061
|
+
});
|
|
4062
|
+
} catch {
|
|
4063
|
+
}
|
|
4064
|
+
const relPlan = path15.relative(paths.root, planFile);
|
|
3307
4065
|
blank();
|
|
3308
|
-
summaryBox(" plan generated ", [
|
|
3309
|
-
{ key: "file", value:
|
|
4066
|
+
summaryBox(success2 ? " plan generated " : " plan saved (incomplete) ", [
|
|
4067
|
+
{ key: "file", value: relPlan },
|
|
3310
4068
|
{ key: "nn", value: String(sequenceNumber).padStart(2, "0") },
|
|
3311
4069
|
{ key: "model", value: `${planner.provider}/${model}` },
|
|
3312
4070
|
{ key: "reads", value: `${snap.reads} files \xB7 ${(snap.bytes / 1024).toFixed(1)} KB` },
|
|
@@ -3314,18 +4072,28 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3314
4072
|
key: "tokens",
|
|
3315
4073
|
value: `${snap.usage.inputTokens} in \xB7 ${snap.usage.outputTokens} out${snap.usage.costUsd !== void 0 ? ` \u2248 $${snap.usage.costUsd.toFixed(2)}` : ""}`
|
|
3316
4074
|
},
|
|
4075
|
+
{ key: "cache", value: formatPlannerCacheLine({ cacheEnabled, stats: result.stats }) },
|
|
4076
|
+
{
|
|
4077
|
+
key: "max out",
|
|
4078
|
+
value: `${planner.maxOutputTokens ?? DEFAULT_PLANNER_MAX_OUTPUT_TOKENS} tok/req`
|
|
4079
|
+
},
|
|
3317
4080
|
{ key: "time", value: `${Math.round(elapsedMs / 1e3)}s` },
|
|
3318
|
-
{
|
|
4081
|
+
{
|
|
4082
|
+
key: "action",
|
|
4083
|
+
value: success2 ? overwrote ? "overwrote existing plan" : "new plan" : "partial plan (review limits)"
|
|
4084
|
+
}
|
|
3319
4085
|
]);
|
|
3320
|
-
if (
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
4086
|
+
if (!success2) {
|
|
4087
|
+
printPlannerRunIncompleteSummary(result, relPlan);
|
|
4088
|
+
throw new SquadExit(2);
|
|
4089
|
+
}
|
|
4090
|
+
if (result.budgetExhausted) {
|
|
4091
|
+
info("Note: file-read budget was reached; the model finalised without further reads.");
|
|
3324
4092
|
}
|
|
3325
4093
|
blank();
|
|
3326
|
-
info(`next \u2192 open a new agent chat and attach only ${
|
|
4094
|
+
info(`next \u2192 open a new agent chat and attach only ${path15.basename(planFile)}`);
|
|
3327
4095
|
}
|
|
3328
|
-
async function emitCopyPrompt(story,
|
|
4096
|
+
async function emitCopyPrompt(story, paths, config2, clipboard) {
|
|
3329
4097
|
const intakeContent = readFile(story.intakePath);
|
|
3330
4098
|
const metaPromptTemplate = readBundledPrompt("generate-plan.md");
|
|
3331
4099
|
const composed = render(metaPromptTemplate, {
|
|
@@ -3334,13 +4102,44 @@ async function emitCopyPrompt(story, _paths, config2, clipboard) {
|
|
|
3334
4102
|
trackerType: config2.tracker.type,
|
|
3335
4103
|
intakeContent
|
|
3336
4104
|
});
|
|
4105
|
+
const promptFile = path15.join(paths.squadDir, ".last-copy-prompt.md");
|
|
4106
|
+
fs22.writeFileSync(promptFile, composed, "utf8");
|
|
4107
|
+
const relPromptFile = path15.relative(paths.root, promptFile);
|
|
4108
|
+
const bytes = Buffer.byteLength(composed, "utf8");
|
|
4109
|
+
const estTokens = Math.round(bytes / 4);
|
|
4110
|
+
const clipResult = clipboard ? await copyToClipboard(composed) : { ok: false, reason: "clipboard disabled (--no-clipboard)" };
|
|
4111
|
+
blank();
|
|
4112
|
+
divider("planner prompt ready");
|
|
4113
|
+
kv("mode", "copy-paste", 10);
|
|
4114
|
+
kv("story", `${story.feature} / ${story.id}`, 10);
|
|
4115
|
+
kv("prompt", relPromptFile, 10);
|
|
4116
|
+
kv("size", `${formatKB(bytes)} \xB7 ~${formatCount(estTokens)} tokens (est)`, 10);
|
|
4117
|
+
if (clipResult.ok) {
|
|
4118
|
+
kv("clipboard", `\u2713 copied via ${clipResult.tool}`, 10);
|
|
4119
|
+
} else {
|
|
4120
|
+
kv("clipboard", `! ${clipResult.reason}`, 10);
|
|
4121
|
+
}
|
|
3337
4122
|
printModelBanner(config2);
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
4123
|
+
blank();
|
|
4124
|
+
step("Next:");
|
|
4125
|
+
info("1) Open your agent chat (Cursor / Claude Code / Copilot / Gemini).");
|
|
4126
|
+
info("2) Switch to a strong plan model (Opus 4.x / GPT-5.3 Codex thinking / Gemini deep-think).");
|
|
4127
|
+
if (clipResult.ok) {
|
|
4128
|
+
info("3) Paste from clipboard and let the agent work. It will read files and write the plan itself.");
|
|
4129
|
+
} else {
|
|
4130
|
+
info(`3) Open ${relPromptFile}, paste its contents into the agent chat, and let it work.`);
|
|
3343
4131
|
}
|
|
4132
|
+
info(
|
|
4133
|
+
`4) The agent writes the plan to .squad/plans/${story.feature}/<nn>-story-${story.id}.md. Review it, then open a fresh chat to implement (attach only the plan file).`
|
|
4134
|
+
);
|
|
4135
|
+
}
|
|
4136
|
+
function formatKB(bytes) {
|
|
4137
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
4138
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
4139
|
+
}
|
|
4140
|
+
function formatCount(n) {
|
|
4141
|
+
if (n < 1e3) return String(n);
|
|
4142
|
+
return `${(n / 1e3).toFixed(1)}k`;
|
|
3344
4143
|
}
|
|
3345
4144
|
function printModelBanner(config2) {
|
|
3346
4145
|
if (!isInteractive()) return;
|
|
@@ -3363,7 +4162,7 @@ function printModelBanner(config2) {
|
|
|
3363
4162
|
}
|
|
3364
4163
|
|
|
3365
4164
|
// src/commands/status.ts
|
|
3366
|
-
import
|
|
4165
|
+
import fs23 from "fs";
|
|
3367
4166
|
var kw = 28;
|
|
3368
4167
|
function probeTrackerCredential(config2, overlay) {
|
|
3369
4168
|
const { client, error: error2 } = clientFor(config2, overlay);
|
|
@@ -3389,7 +4188,7 @@ async function runStatus() {
|
|
|
3389
4188
|
const scan = scanPlans(paths.plansDir);
|
|
3390
4189
|
const storyCount = listStories(paths).length;
|
|
3391
4190
|
const planCount = scan.usedNumbers.length;
|
|
3392
|
-
const secrets =
|
|
4191
|
+
const secrets = fs23.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
3393
4192
|
const overlay = overlayTrackerEnv(secrets);
|
|
3394
4193
|
banner();
|
|
3395
4194
|
step("squad-kit status");
|
|
@@ -3443,13 +4242,13 @@ function buildTrackerLine(config2, overlay) {
|
|
|
3443
4242
|
}
|
|
3444
4243
|
|
|
3445
4244
|
// src/commands/list.ts
|
|
3446
|
-
import
|
|
4245
|
+
import path16 from "path";
|
|
3447
4246
|
async function runList(opts) {
|
|
3448
4247
|
const root = requireSquadRoot();
|
|
3449
4248
|
const paths = buildPaths(root);
|
|
3450
4249
|
const records = listStories(paths, { feature: opts.feature });
|
|
3451
4250
|
const rows = records.map((r) => {
|
|
3452
|
-
const planAbs = r.planFile ?
|
|
4251
|
+
const planAbs = r.planFile ? path16.join(paths.plansDir, r.feature, r.planFile) : void 0;
|
|
3453
4252
|
const meta = planAbs ? readPlanMetadata(planAbs) : void 0;
|
|
3454
4253
|
return {
|
|
3455
4254
|
feature: r.feature,
|
|
@@ -3469,7 +4268,7 @@ async function runList(opts) {
|
|
|
3469
4268
|
r.id,
|
|
3470
4269
|
r.hasIntake ? "yes" : "\u2014",
|
|
3471
4270
|
r.planFile ?? theme_exports.dim("\u2014"),
|
|
3472
|
-
r.meta?.generatedBy ?? (r.planFile ? "
|
|
4271
|
+
r.meta?.generatedBy ?? (r.planFile ? theme_exports.dim("not recorded") : theme_exports.dim("\u2014"))
|
|
3473
4272
|
]);
|
|
3474
4273
|
printTable([header, ...table]);
|
|
3475
4274
|
}
|
|
@@ -3488,9 +4287,9 @@ function stripAnsi(s) {
|
|
|
3488
4287
|
}
|
|
3489
4288
|
|
|
3490
4289
|
// src/commands/tracker-link.ts
|
|
3491
|
-
import
|
|
3492
|
-
import
|
|
3493
|
-
import { input as
|
|
4290
|
+
import fs24 from "fs";
|
|
4291
|
+
import path17 from "path";
|
|
4292
|
+
import { input as input4, select as select6 } from "@inquirer/prompts";
|
|
3494
4293
|
async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
3495
4294
|
const root = requireSquadRoot();
|
|
3496
4295
|
const paths = buildPaths(root);
|
|
@@ -3522,7 +4321,7 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
|
3522
4321
|
"Usage: squad tracker link [story-path] [tracker-id]. Run `squad tracker link` without arguments in a TTY to pick from a list, or pass `--yes` to opt out of prompts."
|
|
3523
4322
|
);
|
|
3524
4323
|
}
|
|
3525
|
-
resolvedTrackerId = (await
|
|
4324
|
+
resolvedTrackerId = (await input4({
|
|
3526
4325
|
message: `${config2.tracker.type} work-item id:`,
|
|
3527
4326
|
validate: (v) => {
|
|
3528
4327
|
const trimmed = v.trim();
|
|
@@ -3536,17 +4335,21 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
|
3536
4335
|
`Invalid tracker id "${resolvedTrackerId}" for tracker type "${config2.tracker.type}". Run \`squad config show\` to review the expected format, or \`squad config set tracker\` if the type is wrong.`
|
|
3537
4336
|
);
|
|
3538
4337
|
}
|
|
3539
|
-
const resolved =
|
|
3540
|
-
const intakeFile = resolved.endsWith("intake.md") ? resolved :
|
|
3541
|
-
if (!
|
|
4338
|
+
const resolved = path17.resolve(resolvedStoryPath);
|
|
4339
|
+
const intakeFile = resolved.endsWith("intake.md") ? resolved : path17.join(resolved, "intake.md");
|
|
4340
|
+
if (!fs24.existsSync(intakeFile)) {
|
|
3542
4341
|
throw new Error(
|
|
3543
4342
|
`intake.md not found at ${intakeFile}. Run \`squad list\` to see valid paths, or \`squad new-story\` to create a story first.`
|
|
3544
4343
|
);
|
|
3545
4344
|
}
|
|
3546
|
-
const content =
|
|
4345
|
+
const content = fs24.readFileSync(intakeFile, "utf8");
|
|
3547
4346
|
const updated = upsertTrackerId(content, resolvedTrackerId);
|
|
3548
|
-
|
|
3549
|
-
success(`Linked tracker id ${resolvedTrackerId} \u2192 ${
|
|
4347
|
+
fs24.writeFileSync(intakeFile, updated, "utf8");
|
|
4348
|
+
success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path17.relative(root, intakeFile)}`);
|
|
4349
|
+
blank();
|
|
4350
|
+
step("Next:");
|
|
4351
|
+
info("1) Review the intake \u2014 the Source block at the top now carries the tracker id.");
|
|
4352
|
+
info("2) Run `squad new-plan --api` (or `--copy`) to generate the plan for this story.");
|
|
3550
4353
|
}
|
|
3551
4354
|
function upsertTrackerId(content, id) {
|
|
3552
4355
|
const pattern = /(-\s*\*\*Work item id:\*\*)[^\n]*/;
|
|
@@ -3560,8 +4363,8 @@ function upsertTrackerId(content, id) {
|
|
|
3560
4363
|
}
|
|
3561
4364
|
|
|
3562
4365
|
// src/commands/doctor.ts
|
|
3563
|
-
import
|
|
3564
|
-
import
|
|
4366
|
+
import fs25 from "fs";
|
|
4367
|
+
import path18 from "path";
|
|
3565
4368
|
async function gatherContext(paths) {
|
|
3566
4369
|
const ctx = { hasLegacyPromptsDir: false };
|
|
3567
4370
|
try {
|
|
@@ -3570,29 +4373,29 @@ async function gatherContext(paths) {
|
|
|
3570
4373
|
ctx.configError = err;
|
|
3571
4374
|
}
|
|
3572
4375
|
try {
|
|
3573
|
-
ctx.secrets =
|
|
4376
|
+
ctx.secrets = fs25.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
3574
4377
|
} catch (err) {
|
|
3575
4378
|
ctx.secretsError = err;
|
|
3576
4379
|
}
|
|
3577
|
-
ctx.hasLegacyPromptsDir =
|
|
4380
|
+
ctx.hasLegacyPromptsDir = fs25.existsSync(paths.promptsDir);
|
|
3578
4381
|
return ctx;
|
|
3579
4382
|
}
|
|
3580
4383
|
function gitignoreHasManagedBlock(repoRoot) {
|
|
3581
|
-
const gitignore =
|
|
3582
|
-
return
|
|
4384
|
+
const gitignore = path18.join(repoRoot, ".gitignore");
|
|
4385
|
+
return fs25.existsSync(gitignore) && fs25.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
|
|
3583
4386
|
}
|
|
3584
4387
|
function gitignoreHasTrashPattern(repoRoot) {
|
|
3585
|
-
const gitignore =
|
|
3586
|
-
return
|
|
4388
|
+
const gitignore = path18.join(repoRoot, ".gitignore");
|
|
4389
|
+
return fs25.existsSync(gitignore) && fs25.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
|
|
3587
4390
|
}
|
|
3588
4391
|
async function checkDirStructure(paths, _ctx, fix) {
|
|
3589
|
-
const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !
|
|
4392
|
+
const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs25.existsSync(p));
|
|
3590
4393
|
if (need.length === 0) {
|
|
3591
4394
|
return { id: "dirs", name: ".squad/ directory structure", status: "ok" };
|
|
3592
4395
|
}
|
|
3593
4396
|
if (fix) {
|
|
3594
4397
|
for (const p of need) {
|
|
3595
|
-
|
|
4398
|
+
fs25.mkdirSync(p, { recursive: true });
|
|
3596
4399
|
}
|
|
3597
4400
|
return { id: "dirs", name: ".squad/ directory structure", status: "ok", detail: "repaired" };
|
|
3598
4401
|
}
|
|
@@ -3600,7 +4403,7 @@ async function checkDirStructure(paths, _ctx, fix) {
|
|
|
3600
4403
|
id: "dirs",
|
|
3601
4404
|
name: ".squad/ directory structure",
|
|
3602
4405
|
status: "warn",
|
|
3603
|
-
detail: `missing: ${need.map((p) =>
|
|
4406
|
+
detail: `missing: ${need.map((p) => path18.relative(paths.root, p)).join(", ")}`,
|
|
3604
4407
|
fixable: true,
|
|
3605
4408
|
fixHint: "squad doctor --fix"
|
|
3606
4409
|
};
|
|
@@ -3655,15 +4458,15 @@ async function checkSecretsPermissions(paths, _ctx, fix) {
|
|
|
3655
4458
|
if (process.platform === "win32") {
|
|
3656
4459
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "skip", detail: "Windows" };
|
|
3657
4460
|
}
|
|
3658
|
-
if (!
|
|
4461
|
+
if (!fs25.existsSync(paths.secretsFile)) {
|
|
3659
4462
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "not present" };
|
|
3660
4463
|
}
|
|
3661
|
-
const mode =
|
|
4464
|
+
const mode = fs25.statSync(paths.secretsFile).mode & 511;
|
|
3662
4465
|
if (mode === 384) {
|
|
3663
4466
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok" };
|
|
3664
4467
|
}
|
|
3665
4468
|
if (fix) {
|
|
3666
|
-
|
|
4469
|
+
fs25.chmodSync(paths.secretsFile, 384);
|
|
3667
4470
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "repaired" };
|
|
3668
4471
|
}
|
|
3669
4472
|
return {
|
|
@@ -3684,7 +4487,7 @@ async function checkSecretsParseable(_paths, ctx) {
|
|
|
3684
4487
|
detail: ctx.secretsError.message
|
|
3685
4488
|
};
|
|
3686
4489
|
}
|
|
3687
|
-
if (!
|
|
4490
|
+
if (!fs25.existsSync(_paths.secretsFile)) {
|
|
3688
4491
|
return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok", detail: "not present" };
|
|
3689
4492
|
}
|
|
3690
4493
|
return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok" };
|
|
@@ -3849,6 +4652,19 @@ async function checkPlannerTierAwareness(_paths, ctx) {
|
|
|
3849
4652
|
if (!/opus/i.test(planModel)) {
|
|
3850
4653
|
return { id, name, status: "ok", detail: `${planModel} is comfortably under Tier 1` };
|
|
3851
4654
|
}
|
|
4655
|
+
const cacheOn = ctx.config.planner?.cache?.enabled !== false;
|
|
4656
|
+
if (cacheOn) {
|
|
4657
|
+
return {
|
|
4658
|
+
id,
|
|
4659
|
+
name,
|
|
4660
|
+
status: "warn",
|
|
4661
|
+
detail: "Anthropic Tier 1 with Opus \u2014 tight but viable with prompt caching on",
|
|
4662
|
+
fixHint: [
|
|
4663
|
+
"Caching saves ~70% on billed input tokens. Keep `planner.cache.enabled: true` in config.",
|
|
4664
|
+
"If you still hit 429s on long runs, reduce `planner.budget.maxContextBytes` or use Haiku for plan phase."
|
|
4665
|
+
].join("\n")
|
|
4666
|
+
};
|
|
4667
|
+
}
|
|
3852
4668
|
return {
|
|
3853
4669
|
id,
|
|
3854
4670
|
name,
|
|
@@ -3857,6 +4673,66 @@ async function checkPlannerTierAwareness(_paths, ctx) {
|
|
|
3857
4673
|
fixHint: "Run `squad config set planner` and pick a Sonnet or Haiku id for planner.modelOverride.anthropic, or upgrade tier at https://console.anthropic.com/settings/limits. squad-kit will auto-retry a 429 once (waiting up to 90s), but repeated throttling means the plan model is simply too big for your quota."
|
|
3858
4674
|
};
|
|
3859
4675
|
}
|
|
4676
|
+
async function checkPlannerCache(paths, ctx) {
|
|
4677
|
+
const id = "planner-cache";
|
|
4678
|
+
const name = "planner cache effectiveness";
|
|
4679
|
+
const config2 = ctx.config;
|
|
4680
|
+
const cacheCfg = config2?.planner?.cache;
|
|
4681
|
+
if (config2?.planner?.enabled !== true) {
|
|
4682
|
+
return { id, name, status: "skip", detail: "planner disabled \u2014 cache check not applicable" };
|
|
4683
|
+
}
|
|
4684
|
+
if (cacheCfg?.enabled === false) {
|
|
4685
|
+
return {
|
|
4686
|
+
id,
|
|
4687
|
+
name,
|
|
4688
|
+
status: "warn",
|
|
4689
|
+
detail: "prompt caching is disabled in .squad/config.yaml",
|
|
4690
|
+
fixHint: "Cache saves ~70% on billed tokens. Re-enable with `squad config set planner`."
|
|
4691
|
+
};
|
|
4692
|
+
}
|
|
4693
|
+
const lastRun = await readLastRun(paths);
|
|
4694
|
+
if (!lastRun) {
|
|
4695
|
+
return {
|
|
4696
|
+
id,
|
|
4697
|
+
name,
|
|
4698
|
+
status: "skip",
|
|
4699
|
+
detail: "no planner runs logged yet",
|
|
4700
|
+
fixHint: "Run `squad new-plan --api` once, then re-run `squad doctor` to see cache telemetry."
|
|
4701
|
+
};
|
|
4702
|
+
}
|
|
4703
|
+
const { stats } = lastRun;
|
|
4704
|
+
const hitPct = Math.round(stats.cacheHitRatio * 100);
|
|
4705
|
+
if (stats.cacheReadTokens === 0 && stats.turns > 1) {
|
|
4706
|
+
return {
|
|
4707
|
+
id,
|
|
4708
|
+
name,
|
|
4709
|
+
status: "fail",
|
|
4710
|
+
detail: `caching configured but last run saw 0% hits across ${stats.turns} turns`,
|
|
4711
|
+
fixHint: [
|
|
4712
|
+
"Possible causes:",
|
|
4713
|
+
" \u2022 Planner provider is not Anthropic and the prefix is below 1024 tokens (OpenAI / Google need larger prefixes).",
|
|
4714
|
+
" \u2022 System prompt is mutating between turns \u2014 check recent changes.",
|
|
4715
|
+
" \u2022 Using an older model that doesn't support caching.",
|
|
4716
|
+
`Provider: ${lastRun.provider}/${lastRun.model}. Run \`NODE_ENV=development squad new-plan --api\` to surface prefix-mismatch warnings.`
|
|
4717
|
+
].join("\n")
|
|
4718
|
+
};
|
|
4719
|
+
}
|
|
4720
|
+
if (hitPct < 30 && stats.turns > 3) {
|
|
4721
|
+
return {
|
|
4722
|
+
id,
|
|
4723
|
+
name,
|
|
4724
|
+
status: "warn",
|
|
4725
|
+
detail: `low cache hit rate: ${hitPct}% (last run, ${stats.turns} turns)`,
|
|
4726
|
+
fixHint: "Expected \u226560% for Anthropic, \u226540% for OpenAI, \u226550% for Google after 3+ turns. Prefix may be unstable."
|
|
4727
|
+
};
|
|
4728
|
+
}
|
|
4729
|
+
return {
|
|
4730
|
+
id,
|
|
4731
|
+
name,
|
|
4732
|
+
status: "ok",
|
|
4733
|
+
detail: `caching active \u2014 last run ${hitPct}% hit (${formatTokenK(stats.cacheReadTokens)} read, ${lastRun.provider}/${lastRun.model})`
|
|
4734
|
+
};
|
|
4735
|
+
}
|
|
3860
4736
|
async function checkTrackerConfig(_paths, ctx) {
|
|
3861
4737
|
if (!ctx.config) {
|
|
3862
4738
|
return { id: "tracker-config", name: "tracker configuration", status: "skip", detail: "config unavailable" };
|
|
@@ -3979,6 +4855,7 @@ async function runAllChecks(paths, ctx, fix) {
|
|
|
3979
4855
|
await add("planner credential resolves", () => checkPlannerCredential(paths, ctx));
|
|
3980
4856
|
await add("planner model resolves at provider", () => checkPlannerModel(paths, ctx));
|
|
3981
4857
|
await add("planner tier vs. model", () => checkPlannerTierAwareness(paths, ctx));
|
|
4858
|
+
await add("planner cache effectiveness", () => checkPlannerCache(paths, ctx));
|
|
3982
4859
|
await add("tracker configuration", () => checkTrackerConfig(paths, ctx));
|
|
3983
4860
|
await add("tracker credential resolves", () => checkTrackerCredential(paths, ctx));
|
|
3984
4861
|
await add("tracker connectivity", () => checkTrackerConnectivity(paths, ctx));
|
|
@@ -4084,23 +4961,23 @@ async function runDoctor(opts) {
|
|
|
4084
4961
|
}
|
|
4085
4962
|
|
|
4086
4963
|
// src/commands/migrate.ts
|
|
4087
|
-
import
|
|
4088
|
-
import
|
|
4964
|
+
import fs26 from "fs";
|
|
4965
|
+
import path19 from "path";
|
|
4089
4966
|
import { confirm as confirm7 } from "@inquirer/prompts";
|
|
4090
4967
|
var LEGACY_PROMPTS_MIGRATION = {
|
|
4091
4968
|
id: "legacy-prompts",
|
|
4092
4969
|
name: "Remove legacy .squad/prompts/",
|
|
4093
|
-
isApplied: (paths) => !
|
|
4094
|
-
describe: (paths) => `delete ${
|
|
4970
|
+
isApplied: (paths) => !fs26.existsSync(paths.promptsDir),
|
|
4971
|
+
describe: (paths) => `delete ${path19.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
|
|
4095
4972
|
apply: (paths) => {
|
|
4096
4973
|
const target = paths.promptsDir;
|
|
4097
|
-
if (!target.includes(
|
|
4974
|
+
if (!target.includes(path19.sep + ".squad" + path19.sep + "prompts")) {
|
|
4098
4975
|
throw new Error(
|
|
4099
4976
|
`Refusing to delete suspicious path: ${target}. This is a safety check \u2014 run \`squad doctor\` if .squad/ paths look wrong.`
|
|
4100
4977
|
);
|
|
4101
4978
|
}
|
|
4102
|
-
if (
|
|
4103
|
-
|
|
4979
|
+
if (fs26.existsSync(target)) {
|
|
4980
|
+
fs26.rmSync(target, { recursive: true, force: true });
|
|
4104
4981
|
}
|
|
4105
4982
|
}
|
|
4106
4983
|
};
|
|
@@ -4108,8 +4985,8 @@ var GITIGNORE_MIGRATION = {
|
|
|
4108
4985
|
id: "gitignore",
|
|
4109
4986
|
name: "Ensure .gitignore managed block",
|
|
4110
4987
|
isApplied: (paths) => {
|
|
4111
|
-
const f =
|
|
4112
|
-
return
|
|
4988
|
+
const f = path19.join(paths.root, ".gitignore");
|
|
4989
|
+
return fs26.existsSync(f) && fs26.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
|
|
4113
4990
|
},
|
|
4114
4991
|
describe: () => "add squad-kit managed block to .gitignore",
|
|
4115
4992
|
apply: (paths) => {
|
|
@@ -4121,23 +4998,23 @@ var SECRETS_PERMS_MIGRATION = {
|
|
|
4121
4998
|
name: "Tighten .squad/secrets.yaml permissions",
|
|
4122
4999
|
isApplied: (paths) => {
|
|
4123
5000
|
if (process.platform === "win32") return true;
|
|
4124
|
-
if (!
|
|
4125
|
-
const st =
|
|
5001
|
+
if (!fs26.existsSync(paths.secretsFile)) return true;
|
|
5002
|
+
const st = fs26.statSync(paths.secretsFile);
|
|
4126
5003
|
return (st.mode & 511) === 384;
|
|
4127
5004
|
},
|
|
4128
|
-
describe: (paths) => `chmod 0600 ${
|
|
5005
|
+
describe: (paths) => `chmod 0600 ${path19.relative(paths.root, paths.secretsFile)}`,
|
|
4129
5006
|
apply: (paths) => {
|
|
4130
5007
|
if (process.platform === "win32") return;
|
|
4131
|
-
if (
|
|
5008
|
+
if (fs26.existsSync(paths.secretsFile)) fs26.chmodSync(paths.secretsFile, 384);
|
|
4132
5009
|
}
|
|
4133
5010
|
};
|
|
4134
5011
|
var PLANNER_BUDGET_DEFAULTS_MIGRATION = {
|
|
4135
5012
|
id: "planner-budget-defaults",
|
|
4136
5013
|
name: "Backfill planner.budget defaults in config.yaml",
|
|
4137
5014
|
isApplied: (paths) => {
|
|
4138
|
-
if (!
|
|
5015
|
+
if (!fs26.existsSync(paths.configFile)) return true;
|
|
4139
5016
|
try {
|
|
4140
|
-
const raw =
|
|
5017
|
+
const raw = fs26.readFileSync(paths.configFile, "utf8");
|
|
4141
5018
|
if (!/^\s*planner:/m.test(raw)) return true;
|
|
4142
5019
|
if (/^\s*budget:/m.test(raw)) return true;
|
|
4143
5020
|
const cfg = loadConfig(paths.configFile);
|
|
@@ -4156,9 +5033,9 @@ var CONFIG_NORMALIZE_MIGRATION = {
|
|
|
4156
5033
|
id: "config-normalize",
|
|
4157
5034
|
name: "Normalise .squad/config.yaml formatting",
|
|
4158
5035
|
isApplied: (paths) => {
|
|
4159
|
-
if (!
|
|
5036
|
+
if (!fs26.existsSync(paths.configFile)) return true;
|
|
4160
5037
|
try {
|
|
4161
|
-
const raw =
|
|
5038
|
+
const raw = fs26.readFileSync(paths.configFile, "utf8");
|
|
4162
5039
|
const cfg = loadConfig(paths.configFile);
|
|
4163
5040
|
return raw.trim() === serializeConfig(cfg).trim();
|
|
4164
5041
|
} catch {
|
|
@@ -4167,7 +5044,7 @@ var CONFIG_NORMALIZE_MIGRATION = {
|
|
|
4167
5044
|
},
|
|
4168
5045
|
describe: () => "rewrite config.yaml with canonical YAML formatting (drops comments)",
|
|
4169
5046
|
apply: (paths) => {
|
|
4170
|
-
if (!
|
|
5047
|
+
if (!fs26.existsSync(paths.configFile)) return;
|
|
4171
5048
|
const cfg = loadConfig(paths.configFile);
|
|
4172
5049
|
saveConfig(paths.configFile, cfg);
|
|
4173
5050
|
}
|
|
@@ -4235,7 +5112,11 @@ async function runMigrate(opts) {
|
|
|
4235
5112
|
}
|
|
4236
5113
|
info("");
|
|
4237
5114
|
success(`Applied ${applied}/${pending.length} migration(s).`);
|
|
4238
|
-
|
|
5115
|
+
blank();
|
|
5116
|
+
step("Next:");
|
|
5117
|
+
info("1) Run `squad doctor` to verify every check is green.");
|
|
5118
|
+
info("2) If you were upgrading from 0.1.x, review .squad/config.yaml \u2014 new defaults (planner, cache) may have been added.");
|
|
5119
|
+
info("3) Continue your usual workflow: `squad new-story` \u2192 fill intake \u2192 `squad new-plan`.");
|
|
4239
5120
|
}
|
|
4240
5121
|
|
|
4241
5122
|
// src/commands/upgrade.ts
|
|
@@ -4244,8 +5125,8 @@ import process3 from "process";
|
|
|
4244
5125
|
import { confirm as confirm8 } from "@inquirer/prompts";
|
|
4245
5126
|
|
|
4246
5127
|
// src/core/package-info.ts
|
|
4247
|
-
import
|
|
4248
|
-
import
|
|
5128
|
+
import fs27 from "fs";
|
|
5129
|
+
import path20 from "path";
|
|
4249
5130
|
function changelogBlobUrl(repositoryUrl, version) {
|
|
4250
5131
|
let base = repositoryUrl;
|
|
4251
5132
|
if (!base) base = "https://github.com/AzmSquad/squad-kit";
|
|
@@ -4256,9 +5137,9 @@ function changelogBlobUrl(repositoryUrl, version) {
|
|
|
4256
5137
|
}
|
|
4257
5138
|
function readInstalledPackage() {
|
|
4258
5139
|
const root = packageRoot();
|
|
4259
|
-
const pkgFile =
|
|
4260
|
-
const pkg = JSON.parse(
|
|
4261
|
-
const isDevInstall =
|
|
5140
|
+
const pkgFile = path20.join(root, "package.json");
|
|
5141
|
+
const pkg = JSON.parse(fs27.readFileSync(pkgFile, "utf8"));
|
|
5142
|
+
const isDevInstall = fs27.existsSync(path20.join(root, "src"));
|
|
4262
5143
|
return {
|
|
4263
5144
|
name: pkg.name,
|
|
4264
5145
|
version: pkg.version,
|
|
@@ -4298,7 +5179,7 @@ function isNewer(a, b) {
|
|
|
4298
5179
|
}
|
|
4299
5180
|
|
|
4300
5181
|
// src/core/package-manager.ts
|
|
4301
|
-
import
|
|
5182
|
+
import path21 from "path";
|
|
4302
5183
|
import process2 from "process";
|
|
4303
5184
|
function detectPackageManager(installRoot) {
|
|
4304
5185
|
const ua = process2.env.npm_config_user_agent;
|
|
@@ -4309,11 +5190,11 @@ function detectPackageManager(installRoot) {
|
|
|
4309
5190
|
if (ua.startsWith("npm/")) return { pm: "npm", reason: "npm_config_user_agent" };
|
|
4310
5191
|
}
|
|
4311
5192
|
const root = installRoot.toLowerCase();
|
|
4312
|
-
if (root.includes(`${
|
|
5193
|
+
if (root.includes(`${path21.sep}pnpm${path21.sep}`) || root.includes(`${path21.sep}.pnpm${path21.sep}`))
|
|
4313
5194
|
return { pm: "pnpm", reason: `install path contains pnpm` };
|
|
4314
|
-
if (root.includes(`${
|
|
5195
|
+
if (root.includes(`${path21.sep}.yarn${path21.sep}`) || root.includes(`${path21.sep}yarn${path21.sep}`))
|
|
4315
5196
|
return { pm: "yarn", reason: `install path contains yarn` };
|
|
4316
|
-
if (root.includes(`${
|
|
5197
|
+
if (root.includes(`${path21.sep}.bun${path21.sep}`) || root.includes(`${path21.sep}bun${path21.sep}`))
|
|
4317
5198
|
return { pm: "bun", reason: `install path contains bun` };
|
|
4318
5199
|
return { pm: "npm", reason: "default fallback" };
|
|
4319
5200
|
}
|
|
@@ -4414,8 +5295,8 @@ function spawnInherit(cmd, args) {
|
|
|
4414
5295
|
}
|
|
4415
5296
|
|
|
4416
5297
|
// src/commands/rm/shared.ts
|
|
4417
|
-
import
|
|
4418
|
-
import
|
|
5298
|
+
import fs28 from "fs";
|
|
5299
|
+
import path22 from "path";
|
|
4419
5300
|
import { select as select7 } from "@inquirer/prompts";
|
|
4420
5301
|
var STORY_PLAN = /^(\d{2,})-story-.+\.md$/;
|
|
4421
5302
|
function canPromptForRm(opts) {
|
|
@@ -4423,9 +5304,9 @@ function canPromptForRm(opts) {
|
|
|
4423
5304
|
return isInteractive() && Boolean(process.stdin.isTTY);
|
|
4424
5305
|
}
|
|
4425
5306
|
function removeOverviewRow(featurePlanDir, storyId) {
|
|
4426
|
-
const overview =
|
|
4427
|
-
if (!
|
|
4428
|
-
const src =
|
|
5307
|
+
const overview = path22.join(featurePlanDir, "00-overview.md");
|
|
5308
|
+
if (!fs28.existsSync(overview)) return;
|
|
5309
|
+
const src = fs28.readFileSync(overview, "utf8");
|
|
4429
5310
|
const lines = src.split("\n");
|
|
4430
5311
|
const filtered = lines.filter((line2) => {
|
|
4431
5312
|
if (!line2.startsWith("|")) return true;
|
|
@@ -4433,12 +5314,12 @@ function removeOverviewRow(featurePlanDir, storyId) {
|
|
|
4433
5314
|
if (/^\|\s*-+/.test(line2)) return true;
|
|
4434
5315
|
return !line2.includes(storyId);
|
|
4435
5316
|
});
|
|
4436
|
-
|
|
5317
|
+
fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
4437
5318
|
}
|
|
4438
5319
|
function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
|
|
4439
|
-
const overview =
|
|
4440
|
-
if (!
|
|
4441
|
-
const src =
|
|
5320
|
+
const overview = path22.join(featurePlanDir, "00-overview.md");
|
|
5321
|
+
if (!fs28.existsSync(overview)) return;
|
|
5322
|
+
const src = fs28.readFileSync(overview, "utf8");
|
|
4442
5323
|
const token = `\`${planBasename}\``;
|
|
4443
5324
|
const lines = src.split("\n");
|
|
4444
5325
|
const filtered = lines.filter((line2) => {
|
|
@@ -4447,25 +5328,25 @@ function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
|
|
|
4447
5328
|
if (/^\|\s*-+/.test(line2)) return true;
|
|
4448
5329
|
return !line2.includes(token);
|
|
4449
5330
|
});
|
|
4450
|
-
|
|
5331
|
+
fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
4451
5332
|
}
|
|
4452
5333
|
function trashOrDelete(absPaths, trashRoot, useTrash) {
|
|
4453
5334
|
if (!useTrash) {
|
|
4454
|
-
for (const p of absPaths)
|
|
5335
|
+
for (const p of absPaths) fs28.rmSync(p, { recursive: true, force: true });
|
|
4455
5336
|
return { removed: absPaths };
|
|
4456
5337
|
}
|
|
4457
5338
|
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
4458
|
-
const bucket =
|
|
4459
|
-
|
|
5339
|
+
const bucket = path22.join(trashRoot, ts);
|
|
5340
|
+
fs28.mkdirSync(bucket, { recursive: true });
|
|
4460
5341
|
for (const src of absPaths) {
|
|
4461
|
-
const rel =
|
|
4462
|
-
let dst =
|
|
5342
|
+
const rel = path22.basename(src);
|
|
5343
|
+
let dst = path22.join(bucket, rel);
|
|
4463
5344
|
let n = 0;
|
|
4464
|
-
while (
|
|
5345
|
+
while (fs28.existsSync(dst)) {
|
|
4465
5346
|
n += 1;
|
|
4466
|
-
dst =
|
|
5347
|
+
dst = path22.join(bucket, `${n}_${rel}`);
|
|
4467
5348
|
}
|
|
4468
|
-
|
|
5349
|
+
fs28.renameSync(src, dst);
|
|
4469
5350
|
}
|
|
4470
5351
|
return { removed: absPaths, trashed: bucket };
|
|
4471
5352
|
}
|
|
@@ -4476,21 +5357,21 @@ function renderPreview(kind, targets, opts) {
|
|
|
4476
5357
|
kv(t.kind, t.label, 6);
|
|
4477
5358
|
}
|
|
4478
5359
|
if (opts.trash) {
|
|
4479
|
-
info(`Items will move to ${
|
|
5360
|
+
info(`Items will move to ${path22.relative(process.cwd(), opts.trashRoot)}/<timestamp>/`);
|
|
4480
5361
|
} else if (!opts.dryRun) {
|
|
4481
5362
|
warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
|
|
4482
5363
|
}
|
|
4483
5364
|
}
|
|
4484
5365
|
function listAllPlanListEntries(paths, featureFilter) {
|
|
4485
|
-
if (!
|
|
5366
|
+
if (!fs28.existsSync(paths.plansDir)) return [];
|
|
4486
5367
|
const out = [];
|
|
4487
|
-
for (const ent of
|
|
5368
|
+
for (const ent of fs28.readdirSync(paths.plansDir, { withFileTypes: true })) {
|
|
4488
5369
|
if (!ent.isDirectory()) continue;
|
|
4489
5370
|
if (featureFilter && ent.name !== featureFilter) continue;
|
|
4490
|
-
const featureDir =
|
|
4491
|
-
for (const name of
|
|
5371
|
+
const featureDir = path22.join(paths.plansDir, ent.name);
|
|
5372
|
+
for (const name of fs28.readdirSync(featureDir)) {
|
|
4492
5373
|
if (!STORY_PLAN.test(name)) continue;
|
|
4493
|
-
out.push({ feature: ent.name, file: name, abs:
|
|
5374
|
+
out.push({ feature: ent.name, file: name, abs: path22.join(featureDir, name) });
|
|
4494
5375
|
}
|
|
4495
5376
|
}
|
|
4496
5377
|
return out.sort((a, b) => (a.feature + a.file).localeCompare(b.feature + b.file));
|
|
@@ -4541,34 +5422,34 @@ function resolveStoryFromArg(arg, stories, root) {
|
|
|
4541
5422
|
if (s.id === t) return s;
|
|
4542
5423
|
if (`${s.feature}/${s.id}` === t) return s;
|
|
4543
5424
|
}
|
|
4544
|
-
const abs =
|
|
4545
|
-
const withIntake = t.endsWith("intake.md") ? abs :
|
|
5425
|
+
const abs = path22.isAbsolute(t) ? t : path22.resolve(root, t);
|
|
5426
|
+
const withIntake = t.endsWith("intake.md") ? abs : path22.join(abs, "intake.md");
|
|
4546
5427
|
return findStoryByIntake(stories, withIntake) ?? findStoryByIntake(stories, abs);
|
|
4547
5428
|
}
|
|
4548
5429
|
function isSequenceToken(s) {
|
|
4549
5430
|
return /^\d{1,4}$/.test(s) && s.length > 0;
|
|
4550
5431
|
}
|
|
4551
5432
|
function tryParsePlanFilePath(absPath, paths, featureFilter) {
|
|
4552
|
-
if (!
|
|
4553
|
-
const planRoot =
|
|
4554
|
-
const fileReal =
|
|
4555
|
-
const rel =
|
|
4556
|
-
if (rel.startsWith("..") ||
|
|
4557
|
-
const segs = rel.split(
|
|
5433
|
+
if (!fs28.existsSync(absPath) || !STORY_PLAN.test(path22.basename(absPath))) return void 0;
|
|
5434
|
+
const planRoot = fs28.realpathSync(path22.resolve(paths.plansDir));
|
|
5435
|
+
const fileReal = fs28.realpathSync(path22.resolve(absPath));
|
|
5436
|
+
const rel = path22.relative(planRoot, fileReal);
|
|
5437
|
+
if (rel.startsWith("..") || path22.isAbsolute(rel)) return void 0;
|
|
5438
|
+
const segs = rel.split(path22.sep);
|
|
4558
5439
|
if (segs.length < 2) return void 0;
|
|
4559
5440
|
const feature = segs[0];
|
|
4560
|
-
const file = segs.slice(1).join(
|
|
5441
|
+
const file = segs.slice(1).join(path22.sep);
|
|
4561
5442
|
if (featureFilter && feature !== featureFilter) return void 0;
|
|
4562
|
-
return { feature, file, abs:
|
|
5443
|
+
return { feature, file, abs: path22.resolve(absPath) };
|
|
4563
5444
|
}
|
|
4564
5445
|
function resolvePlanFromArg(arg, paths, featureFilter) {
|
|
4565
5446
|
const t = arg.trim();
|
|
4566
5447
|
if (!t) return void 0;
|
|
4567
|
-
const asPath =
|
|
5448
|
+
const asPath = path22.isAbsolute(t) ? t : path22.resolve(process.cwd(), t);
|
|
4568
5449
|
const a = tryParsePlanFilePath(asPath, paths, featureFilter);
|
|
4569
5450
|
if (a) return a;
|
|
4570
|
-
if (!
|
|
4571
|
-
const under = tryParsePlanFilePath(
|
|
5451
|
+
if (!path22.isAbsolute(t)) {
|
|
5452
|
+
const under = tryParsePlanFilePath(path22.join(paths.plansDir, t), paths, featureFilter);
|
|
4572
5453
|
if (under) return under;
|
|
4573
5454
|
}
|
|
4574
5455
|
if (isSequenceToken(t)) {
|
|
@@ -4598,24 +5479,24 @@ function countOverviewDataRows(overviewContent) {
|
|
|
4598
5479
|
return n;
|
|
4599
5480
|
}
|
|
4600
5481
|
function featureStoriesDirEmpty(paths, feature) {
|
|
4601
|
-
const d =
|
|
4602
|
-
if (!
|
|
4603
|
-
return
|
|
5482
|
+
const d = path22.join(paths.storiesDir, feature);
|
|
5483
|
+
if (!fs28.existsSync(d)) return true;
|
|
5484
|
+
return fs28.readdirSync(d).length === 0;
|
|
4604
5485
|
}
|
|
4605
5486
|
|
|
4606
5487
|
// src/commands/rm/story.ts
|
|
4607
|
-
import
|
|
4608
|
-
import
|
|
5488
|
+
import fs30 from "fs";
|
|
5489
|
+
import path24 from "path";
|
|
4609
5490
|
import { confirm as confirm10 } from "@inquirer/prompts";
|
|
4610
5491
|
|
|
4611
5492
|
// src/commands/rm/feature.ts
|
|
4612
|
-
import
|
|
4613
|
-
import
|
|
5493
|
+
import fs29 from "fs";
|
|
5494
|
+
import path23 from "path";
|
|
4614
5495
|
import { confirm as confirm9 } from "@inquirer/prompts";
|
|
4615
5496
|
function countStoryDirs(storiesDir, feature) {
|
|
4616
|
-
const d =
|
|
4617
|
-
if (!
|
|
4618
|
-
return
|
|
5497
|
+
const d = path23.join(storiesDir, feature);
|
|
5498
|
+
if (!fs29.existsSync(d)) return 0;
|
|
5499
|
+
return fs29.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
|
|
4619
5500
|
}
|
|
4620
5501
|
async function runRmFeature(featureSlug, opts = {}) {
|
|
4621
5502
|
const root = requireSquadRoot();
|
|
@@ -4649,17 +5530,17 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
4649
5530
|
feature = await pickFeatureInteractive(paths);
|
|
4650
5531
|
}
|
|
4651
5532
|
}
|
|
4652
|
-
const storyDir =
|
|
4653
|
-
const planDir =
|
|
5533
|
+
const storyDir = path23.join(paths.storiesDir, feature);
|
|
5534
|
+
const planDir = path23.join(paths.plansDir, feature);
|
|
4654
5535
|
const nStories = countStoryDirs(paths.storiesDir, feature);
|
|
4655
5536
|
const nPlans = listAllPlanListEntries(paths, feature).length;
|
|
4656
|
-
const hasOverview =
|
|
5537
|
+
const hasOverview = fs29.existsSync(path23.join(planDir, "00-overview.md")) ? 1 : 0;
|
|
4657
5538
|
const targets = [];
|
|
4658
|
-
if (
|
|
4659
|
-
targets.push({ label:
|
|
5539
|
+
if (fs29.existsSync(storyDir)) {
|
|
5540
|
+
targets.push({ label: path23.relative(root, storyDir), absPath: storyDir, kind: "dir" });
|
|
4660
5541
|
}
|
|
4661
|
-
if (
|
|
4662
|
-
targets.push({ label:
|
|
5542
|
+
if (fs29.existsSync(planDir)) {
|
|
5543
|
+
targets.push({ label: path23.relative(root, planDir), absPath: planDir, kind: "dir" });
|
|
4663
5544
|
}
|
|
4664
5545
|
divider("squad rm feature");
|
|
4665
5546
|
info(`Feature: ${feature}`);
|
|
@@ -4674,7 +5555,7 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
4674
5555
|
kv(t.kind, t.label, 6);
|
|
4675
5556
|
}
|
|
4676
5557
|
if (useTrash) {
|
|
4677
|
-
info(`Items will move to ${
|
|
5558
|
+
info(`Items will move to ${path23.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
|
|
4678
5559
|
} else if (!dryRun) {
|
|
4679
5560
|
warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
|
|
4680
5561
|
}
|
|
@@ -4707,7 +5588,7 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
4707
5588
|
useTrash
|
|
4708
5589
|
);
|
|
4709
5590
|
if (result.trashed) {
|
|
4710
|
-
success(`Feature removed. Items moved to ${
|
|
5591
|
+
success(`Feature removed. Items moved to ${path23.relative(root, result.trashed)}.`);
|
|
4711
5592
|
} else {
|
|
4712
5593
|
success("Feature removed (stories and plans directories).");
|
|
4713
5594
|
}
|
|
@@ -4748,11 +5629,11 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
4748
5629
|
}
|
|
4749
5630
|
}
|
|
4750
5631
|
const targets = [
|
|
4751
|
-
{ label:
|
|
5632
|
+
{ label: path24.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
|
|
4752
5633
|
];
|
|
4753
5634
|
if (story.planFile) {
|
|
4754
|
-
const planAbs =
|
|
4755
|
-
targets.push({ label:
|
|
5635
|
+
const planAbs = path24.join(paths.plansDir, story.feature, story.planFile);
|
|
5636
|
+
targets.push({ label: path24.relative(root, planAbs), absPath: planAbs, kind: "file" });
|
|
4756
5637
|
}
|
|
4757
5638
|
renderPreview("story", targets, {
|
|
4758
5639
|
dryRun,
|
|
@@ -4777,16 +5658,16 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
4777
5658
|
}
|
|
4778
5659
|
const absPaths = targets.map((t) => t.absPath);
|
|
4779
5660
|
const result = trashOrDelete(absPaths, paths.trashDir, useTrash);
|
|
4780
|
-
removeOverviewRow(
|
|
5661
|
+
removeOverviewRow(path24.join(paths.plansDir, story.feature), story.id);
|
|
4781
5662
|
if (result.trashed) {
|
|
4782
|
-
success(`Removed (moved to ${
|
|
5663
|
+
success(`Removed (moved to ${path24.relative(root, result.trashed)}).`);
|
|
4783
5664
|
} else {
|
|
4784
5665
|
success("Removed story folder, plan file (if any), and overview row.");
|
|
4785
5666
|
}
|
|
4786
|
-
const featurePlanDir =
|
|
4787
|
-
const overviewPath =
|
|
4788
|
-
if (
|
|
4789
|
-
const body =
|
|
5667
|
+
const featurePlanDir = path24.join(paths.plansDir, story.feature);
|
|
5668
|
+
const overviewPath = path24.join(featurePlanDir, "00-overview.md");
|
|
5669
|
+
if (fs30.existsSync(overviewPath)) {
|
|
5670
|
+
const body = fs30.readFileSync(overviewPath, "utf8");
|
|
4790
5671
|
const onlyShell = countOverviewDataRows(body) === 0;
|
|
4791
5672
|
if (onlyShell && featureStoriesDirEmpty(paths, story.feature)) {
|
|
4792
5673
|
if (canPrompt) {
|
|
@@ -4806,7 +5687,7 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
4806
5687
|
}
|
|
4807
5688
|
|
|
4808
5689
|
// src/commands/rm/plan.ts
|
|
4809
|
-
import
|
|
5690
|
+
import path25 from "path";
|
|
4810
5691
|
import { confirm as confirm11 } from "@inquirer/prompts";
|
|
4811
5692
|
async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
4812
5693
|
const root = requireSquadRoot();
|
|
@@ -4842,7 +5723,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
|
4842
5723
|
}
|
|
4843
5724
|
const storyRows = listStories(paths, { feature: plan.feature });
|
|
4844
5725
|
const linked = storyRows.find((s) => s.planFile === plan.file);
|
|
4845
|
-
const targets = [{ label:
|
|
5726
|
+
const targets = [{ label: path25.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
|
|
4846
5727
|
renderPreview("plan", targets, {
|
|
4847
5728
|
dryRun,
|
|
4848
5729
|
trash: useTrash,
|
|
@@ -4865,14 +5746,14 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
|
4865
5746
|
}
|
|
4866
5747
|
}
|
|
4867
5748
|
trashOrDelete([plan.abs], paths.trashDir, useTrash);
|
|
4868
|
-
removeOverviewRowForPlanFile(
|
|
4869
|
-
const intakeHint = linked ?
|
|
5749
|
+
removeOverviewRowForPlanFile(path25.join(paths.plansDir, plan.feature), plan.file);
|
|
5750
|
+
const intakeHint = linked ? path25.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
|
|
4870
5751
|
success(`Plan removed. Run \`squad new-plan ${intakeHint}\` to regenerate.`);
|
|
4871
5752
|
}
|
|
4872
5753
|
|
|
4873
5754
|
// src/cli.ts
|
|
4874
5755
|
var program = new Command();
|
|
4875
|
-
program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.
|
|
5756
|
+
program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.4.0");
|
|
4876
5757
|
program.command("init").description("Bootstrap .squad/ in the current directory").option("--agents <list>", "Comma-separated agent list: claude-code,cursor,copilot,gemini").option("--tracker <type>", "Tracker type: none|github|linear|jira|azure").option(
|
|
4877
5758
|
"--tracker-workspace <hostOrOrg>",
|
|
4878
5759
|
"Jira host or Azure org (non-interactive default; also written to config for jira/azure)"
|
|
@@ -4926,6 +5807,10 @@ config.command("remove-credential <section>").description('Delete credentials fo
|
|
|
4926
5807
|
)
|
|
4927
5808
|
);
|
|
4928
5809
|
program.parseAsync(process.argv).catch((err) => {
|
|
5810
|
+
if (isSquadExit(err)) {
|
|
5811
|
+
if (err.message.trim()) info(err.message);
|
|
5812
|
+
process.exit(err.exitCode);
|
|
5813
|
+
}
|
|
4929
5814
|
renderError(err);
|
|
4930
5815
|
process.exit(1);
|
|
4931
5816
|
});
|
|
@@ -4934,6 +5819,10 @@ function wrap(fn) {
|
|
|
4934
5819
|
try {
|
|
4935
5820
|
await fn(opts);
|
|
4936
5821
|
} catch (err) {
|
|
5822
|
+
if (isSquadExit(err)) {
|
|
5823
|
+
if (err.message.trim()) info(err.message);
|
|
5824
|
+
process.exit(err.exitCode);
|
|
5825
|
+
}
|
|
4937
5826
|
renderError(err);
|
|
4938
5827
|
process.exit(1);
|
|
4939
5828
|
}
|
|
@@ -4944,6 +5833,10 @@ function wrapArgs(fn) {
|
|
|
4944
5833
|
try {
|
|
4945
5834
|
await fn(...args);
|
|
4946
5835
|
} catch (err) {
|
|
5836
|
+
if (isSquadExit(err)) {
|
|
5837
|
+
if (err.message.trim()) info(err.message);
|
|
5838
|
+
process.exit(err.exitCode);
|
|
5839
|
+
}
|
|
4947
5840
|
renderError(err);
|
|
4948
5841
|
process.exit(1);
|
|
4949
5842
|
}
|