squad-kit 0.3.0 → 0.6.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 +95 -1
- package/README.md +27 -4
- package/dist/cli.js +2514 -624
- package/dist/cli.js.map +1 -1
- package/dist/console-ui/assets/index-BDlxrwaG.css +1 -0
- package/dist/console-ui/assets/index-Lxr-TdWe.js +416 -0
- package/dist/console-ui/favicon.ico +0 -0
- package/dist/console-ui/index.html +16 -0
- package/dist/console-ui/logo.svg +18 -0
- package/package.json +16 -4
- 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,6 +169,19 @@ 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";
|
|
@@ -224,6 +237,7 @@ var SQUAD_ATTACHMENTS_PATTERN = ".squad/stories/**/attachments/";
|
|
|
224
237
|
var SQUAD_TRASH_PATTERN = ".squad/.trash/";
|
|
225
238
|
var SQUAD_LAST_RUN_PATTERN = ".squad/.last-run.json";
|
|
226
239
|
var SQUAD_LAST_COPY_PROMPT_PATTERN = ".squad/.last-copy-prompt.md";
|
|
240
|
+
var SQUAD_RUNS_PATTERN = ".squad/runs/";
|
|
227
241
|
var MANAGED_BLOCK_HEADER = "# Managed by squad-kit \u2014 do not edit this block";
|
|
228
242
|
var MANAGED_BLOCK_FOOTER = "# End squad-kit block";
|
|
229
243
|
function ensureGitignore(repoRoot) {
|
|
@@ -234,7 +248,8 @@ function ensureGitignore(repoRoot) {
|
|
|
234
248
|
SQUAD_ATTACHMENTS_PATTERN,
|
|
235
249
|
SQUAD_TRASH_PATTERN,
|
|
236
250
|
SQUAD_LAST_RUN_PATTERN,
|
|
237
|
-
SQUAD_LAST_COPY_PROMPT_PATTERN
|
|
251
|
+
SQUAD_LAST_COPY_PROMPT_PATTERN,
|
|
252
|
+
SQUAD_RUNS_PATTERN
|
|
238
253
|
];
|
|
239
254
|
const allPresent = requiredPatterns.every((p) => text.includes(p));
|
|
240
255
|
if (allPresent) return false;
|
|
@@ -276,15 +291,27 @@ var DEFAULT_CONFIG = {
|
|
|
276
291
|
planner: void 0
|
|
277
292
|
};
|
|
278
293
|
var FORBIDDEN_KEYS = ["apikey", "api_key", "token", "secret", "credential", "credentials"];
|
|
279
|
-
|
|
294
|
+
var DEFAULT_PLANNER_MAX_OUTPUT_TOKENS = 16384;
|
|
295
|
+
var MIN_PLANNER_MAX_OUTPUT_TOKENS = 1024;
|
|
296
|
+
var MAX_PLANNER_MAX_OUTPUT_TOKENS = 128e3;
|
|
297
|
+
function clampPlannerMaxOut(raw) {
|
|
298
|
+
if (raw === void 0 || raw === null) return DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
|
|
299
|
+
const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseInt(raw, 10) : NaN;
|
|
300
|
+
if (!Number.isFinite(n) || n < 1) return DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
|
|
301
|
+
return Math.min(
|
|
302
|
+
MAX_PLANNER_MAX_OUTPUT_TOKENS,
|
|
303
|
+
Math.max(MIN_PLANNER_MAX_OUTPUT_TOKENS, Math.round(n))
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
function rejectSecretsInYaml(node, configFile, path34 = []) {
|
|
280
307
|
if (node === null || typeof node !== "object") return;
|
|
281
308
|
for (const [key, value] of Object.entries(node)) {
|
|
282
309
|
if (FORBIDDEN_KEYS.includes(key.toLowerCase())) {
|
|
283
310
|
throw new Error(
|
|
284
|
-
`Refusing to load ${configFile}: key "${[...
|
|
311
|
+
`Refusing to load ${configFile}: key "${[...path34, 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.`
|
|
285
312
|
);
|
|
286
313
|
}
|
|
287
|
-
if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...
|
|
314
|
+
if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...path34, key]);
|
|
288
315
|
}
|
|
289
316
|
}
|
|
290
317
|
function mergePlanner(base, override) {
|
|
@@ -305,7 +332,8 @@ function mergePlanner(base, override) {
|
|
|
305
332
|
},
|
|
306
333
|
cache: {
|
|
307
334
|
enabled: override.cache?.enabled ?? base?.cache?.enabled ?? true
|
|
308
|
-
}
|
|
335
|
+
},
|
|
336
|
+
maxOutputTokens: clampPlannerMaxOut(override.maxOutputTokens ?? base?.maxOutputTokens)
|
|
309
337
|
};
|
|
310
338
|
if (merged.modelOverride) {
|
|
311
339
|
const pruned = Object.fromEntries(
|
|
@@ -331,8 +359,7 @@ function validateModelOverride(mo, configFile) {
|
|
|
331
359
|
}
|
|
332
360
|
}
|
|
333
361
|
}
|
|
334
|
-
function
|
|
335
|
-
const raw = fs3.readFileSync(configFile, "utf8");
|
|
362
|
+
function parseConfig(raw, configFile) {
|
|
336
363
|
let parsed;
|
|
337
364
|
try {
|
|
338
365
|
parsed = yaml.load(raw);
|
|
@@ -387,11 +414,17 @@ function loadConfig(configFile) {
|
|
|
387
414
|
}
|
|
388
415
|
return merged;
|
|
389
416
|
}
|
|
417
|
+
function loadConfig(configFile) {
|
|
418
|
+
const raw = fs3.readFileSync(configFile, "utf8");
|
|
419
|
+
return parseConfig(raw, configFile);
|
|
420
|
+
}
|
|
390
421
|
function serializeConfig(config2) {
|
|
391
422
|
return yaml.dump(config2, { lineWidth: 100, noRefs: true, sortKeys: false });
|
|
392
423
|
}
|
|
393
424
|
function saveConfig(configFile, config2) {
|
|
394
|
-
|
|
425
|
+
const body = serializeConfig(config2);
|
|
426
|
+
parseConfig(body, configFile);
|
|
427
|
+
fs3.writeFileSync(configFile, body, "utf8");
|
|
395
428
|
}
|
|
396
429
|
function mergeConfig(base, override) {
|
|
397
430
|
return {
|
|
@@ -525,6 +558,19 @@ function resolveProviderKey(provider) {
|
|
|
525
558
|
function readProviderKey(provider) {
|
|
526
559
|
return resolveProviderKey(provider)?.value;
|
|
527
560
|
}
|
|
561
|
+
function readProviderKeyForPaths(paths, provider) {
|
|
562
|
+
const envVar = providerEnvVar(provider);
|
|
563
|
+
const envValue = process.env[envVar];
|
|
564
|
+
if (envValue) return envValue;
|
|
565
|
+
const fallback = process.env.SQUAD_PLANNER_API_KEY;
|
|
566
|
+
if (fallback) return fallback;
|
|
567
|
+
if (fs5.existsSync(paths.secretsFile)) {
|
|
568
|
+
const secrets = loadSecrets(paths.secretsFile);
|
|
569
|
+
const fromFile = secrets.planner?.[provider];
|
|
570
|
+
if (fromFile) return fromFile;
|
|
571
|
+
}
|
|
572
|
+
return void 0;
|
|
573
|
+
}
|
|
528
574
|
|
|
529
575
|
// src/utils/fs.ts
|
|
530
576
|
import fs6 from "fs";
|
|
@@ -584,6 +630,9 @@ function render(template, vars) {
|
|
|
584
630
|
return value === void 0 || value === null ? "" : String(value);
|
|
585
631
|
});
|
|
586
632
|
}
|
|
633
|
+
function escapeTemplateValue(s) {
|
|
634
|
+
return s.replace(/\{\{/g, "{ {");
|
|
635
|
+
}
|
|
587
636
|
|
|
588
637
|
// src/commands/config/show.ts
|
|
589
638
|
import fs7 from "fs";
|
|
@@ -856,7 +905,8 @@ function newPlannerBlock(provider) {
|
|
|
856
905
|
maxContextBytes: 5e4,
|
|
857
906
|
maxDurationSeconds: 180
|
|
858
907
|
},
|
|
859
|
-
cache: { enabled: true }
|
|
908
|
+
cache: { enabled: true },
|
|
909
|
+
maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
|
|
860
910
|
};
|
|
861
911
|
}
|
|
862
912
|
function mergePlannerKeyIntoSecrets(base, provider, key) {
|
|
@@ -1196,6 +1246,55 @@ var AzureDevOpsClient = class {
|
|
|
1196
1246
|
baseUrl;
|
|
1197
1247
|
apiVersion;
|
|
1198
1248
|
webBase;
|
|
1249
|
+
async searchIssues(query, opts) {
|
|
1250
|
+
const limit = Math.min(50, Math.max(1, opts?.limit ?? 25));
|
|
1251
|
+
const q = query.trim();
|
|
1252
|
+
let wiql;
|
|
1253
|
+
if (/^\d+$/.test(q)) {
|
|
1254
|
+
wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Id] = ${q}`;
|
|
1255
|
+
} else if (q.length === 0) {
|
|
1256
|
+
wiql = "SELECT [System.Id] FROM WorkItems ORDER BY [System.ChangedDate] DESC";
|
|
1257
|
+
} else {
|
|
1258
|
+
const esc = q.replace(/'/g, "''");
|
|
1259
|
+
wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Title] CONTAINS STRING '${esc}' ORDER BY [System.ChangedDate] DESC`;
|
|
1260
|
+
}
|
|
1261
|
+
const wiqlUrl = `${this.baseUrl}/wiql?api-version=${this.apiVersion}`;
|
|
1262
|
+
let wiqlRes;
|
|
1263
|
+
try {
|
|
1264
|
+
wiqlRes = await fetch(wiqlUrl, {
|
|
1265
|
+
method: "POST",
|
|
1266
|
+
headers: { ...this.headers(), "content-type": "application/json" },
|
|
1267
|
+
body: JSON.stringify({ query: `${wiql}` })
|
|
1268
|
+
});
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
throw new TrackerError(`Azure DevOps search failed: ${err.message}`, "network");
|
|
1271
|
+
}
|
|
1272
|
+
if (!wiqlRes.ok) throw this.mapHttpError(wiqlRes.status, "search");
|
|
1273
|
+
const wiqlBody = await wiqlRes.json();
|
|
1274
|
+
const ids = (wiqlBody.workItems ?? []).slice(0, limit).map((w) => w.id);
|
|
1275
|
+
if (ids.length === 0) return [];
|
|
1276
|
+
const batchUrl = `${this.baseUrl}/workitems?ids=${ids.map(String).join(",")}&$expand=all&api-version=${this.apiVersion}`;
|
|
1277
|
+
let batchRes;
|
|
1278
|
+
try {
|
|
1279
|
+
batchRes = await fetch(batchUrl, { headers: this.headers() });
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
throw new TrackerError(`Azure DevOps work items fetch failed: ${err.message}`, "network");
|
|
1282
|
+
}
|
|
1283
|
+
if (!batchRes.ok) throw this.mapHttpError(batchRes.status, "search");
|
|
1284
|
+
const batchBody = await batchRes.json();
|
|
1285
|
+
const value = batchBody.value ?? [];
|
|
1286
|
+
return value.map((body) => {
|
|
1287
|
+
const id = String(body.id ?? "");
|
|
1288
|
+
const fields = body.fields ?? {};
|
|
1289
|
+
return {
|
|
1290
|
+
id,
|
|
1291
|
+
title: fields["System.Title"] ?? "(no title)",
|
|
1292
|
+
type: fields["System.WorkItemType"],
|
|
1293
|
+
status: fields["System.State"],
|
|
1294
|
+
url: `${this.webBase}/${id}`
|
|
1295
|
+
};
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1199
1298
|
async fetchIssue(id) {
|
|
1200
1299
|
const url = `${this.baseUrl}/workitems/${encodeURIComponent(id)}?$expand=all&api-version=${this.apiVersion}`;
|
|
1201
1300
|
let res;
|
|
@@ -1217,6 +1316,7 @@ var AzureDevOpsClient = class {
|
|
|
1217
1316
|
id: String(body.id ?? id),
|
|
1218
1317
|
title: fields["System.Title"] ?? "(no title)",
|
|
1219
1318
|
description: stripHtml(fields["System.Description"] ?? ""),
|
|
1319
|
+
acceptanceCriteria: stripHtml(fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? ""),
|
|
1220
1320
|
url: `${this.webBase}/${body.id ?? id}`,
|
|
1221
1321
|
labels: parseTags(fields["System.Tags"]),
|
|
1222
1322
|
type: fields["System.WorkItemType"],
|
|
@@ -1245,7 +1345,7 @@ var AzureDevOpsClient = class {
|
|
|
1245
1345
|
}
|
|
1246
1346
|
if (status === 404) {
|
|
1247
1347
|
return new TrackerError(
|
|
1248
|
-
`Azure DevOps work item "${id}" not found in ${this.cfg.organization}/${this.cfg.project} (HTTP 404). Check the id and the organization/project in .squad/config.yaml.`,
|
|
1348
|
+
id === "search" ? `Azure DevOps search failed (HTTP 404) in ${this.cfg.organization}/${this.cfg.project}.` : `Azure DevOps work item "${id}" not found in ${this.cfg.organization}/${this.cfg.project} (HTTP 404). Check the id and the organization/project in .squad/config.yaml.`,
|
|
1249
1349
|
"not-found",
|
|
1250
1350
|
status
|
|
1251
1351
|
);
|
|
@@ -1253,7 +1353,11 @@ var AzureDevOpsClient = class {
|
|
|
1253
1353
|
if (status === 429) {
|
|
1254
1354
|
return new TrackerError(`Azure DevOps rate limit hit (HTTP 429). Wait a minute and retry.`, "rate-limited", status);
|
|
1255
1355
|
}
|
|
1256
|
-
return new TrackerError(
|
|
1356
|
+
return new TrackerError(
|
|
1357
|
+
id === "search" ? `Azure DevOps search failed (HTTP ${status}).` : `Azure DevOps request failed (HTTP ${status}).`,
|
|
1358
|
+
"other",
|
|
1359
|
+
status
|
|
1360
|
+
);
|
|
1257
1361
|
}
|
|
1258
1362
|
};
|
|
1259
1363
|
function parseTags(raw) {
|
|
@@ -1340,6 +1444,7 @@ var JiraClient = class {
|
|
|
1340
1444
|
id: body.key ?? id,
|
|
1341
1445
|
title: fields.summary ?? "(no title)",
|
|
1342
1446
|
description,
|
|
1447
|
+
acceptanceCriteria: "",
|
|
1343
1448
|
url: `${this.baseUrl}/browse/${body.key ?? id}`,
|
|
1344
1449
|
labels: Array.isArray(fields.labels) ? fields.labels : [],
|
|
1345
1450
|
type: fields.issuetype?.name,
|
|
@@ -1349,6 +1454,40 @@ var JiraClient = class {
|
|
|
1349
1454
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1350
1455
|
};
|
|
1351
1456
|
}
|
|
1457
|
+
async searchIssues(query, opts) {
|
|
1458
|
+
const limit = Math.min(50, Math.max(1, opts?.limit ?? 25));
|
|
1459
|
+
const q = query.trim();
|
|
1460
|
+
let jql;
|
|
1461
|
+
if (/^[A-Z][A-Z0-9_]+-\d+$/i.test(q)) {
|
|
1462
|
+
jql = `issuekey = "${q.toUpperCase()}"`;
|
|
1463
|
+
} else if (q.length === 0) {
|
|
1464
|
+
jql = "order by updated DESC";
|
|
1465
|
+
} else {
|
|
1466
|
+
const esc = q.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1467
|
+
jql = `text ~ "${esc}" order by updated DESC`;
|
|
1468
|
+
}
|
|
1469
|
+
const url = `${this.baseUrl}/rest/api/3/search?jql=${encodeURIComponent(jql)}&fields=summary,status,issuetype&maxResults=${limit}`;
|
|
1470
|
+
let res;
|
|
1471
|
+
try {
|
|
1472
|
+
res = await fetch(url, { headers: this.headers() });
|
|
1473
|
+
} catch (err) {
|
|
1474
|
+
throw new TrackerError(`Jira search failed: ${err.message}`, "network");
|
|
1475
|
+
}
|
|
1476
|
+
if (!res.ok) throw this.mapHttpError(res.status, "search");
|
|
1477
|
+
const body = await res.json();
|
|
1478
|
+
const issues = body.issues ?? [];
|
|
1479
|
+
return issues.map((it) => {
|
|
1480
|
+
const key = it.key ?? "";
|
|
1481
|
+
const f = it.fields ?? {};
|
|
1482
|
+
return {
|
|
1483
|
+
id: key,
|
|
1484
|
+
title: f.summary ?? "(no title)",
|
|
1485
|
+
type: f.issuetype?.name,
|
|
1486
|
+
status: f.status?.name,
|
|
1487
|
+
url: `${this.baseUrl}/browse/${key}`
|
|
1488
|
+
};
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1352
1491
|
async downloadAttachments(refs, targetDir, opts) {
|
|
1353
1492
|
return downloadAttachmentsWith(refs, targetDir, this.headers(), opts);
|
|
1354
1493
|
}
|
|
@@ -1368,7 +1507,7 @@ var JiraClient = class {
|
|
|
1368
1507
|
}
|
|
1369
1508
|
if (status === 404) {
|
|
1370
1509
|
return new TrackerError(
|
|
1371
|
-
`Jira issue "${id}" not found on ${this.cfg.host} (HTTP 404). Check the id and your workspace host.`,
|
|
1510
|
+
id === "search" ? `Jira search not found (HTTP 404) on ${this.cfg.host}.` : `Jira issue "${id}" not found on ${this.cfg.host} (HTTP 404). Check the id and your workspace host.`,
|
|
1372
1511
|
"not-found",
|
|
1373
1512
|
status
|
|
1374
1513
|
);
|
|
@@ -1376,7 +1515,11 @@ var JiraClient = class {
|
|
|
1376
1515
|
if (status === 429) {
|
|
1377
1516
|
return new TrackerError(`Jira rate limit hit (HTTP 429). Wait a minute and retry.`, "rate-limited", status);
|
|
1378
1517
|
}
|
|
1379
|
-
return new TrackerError(
|
|
1518
|
+
return new TrackerError(
|
|
1519
|
+
id === "search" ? `Jira search failed (HTTP ${status}).` : `Jira request failed (HTTP ${status}).`,
|
|
1520
|
+
"other",
|
|
1521
|
+
status
|
|
1522
|
+
);
|
|
1380
1523
|
}
|
|
1381
1524
|
};
|
|
1382
1525
|
function stripHtml2(html) {
|
|
@@ -1772,7 +1915,9 @@ async function runInit(opts) {
|
|
|
1772
1915
|
maxFileReads: 25,
|
|
1773
1916
|
maxContextBytes: 5e4,
|
|
1774
1917
|
maxDurationSeconds: 180
|
|
1775
|
-
}
|
|
1918
|
+
},
|
|
1919
|
+
cache: { enabled: true },
|
|
1920
|
+
maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
|
|
1776
1921
|
};
|
|
1777
1922
|
}
|
|
1778
1923
|
} else {
|
|
@@ -1798,7 +1943,9 @@ async function runInit(opts) {
|
|
|
1798
1943
|
maxFileReads: 25,
|
|
1799
1944
|
maxContextBytes: 5e4,
|
|
1800
1945
|
maxDurationSeconds: 180
|
|
1801
|
-
}
|
|
1946
|
+
},
|
|
1947
|
+
cache: { enabled: true },
|
|
1948
|
+
maxOutputTokens: DEFAULT_PLANNER_MAX_OUTPUT_TOKENS
|
|
1802
1949
|
};
|
|
1803
1950
|
}
|
|
1804
1951
|
}
|
|
@@ -1927,9 +2074,9 @@ Plan meta-prompts (\`generate-plan.md\`, \`story-skeleton.md\`) ship inside the
|
|
|
1927
2074
|
}
|
|
1928
2075
|
|
|
1929
2076
|
// src/commands/new-story.ts
|
|
1930
|
-
import
|
|
1931
|
-
import
|
|
1932
|
-
import { input as input3, select as
|
|
2077
|
+
import fs20 from "fs";
|
|
2078
|
+
import path12 from "path";
|
|
2079
|
+
import { input as input3, select as select5 } from "@inquirer/prompts";
|
|
1933
2080
|
|
|
1934
2081
|
// src/core/stories.ts
|
|
1935
2082
|
import fs15 from "fs";
|
|
@@ -1939,6 +2086,15 @@ function listFeatures(paths) {
|
|
|
1939
2086
|
if (!fs15.existsSync(paths.storiesDir)) return [];
|
|
1940
2087
|
return fs15.readdirSync(paths.storiesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
1941
2088
|
}
|
|
2089
|
+
function listPlanFeatureNames(paths) {
|
|
2090
|
+
const names = new Set(listFeatures(paths));
|
|
2091
|
+
if (fs15.existsSync(paths.plansDir)) {
|
|
2092
|
+
for (const e of fs15.readdirSync(paths.plansDir, { withFileTypes: true })) {
|
|
2093
|
+
if (e.isDirectory()) names.add(e.name);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
return [...names].sort();
|
|
2097
|
+
}
|
|
1942
2098
|
function listAllStories(paths) {
|
|
1943
2099
|
return listStories(paths);
|
|
1944
2100
|
}
|
|
@@ -1969,11 +2125,14 @@ function listStories(paths, opts = {}) {
|
|
|
1969
2125
|
function findPlanFor(plansDir, feature, storyId) {
|
|
1970
2126
|
const featureDir = path7.join(plansDir, feature);
|
|
1971
2127
|
if (!fs15.existsSync(featureDir)) return void 0;
|
|
2128
|
+
const candidates = [];
|
|
1972
2129
|
for (const entry of fs15.readdirSync(featureDir)) {
|
|
1973
2130
|
if (!STORY_FILE.test(entry)) continue;
|
|
1974
|
-
if (entry.includes(storyId))
|
|
2131
|
+
if (entry.includes(storyId)) candidates.push(entry);
|
|
1975
2132
|
}
|
|
1976
|
-
return void 0;
|
|
2133
|
+
if (candidates.length === 0) return void 0;
|
|
2134
|
+
const complete = candidates.find((e) => !e.endsWith(".partial.md"));
|
|
2135
|
+
return complete ?? candidates[0];
|
|
1977
2136
|
}
|
|
1978
2137
|
function findStoryByIntake(records, intakePath) {
|
|
1979
2138
|
const target = normalizeIntakePath(intakePath.trim());
|
|
@@ -1989,21 +2148,53 @@ function normalizeIntakePath(p) {
|
|
|
1989
2148
|
}
|
|
1990
2149
|
function readTitleHint(intakePath) {
|
|
1991
2150
|
try {
|
|
1992
|
-
const
|
|
1993
|
-
const
|
|
1994
|
-
|
|
2151
|
+
const raw = fs15.readFileSync(intakePath, "utf8");
|
|
2152
|
+
const head = raw.split("\n").slice(0, 30).join("\n");
|
|
2153
|
+
const hintLine = head.match(/>\s*\*\*Title hint \(from CLI\):\*\*\s*(.+?)\s*$/m);
|
|
2154
|
+
const fromHint = hintLine?.[1]?.trim();
|
|
2155
|
+
if (fromHint) return fromHint;
|
|
2156
|
+
const titleFence = raw.match(/(?:^|\n)## Title\b[\s\S]*?\n```(?:[\w-]*)?\s*\n([\s\S]*?)\n```/);
|
|
2157
|
+
const fromFence = titleFence?.[1]?.trim();
|
|
2158
|
+
if (fromFence) return fromFence;
|
|
2159
|
+
return void 0;
|
|
1995
2160
|
} catch {
|
|
1996
2161
|
return void 0;
|
|
1997
2162
|
}
|
|
1998
2163
|
}
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2164
|
+
var PLAN_GENERATED_BY = /^<!--\s*squad-kit:\s*generated by\s+([^\s,]+)/;
|
|
2165
|
+
var PLAN_ANY_SQUAD = /^<!--\s*squad-kit:\s*(.+?)\s*-->$/;
|
|
2166
|
+
var MAX_FREEFORM_META_LEN = 96;
|
|
2167
|
+
function parsePlanMetadataLine(line2) {
|
|
2168
|
+
const strict = line2.match(PLAN_GENERATED_BY);
|
|
2169
|
+
if (strict) {
|
|
2170
|
+
const generatedBy = strict[1];
|
|
2171
|
+
const [provider, model] = generatedBy.split("/");
|
|
2172
|
+
return { provider, model, generatedBy };
|
|
2173
|
+
}
|
|
2174
|
+
const any = line2.match(PLAN_ANY_SQUAD);
|
|
2175
|
+
if (!any) return null;
|
|
2176
|
+
const inner = any[1].trim();
|
|
2177
|
+
const flex = inner.match(/^generated\s+by\s+([^\s,]+)/i);
|
|
2178
|
+
if (flex) {
|
|
2179
|
+
const generatedBy = flex[1];
|
|
2005
2180
|
const [provider, model] = generatedBy.split("/");
|
|
2006
2181
|
return { provider, model, generatedBy };
|
|
2182
|
+
}
|
|
2183
|
+
const short = inner.length > MAX_FREEFORM_META_LEN ? `${inner.slice(0, MAX_FREEFORM_META_LEN - 1)}\u2026` : inner;
|
|
2184
|
+
return { generatedBy: short };
|
|
2185
|
+
}
|
|
2186
|
+
function readPlanMetadata(planFileAbsPath) {
|
|
2187
|
+
try {
|
|
2188
|
+
const raw = fs15.readFileSync(planFileAbsPath, "utf8").replace(/^\uFEFF/, "");
|
|
2189
|
+
const lines = raw.split(/\n/);
|
|
2190
|
+
const scan = Math.min(lines.length, 40);
|
|
2191
|
+
for (let i = 0; i < scan; i++) {
|
|
2192
|
+
const line2 = (lines[i] ?? "").replace(/\r$/, "").trim();
|
|
2193
|
+
if (!line2) continue;
|
|
2194
|
+
const parsed = parsePlanMetadataLine(line2);
|
|
2195
|
+
if (parsed) return parsed;
|
|
2196
|
+
}
|
|
2197
|
+
return {};
|
|
2007
2198
|
} catch {
|
|
2008
2199
|
return {};
|
|
2009
2200
|
}
|
|
@@ -2026,56 +2217,494 @@ function trackerIdForFilename(type, id) {
|
|
|
2026
2217
|
return id;
|
|
2027
2218
|
}
|
|
2028
2219
|
|
|
2029
|
-
// src/
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
const
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2220
|
+
// src/core/story-intake-helpers.ts
|
|
2221
|
+
import fs16 from "fs";
|
|
2222
|
+
import path8 from "path";
|
|
2223
|
+
function buildIntakeTemplateVars(args) {
|
|
2224
|
+
const e = escapeTemplateValue;
|
|
2225
|
+
const { config: config2, featureSlug, storyFolderName, trackerId, title, fetchedIssue } = args;
|
|
2226
|
+
return {
|
|
2227
|
+
featureSlug,
|
|
2228
|
+
storyId: storyFolderName,
|
|
2229
|
+
trackerType: config2.tracker.type,
|
|
2230
|
+
trackerWorkItemId: e((fetchedIssue?.id ?? trackerId ?? "").trim()),
|
|
2231
|
+
trackerWorkItemType: e((fetchedIssue?.type ?? "").trim()),
|
|
2232
|
+
trackerStatus: e((fetchedIssue?.status ?? "").trim()),
|
|
2233
|
+
trackerAssignee: e((fetchedIssue?.assignee ?? "").trim()),
|
|
2234
|
+
trackerLabels: e((fetchedIssue?.labels ?? []).join(", ").trim()),
|
|
2235
|
+
trackerTitle: e((title ?? "").trim()),
|
|
2236
|
+
trackerDescription: e((fetchedIssue?.description ?? "").trim()),
|
|
2237
|
+
trackerAcceptanceCriteria: e((fetchedIssue?.acceptanceCriteria ?? "").trim()),
|
|
2238
|
+
projectRoots: (config2.project.projectRoots ?? ["."]).join(", "),
|
|
2239
|
+
primaryLanguage: config2.project.primaryLanguage ?? ""
|
|
2240
|
+
};
|
|
2037
2241
|
}
|
|
2038
|
-
|
|
2039
|
-
const
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2242
|
+
function buildSourcePreamble(issue, downloads, trackerType) {
|
|
2243
|
+
const header = `> **Fetched from ${trackerType}:** [${issue.id}](${issue.url})
|
|
2244
|
+
> *Fetched ${issue.fetchedAt}. Edit the sections below as needed; the planner reads this file verbatim.*
|
|
2245
|
+
|
|
2246
|
+
`;
|
|
2247
|
+
const metaLines = [];
|
|
2248
|
+
metaLines.push(`**Title:** ${issue.title}`);
|
|
2249
|
+
if (issue.type) metaLines.push(`**Type:** ${issue.type}`);
|
|
2250
|
+
if (issue.status) metaLines.push(`**Status:** ${issue.status}`);
|
|
2251
|
+
if (issue.assignee) metaLines.push(`**Assignee:** ${issue.assignee}`);
|
|
2252
|
+
if (issue.labels.length > 0) metaLines.push(`**Labels:** ${issue.labels.join(", ")}`);
|
|
2253
|
+
const description = issue.description.trim().length > 0 ? issue.description.trim() : "*(tracker returned an empty description)*";
|
|
2254
|
+
const attachmentsTable = renderAttachmentsTable(downloads);
|
|
2255
|
+
return [
|
|
2256
|
+
header,
|
|
2257
|
+
"## Source \u2014 work item (from tracker)",
|
|
2258
|
+
"",
|
|
2259
|
+
metaLines.join(" \n"),
|
|
2260
|
+
"",
|
|
2261
|
+
"### Description",
|
|
2262
|
+
"",
|
|
2263
|
+
description,
|
|
2264
|
+
"",
|
|
2265
|
+
attachmentsTable,
|
|
2266
|
+
"",
|
|
2267
|
+
"---",
|
|
2268
|
+
""
|
|
2269
|
+
].join("\n");
|
|
2270
|
+
}
|
|
2271
|
+
function renderAttachmentsTable(downloads) {
|
|
2272
|
+
if (downloads.length === 0) return "### Attachments\n\nNone.";
|
|
2273
|
+
const rows = downloads.map((d) => {
|
|
2274
|
+
const sizeNum = (d.bytesWritten || d.source.size) / 1024;
|
|
2275
|
+
const size = `${sizeNum | 0} KB`;
|
|
2276
|
+
switch (d.outcome) {
|
|
2277
|
+
case "written":
|
|
2278
|
+
return `| \`attachments/${path8.basename(d.absolutePath)}\` | ${size} | downloaded |`;
|
|
2279
|
+
case "skipped-oversize":
|
|
2280
|
+
return `| ${d.source.filename} | ${size} | **skipped** \u2014 ${d.skipReason} |`;
|
|
2281
|
+
case "skipped-error":
|
|
2282
|
+
return `| ${d.source.filename} | \u2014 | **skipped** \u2014 ${d.skipReason} |`;
|
|
2046
2283
|
}
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2284
|
+
});
|
|
2285
|
+
return ["### Attachments", "", "| File | Size | Status |", "| ---- | ---- | ------ |", ...rows].join("\n");
|
|
2286
|
+
}
|
|
2287
|
+
function buildFallbackPreamble(reason, hint) {
|
|
2288
|
+
const hintLine = hint ? `
|
|
2289
|
+
> Hint: ${hint}` : "";
|
|
2290
|
+
return `> **Tracker auto-fetch skipped.**
|
|
2291
|
+
> ${reason}${hintLine}
|
|
2292
|
+
|
|
2293
|
+
---
|
|
2294
|
+
|
|
2295
|
+
`;
|
|
2296
|
+
}
|
|
2297
|
+
function buildSkippedTrackerPreamble(trackerType) {
|
|
2298
|
+
return [
|
|
2299
|
+
"> **Source:** manual entry (tracker skipped via `--no-tracker`).",
|
|
2300
|
+
`> Active tracker for this workspace: \`${trackerType}\` \u2014 this story is not linked. `,
|
|
2301
|
+
"> Run `squad tracker link <story-path> <tracker-id>` later if you want to attach one.",
|
|
2302
|
+
"",
|
|
2303
|
+
""
|
|
2304
|
+
].join("\n");
|
|
2305
|
+
}
|
|
2306
|
+
function ensureFeatureOverview(featurePlanDir, slug) {
|
|
2307
|
+
fs16.mkdirSync(featurePlanDir, { recursive: true });
|
|
2308
|
+
const overviewFile = path8.join(featurePlanDir, "00-overview.md");
|
|
2309
|
+
if (fs16.existsSync(overviewFile)) return;
|
|
2310
|
+
const template = readFile(path8.join(templatesDir(), "overview.md"));
|
|
2311
|
+
writeFileSafe(overviewFile, render(template, { featureSlug: slug }), false);
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// src/core/story-mutations.ts
|
|
2315
|
+
import fs19 from "fs";
|
|
2316
|
+
import path11 from "path";
|
|
2317
|
+
|
|
2318
|
+
// src/commands/rm/shared.ts
|
|
2319
|
+
import fs18 from "fs";
|
|
2320
|
+
import path10 from "path";
|
|
2321
|
+
import { select as select4 } from "@inquirer/prompts";
|
|
2322
|
+
|
|
2323
|
+
// src/core/sequence.ts
|
|
2324
|
+
import fs17 from "fs";
|
|
2325
|
+
import path9 from "path";
|
|
2326
|
+
var STORY_FILE2 = /^(\d{2,})-story-.+\.md$/;
|
|
2327
|
+
function scanPlans(plansDir) {
|
|
2328
|
+
const used = [];
|
|
2329
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2330
|
+
if (fs17.existsSync(plansDir)) {
|
|
2331
|
+
for (const feature of fs17.readdirSync(plansDir, { withFileTypes: true })) {
|
|
2332
|
+
if (!feature.isDirectory()) continue;
|
|
2333
|
+
const featureDir = path9.join(plansDir, feature.name);
|
|
2334
|
+
for (const entry of fs17.readdirSync(featureDir)) {
|
|
2335
|
+
const match = entry.match(STORY_FILE2);
|
|
2336
|
+
if (!match) continue;
|
|
2337
|
+
const n = parseInt(match[1], 10);
|
|
2338
|
+
used.push(n);
|
|
2339
|
+
seen.set(n, (seen.get(n) ?? 0) + 1);
|
|
2060
2340
|
}
|
|
2061
2341
|
}
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2342
|
+
}
|
|
2343
|
+
const duplicates = [...seen.entries()].filter(([, c]) => c > 1).map(([n]) => n);
|
|
2344
|
+
const maxNumber = used.length ? Math.max(...used) : 0;
|
|
2345
|
+
return { usedNumbers: used.sort((a, b) => a - b), maxNumber, duplicates, nextGlobal: maxNumber + 1 };
|
|
2346
|
+
}
|
|
2347
|
+
function formatSequence(n) {
|
|
2348
|
+
return String(n).padStart(2, "0");
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// src/commands/rm/shared.ts
|
|
2352
|
+
var STORY_PLAN = /^(\d{2,})-story-.+\.md$/;
|
|
2353
|
+
function canPromptForRm(opts) {
|
|
2354
|
+
if (opts.yes) return false;
|
|
2355
|
+
return isInteractive() && Boolean(process.stdin.isTTY);
|
|
2356
|
+
}
|
|
2357
|
+
function removeOverviewRow(featurePlanDir, storyId) {
|
|
2358
|
+
const overview = path10.join(featurePlanDir, "00-overview.md");
|
|
2359
|
+
if (!fs18.existsSync(overview)) return;
|
|
2360
|
+
const src = fs18.readFileSync(overview, "utf8");
|
|
2361
|
+
const lines = src.split("\n");
|
|
2362
|
+
const filtered = lines.filter((line2) => {
|
|
2363
|
+
if (!line2.startsWith("|")) return true;
|
|
2364
|
+
if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) return true;
|
|
2365
|
+
if (/^\|\s*-+/.test(line2)) return true;
|
|
2366
|
+
return !line2.includes(storyId);
|
|
2367
|
+
});
|
|
2368
|
+
fs18.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
2369
|
+
}
|
|
2370
|
+
function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
|
|
2371
|
+
const overview = path10.join(featurePlanDir, "00-overview.md");
|
|
2372
|
+
if (!fs18.existsSync(overview)) return;
|
|
2373
|
+
const src = fs18.readFileSync(overview, "utf8");
|
|
2374
|
+
const token = `\`${planBasename}\``;
|
|
2375
|
+
const lines = src.split("\n");
|
|
2376
|
+
const filtered = lines.filter((line2) => {
|
|
2377
|
+
if (!line2.startsWith("|")) return true;
|
|
2378
|
+
if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) return true;
|
|
2379
|
+
if (/^\|\s*-+/.test(line2)) return true;
|
|
2380
|
+
return !line2.includes(token);
|
|
2381
|
+
});
|
|
2382
|
+
fs18.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
2383
|
+
}
|
|
2384
|
+
function trashOrDelete(absPaths, trashRoot, useTrash) {
|
|
2385
|
+
if (!useTrash) {
|
|
2386
|
+
for (const p of absPaths) fs18.rmSync(p, { recursive: true, force: true });
|
|
2387
|
+
return { removed: absPaths };
|
|
2388
|
+
}
|
|
2389
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2390
|
+
const bucket = path10.join(trashRoot, ts);
|
|
2391
|
+
fs18.mkdirSync(bucket, { recursive: true });
|
|
2392
|
+
for (const src of absPaths) {
|
|
2393
|
+
const rel = path10.basename(src);
|
|
2394
|
+
let dst = path10.join(bucket, rel);
|
|
2395
|
+
let n = 0;
|
|
2396
|
+
while (fs18.existsSync(dst)) {
|
|
2397
|
+
n += 1;
|
|
2398
|
+
dst = path10.join(bucket, `${n}_${rel}`);
|
|
2067
2399
|
}
|
|
2400
|
+
fs18.renameSync(src, dst);
|
|
2068
2401
|
}
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2402
|
+
return { removed: absPaths, trashed: bucket };
|
|
2403
|
+
}
|
|
2404
|
+
function renderPreview(kind, targets, opts) {
|
|
2405
|
+
divider(`squad rm ${kind}`);
|
|
2406
|
+
step(opts.dryRun ? "Dry run \u2014 nothing will be changed." : "About to remove:");
|
|
2407
|
+
for (const t of targets) {
|
|
2408
|
+
kv(t.kind, t.label, 6);
|
|
2074
2409
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2410
|
+
if (opts.trash) {
|
|
2411
|
+
info(`Items will move to ${path10.relative(process.cwd(), opts.trashRoot)}/<timestamp>/`);
|
|
2412
|
+
} else if (!opts.dryRun) {
|
|
2413
|
+
warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
function listAllPlanListEntries(paths, featureFilter) {
|
|
2417
|
+
if (!fs18.existsSync(paths.plansDir)) return [];
|
|
2418
|
+
const out = [];
|
|
2419
|
+
for (const ent of fs18.readdirSync(paths.plansDir, { withFileTypes: true })) {
|
|
2420
|
+
if (!ent.isDirectory()) continue;
|
|
2421
|
+
if (featureFilter && ent.name !== featureFilter) continue;
|
|
2422
|
+
const featureDir = path10.join(paths.plansDir, ent.name);
|
|
2423
|
+
for (const name of fs18.readdirSync(featureDir)) {
|
|
2424
|
+
if (!STORY_PLAN.test(name)) continue;
|
|
2425
|
+
out.push({ feature: ent.name, file: name, abs: path10.join(featureDir, name) });
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
return out.sort((a, b) => (a.feature + a.file).localeCompare(b.feature + b.file));
|
|
2429
|
+
}
|
|
2430
|
+
async function pickStoryInteractive(paths, featureFilter) {
|
|
2431
|
+
const stories = listAllStories(paths).filter((s) => !featureFilter || s.feature === featureFilter);
|
|
2432
|
+
if (stories.length === 0) {
|
|
2433
|
+
throw new Error("No stories to pick. Run `squad new-story` first, or `squad list` to verify paths.");
|
|
2434
|
+
}
|
|
2435
|
+
if (stories.length === 1) return stories[0];
|
|
2436
|
+
const choice = await select4({
|
|
2437
|
+
message: "Story to remove:",
|
|
2438
|
+
choices: stories.map((s) => ({
|
|
2439
|
+
name: `${s.feature}/${s.id} \xB7 ${s.titleHint ?? s.id}`,
|
|
2440
|
+
value: s
|
|
2441
|
+
}))
|
|
2442
|
+
});
|
|
2443
|
+
return choice;
|
|
2444
|
+
}
|
|
2445
|
+
async function pickPlanInteractive(paths, featureFilter) {
|
|
2446
|
+
const plans = listAllPlanListEntries(paths, featureFilter);
|
|
2447
|
+
if (plans.length === 0) {
|
|
2448
|
+
throw new Error("No plan files to pick. Run `squad new-plan` first, or `squad list` to see plans.");
|
|
2449
|
+
}
|
|
2450
|
+
if (plans.length === 1) return plans[0];
|
|
2451
|
+
return select4({
|
|
2452
|
+
message: "Plan file to remove:",
|
|
2453
|
+
choices: plans.map((p) => ({
|
|
2454
|
+
name: `${p.feature}/${p.file}`,
|
|
2455
|
+
value: p
|
|
2456
|
+
}))
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
async function pickFeatureInteractive(paths) {
|
|
2460
|
+
const feats = listFeatures(paths);
|
|
2461
|
+
if (feats.length === 0) {
|
|
2462
|
+
throw new Error("No features to pick. Run `squad new-story` first, or `squad list` to see features.");
|
|
2463
|
+
}
|
|
2464
|
+
if (feats.length === 1) return feats[0];
|
|
2465
|
+
return select4({
|
|
2466
|
+
message: "Feature to remove:",
|
|
2467
|
+
choices: feats.map((f) => ({ name: f, value: f }))
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
function resolveStoryFromArg(arg, stories, root) {
|
|
2471
|
+
const t = arg.trim();
|
|
2472
|
+
for (const s of stories) {
|
|
2473
|
+
if (s.id === t) return s;
|
|
2474
|
+
if (`${s.feature}/${s.id}` === t) return s;
|
|
2475
|
+
}
|
|
2476
|
+
const abs = path10.isAbsolute(t) ? t : path10.resolve(root, t);
|
|
2477
|
+
const withIntake = t.endsWith("intake.md") ? abs : path10.join(abs, "intake.md");
|
|
2478
|
+
return findStoryByIntake(stories, withIntake) ?? findStoryByIntake(stories, abs);
|
|
2479
|
+
}
|
|
2480
|
+
function isSequenceToken(s) {
|
|
2481
|
+
return /^\d{1,4}$/.test(s) && s.length > 0;
|
|
2482
|
+
}
|
|
2483
|
+
function tryParsePlanFilePath(absPath, paths, featureFilter) {
|
|
2484
|
+
if (!fs18.existsSync(absPath) || !STORY_PLAN.test(path10.basename(absPath))) return void 0;
|
|
2485
|
+
const planRoot = fs18.realpathSync(path10.resolve(paths.plansDir));
|
|
2486
|
+
const fileReal = fs18.realpathSync(path10.resolve(absPath));
|
|
2487
|
+
const rel = path10.relative(planRoot, fileReal);
|
|
2488
|
+
if (rel.startsWith("..") || path10.isAbsolute(rel)) return void 0;
|
|
2489
|
+
const segs = rel.split(path10.sep);
|
|
2490
|
+
if (segs.length < 2) return void 0;
|
|
2491
|
+
const feature = segs[0];
|
|
2492
|
+
const file = segs.slice(1).join(path10.sep);
|
|
2493
|
+
if (featureFilter && feature !== featureFilter) return void 0;
|
|
2494
|
+
return { feature, file, abs: path10.resolve(absPath) };
|
|
2495
|
+
}
|
|
2496
|
+
function resolvePlanFromArg(arg, paths, featureFilter) {
|
|
2497
|
+
const t = arg.trim();
|
|
2498
|
+
if (!t) return void 0;
|
|
2499
|
+
const asPath = path10.isAbsolute(t) ? t : path10.resolve(process.cwd(), t);
|
|
2500
|
+
const a = tryParsePlanFilePath(asPath, paths, featureFilter);
|
|
2501
|
+
if (a) return a;
|
|
2502
|
+
if (!path10.isAbsolute(t)) {
|
|
2503
|
+
const under = tryParsePlanFilePath(path10.join(paths.plansDir, t), paths, featureFilter);
|
|
2504
|
+
if (under) return under;
|
|
2505
|
+
}
|
|
2506
|
+
if (isSequenceToken(t)) {
|
|
2507
|
+
const nn = formatSequence(parseInt(t, 10));
|
|
2508
|
+
const prefix = `${nn}-`;
|
|
2509
|
+
const cands = [];
|
|
2510
|
+
for (const p of listAllPlanListEntries(paths, featureFilter)) {
|
|
2511
|
+
if (p.file.startsWith(prefix)) cands.push(p);
|
|
2512
|
+
}
|
|
2513
|
+
if (cands.length === 1) return cands[0];
|
|
2514
|
+
if (cands.length > 1) {
|
|
2515
|
+
throw new Error(
|
|
2516
|
+
`Multiple plans match sequence "${t}". Run \`squad rm plan\` in a TTY, or add \`--feature <slug>\` to disambiguate.`
|
|
2517
|
+
);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
return void 0;
|
|
2521
|
+
}
|
|
2522
|
+
function countOverviewDataRows(overviewContent) {
|
|
2523
|
+
let n = 0;
|
|
2524
|
+
for (const line2 of overviewContent.split("\n")) {
|
|
2525
|
+
if (!line2.startsWith("|")) continue;
|
|
2526
|
+
if (/^\|\s*-+/.test(line2)) continue;
|
|
2527
|
+
if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) continue;
|
|
2528
|
+
n++;
|
|
2529
|
+
}
|
|
2530
|
+
return n;
|
|
2531
|
+
}
|
|
2532
|
+
function featureStoriesDirEmpty(paths, feature) {
|
|
2533
|
+
const d = path10.join(paths.storiesDir, feature);
|
|
2534
|
+
if (!fs18.existsSync(d)) return true;
|
|
2535
|
+
return fs18.readdirSync(d).length === 0;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
// src/core/story-mutations.ts
|
|
2539
|
+
function writeNewStoryToDisk(input5) {
|
|
2540
|
+
const { paths, config: config2, featureSlug, storyFolderName, id, title, noTracker, sourceBlock, fetchedIssue } = input5;
|
|
2541
|
+
const storyDir = path11.join(paths.storiesDir, featureSlug, storyFolderName);
|
|
2542
|
+
const intakeFile = path11.join(storyDir, "intake.md");
|
|
2543
|
+
const attachmentsDir = path11.join(storyDir, "attachments");
|
|
2544
|
+
if (fs19.existsSync(intakeFile)) {
|
|
2545
|
+
const rel = path11.relative(paths.root, intakeFile);
|
|
2546
|
+
throw new Error(
|
|
2547
|
+
`Intake already exists: ${intakeFile}. Run \`squad new-plan ${rel}\` to plan it, or \`squad rm story\` to remove the story first.`
|
|
2548
|
+
);
|
|
2549
|
+
}
|
|
2550
|
+
fs19.mkdirSync(attachmentsDir, { recursive: true });
|
|
2551
|
+
const template = readBundledPrompt("intake.md");
|
|
2552
|
+
const rendered = render(
|
|
2553
|
+
template,
|
|
2554
|
+
buildIntakeTemplateVars({
|
|
2555
|
+
config: config2,
|
|
2556
|
+
featureSlug,
|
|
2557
|
+
storyFolderName,
|
|
2558
|
+
trackerId: id,
|
|
2559
|
+
title,
|
|
2560
|
+
fetchedIssue
|
|
2561
|
+
})
|
|
2562
|
+
);
|
|
2563
|
+
const skippedPreamble = noTracker && config2.tracker.type !== "none" && !sourceBlock ? buildSkippedTrackerPreamble(config2.tracker.type) : "";
|
|
2564
|
+
writeFileSafe(intakeFile, skippedPreamble + sourceBlock + rendered, false);
|
|
2565
|
+
ensureFeatureOverview(path11.join(paths.plansDir, featureSlug), featureSlug);
|
|
2566
|
+
return { storyDir, intakePath: intakeFile, feature: featureSlug, id: storyFolderName };
|
|
2567
|
+
}
|
|
2568
|
+
function createStoryRecord(input5) {
|
|
2569
|
+
const feature = slugify(input5.feature);
|
|
2570
|
+
if (!feature) {
|
|
2571
|
+
throw new Error(`Invalid feature slug: "${input5.feature}". Use kebab-case (letters, digits, hyphens).`);
|
|
2572
|
+
}
|
|
2573
|
+
const rawId = input5.trackerId?.trim() || void 0;
|
|
2574
|
+
const noTracker = input5.config.tracker.type !== "none" && !rawId;
|
|
2575
|
+
let id = rawId;
|
|
2576
|
+
if (noTracker) id = void 0;
|
|
2577
|
+
if (id && !validateTrackerId(input5.config.tracker.type, id)) {
|
|
2578
|
+
throw new Error(
|
|
2579
|
+
`Invalid tracker id "${id}" for tracker type "${input5.config.tracker.type}". Check the expected format, or \`squad config set tracker\` if the tracker type is wrong.`
|
|
2580
|
+
);
|
|
2581
|
+
}
|
|
2582
|
+
const title = input5.title?.trim() || void 0;
|
|
2583
|
+
const storyFolderName = id ? trackerIdForFilename(input5.config.tracker.type, id) : slugify(title ?? "");
|
|
2584
|
+
if (!storyFolderName) {
|
|
2585
|
+
throw new Error(
|
|
2586
|
+
'Could not derive a story folder name. Pass a "title" when not linking a tracker id, or provide a "trackerId" to name the folder after it.'
|
|
2587
|
+
);
|
|
2588
|
+
}
|
|
2589
|
+
return writeNewStoryToDisk({
|
|
2590
|
+
paths: input5.paths,
|
|
2591
|
+
config: input5.config,
|
|
2592
|
+
featureSlug: feature,
|
|
2593
|
+
storyFolderName,
|
|
2594
|
+
id,
|
|
2595
|
+
title,
|
|
2596
|
+
noTracker,
|
|
2597
|
+
sourceBlock: "",
|
|
2598
|
+
fetchedIssue: void 0
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
async function createStoryRecordFromIssue(input5) {
|
|
2602
|
+
const feature = slugify(input5.feature);
|
|
2603
|
+
if (!feature) {
|
|
2604
|
+
throw new Error(`Invalid feature slug: "${input5.feature}". Use kebab-case (letters, digits, hyphens).`);
|
|
2605
|
+
}
|
|
2606
|
+
const id = input5.issue.id.trim();
|
|
2607
|
+
if (!validateTrackerId(input5.config.tracker.type, id)) {
|
|
2608
|
+
throw new Error(
|
|
2609
|
+
`Invalid tracker id "${id}" for tracker type "${input5.config.tracker.type}". Check the expected format, or \`squad config set tracker\` if the tracker type is wrong.`
|
|
2610
|
+
);
|
|
2611
|
+
}
|
|
2612
|
+
const title = input5.issue.title;
|
|
2613
|
+
const storyFolderName = trackerIdForFilename(input5.config.tracker.type, id);
|
|
2614
|
+
const storyDir = path11.join(input5.paths.storiesDir, feature, storyFolderName);
|
|
2615
|
+
const intakeFile = path11.join(storyDir, "intake.md");
|
|
2616
|
+
const attachmentsDir = path11.join(storyDir, "attachments");
|
|
2617
|
+
if (fs19.existsSync(intakeFile)) {
|
|
2618
|
+
const rel = path11.relative(input5.paths.root, intakeFile);
|
|
2619
|
+
throw new Error(
|
|
2620
|
+
`Intake already exists: ${intakeFile}. Run \`squad new-plan ${rel}\` to plan it, or \`squad rm story\` to remove the story first.`
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
fs19.mkdirSync(attachmentsDir, { recursive: true });
|
|
2624
|
+
let downloads = [];
|
|
2625
|
+
if (input5.withAttachments && input5.issue.attachments.length > 0) {
|
|
2626
|
+
downloads = await input5.client.downloadAttachments(input5.issue.attachments, attachmentsDir);
|
|
2627
|
+
} else if (!input5.withAttachments && input5.issue.attachments.length > 0) {
|
|
2628
|
+
downloads = input5.issue.attachments.map((source) => ({
|
|
2629
|
+
source,
|
|
2630
|
+
outcome: "skipped-error",
|
|
2631
|
+
bytesWritten: 0,
|
|
2632
|
+
skipReason: "not downloaded"
|
|
2633
|
+
}));
|
|
2634
|
+
}
|
|
2635
|
+
const sourceBlock = buildSourcePreamble(input5.issue, downloads, input5.config.tracker.type);
|
|
2636
|
+
return writeNewStoryToDisk({
|
|
2637
|
+
paths: input5.paths,
|
|
2638
|
+
config: input5.config,
|
|
2639
|
+
featureSlug: feature,
|
|
2640
|
+
storyFolderName,
|
|
2641
|
+
id,
|
|
2642
|
+
title,
|
|
2643
|
+
noTracker: false,
|
|
2644
|
+
sourceBlock,
|
|
2645
|
+
fetchedIssue: input5.issue
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
function deleteStoryRecord(input5) {
|
|
2649
|
+
const targets = [input5.story.storyDir];
|
|
2650
|
+
if (input5.story.planFile) {
|
|
2651
|
+
targets.push(path11.join(input5.paths.plansDir, input5.story.feature, input5.story.planFile));
|
|
2652
|
+
}
|
|
2653
|
+
const result = trashOrDelete(targets, input5.paths.trashDir, input5.trash);
|
|
2654
|
+
removeOverviewRow(path11.join(input5.paths.plansDir, input5.story.feature), input5.story.id);
|
|
2655
|
+
return result;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// src/commands/new-story.ts
|
|
2659
|
+
function validateFeatureSlugForPrompt(v) {
|
|
2660
|
+
const trimmed = v.trim();
|
|
2661
|
+
if (!trimmed) return "Required.";
|
|
2662
|
+
const s = slugify(trimmed);
|
|
2663
|
+
if (!s) return "Required.";
|
|
2664
|
+
if (s !== trimmed) return "Use lowercase kebab-case (letters, digits, hyphens).";
|
|
2665
|
+
return true;
|
|
2666
|
+
}
|
|
2667
|
+
async function runNewStory(featureSlug, opts) {
|
|
2668
|
+
const interactive = !opts.yes && Boolean(process.stdin.isTTY) && process.env.CI !== "true";
|
|
2669
|
+
let featureSlugInput = featureSlug;
|
|
2670
|
+
if (!featureSlugInput) {
|
|
2671
|
+
if (!interactive) {
|
|
2672
|
+
throw new Error(
|
|
2673
|
+
"feature-slug is required. Run `squad new-story` in a TTY to pick interactively, or pass a slug like `squad new-story <feature-slug>`, or add `--yes` to fail fast in scripts."
|
|
2674
|
+
);
|
|
2675
|
+
}
|
|
2676
|
+
const rootForPrompt = requireSquadRoot();
|
|
2677
|
+
const pathsForPrompt = buildPaths(rootForPrompt);
|
|
2678
|
+
const existing = listFeatures(pathsForPrompt);
|
|
2679
|
+
if (existing.length > 0) {
|
|
2680
|
+
const choice = await select5({
|
|
2681
|
+
message: "Feature:",
|
|
2682
|
+
choices: [
|
|
2683
|
+
...existing.map((f) => ({ name: f, value: f })),
|
|
2684
|
+
{ name: "\u2500\u2500\u2500 new feature \u2500\u2500\u2500", value: "__NEW__" }
|
|
2685
|
+
]
|
|
2686
|
+
});
|
|
2687
|
+
if (choice !== "__NEW__") {
|
|
2688
|
+
featureSlugInput = choice;
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
if (!featureSlugInput) {
|
|
2692
|
+
featureSlugInput = (await input3({
|
|
2693
|
+
message: "New feature slug (kebab-case):",
|
|
2694
|
+
validate: validateFeatureSlugForPrompt
|
|
2695
|
+
})).trim();
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
const slug = slugify(featureSlugInput);
|
|
2699
|
+
if (!slug) {
|
|
2700
|
+
throw new Error(
|
|
2701
|
+
`Invalid feature slug: "${featureSlugInput}". Run \`squad new-story\` and use kebab-case (letters, digits, hyphens).`
|
|
2702
|
+
);
|
|
2703
|
+
}
|
|
2704
|
+
const root = requireSquadRoot();
|
|
2705
|
+
const paths = buildPaths(root);
|
|
2706
|
+
const config2 = loadConfig(paths.configFile);
|
|
2707
|
+
let noTracker = opts.tracker === false && config2.tracker.type !== "none";
|
|
2079
2708
|
let id = opts.id;
|
|
2080
2709
|
if (noTracker) id = void 0;
|
|
2081
2710
|
let trackerNeedsId = !noTracker && config2.tracker.type !== "none" && config2.naming.includeTrackerId;
|
|
@@ -2086,7 +2715,7 @@ async function runNewStory(featureSlug, opts) {
|
|
|
2086
2715
|
`Tracker "${config2.tracker.type}" is configured and naming.includeTrackerId is true. Run \`squad new-story <feature-slug> --id <tracker-id>\` to link now, or \`squad new-story <feature-slug> --no-tracker\` to skip the tracker for this story only.`
|
|
2087
2716
|
);
|
|
2088
2717
|
}
|
|
2089
|
-
const mode = await
|
|
2718
|
+
const mode = await select5({
|
|
2090
2719
|
message: `This workspace requires a ${config2.tracker.type} id for new stories. How do you want to proceed?`,
|
|
2091
2720
|
choices: [
|
|
2092
2721
|
{ name: `Enter a ${config2.tracker.type} id now`, value: "enter" },
|
|
@@ -2132,21 +2761,23 @@ async function runNewStory(featureSlug, opts) {
|
|
|
2132
2761
|
'Could not derive a story folder name. Pass `--title "<hint>"` when using `--no-tracker`, or `--id <tracker-id>` to name the folder after the tracker id.'
|
|
2133
2762
|
);
|
|
2134
2763
|
}
|
|
2135
|
-
const storyDir =
|
|
2136
|
-
const intakeFile =
|
|
2137
|
-
const attachmentsDir =
|
|
2138
|
-
if (
|
|
2764
|
+
const storyDir = path12.join(paths.storiesDir, slug, storyFolderName);
|
|
2765
|
+
const intakeFile = path12.join(storyDir, "intake.md");
|
|
2766
|
+
const attachmentsDir = path12.join(storyDir, "attachments");
|
|
2767
|
+
if (fs20.existsSync(intakeFile)) {
|
|
2139
2768
|
throw new Error(
|
|
2140
|
-
`Intake already exists: ${intakeFile}. Run \`squad new-plan ${
|
|
2769
|
+
`Intake already exists: ${intakeFile}. Run \`squad new-plan ${path12.relative(root, intakeFile)}\` to plan it, or \`squad rm story\` to remove the story first.`
|
|
2141
2770
|
);
|
|
2142
2771
|
}
|
|
2143
|
-
|
|
2772
|
+
fs20.mkdirSync(attachmentsDir, { recursive: true });
|
|
2144
2773
|
let sourceBlock = "";
|
|
2145
2774
|
let fetchedSummary;
|
|
2775
|
+
let fetchedIssue;
|
|
2146
2776
|
const shouldFetch = !noTracker && opts.fetch !== false && Boolean(id) && config2.tracker.type !== "none";
|
|
2147
2777
|
if (shouldFetch) {
|
|
2148
2778
|
const outcome = await tryFetchIssue(config2, id, opts, paths, attachmentsDir);
|
|
2149
2779
|
if (outcome.outcome === "ok") {
|
|
2780
|
+
fetchedIssue = outcome.issue;
|
|
2150
2781
|
sourceBlock = buildSourcePreamble(outcome.issue, outcome.downloads, config2.tracker.type);
|
|
2151
2782
|
if (!title) title = outcome.issue.title;
|
|
2152
2783
|
const written = outcome.downloads.filter((d) => d.outcome === "written").length;
|
|
@@ -2157,28 +2788,25 @@ async function runNewStory(featureSlug, opts) {
|
|
|
2157
2788
|
sourceBlock = buildFallbackPreamble(outcome.reason, outcome.hint);
|
|
2158
2789
|
}
|
|
2159
2790
|
}
|
|
2160
|
-
|
|
2161
|
-
|
|
2791
|
+
writeNewStoryToDisk({
|
|
2792
|
+
paths,
|
|
2793
|
+
config: config2,
|
|
2162
2794
|
featureSlug: slug,
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2795
|
+
storyFolderName,
|
|
2796
|
+
id,
|
|
2797
|
+
title,
|
|
2798
|
+
noTracker,
|
|
2799
|
+
sourceBlock,
|
|
2800
|
+
fetchedIssue
|
|
2167
2801
|
});
|
|
2168
|
-
const
|
|
2169
|
-
const
|
|
2170
|
-
|
|
2171
|
-
` : "";
|
|
2172
|
-
writeFileSafe(intakeFile, skippedPreamble + sourceBlock + titleHint + rendered, false);
|
|
2173
|
-
ensureFeatureOverview(path8.join(paths.plansDir, slug), slug);
|
|
2174
|
-
const relativePath = path8.relative(root, intakeFile);
|
|
2175
|
-
const relAttachDir = path8.relative(root, attachmentsDir);
|
|
2802
|
+
const relativePath = path12.relative(root, intakeFile);
|
|
2803
|
+
const relAttachDir = path12.relative(root, attachmentsDir);
|
|
2176
2804
|
success("Created intake");
|
|
2177
2805
|
if (noTracker && config2.tracker.type !== "none") {
|
|
2178
2806
|
warning("Created without a tracker link. Use `squad tracker link` later to attach one.");
|
|
2179
2807
|
}
|
|
2180
2808
|
kv("intake", relativePath, 11);
|
|
2181
|
-
kv("attachments", relAttachDir +
|
|
2809
|
+
kv("attachments", relAttachDir + path12.sep, 11);
|
|
2182
2810
|
if (fetchedSummary && fetchedSummary.written > 0) {
|
|
2183
2811
|
kv("fetched", `${fetchedSummary.id} \xB7 ${fetchedSummary.written} attachments saved`, 11);
|
|
2184
2812
|
}
|
|
@@ -2187,7 +2815,7 @@ async function runNewStory(featureSlug, opts) {
|
|
|
2187
2815
|
info(`squad new-plan ${relativePath}`);
|
|
2188
2816
|
}
|
|
2189
2817
|
async function tryFetchIssue(config2, id, opts, paths, attachmentsDir) {
|
|
2190
|
-
const secretsFromFile =
|
|
2818
|
+
const secretsFromFile = fs20.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
2191
2819
|
const secrets = overlayTrackerEnv(secretsFromFile);
|
|
2192
2820
|
const resolution = clientFor(config2, secrets);
|
|
2193
2821
|
if (resolution.error) {
|
|
@@ -2242,82 +2870,11 @@ function explainTrackerErrorHint(err) {
|
|
|
2242
2870
|
function truncate(s, n) {
|
|
2243
2871
|
return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
|
|
2244
2872
|
}
|
|
2245
|
-
function buildSourcePreamble(issue, downloads, trackerType) {
|
|
2246
|
-
const header = `> **Fetched from ${trackerType}:** [${issue.id}](${issue.url})
|
|
2247
|
-
> *Fetched ${issue.fetchedAt}. Edit the sections below as needed; the planner reads this file verbatim.*
|
|
2248
|
-
|
|
2249
|
-
`;
|
|
2250
|
-
const metaLines = [];
|
|
2251
|
-
metaLines.push(`**Title:** ${issue.title}`);
|
|
2252
|
-
if (issue.type) metaLines.push(`**Type:** ${issue.type}`);
|
|
2253
|
-
if (issue.status) metaLines.push(`**Status:** ${issue.status}`);
|
|
2254
|
-
if (issue.assignee) metaLines.push(`**Assignee:** ${issue.assignee}`);
|
|
2255
|
-
if (issue.labels.length > 0) metaLines.push(`**Labels:** ${issue.labels.join(", ")}`);
|
|
2256
|
-
const description = issue.description.trim().length > 0 ? issue.description.trim() : "*(tracker returned an empty description)*";
|
|
2257
|
-
const attachmentsTable = renderAttachmentsTable(downloads);
|
|
2258
|
-
return [
|
|
2259
|
-
header,
|
|
2260
|
-
"## Source \u2014 work item (from tracker)",
|
|
2261
|
-
"",
|
|
2262
|
-
metaLines.join(" \n"),
|
|
2263
|
-
"",
|
|
2264
|
-
"### Description",
|
|
2265
|
-
"",
|
|
2266
|
-
description,
|
|
2267
|
-
"",
|
|
2268
|
-
attachmentsTable,
|
|
2269
|
-
"",
|
|
2270
|
-
"---",
|
|
2271
|
-
""
|
|
2272
|
-
].join("\n");
|
|
2273
|
-
}
|
|
2274
|
-
function renderAttachmentsTable(downloads) {
|
|
2275
|
-
if (downloads.length === 0) return "### Attachments\n\nNone.";
|
|
2276
|
-
const rows = downloads.map((d) => {
|
|
2277
|
-
const sizeNum = (d.bytesWritten || d.source.size) / 1024;
|
|
2278
|
-
const size = `${sizeNum | 0} KB`;
|
|
2279
|
-
switch (d.outcome) {
|
|
2280
|
-
case "written":
|
|
2281
|
-
return `| \`attachments/${path8.basename(d.absolutePath)}\` | ${size} | downloaded |`;
|
|
2282
|
-
case "skipped-oversize":
|
|
2283
|
-
return `| ${d.source.filename} | ${size} | **skipped** \u2014 ${d.skipReason} |`;
|
|
2284
|
-
case "skipped-error":
|
|
2285
|
-
return `| ${d.source.filename} | \u2014 | **skipped** \u2014 ${d.skipReason} |`;
|
|
2286
|
-
}
|
|
2287
|
-
});
|
|
2288
|
-
return ["### Attachments", "", "| File | Size | Status |", "| ---- | ---- | ------ |", ...rows].join("\n");
|
|
2289
|
-
}
|
|
2290
|
-
function buildFallbackPreamble(reason, hint) {
|
|
2291
|
-
const hintLine = hint ? `
|
|
2292
|
-
> Hint: ${hint}` : "";
|
|
2293
|
-
return `> **Tracker auto-fetch skipped.**
|
|
2294
|
-
> ${reason}${hintLine}
|
|
2295
|
-
|
|
2296
|
-
---
|
|
2297
|
-
|
|
2298
|
-
`;
|
|
2299
|
-
}
|
|
2300
|
-
function buildSkippedTrackerPreamble(trackerType) {
|
|
2301
|
-
return [
|
|
2302
|
-
"> **Source:** manual entry (tracker skipped via `--no-tracker`).",
|
|
2303
|
-
`> Active tracker for this workspace: \`${trackerType}\` \u2014 this story is not linked. `,
|
|
2304
|
-
"> Run `squad tracker link <story-path> <tracker-id>` later if you want to attach one.",
|
|
2305
|
-
"",
|
|
2306
|
-
""
|
|
2307
|
-
].join("\n");
|
|
2308
|
-
}
|
|
2309
|
-
function ensureFeatureOverview(featurePlanDir, slug) {
|
|
2310
|
-
fs16.mkdirSync(featurePlanDir, { recursive: true });
|
|
2311
|
-
const overviewFile = path8.join(featurePlanDir, "00-overview.md");
|
|
2312
|
-
if (fs16.existsSync(overviewFile)) return;
|
|
2313
|
-
const template = readFile(path8.join(templatesDir(), "overview.md"));
|
|
2314
|
-
writeFileSafe(overviewFile, render(template, { featureSlug: slug }), false);
|
|
2315
|
-
}
|
|
2316
2873
|
|
|
2317
2874
|
// src/commands/new-plan.ts
|
|
2318
|
-
import
|
|
2319
|
-
import
|
|
2320
|
-
import { confirm as confirm6, select as
|
|
2875
|
+
import fs26 from "fs";
|
|
2876
|
+
import path19 from "path";
|
|
2877
|
+
import { confirm as confirm6, select as select6 } from "@inquirer/prompts";
|
|
2321
2878
|
|
|
2322
2879
|
// src/utils/clipboard.ts
|
|
2323
2880
|
import { spawn, spawnSync } from "child_process";
|
|
@@ -2340,12 +2897,12 @@ async function copyToClipboard(text) {
|
|
|
2340
2897
|
return { ok: false, reason: lastReason ?? "no working clipboard tool found" };
|
|
2341
2898
|
}
|
|
2342
2899
|
function tryCopy(cmd, text) {
|
|
2343
|
-
return new Promise((
|
|
2900
|
+
return new Promise((resolve2) => {
|
|
2344
2901
|
let child;
|
|
2345
2902
|
try {
|
|
2346
2903
|
child = spawn(cmd.command, cmd.args, { stdio: ["pipe", "ignore", "pipe"] });
|
|
2347
2904
|
} catch (err) {
|
|
2348
|
-
|
|
2905
|
+
resolve2({ ok: false, reason: `${cmd.command} failed to spawn: ${err.message}` });
|
|
2349
2906
|
return;
|
|
2350
2907
|
}
|
|
2351
2908
|
let stderr = "";
|
|
@@ -2353,13 +2910,13 @@ function tryCopy(cmd, text) {
|
|
|
2353
2910
|
stderr += chunk.toString();
|
|
2354
2911
|
});
|
|
2355
2912
|
child.on("error", (err) => {
|
|
2356
|
-
|
|
2913
|
+
resolve2({ ok: false, reason: `${cmd.command} spawn error: ${err.message}` });
|
|
2357
2914
|
});
|
|
2358
2915
|
child.on("close", (code) => {
|
|
2359
2916
|
if (code === 0) {
|
|
2360
|
-
|
|
2917
|
+
resolve2({ ok: true, tool: cmd.command });
|
|
2361
2918
|
} else {
|
|
2362
|
-
|
|
2919
|
+
resolve2({
|
|
2363
2920
|
ok: false,
|
|
2364
2921
|
reason: `${cmd.command} exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 200)}` : ""}`
|
|
2365
2922
|
});
|
|
@@ -2408,11 +2965,11 @@ function noCandidateReason() {
|
|
|
2408
2965
|
}
|
|
2409
2966
|
|
|
2410
2967
|
// src/planner/loop.ts
|
|
2411
|
-
import
|
|
2968
|
+
import path15 from "path";
|
|
2412
2969
|
|
|
2413
2970
|
// src/planner/tools.ts
|
|
2414
|
-
import
|
|
2415
|
-
import
|
|
2971
|
+
import fs21 from "fs";
|
|
2972
|
+
import path13 from "path";
|
|
2416
2973
|
var READ_FILE_TOOL = {
|
|
2417
2974
|
name: "read_file",
|
|
2418
2975
|
description: "Read a UTF-8 text file from the project, returning its contents so you can plan against real code. Paths must be relative to the project root. Binary files and files outside the project are refused. You have a bounded context budget; prefer small, targeted reads.",
|
|
@@ -2431,15 +2988,15 @@ function readFileTool(root, budget, input5) {
|
|
|
2431
2988
|
if (typeof raw !== "string" || raw.length === 0) {
|
|
2432
2989
|
return error('read_file: missing or invalid "path" argument.');
|
|
2433
2990
|
}
|
|
2434
|
-
const resolved =
|
|
2435
|
-
const relCheck =
|
|
2436
|
-
if (relCheck.startsWith("..") ||
|
|
2991
|
+
const resolved = path13.resolve(root, raw);
|
|
2992
|
+
const relCheck = path13.relative(root, resolved);
|
|
2993
|
+
if (relCheck.startsWith("..") || path13.isAbsolute(relCheck)) {
|
|
2437
2994
|
return error(`read_file: path "${raw}" escapes the project root and was refused.`);
|
|
2438
2995
|
}
|
|
2439
|
-
if (!
|
|
2996
|
+
if (!fs21.existsSync(resolved)) {
|
|
2440
2997
|
return error(`read_file: not found "${raw}".`);
|
|
2441
2998
|
}
|
|
2442
|
-
const stat =
|
|
2999
|
+
const stat = fs21.statSync(resolved);
|
|
2443
3000
|
if (stat.isDirectory()) {
|
|
2444
3001
|
return error(`read_file: "${raw}" is a directory. Ask for a specific file path.`);
|
|
2445
3002
|
}
|
|
@@ -2454,7 +3011,7 @@ function readFileTool(root, budget, input5) {
|
|
|
2454
3011
|
}
|
|
2455
3012
|
let buf;
|
|
2456
3013
|
try {
|
|
2457
|
-
buf =
|
|
3014
|
+
buf = fs21.readFileSync(resolved);
|
|
2458
3015
|
} catch (e) {
|
|
2459
3016
|
return error(`read_file: failed to read "${raw}" (${e.message}).`);
|
|
2460
3017
|
}
|
|
@@ -2619,7 +3176,8 @@ async function callAnthropic(req) {
|
|
|
2619
3176
|
"anthropic-version": API_VERSION,
|
|
2620
3177
|
"content-type": "application/json"
|
|
2621
3178
|
},
|
|
2622
|
-
body: JSON.stringify(body)
|
|
3179
|
+
body: JSON.stringify(body),
|
|
3180
|
+
signal: req.abort
|
|
2623
3181
|
});
|
|
2624
3182
|
if (!res.ok) {
|
|
2625
3183
|
const text = await res.text().catch(() => "");
|
|
@@ -2757,7 +3315,8 @@ async function callOpenAI(req) {
|
|
|
2757
3315
|
Authorization: `Bearer ${req.apiKey}`,
|
|
2758
3316
|
"content-type": "application/json"
|
|
2759
3317
|
},
|
|
2760
|
-
body: JSON.stringify(body)
|
|
3318
|
+
body: JSON.stringify(body),
|
|
3319
|
+
signal: req.abort
|
|
2761
3320
|
});
|
|
2762
3321
|
if (!res.ok) {
|
|
2763
3322
|
const text = await res.text().catch(() => "");
|
|
@@ -2875,7 +3434,8 @@ async function callGoogle(req) {
|
|
|
2875
3434
|
const res = await fetch(url, {
|
|
2876
3435
|
method: "POST",
|
|
2877
3436
|
headers: { "content-type": "application/json" },
|
|
2878
|
-
body: JSON.stringify(body)
|
|
3437
|
+
body: JSON.stringify(body),
|
|
3438
|
+
signal: req.abort
|
|
2879
3439
|
});
|
|
2880
3440
|
if (!res.ok) {
|
|
2881
3441
|
const text = await res.text().catch(() => "");
|
|
@@ -2965,9 +3525,91 @@ function prefixOf(provider, req) {
|
|
|
2965
3525
|
return s.length > 0 ? s.slice(0, -1) : s;
|
|
2966
3526
|
}
|
|
2967
3527
|
|
|
3528
|
+
// src/core/runs.ts
|
|
3529
|
+
import fs22 from "fs/promises";
|
|
3530
|
+
import path14 from "path";
|
|
3531
|
+
import { randomBytes } from "crypto";
|
|
3532
|
+
var RING_SIZE = 20;
|
|
3533
|
+
function newRunId() {
|
|
3534
|
+
const ts = Date.now().toString(36).padStart(9, "0");
|
|
3535
|
+
const rnd = randomBytes(8).toString("hex");
|
|
3536
|
+
return `${ts}-${rnd}`;
|
|
3537
|
+
}
|
|
3538
|
+
async function appendRun(paths, rec) {
|
|
3539
|
+
const dir = path14.join(paths.squadDir, "runs");
|
|
3540
|
+
await fs22.mkdir(dir, { recursive: true });
|
|
3541
|
+
const file = path14.join(dir, `${rec.runId}.json`);
|
|
3542
|
+
await fs22.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
|
|
3543
|
+
await pruneOldRuns(dir);
|
|
3544
|
+
}
|
|
3545
|
+
async function pruneOldRuns(dir) {
|
|
3546
|
+
const entries = (await fs22.readdir(dir)).filter((n) => n.endsWith(".json"));
|
|
3547
|
+
if (entries.length <= RING_SIZE) return;
|
|
3548
|
+
entries.sort();
|
|
3549
|
+
const drop = entries.slice(0, entries.length - RING_SIZE);
|
|
3550
|
+
await Promise.all(drop.map((n) => fs22.rm(path14.join(dir, n), { force: true })));
|
|
3551
|
+
}
|
|
3552
|
+
async function listRuns(paths) {
|
|
3553
|
+
const dir = path14.join(paths.squadDir, "runs");
|
|
3554
|
+
let entries;
|
|
3555
|
+
try {
|
|
3556
|
+
entries = await fs22.readdir(dir);
|
|
3557
|
+
} catch {
|
|
3558
|
+
return [];
|
|
3559
|
+
}
|
|
3560
|
+
const files = entries.filter((n) => n.endsWith(".json")).sort().reverse();
|
|
3561
|
+
const out = [];
|
|
3562
|
+
for (const f of files) {
|
|
3563
|
+
try {
|
|
3564
|
+
const raw = await fs22.readFile(path14.join(dir, f), "utf8");
|
|
3565
|
+
const parsed = JSON.parse(raw);
|
|
3566
|
+
if (parsed.version === 1) out.push(parsed);
|
|
3567
|
+
} catch {
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
return out;
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
// src/planner/session-limits.ts
|
|
3574
|
+
var DEFAULT_PLANNER_MAX_ITERATIONS = 40;
|
|
3575
|
+
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.";
|
|
3576
|
+
|
|
3577
|
+
// src/planner/events.ts
|
|
3578
|
+
var PlannerEventBus = class {
|
|
3579
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3580
|
+
emit(e) {
|
|
3581
|
+
for (const fn of this.listeners) {
|
|
3582
|
+
try {
|
|
3583
|
+
fn(e);
|
|
3584
|
+
} catch {
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
subscribe(fn) {
|
|
3589
|
+
this.listeners.add(fn);
|
|
3590
|
+
return () => this.listeners.delete(fn);
|
|
3591
|
+
}
|
|
3592
|
+
};
|
|
3593
|
+
|
|
2968
3594
|
// src/planner/loop.ts
|
|
2969
3595
|
var MAX_RATE_LIMIT_RETRY_SEC = 90;
|
|
2970
|
-
var defaultSleep = (ms) => new Promise((
|
|
3596
|
+
var defaultSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
3597
|
+
function isAbortError(e) {
|
|
3598
|
+
return e instanceof Error && e.name === "AbortError";
|
|
3599
|
+
}
|
|
3600
|
+
async function sleepWithAbort(ms, sleepFn, abort) {
|
|
3601
|
+
if (!abort) {
|
|
3602
|
+
await sleepFn(ms);
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
3605
|
+
if (abort.aborted) throw new DOMException("Aborted", "AbortError");
|
|
3606
|
+
await Promise.race([
|
|
3607
|
+
sleepFn(ms),
|
|
3608
|
+
new Promise((_, reject) => {
|
|
3609
|
+
abort.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
|
3610
|
+
})
|
|
3611
|
+
]);
|
|
3612
|
+
}
|
|
2971
3613
|
function buildPlannerRunStats(budget, turns, runStartedAt) {
|
|
2972
3614
|
const u = budget.snapshot().usage;
|
|
2973
3615
|
const cacheRead = u.cacheReadTokens ?? 0;
|
|
@@ -2986,18 +3628,130 @@ function buildPlannerRunStats(budget, turns, runStartedAt) {
|
|
|
2986
3628
|
durationMs: Date.now() - runStartedAt
|
|
2987
3629
|
};
|
|
2988
3630
|
}
|
|
3631
|
+
function readBudgetishError(result) {
|
|
3632
|
+
return result.isError && (/budget/i.test(result.content) || /max file reads/i.test(result.content));
|
|
3633
|
+
}
|
|
3634
|
+
function extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut) {
|
|
3635
|
+
input5.budget.extendSession();
|
|
3636
|
+
maxIter.current += baseMaxIter;
|
|
3637
|
+
maxOut.current += baseMaxOut;
|
|
3638
|
+
}
|
|
3639
|
+
function recordUsageAndEmit(bus, input5, runId, turn, usage) {
|
|
3640
|
+
input5.budget.recordUsage(usage);
|
|
3641
|
+
input5.onUsage?.(usage);
|
|
3642
|
+
bus.emit({ kind: "usage", runId, turn, usage });
|
|
3643
|
+
const u = input5.budget.snapshot().usage;
|
|
3644
|
+
const cacheRead = u.cacheReadTokens ?? 0;
|
|
3645
|
+
const cacheCreate = u.cacheCreationTokens ?? 0;
|
|
3646
|
+
if (cacheRead > 0 || cacheCreate > 0) {
|
|
3647
|
+
const inTok = u.inputTokens;
|
|
3648
|
+
const totalInput = inTok + cacheRead;
|
|
3649
|
+
const cacheHitRatio = totalInput === 0 ? 0 : Math.round(cacheRead / totalInput * 100) / 100;
|
|
3650
|
+
bus.emit({
|
|
3651
|
+
kind: "cache_summary",
|
|
3652
|
+
runId,
|
|
3653
|
+
turn,
|
|
3654
|
+
cacheHitRatio,
|
|
3655
|
+
cacheReadTokens: cacheRead,
|
|
3656
|
+
cacheCreationTokens: cacheCreate
|
|
3657
|
+
});
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
function cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt) {
|
|
3661
|
+
bus.emit({ kind: "cancelled", runId });
|
|
3662
|
+
return {
|
|
3663
|
+
planText: accumulatedText,
|
|
3664
|
+
budgetExhausted,
|
|
3665
|
+
timedOut: false,
|
|
3666
|
+
finishedNormally: false,
|
|
3667
|
+
iterations,
|
|
3668
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3669
|
+
userCancelled: true
|
|
3670
|
+
};
|
|
3671
|
+
}
|
|
2989
3672
|
async function runPlanner(input5) {
|
|
2990
3673
|
const runStartedAt = Date.now();
|
|
2991
|
-
const
|
|
3674
|
+
const bus = input5.events ?? new PlannerEventBus();
|
|
3675
|
+
const runId = input5.runId ?? newRunId();
|
|
3676
|
+
const cacheEnabled = input5.cacheEnabled ?? true;
|
|
3677
|
+
bus.emit({
|
|
3678
|
+
kind: "started",
|
|
3679
|
+
runId,
|
|
3680
|
+
provider: input5.provider.name,
|
|
3681
|
+
model: input5.model,
|
|
3682
|
+
cacheEnabled
|
|
3683
|
+
});
|
|
3684
|
+
const baseMaxIter = input5.maxIterations ?? DEFAULT_PLANNER_MAX_ITERATIONS;
|
|
3685
|
+
const maxIter = { current: baseMaxIter };
|
|
3686
|
+
const baseMaxOut = input5.maxOutputTokens ?? DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
|
|
3687
|
+
const maxOut = { current: baseMaxOut };
|
|
2992
3688
|
const turns = [{ role: "user", text: input5.userPrompt }];
|
|
2993
3689
|
let accumulatedText = "";
|
|
2994
3690
|
let iterations = 0;
|
|
2995
3691
|
let budgetExhausted = false;
|
|
2996
3692
|
let finishedNormally = false;
|
|
2997
3693
|
let prevRequestPrefix;
|
|
2998
|
-
|
|
3694
|
+
const decide = input5.decideOnLimit;
|
|
3695
|
+
const sleepFn = input5.sleep ?? defaultSleep;
|
|
3696
|
+
const limitCtx = (kind) => ({
|
|
3697
|
+
kind,
|
|
3698
|
+
budgetSnapshot: input5.budget.snapshot(),
|
|
3699
|
+
iterations,
|
|
3700
|
+
maxIterations: maxIter.current,
|
|
3701
|
+
maxOutputTokens: maxOut.current
|
|
3702
|
+
});
|
|
3703
|
+
for (; ; ) {
|
|
3704
|
+
if (input5.abort?.aborted) {
|
|
3705
|
+
return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
|
|
3706
|
+
}
|
|
3707
|
+
if (iterations >= maxIter.current) {
|
|
3708
|
+
if (decide) {
|
|
3709
|
+
const d = await decide(limitCtx("max_iterations"));
|
|
3710
|
+
if (d === "cancel") {
|
|
3711
|
+
return {
|
|
3712
|
+
planText: accumulatedText,
|
|
3713
|
+
budgetExhausted,
|
|
3714
|
+
timedOut: false,
|
|
3715
|
+
finishedNormally: false,
|
|
3716
|
+
iterations,
|
|
3717
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3718
|
+
incompleteKind: "max_iterations",
|
|
3719
|
+
userCancelled: true
|
|
3720
|
+
};
|
|
3721
|
+
}
|
|
3722
|
+
extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
|
|
3723
|
+
continue;
|
|
3724
|
+
}
|
|
3725
|
+
return {
|
|
3726
|
+
planText: accumulatedText,
|
|
3727
|
+
budgetExhausted,
|
|
3728
|
+
timedOut: false,
|
|
3729
|
+
finishedNormally: false,
|
|
3730
|
+
iterations,
|
|
3731
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3732
|
+
incompleteKind: "max_iterations"
|
|
3733
|
+
};
|
|
3734
|
+
}
|
|
2999
3735
|
iterations += 1;
|
|
3000
3736
|
if (input5.budget.timedOut()) {
|
|
3737
|
+
if (decide) {
|
|
3738
|
+
const d = await decide(limitCtx("wall_clock"));
|
|
3739
|
+
if (d === "cancel") {
|
|
3740
|
+
return {
|
|
3741
|
+
planText: accumulatedText,
|
|
3742
|
+
budgetExhausted,
|
|
3743
|
+
timedOut: true,
|
|
3744
|
+
finishedNormally: false,
|
|
3745
|
+
iterations,
|
|
3746
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3747
|
+
incompleteKind: "wall_clock",
|
|
3748
|
+
userCancelled: true
|
|
3749
|
+
};
|
|
3750
|
+
}
|
|
3751
|
+
extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
|
|
3752
|
+
iterations -= 1;
|
|
3753
|
+
continue;
|
|
3754
|
+
}
|
|
3001
3755
|
return {
|
|
3002
3756
|
planText: accumulatedText,
|
|
3003
3757
|
budgetExhausted,
|
|
@@ -3008,6 +3762,24 @@ async function runPlanner(input5) {
|
|
|
3008
3762
|
};
|
|
3009
3763
|
}
|
|
3010
3764
|
if (input5.budget.overCost()) {
|
|
3765
|
+
if (decide) {
|
|
3766
|
+
const d = await decide(limitCtx("cost_cap"));
|
|
3767
|
+
if (d === "cancel") {
|
|
3768
|
+
return {
|
|
3769
|
+
planText: accumulatedText,
|
|
3770
|
+
budgetExhausted,
|
|
3771
|
+
timedOut: false,
|
|
3772
|
+
finishedNormally: false,
|
|
3773
|
+
iterations,
|
|
3774
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3775
|
+
incompleteKind: "cost_cap",
|
|
3776
|
+
userCancelled: true
|
|
3777
|
+
};
|
|
3778
|
+
}
|
|
3779
|
+
extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
|
|
3780
|
+
iterations -= 1;
|
|
3781
|
+
continue;
|
|
3782
|
+
}
|
|
3011
3783
|
return {
|
|
3012
3784
|
planText: accumulatedText,
|
|
3013
3785
|
budgetExhausted: true,
|
|
@@ -3017,15 +3789,20 @@ async function runPlanner(input5) {
|
|
|
3017
3789
|
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
|
|
3018
3790
|
};
|
|
3019
3791
|
}
|
|
3792
|
+
if (iterations >= 2) {
|
|
3793
|
+
bus.emit({ kind: "turn_started", runId, turn: iterations });
|
|
3794
|
+
}
|
|
3020
3795
|
const request = {
|
|
3021
3796
|
systemPrompt: input5.systemPrompt,
|
|
3022
3797
|
model: input5.model,
|
|
3023
3798
|
tools: [READ_FILE_TOOL],
|
|
3024
3799
|
turns,
|
|
3025
3800
|
apiKey: input5.apiKey,
|
|
3026
|
-
maxOutputTokens:
|
|
3027
|
-
cacheEnabled
|
|
3801
|
+
maxOutputTokens: maxOut.current,
|
|
3802
|
+
cacheEnabled,
|
|
3803
|
+
abort: input5.abort
|
|
3028
3804
|
};
|
|
3805
|
+
bus.emit({ kind: "request_sent", runId, turn: iterations });
|
|
3029
3806
|
if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test" && prevRequestPrefix !== void 0) {
|
|
3030
3807
|
const currentPrefix = prefixOf(input5.provider.name, request);
|
|
3031
3808
|
if (!currentPrefix.startsWith(prevRequestPrefix)) {
|
|
@@ -3035,10 +3812,17 @@ async function runPlanner(input5) {
|
|
|
3035
3812
|
);
|
|
3036
3813
|
}
|
|
3037
3814
|
}
|
|
3038
|
-
let response
|
|
3815
|
+
let response;
|
|
3816
|
+
try {
|
|
3817
|
+
response = await input5.provider.send(request);
|
|
3818
|
+
} catch (e) {
|
|
3819
|
+
if (isAbortError(e)) {
|
|
3820
|
+
return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
|
|
3821
|
+
}
|
|
3822
|
+
throw e;
|
|
3823
|
+
}
|
|
3039
3824
|
if (response.usage) {
|
|
3040
|
-
input5
|
|
3041
|
-
input5.onUsage?.(response.usage);
|
|
3825
|
+
recordUsageAndEmit(bus, input5, runId, iterations, response.usage);
|
|
3042
3826
|
}
|
|
3043
3827
|
let retriedRateLimit = false;
|
|
3044
3828
|
let retrySkippedReason;
|
|
@@ -3049,11 +3833,25 @@ async function runPlanner(input5) {
|
|
|
3049
3833
|
} else {
|
|
3050
3834
|
const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
|
|
3051
3835
|
input5.onRateLimit?.(waitSec);
|
|
3052
|
-
|
|
3053
|
-
|
|
3836
|
+
bus.emit({ kind: "rate_limit", runId, turn: iterations, waitSec });
|
|
3837
|
+
try {
|
|
3838
|
+
await sleepWithAbort(waitSec * 1e3, sleepFn, input5.abort);
|
|
3839
|
+
} catch (e) {
|
|
3840
|
+
if (isAbortError(e)) {
|
|
3841
|
+
return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
|
|
3842
|
+
}
|
|
3843
|
+
throw e;
|
|
3844
|
+
}
|
|
3845
|
+
try {
|
|
3846
|
+
response = await input5.provider.send(request);
|
|
3847
|
+
} catch (e) {
|
|
3848
|
+
if (isAbortError(e)) {
|
|
3849
|
+
return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
|
|
3850
|
+
}
|
|
3851
|
+
throw e;
|
|
3852
|
+
}
|
|
3054
3853
|
if (response.usage) {
|
|
3055
|
-
input5
|
|
3056
|
-
input5.onUsage?.(response.usage);
|
|
3854
|
+
recordUsageAndEmit(bus, input5, runId, iterations, response.usage);
|
|
3057
3855
|
}
|
|
3058
3856
|
retriedRateLimit = true;
|
|
3059
3857
|
}
|
|
@@ -3065,18 +3863,42 @@ async function runPlanner(input5) {
|
|
|
3065
3863
|
if (response.text) {
|
|
3066
3864
|
accumulatedText += response.text;
|
|
3067
3865
|
input5.onAssistantText?.(response.text);
|
|
3866
|
+
bus.emit({ kind: "assistant_text", runId, turn: iterations, delta: response.text });
|
|
3068
3867
|
}
|
|
3069
3868
|
if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
|
|
3869
|
+
bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
|
|
3870
|
+
if (decide) {
|
|
3871
|
+
const d = await decide(limitCtx("max_output_tokens"));
|
|
3872
|
+
if (d === "cancel") {
|
|
3873
|
+
return {
|
|
3874
|
+
planText: accumulatedText,
|
|
3875
|
+
budgetExhausted,
|
|
3876
|
+
timedOut: false,
|
|
3877
|
+
finishedNormally: false,
|
|
3878
|
+
iterations,
|
|
3879
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3880
|
+
incompleteKind: "max_output_tokens",
|
|
3881
|
+
userCancelled: true
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
|
|
3885
|
+
turns.push({ role: "assistant", text: response.text ?? "" });
|
|
3886
|
+
turns.push({ role: "user", text: PLANNER_MARKDOWN_CONTINUATION_USER });
|
|
3887
|
+
iterations -= 1;
|
|
3888
|
+
continue;
|
|
3889
|
+
}
|
|
3070
3890
|
return {
|
|
3071
3891
|
planText: accumulatedText,
|
|
3072
3892
|
budgetExhausted,
|
|
3073
3893
|
timedOut: false,
|
|
3074
3894
|
finishedNormally: false,
|
|
3075
3895
|
iterations,
|
|
3076
|
-
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
|
|
3896
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3897
|
+
incompleteKind: "max_output_tokens"
|
|
3077
3898
|
};
|
|
3078
3899
|
}
|
|
3079
3900
|
if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
|
|
3901
|
+
bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
|
|
3080
3902
|
finishedNormally = true;
|
|
3081
3903
|
return {
|
|
3082
3904
|
planText: accumulatedText,
|
|
@@ -3093,36 +3915,68 @@ async function runPlanner(input5) {
|
|
|
3093
3915
|
toolCalls: response.toolCalls
|
|
3094
3916
|
});
|
|
3095
3917
|
const toolResults = [];
|
|
3096
|
-
|
|
3918
|
+
const toolCalls = response.toolCalls;
|
|
3919
|
+
for (let i = 0; i < toolCalls.length; i += 1) {
|
|
3920
|
+
const tc = toolCalls[i];
|
|
3097
3921
|
if (tc.name !== READ_FILE_TOOL.name) {
|
|
3098
3922
|
toolResults.push({ toolCallId: tc.id, content: `unknown tool "${tc.name}"`, isError: true });
|
|
3099
3923
|
continue;
|
|
3100
3924
|
}
|
|
3101
3925
|
const before = input5.budget.snapshot().bytes;
|
|
3102
|
-
|
|
3926
|
+
let result = readFileTool(input5.root, input5.budget, tc.input);
|
|
3103
3927
|
const after = input5.budget.snapshot().bytes;
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3928
|
+
const bytesLoaded = after - before;
|
|
3929
|
+
input5.onToolCall?.(tc, bytesLoaded, after);
|
|
3930
|
+
bus.emit({
|
|
3931
|
+
kind: "tool_call",
|
|
3932
|
+
runId,
|
|
3933
|
+
turn: iterations,
|
|
3934
|
+
toolCall: tc,
|
|
3935
|
+
bytesLoaded,
|
|
3936
|
+
totalBytes: after
|
|
3937
|
+
});
|
|
3938
|
+
if (readBudgetishError(result)) {
|
|
3939
|
+
if (decide) {
|
|
3940
|
+
const d = await decide(limitCtx("file_or_context_reads"));
|
|
3941
|
+
if (d === "cancel") {
|
|
3942
|
+
toolResults.push({ toolCallId: tc.id, content: result.content, isError: true });
|
|
3943
|
+
for (let j = i + 1; j < toolCalls.length; j += 1) {
|
|
3944
|
+
const t2 = toolCalls[j];
|
|
3945
|
+
toolResults.push({
|
|
3946
|
+
toolCallId: t2.id,
|
|
3947
|
+
content: t2.name === READ_FILE_TOOL.name ? "read_file: not executed (planning session stopped after file/context budget)." : `unknown tool "${t2.name}"`,
|
|
3948
|
+
isError: true
|
|
3949
|
+
});
|
|
3950
|
+
}
|
|
3951
|
+
turns.push({ role: "user", toolResults });
|
|
3952
|
+
return {
|
|
3953
|
+
planText: accumulatedText,
|
|
3954
|
+
budgetExhausted: false,
|
|
3955
|
+
timedOut: false,
|
|
3956
|
+
finishedNormally: false,
|
|
3957
|
+
iterations,
|
|
3958
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3959
|
+
incompleteKind: "budget_reads",
|
|
3960
|
+
userCancelled: true
|
|
3961
|
+
};
|
|
3962
|
+
}
|
|
3963
|
+
extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut);
|
|
3964
|
+
result = readFileTool(input5.root, input5.budget, tc.input);
|
|
3965
|
+
} else {
|
|
3966
|
+
budgetExhausted = true;
|
|
3967
|
+
}
|
|
3107
3968
|
}
|
|
3108
3969
|
toolResults.push({ toolCallId: tc.id, content: result.content, isError: result.isError });
|
|
3109
3970
|
}
|
|
3110
3971
|
turns.push({ role: "user", toolResults });
|
|
3111
3972
|
if (budgetExhausted) {
|
|
3112
|
-
turns.push({
|
|
3113
|
-
role: "user",
|
|
3114
|
-
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."
|
|
3115
|
-
});
|
|
3116
|
-
}
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
planText: accumulatedText,
|
|
3120
|
-
budgetExhausted,
|
|
3121
|
-
timedOut: false,
|
|
3122
|
-
finishedNormally: false,
|
|
3123
|
-
iterations,
|
|
3124
|
-
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
|
|
3125
|
-
};
|
|
3973
|
+
turns.push({
|
|
3974
|
+
role: "user",
|
|
3975
|
+
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."
|
|
3976
|
+
});
|
|
3977
|
+
}
|
|
3978
|
+
bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
|
|
3979
|
+
}
|
|
3126
3980
|
}
|
|
3127
3981
|
function firstByteDiff(a, b) {
|
|
3128
3982
|
const n = Math.min(a.length, b.length);
|
|
@@ -3151,6 +4005,84 @@ function composePlannerError(providerName, response, retriedRateLimit, retrySkip
|
|
|
3151
4005
|
return new Error(`${base} Run \`squad doctor\` to diagnose, or retry \u2014 most 5xx errors are transient.`);
|
|
3152
4006
|
}
|
|
3153
4007
|
|
|
4008
|
+
// src/planner/planner-limit-messages.ts
|
|
4009
|
+
function printPlannerApiCostNotice() {
|
|
4010
|
+
blank();
|
|
4011
|
+
step("Billing");
|
|
4012
|
+
info(
|
|
4013
|
+
"`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."
|
|
4014
|
+
);
|
|
4015
|
+
blank();
|
|
4016
|
+
}
|
|
4017
|
+
function kindTitle(kind) {
|
|
4018
|
+
switch (kind) {
|
|
4019
|
+
case "max_output_tokens":
|
|
4020
|
+
return "Output length limit (single response)";
|
|
4021
|
+
case "max_iterations":
|
|
4022
|
+
return "Model round limit";
|
|
4023
|
+
case "wall_clock":
|
|
4024
|
+
return "Wall-clock time limit";
|
|
4025
|
+
case "cost_cap":
|
|
4026
|
+
return "Estimated cost cap";
|
|
4027
|
+
case "file_or_context_reads":
|
|
4028
|
+
return "File read or context size limit";
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
function kindDetail(ctx) {
|
|
4032
|
+
const snap = ctx.budgetSnapshot;
|
|
4033
|
+
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.";
|
|
4034
|
+
switch (ctx.kind) {
|
|
4035
|
+
case "max_output_tokens":
|
|
4036
|
+
return [
|
|
4037
|
+
`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.`,
|
|
4038
|
+
"You can continue: squad-kit will ask the model to append the rest of the plan in a follow-up request (extra tokens apply).",
|
|
4039
|
+
cost
|
|
4040
|
+
];
|
|
4041
|
+
case "max_iterations":
|
|
4042
|
+
return [
|
|
4043
|
+
`The planner reached its round cap (${ctx.maxIterations} model turns) without a clean stop.`,
|
|
4044
|
+
"Continuing raises the round cap and file/time budgets for this run by another full slice from your config.",
|
|
4045
|
+
cost
|
|
4046
|
+
];
|
|
4047
|
+
case "wall_clock":
|
|
4048
|
+
return [
|
|
4049
|
+
"The configured wall-clock budget for this planning session elapsed before the model finished.",
|
|
4050
|
+
"Continuing adds another slice of time (and read/output/round limits) from your planner budget.",
|
|
4051
|
+
cost
|
|
4052
|
+
];
|
|
4053
|
+
case "cost_cap":
|
|
4054
|
+
return [
|
|
4055
|
+
"Spending crossed the optional `planner.budget.maxCostUsd` ceiling for this session.",
|
|
4056
|
+
"Continuing raises that ceiling for this run by another slice from config (if a cap is set).",
|
|
4057
|
+
cost
|
|
4058
|
+
];
|
|
4059
|
+
case "file_or_context_reads":
|
|
4060
|
+
return [
|
|
4061
|
+
"A `read_file` call could not run because the file-read count or total read-bytes budget was exhausted.",
|
|
4062
|
+
"Continuing extends read, context, time, round, and output limits for this session so the model can read again or finish without reads.",
|
|
4063
|
+
cost
|
|
4064
|
+
];
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
function printPlannerLimitExplanation(ctx) {
|
|
4068
|
+
warning(kindTitle(ctx.kind));
|
|
4069
|
+
for (const line2 of kindDetail(ctx)) {
|
|
4070
|
+
info(line2);
|
|
4071
|
+
}
|
|
4072
|
+
kv("session so far", `${snapSummary(ctx)}`, 18);
|
|
4073
|
+
}
|
|
4074
|
+
function snapSummary(ctx) {
|
|
4075
|
+
const s = ctx.budgetSnapshot;
|
|
4076
|
+
return `${s.reads} reads \xB7 ${(s.bytes / 1024).toFixed(1)} KB read context \xB7 ${s.usage.inputTokens} in / ${s.usage.outputTokens} out tokens`;
|
|
4077
|
+
}
|
|
4078
|
+
function printPlannerLimitNextSteps() {
|
|
4079
|
+
blank();
|
|
4080
|
+
step("If you stop here");
|
|
4081
|
+
info("An incomplete plan is saved as `*.partial.md` with front matter `squad-kit-plan-status: partial`.");
|
|
4082
|
+
info("Fix limits in `.squad/config.yaml` (`planner.budget`, `planner.maxOutputTokens`) or run again when ready.");
|
|
4083
|
+
blank();
|
|
4084
|
+
}
|
|
4085
|
+
|
|
3154
4086
|
// src/planner/providers/index.ts
|
|
3155
4087
|
function providerFor(name) {
|
|
3156
4088
|
switch (name) {
|
|
@@ -3167,18 +4099,35 @@ function providerFor(name) {
|
|
|
3167
4099
|
var Budget = class {
|
|
3168
4100
|
constructor(cfg) {
|
|
3169
4101
|
this.cfg = cfg;
|
|
4102
|
+
this.maxFileReadsAllowed = cfg.maxFileReads;
|
|
4103
|
+
this.maxContextBytesAllowed = cfg.maxContextBytes;
|
|
4104
|
+
this.maxWallSeconds = cfg.maxDurationSeconds;
|
|
4105
|
+
this.maxCostUsdCeiling = cfg.maxCostUsd;
|
|
3170
4106
|
}
|
|
3171
4107
|
cfg;
|
|
3172
4108
|
reads = 0;
|
|
3173
4109
|
bytes = 0;
|
|
3174
4110
|
startedAt = Date.now();
|
|
3175
4111
|
totalUsage = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
|
|
4112
|
+
maxFileReadsAllowed;
|
|
4113
|
+
maxContextBytesAllowed;
|
|
4114
|
+
maxWallSeconds;
|
|
4115
|
+
maxCostUsdCeiling;
|
|
4116
|
+
/** Add another slice of the original config limits (used when the user chooses to continue a planner session). */
|
|
4117
|
+
extendSession() {
|
|
4118
|
+
this.maxFileReadsAllowed += this.cfg.maxFileReads;
|
|
4119
|
+
this.maxContextBytesAllowed += this.cfg.maxContextBytes;
|
|
4120
|
+
this.maxWallSeconds += this.cfg.maxDurationSeconds;
|
|
4121
|
+
if (this.cfg.maxCostUsd !== void 0) {
|
|
4122
|
+
this.maxCostUsdCeiling = (this.maxCostUsdCeiling ?? 0) + this.cfg.maxCostUsd;
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
3176
4125
|
canRead(nextFileBytes) {
|
|
3177
|
-
if (this.reads >= this.
|
|
3178
|
-
return { ok: false, reason: `max file reads (${this.
|
|
4126
|
+
if (this.reads >= this.maxFileReadsAllowed) {
|
|
4127
|
+
return { ok: false, reason: `max file reads (${this.maxFileReadsAllowed}) reached` };
|
|
3179
4128
|
}
|
|
3180
|
-
if (this.bytes + nextFileBytes > this.
|
|
3181
|
-
return { ok: false, reason: `context budget (${this.
|
|
4129
|
+
if (this.bytes + nextFileBytes > this.maxContextBytesAllowed) {
|
|
4130
|
+
return { ok: false, reason: `context budget (${this.maxContextBytesAllowed} bytes) would be exceeded` };
|
|
3182
4131
|
}
|
|
3183
4132
|
return { ok: true };
|
|
3184
4133
|
}
|
|
@@ -3194,11 +4143,11 @@ var Budget = class {
|
|
|
3194
4143
|
this.totalUsage.cacheReadTokens = (this.totalUsage.cacheReadTokens ?? 0) + (u.cacheReadTokens ?? 0);
|
|
3195
4144
|
}
|
|
3196
4145
|
timedOut() {
|
|
3197
|
-
return (Date.now() - this.startedAt) / 1e3 > this.
|
|
4146
|
+
return (Date.now() - this.startedAt) / 1e3 > this.maxWallSeconds;
|
|
3198
4147
|
}
|
|
3199
4148
|
overCost() {
|
|
3200
|
-
if (this.
|
|
3201
|
-
return (this.totalUsage.costUsd ?? 0) > this.
|
|
4149
|
+
if (this.maxCostUsdCeiling === void 0) return false;
|
|
4150
|
+
return (this.totalUsage.costUsd ?? 0) > this.maxCostUsdCeiling;
|
|
3202
4151
|
}
|
|
3203
4152
|
snapshot() {
|
|
3204
4153
|
return {
|
|
@@ -3211,8 +4160,8 @@ var Budget = class {
|
|
|
3211
4160
|
};
|
|
3212
4161
|
|
|
3213
4162
|
// src/core/repo-map.ts
|
|
3214
|
-
import
|
|
3215
|
-
import
|
|
4163
|
+
import fs23 from "fs";
|
|
4164
|
+
import path16 from "path";
|
|
3216
4165
|
import ignore from "ignore";
|
|
3217
4166
|
var DEFAULT_IGNORES = [
|
|
3218
4167
|
"node_modules",
|
|
@@ -3236,10 +4185,10 @@ function buildRepoMap(root, opts = {}) {
|
|
|
3236
4185
|
}
|
|
3237
4186
|
function buildIgnore(root) {
|
|
3238
4187
|
const ig = ignore().add(DEFAULT_IGNORES);
|
|
3239
|
-
const gitignore =
|
|
3240
|
-
if (
|
|
4188
|
+
const gitignore = path16.join(root, ".gitignore");
|
|
4189
|
+
if (fs23.existsSync(gitignore)) {
|
|
3241
4190
|
try {
|
|
3242
|
-
ig.add(
|
|
4191
|
+
ig.add(fs23.readFileSync(gitignore, "utf8"));
|
|
3243
4192
|
} catch {
|
|
3244
4193
|
}
|
|
3245
4194
|
}
|
|
@@ -3249,15 +4198,15 @@ function walk2(root, dir, ig, out, max, includeHidden) {
|
|
|
3249
4198
|
if (out.length >= max) return;
|
|
3250
4199
|
let entries;
|
|
3251
4200
|
try {
|
|
3252
|
-
entries =
|
|
4201
|
+
entries = fs23.readdirSync(dir, { withFileTypes: true });
|
|
3253
4202
|
} catch {
|
|
3254
4203
|
return;
|
|
3255
4204
|
}
|
|
3256
4205
|
for (const entry of entries) {
|
|
3257
4206
|
if (out.length >= max) return;
|
|
3258
4207
|
if (!includeHidden && entry.name.startsWith(".") && entry.name !== ".squad") continue;
|
|
3259
|
-
const abs =
|
|
3260
|
-
const rel =
|
|
4208
|
+
const abs = path16.join(dir, entry.name);
|
|
4209
|
+
const rel = path16.relative(root, abs);
|
|
3261
4210
|
if (!rel || ig.ignores(rel) || entry.isDirectory() && ig.ignores(rel + "/")) continue;
|
|
3262
4211
|
if (entry.isDirectory()) {
|
|
3263
4212
|
walk2(root, abs, ig, out, max, includeHidden);
|
|
@@ -3305,42 +4254,21 @@ function composeUserPrompt(args) {
|
|
|
3305
4254
|
}
|
|
3306
4255
|
|
|
3307
4256
|
// src/planner/writer.ts
|
|
3308
|
-
import
|
|
3309
|
-
import
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
function scanPlans(plansDir) {
|
|
3316
|
-
const used = [];
|
|
3317
|
-
const seen = /* @__PURE__ */ new Map();
|
|
3318
|
-
if (fs19.existsSync(plansDir)) {
|
|
3319
|
-
for (const feature of fs19.readdirSync(plansDir, { withFileTypes: true })) {
|
|
3320
|
-
if (!feature.isDirectory()) continue;
|
|
3321
|
-
const featureDir = path12.join(plansDir, feature.name);
|
|
3322
|
-
for (const entry of fs19.readdirSync(featureDir)) {
|
|
3323
|
-
const match = entry.match(STORY_FILE2);
|
|
3324
|
-
if (!match) continue;
|
|
3325
|
-
const n = parseInt(match[1], 10);
|
|
3326
|
-
used.push(n);
|
|
3327
|
-
seen.set(n, (seen.get(n) ?? 0) + 1);
|
|
3328
|
-
}
|
|
3329
|
-
}
|
|
3330
|
-
}
|
|
3331
|
-
const duplicates = [...seen.entries()].filter(([, c]) => c > 1).map(([n]) => n);
|
|
3332
|
-
const maxNumber = used.length ? Math.max(...used) : 0;
|
|
3333
|
-
return { usedNumbers: used.sort((a, b) => a - b), maxNumber, duplicates, nextGlobal: maxNumber + 1 };
|
|
3334
|
-
}
|
|
3335
|
-
function formatSequence(n) {
|
|
3336
|
-
return String(n).padStart(2, "0");
|
|
4257
|
+
import fs24 from "fs";
|
|
4258
|
+
import path17 from "path";
|
|
4259
|
+
function planFileSlugParts(config2, story) {
|
|
4260
|
+
const baseSlug = slugify(story.id);
|
|
4261
|
+
const trackerId = config2.naming.includeTrackerId && config2.tracker.type !== "none" ? trackerIdForFilename(config2.tracker.type, story.id) : "";
|
|
4262
|
+
const trackerPart = trackerId && trackerId.toLowerCase() !== baseSlug ? `-${trackerId}` : "";
|
|
4263
|
+
return { baseSlug, trackerPart };
|
|
3337
4264
|
}
|
|
3338
|
-
|
|
3339
|
-
// src/planner/writer.ts
|
|
3340
4265
|
function writePlanFile(input5) {
|
|
3341
4266
|
const { paths, config: config2, story } = input5;
|
|
3342
|
-
const featurePlanDir =
|
|
3343
|
-
|
|
4267
|
+
const featurePlanDir = path17.join(paths.plansDir, story.feature);
|
|
4268
|
+
fs24.mkdirSync(featurePlanDir, { recursive: true });
|
|
4269
|
+
if (input5.partial) {
|
|
4270
|
+
return writePartialPlanFile(input5, featurePlanDir, paths, config2, story);
|
|
4271
|
+
}
|
|
3344
4272
|
const existing = findExistingPlan(featurePlanDir, story.id);
|
|
3345
4273
|
let targetPath;
|
|
3346
4274
|
let nn;
|
|
@@ -3351,34 +4279,65 @@ function writePlanFile(input5) {
|
|
|
3351
4279
|
overwrote = true;
|
|
3352
4280
|
} else {
|
|
3353
4281
|
nn = scanPlans(paths.plansDir).nextGlobal;
|
|
3354
|
-
const
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
4282
|
+
const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
|
|
4283
|
+
targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
|
|
4284
|
+
}
|
|
4285
|
+
const body = `${input5.metadataHeader.trimEnd()}
|
|
4286
|
+
|
|
4287
|
+
${input5.planBodyMarkdown.trimStart()}
|
|
4288
|
+
`;
|
|
4289
|
+
fs24.writeFileSync(targetPath, body, "utf8");
|
|
4290
|
+
upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, false);
|
|
4291
|
+
return { planFile: targetPath, sequenceNumber: nn, overwrote };
|
|
4292
|
+
}
|
|
4293
|
+
function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
|
|
4294
|
+
const existingPartial = findExistingPartialPlan(featurePlanDir, story.id);
|
|
4295
|
+
let targetPath;
|
|
4296
|
+
let nn;
|
|
4297
|
+
let overwrote = false;
|
|
4298
|
+
if (existingPartial) {
|
|
4299
|
+
targetPath = existingPartial.absPath;
|
|
4300
|
+
nn = existingPartial.nn;
|
|
4301
|
+
overwrote = true;
|
|
4302
|
+
} else {
|
|
4303
|
+
nn = scanPlans(paths.plansDir).nextGlobal;
|
|
4304
|
+
const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
|
|
4305
|
+
targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.partial.md`);
|
|
3359
4306
|
}
|
|
3360
4307
|
const body = `${input5.metadataHeader.trimEnd()}
|
|
3361
4308
|
|
|
3362
4309
|
${input5.planBodyMarkdown.trimStart()}
|
|
3363
4310
|
`;
|
|
3364
|
-
|
|
3365
|
-
upsertOverviewRow(featurePlanDir, story.feature, nn,
|
|
4311
|
+
fs24.writeFileSync(targetPath, body, "utf8");
|
|
4312
|
+
upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, true);
|
|
3366
4313
|
return { planFile: targetPath, sequenceNumber: nn, overwrote };
|
|
3367
4314
|
}
|
|
3368
4315
|
function findExistingPlan(featureDir, storyId) {
|
|
3369
|
-
if (!
|
|
4316
|
+
if (!fs24.existsSync(featureDir)) return void 0;
|
|
3370
4317
|
const pattern = /^(\d{2,})-story-.+\.md$/;
|
|
3371
|
-
for (const entry of
|
|
4318
|
+
for (const entry of fs24.readdirSync(featureDir)) {
|
|
4319
|
+
if (entry.endsWith(".partial.md")) continue;
|
|
3372
4320
|
const m = entry.match(pattern);
|
|
3373
4321
|
if (!m) continue;
|
|
3374
|
-
if (entry.includes(storyId)) return { absPath:
|
|
4322
|
+
if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
|
|
3375
4323
|
}
|
|
3376
4324
|
return void 0;
|
|
3377
4325
|
}
|
|
3378
|
-
function
|
|
3379
|
-
|
|
3380
|
-
const
|
|
3381
|
-
|
|
4326
|
+
function findExistingPartialPlan(featureDir, storyId) {
|
|
4327
|
+
if (!fs24.existsSync(featureDir)) return void 0;
|
|
4328
|
+
const pattern = /^(\d{2,})-story-.+\.partial\.md$/;
|
|
4329
|
+
for (const entry of fs24.readdirSync(featureDir)) {
|
|
4330
|
+
const m = entry.match(pattern);
|
|
4331
|
+
if (!m) continue;
|
|
4332
|
+
if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
|
|
4333
|
+
}
|
|
4334
|
+
return void 0;
|
|
4335
|
+
}
|
|
4336
|
+
function upsertOverviewRow(featureDir, feature, nn, filename, story, isPartial) {
|
|
4337
|
+
const overviewPath = path17.join(featureDir, "00-overview.md");
|
|
4338
|
+
const titleCell = isPartial ? `${story.titleHint ?? story.id} (partial)` : story.titleHint ?? story.id;
|
|
4339
|
+
const row = `| ${formatSequence(nn)} | \`${filename}\` | ${titleCell} | ${story.id} | \u2014 |`;
|
|
4340
|
+
if (!fs24.existsSync(overviewPath)) {
|
|
3382
4341
|
const content = `# ${feature} \u2014 plan overview
|
|
3383
4342
|
|
|
3384
4343
|
Entry point for the **${feature}** feature. Stories execute in order by their \`NN\` prefix.
|
|
@@ -3389,10 +4348,10 @@ Entry point for the **${feature}** feature. Stories execute in order by their \`
|
|
|
3389
4348
|
|----|------|-------|------------|------------|
|
|
3390
4349
|
${row}
|
|
3391
4350
|
`;
|
|
3392
|
-
|
|
4351
|
+
fs24.writeFileSync(overviewPath, content, "utf8");
|
|
3393
4352
|
return;
|
|
3394
4353
|
}
|
|
3395
|
-
const current =
|
|
4354
|
+
const current = fs24.readFileSync(overviewPath, "utf8");
|
|
3396
4355
|
const lines = current.split("\n");
|
|
3397
4356
|
const existingIdx = lines.findIndex((l) => l.includes(`\`${filename}\``) || l.includes(`| ${formatSequence(nn)} |`));
|
|
3398
4357
|
if (existingIdx >= 0) {
|
|
@@ -3401,7 +4360,7 @@ ${row}
|
|
|
3401
4360
|
const tableEnd = findTableEnd(lines);
|
|
3402
4361
|
lines.splice(tableEnd, 0, row);
|
|
3403
4362
|
}
|
|
3404
|
-
|
|
4363
|
+
fs24.writeFileSync(overviewPath, lines.join("\n"), "utf8");
|
|
3405
4364
|
}
|
|
3406
4365
|
function findTableEnd(lines) {
|
|
3407
4366
|
let inTable = false;
|
|
@@ -3423,20 +4382,39 @@ function buildMetadataHeader(args) {
|
|
|
3423
4382
|
`${args.inputTokens} in / ${args.outputTokens} out tokens`,
|
|
3424
4383
|
args.costUsd !== void 0 ? `~$${args.costUsd.toFixed(2)}` : null
|
|
3425
4384
|
].filter(Boolean);
|
|
3426
|
-
|
|
4385
|
+
const line1 = `<!-- squad-kit: ${parts.join(", ")} -->`;
|
|
4386
|
+
if (args.planStatus === "partial") {
|
|
4387
|
+
return `${line1}
|
|
4388
|
+
|
|
4389
|
+
---
|
|
4390
|
+
squad-kit-plan-status: partial
|
|
4391
|
+
---`;
|
|
4392
|
+
}
|
|
4393
|
+
return line1;
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4396
|
+
// src/core/copy-plan-prompt.ts
|
|
4397
|
+
function buildCopyPlanPromptMarkdown(config2, intakeContent) {
|
|
4398
|
+
const metaPromptTemplate = readBundledPrompt("generate-plan.md");
|
|
4399
|
+
return render(metaPromptTemplate, {
|
|
4400
|
+
projectRoots: (config2.project.projectRoots ?? ["."]).join(", "),
|
|
4401
|
+
primaryLanguage: config2.project.primaryLanguage ?? "",
|
|
4402
|
+
trackerType: config2.tracker.type,
|
|
4403
|
+
intakeContent
|
|
4404
|
+
});
|
|
3427
4405
|
}
|
|
3428
4406
|
|
|
3429
4407
|
// src/core/last-run.ts
|
|
3430
|
-
import
|
|
3431
|
-
import
|
|
4408
|
+
import fs25 from "fs/promises";
|
|
4409
|
+
import path18 from "path";
|
|
3432
4410
|
async function writeLastRun(paths, rec) {
|
|
3433
|
-
const file =
|
|
3434
|
-
await
|
|
4411
|
+
const file = path18.join(paths.squadDir, ".last-run.json");
|
|
4412
|
+
await fs25.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
|
|
3435
4413
|
}
|
|
3436
4414
|
async function readLastRun(paths) {
|
|
3437
|
-
const file =
|
|
4415
|
+
const file = path18.join(paths.squadDir, ".last-run.json");
|
|
3438
4416
|
try {
|
|
3439
|
-
const raw = await
|
|
4417
|
+
const raw = await fs25.readFile(file, "utf8");
|
|
3440
4418
|
const parsed = JSON.parse(raw);
|
|
3441
4419
|
if (parsed.version !== 1) return void 0;
|
|
3442
4420
|
return parsed;
|
|
@@ -3496,8 +4474,8 @@ async function runNewPlan(intakePath, opts) {
|
|
|
3496
4474
|
}
|
|
3497
4475
|
}
|
|
3498
4476
|
function resolveFromPath(intakePath, stories, root) {
|
|
3499
|
-
const resolved =
|
|
3500
|
-
if (!
|
|
4477
|
+
const resolved = path19.resolve(intakePath.trim());
|
|
4478
|
+
if (!fs26.existsSync(resolved)) {
|
|
3501
4479
|
throw new Error(
|
|
3502
4480
|
`Intake not found: ${resolved}. Run \`squad list\` to see intakes, or \`squad new-story\` to create one first.`
|
|
3503
4481
|
);
|
|
@@ -3509,7 +4487,7 @@ function resolveFromPath(intakePath, stories, root) {
|
|
|
3509
4487
|
);
|
|
3510
4488
|
}
|
|
3511
4489
|
function relativeStoriesDir(root) {
|
|
3512
|
-
return
|
|
4490
|
+
return path19.relative(root, path19.join(root, ".squad", "stories"));
|
|
3513
4491
|
}
|
|
3514
4492
|
async function pickStory(stories, opts) {
|
|
3515
4493
|
const candidates = opts.all ? stories : stories.filter((s) => !s.planFile);
|
|
@@ -3532,7 +4510,7 @@ async function pickStory(stories, opts) {
|
|
|
3532
4510
|
"No intake path provided and not running interactively. Run `squad new-plan` with a path to intake.md, or `squad new-plan` in a TTY to pick, or add `--yes` with an explicit path in CI."
|
|
3533
4511
|
);
|
|
3534
4512
|
}
|
|
3535
|
-
const pick = await
|
|
4513
|
+
const pick = await select6({
|
|
3536
4514
|
message: "Pick a story to plan:",
|
|
3537
4515
|
pageSize: Math.min(10, candidates.length),
|
|
3538
4516
|
choices: candidates.map((s) => ({
|
|
@@ -3540,7 +4518,7 @@ async function pickStory(stories, opts) {
|
|
|
3540
4518
|
value: s.intakePath
|
|
3541
4519
|
}))
|
|
3542
4520
|
});
|
|
3543
|
-
return candidates.find((s) =>
|
|
4521
|
+
return candidates.find((s) => path19.resolve(s.intakePath) === path19.resolve(pick));
|
|
3544
4522
|
}
|
|
3545
4523
|
function formatLabel(s) {
|
|
3546
4524
|
const head = `${s.feature} / ${s.id}`;
|
|
@@ -3561,7 +4539,19 @@ async function confirmOverwrite(story, interactive, yes) {
|
|
|
3561
4539
|
const go = await confirm6({ message: "Proceed and regenerate?", default: false });
|
|
3562
4540
|
return go;
|
|
3563
4541
|
}
|
|
3564
|
-
|
|
4542
|
+
function printPlannerRunIncompleteSummary(result, relPlanFile) {
|
|
4543
|
+
blank();
|
|
4544
|
+
warning("Planning stopped before the model reported a clean completion.");
|
|
4545
|
+
kv("saved", relPlanFile, 8);
|
|
4546
|
+
if (result.userCancelled) kv("stopped", "you chose not to continue after a limit", 8);
|
|
4547
|
+
if (result.timedOut) kv("timed out", "yes", 8);
|
|
4548
|
+
if (result.budgetExhausted) kv("read budget", "exhausted (model was asked to finalise)", 8);
|
|
4549
|
+
if (result.incompleteKind) kv("detail", result.incompleteKind, 8);
|
|
4550
|
+
blank();
|
|
4551
|
+
info("The file uses the `.partial.md` suffix and YAML `squad-kit-plan-status: partial`.");
|
|
4552
|
+
info("Raise limits in `.squad/config.yaml` (`planner.budget`, `planner.maxOutputTokens`) or re-run when ready.");
|
|
4553
|
+
}
|
|
4554
|
+
async function emitViaApi(story, paths, config2, opts) {
|
|
3565
4555
|
const planner = config2.planner;
|
|
3566
4556
|
if (!planner?.enabled) {
|
|
3567
4557
|
throw new Error(
|
|
@@ -3586,6 +4576,10 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3586
4576
|
`${planner.budget.maxFileReads} reads \xB7 ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB \xB7 ${planner.budget.maxDurationSeconds}s`
|
|
3587
4577
|
);
|
|
3588
4578
|
blank();
|
|
4579
|
+
const interactive = !opts.yes && Boolean(process.stdin.isTTY);
|
|
4580
|
+
if (interactive) {
|
|
4581
|
+
printPlannerApiCostNotice();
|
|
4582
|
+
}
|
|
3589
4583
|
const mapSpinner = spinner("building repo map\u2026");
|
|
3590
4584
|
const repoMap = buildRepoMap(paths.root);
|
|
3591
4585
|
mapSpinner.succeed(
|
|
@@ -3602,33 +4596,65 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3602
4596
|
const sessionSpinner = { current: null };
|
|
3603
4597
|
const startedAt = Date.now();
|
|
3604
4598
|
const cacheEnabled = planner.cache?.enabled ?? true;
|
|
3605
|
-
const
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
4599
|
+
const runId = newRunId();
|
|
4600
|
+
const bus = new PlannerEventBus();
|
|
4601
|
+
const unsubscribe = bus.subscribe((e) => {
|
|
4602
|
+
switch (e.kind) {
|
|
4603
|
+
case "tool_call": {
|
|
4604
|
+
const p = e.toolCall.input.path ?? "<unknown>";
|
|
4605
|
+
sessionSpinner.current?.succeed(
|
|
4606
|
+
`read ${p} (${(e.bytesLoaded / 1024).toFixed(1)} KB \xB7 ${(e.totalBytes / 1024).toFixed(1)} KB / ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB)`
|
|
4607
|
+
);
|
|
4608
|
+
sessionSpinner.current = spinner("reading next file\u2026");
|
|
4609
|
+
break;
|
|
4610
|
+
}
|
|
4611
|
+
case "assistant_text":
|
|
4612
|
+
sessionSpinner.current?.succeed("planner thinking complete (this chunk)");
|
|
4613
|
+
sessionSpinner.current = spinner("thinking\u2026");
|
|
4614
|
+
break;
|
|
4615
|
+
case "rate_limit":
|
|
4616
|
+
sessionSpinner.current?.stop();
|
|
4617
|
+
warning(`${planner.provider} rate limit hit \u2014 retrying in ${e.waitSec}s`);
|
|
4618
|
+
sessionSpinner.current = spinner("waiting for rate limit to reset\u2026");
|
|
4619
|
+
break;
|
|
4620
|
+
case "usage":
|
|
4621
|
+
break;
|
|
4622
|
+
default:
|
|
4623
|
+
break;
|
|
3629
4624
|
}
|
|
3630
4625
|
});
|
|
3631
|
-
|
|
4626
|
+
const decideOnLimit = interactive ? async (ctx) => {
|
|
4627
|
+
printPlannerLimitExplanation(ctx);
|
|
4628
|
+
const ans = await select6({
|
|
4629
|
+
message: "Continue this planning session? (Continuing sends more API requests and is billed.)",
|
|
4630
|
+
choices: [
|
|
4631
|
+
{ name: "Continue \u2014 extend limits for this run", value: "continue" },
|
|
4632
|
+
{ name: "Stop \u2014 save partial plan and exit", value: "cancel" }
|
|
4633
|
+
]
|
|
4634
|
+
});
|
|
4635
|
+
if (ans === "cancel") printPlannerLimitNextSteps();
|
|
4636
|
+
return ans;
|
|
4637
|
+
} : void 0;
|
|
4638
|
+
let result;
|
|
4639
|
+
try {
|
|
4640
|
+
result = await runPlanner({
|
|
4641
|
+
root: paths.root,
|
|
4642
|
+
provider,
|
|
4643
|
+
model,
|
|
4644
|
+
apiKey,
|
|
4645
|
+
systemPrompt,
|
|
4646
|
+
userPrompt,
|
|
4647
|
+
budget,
|
|
4648
|
+
maxOutputTokens: planner.maxOutputTokens,
|
|
4649
|
+
cacheEnabled,
|
|
4650
|
+
decideOnLimit,
|
|
4651
|
+
events: bus,
|
|
4652
|
+
runId
|
|
4653
|
+
});
|
|
4654
|
+
} finally {
|
|
4655
|
+
unsubscribe();
|
|
4656
|
+
sessionSpinner.current?.stop();
|
|
4657
|
+
}
|
|
3632
4658
|
if (!result.planText.trim()) {
|
|
3633
4659
|
throw new Error(
|
|
3634
4660
|
"Planner returned no plan text. Run `squad doctor` to check provider and models, or `squad new-plan --copy` to avoid the API."
|
|
@@ -3636,6 +4662,7 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3636
4662
|
}
|
|
3637
4663
|
const snap = budget.snapshot();
|
|
3638
4664
|
const elapsedMs = Date.now() - startedAt;
|
|
4665
|
+
const success2 = result.finishedNormally && !result.timedOut && !result.userCancelled;
|
|
3639
4666
|
const header = buildMetadataHeader({
|
|
3640
4667
|
provider: planner.provider,
|
|
3641
4668
|
model,
|
|
@@ -3644,15 +4671,18 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3644
4671
|
inputTokens: snap.usage.inputTokens,
|
|
3645
4672
|
outputTokens: snap.usage.outputTokens,
|
|
3646
4673
|
durationMs: elapsedMs,
|
|
3647
|
-
costUsd: snap.usage.costUsd
|
|
4674
|
+
costUsd: snap.usage.costUsd,
|
|
4675
|
+
planStatus: success2 ? void 0 : "partial"
|
|
3648
4676
|
});
|
|
3649
4677
|
const { planFile, sequenceNumber, overwrote } = writePlanFile({
|
|
3650
4678
|
paths,
|
|
3651
4679
|
config: config2,
|
|
3652
4680
|
story,
|
|
3653
4681
|
planBodyMarkdown: result.planText,
|
|
3654
|
-
metadataHeader: header
|
|
4682
|
+
metadataHeader: header,
|
|
4683
|
+
partial: !success2
|
|
3655
4684
|
});
|
|
4685
|
+
const relPlan = path19.relative(paths.root, planFile);
|
|
3656
4686
|
try {
|
|
3657
4687
|
await writeLastRun(paths, {
|
|
3658
4688
|
stats: result.stats,
|
|
@@ -3660,11 +4690,26 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3660
4690
|
provider: planner.provider,
|
|
3661
4691
|
model
|
|
3662
4692
|
});
|
|
4693
|
+
await appendRun(paths, {
|
|
4694
|
+
runId,
|
|
4695
|
+
provider: planner.provider,
|
|
4696
|
+
model,
|
|
4697
|
+
feature: story.feature,
|
|
4698
|
+
storyId: story.id,
|
|
4699
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
4700
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4701
|
+
success: success2,
|
|
4702
|
+
partial: !success2,
|
|
4703
|
+
planFile: relPlan,
|
|
4704
|
+
stats: result.stats,
|
|
4705
|
+
cacheEnabled,
|
|
4706
|
+
durationMs: elapsedMs
|
|
4707
|
+
});
|
|
3663
4708
|
} catch {
|
|
3664
4709
|
}
|
|
3665
4710
|
blank();
|
|
3666
|
-
summaryBox(" plan generated ", [
|
|
3667
|
-
{ key: "file", value:
|
|
4711
|
+
summaryBox(success2 ? " plan generated " : " plan saved (incomplete) ", [
|
|
4712
|
+
{ key: "file", value: relPlan },
|
|
3668
4713
|
{ key: "nn", value: String(sequenceNumber).padStart(2, "0") },
|
|
3669
4714
|
{ key: "model", value: `${planner.provider}/${model}` },
|
|
3670
4715
|
{ key: "reads", value: `${snap.reads} files \xB7 ${(snap.bytes / 1024).toFixed(1)} KB` },
|
|
@@ -3673,29 +4718,32 @@ async function emitViaApi(story, paths, config2, _opts) {
|
|
|
3673
4718
|
value: `${snap.usage.inputTokens} in \xB7 ${snap.usage.outputTokens} out${snap.usage.costUsd !== void 0 ? ` \u2248 $${snap.usage.costUsd.toFixed(2)}` : ""}`
|
|
3674
4719
|
},
|
|
3675
4720
|
{ key: "cache", value: formatPlannerCacheLine({ cacheEnabled, stats: result.stats }) },
|
|
4721
|
+
{
|
|
4722
|
+
key: "max out",
|
|
4723
|
+
value: `${planner.maxOutputTokens ?? DEFAULT_PLANNER_MAX_OUTPUT_TOKENS} tok/req`
|
|
4724
|
+
},
|
|
3676
4725
|
{ key: "time", value: `${Math.round(elapsedMs / 1e3)}s` },
|
|
3677
|
-
{
|
|
4726
|
+
{
|
|
4727
|
+
key: "action",
|
|
4728
|
+
value: success2 ? overwrote ? "overwrote existing plan" : "new plan" : "partial plan (review limits)"
|
|
4729
|
+
}
|
|
3678
4730
|
]);
|
|
3679
|
-
if (
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
4731
|
+
if (!success2) {
|
|
4732
|
+
printPlannerRunIncompleteSummary(result, relPlan);
|
|
4733
|
+
throw new SquadExit(2);
|
|
4734
|
+
}
|
|
4735
|
+
if (result.budgetExhausted) {
|
|
4736
|
+
info("Note: file-read budget was reached; the model finalised without further reads.");
|
|
3683
4737
|
}
|
|
3684
4738
|
blank();
|
|
3685
|
-
info(`next \u2192 open a new agent chat and attach only ${
|
|
4739
|
+
info(`next \u2192 open a new agent chat and attach only ${path19.basename(planFile)}`);
|
|
3686
4740
|
}
|
|
3687
4741
|
async function emitCopyPrompt(story, paths, config2, clipboard) {
|
|
3688
4742
|
const intakeContent = readFile(story.intakePath);
|
|
3689
|
-
const
|
|
3690
|
-
const
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
trackerType: config2.tracker.type,
|
|
3694
|
-
intakeContent
|
|
3695
|
-
});
|
|
3696
|
-
const promptFile = path15.join(paths.squadDir, ".last-copy-prompt.md");
|
|
3697
|
-
fs22.writeFileSync(promptFile, composed, "utf8");
|
|
3698
|
-
const relPromptFile = path15.relative(paths.root, promptFile);
|
|
4743
|
+
const composed = buildCopyPlanPromptMarkdown(config2, intakeContent);
|
|
4744
|
+
const promptFile = path19.join(paths.squadDir, ".last-copy-prompt.md");
|
|
4745
|
+
fs26.writeFileSync(promptFile, composed, "utf8");
|
|
4746
|
+
const relPromptFile = path19.relative(paths.root, promptFile);
|
|
3699
4747
|
const bytes = Buffer.byteLength(composed, "utf8");
|
|
3700
4748
|
const estTokens = Math.round(bytes / 4);
|
|
3701
4749
|
const clipResult = clipboard ? await copyToClipboard(composed) : { ok: false, reason: "clipboard disabled (--no-clipboard)" };
|
|
@@ -3753,7 +4801,7 @@ function printModelBanner(config2) {
|
|
|
3753
4801
|
}
|
|
3754
4802
|
|
|
3755
4803
|
// src/commands/status.ts
|
|
3756
|
-
import
|
|
4804
|
+
import fs27 from "fs";
|
|
3757
4805
|
var kw = 28;
|
|
3758
4806
|
function probeTrackerCredential(config2, overlay) {
|
|
3759
4807
|
const { client, error: error2 } = clientFor(config2, overlay);
|
|
@@ -3779,7 +4827,7 @@ async function runStatus() {
|
|
|
3779
4827
|
const scan = scanPlans(paths.plansDir);
|
|
3780
4828
|
const storyCount = listStories(paths).length;
|
|
3781
4829
|
const planCount = scan.usedNumbers.length;
|
|
3782
|
-
const secrets =
|
|
4830
|
+
const secrets = fs27.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
3783
4831
|
const overlay = overlayTrackerEnv(secrets);
|
|
3784
4832
|
banner();
|
|
3785
4833
|
step("squad-kit status");
|
|
@@ -3833,13 +4881,13 @@ function buildTrackerLine(config2, overlay) {
|
|
|
3833
4881
|
}
|
|
3834
4882
|
|
|
3835
4883
|
// src/commands/list.ts
|
|
3836
|
-
import
|
|
4884
|
+
import path20 from "path";
|
|
3837
4885
|
async function runList(opts) {
|
|
3838
4886
|
const root = requireSquadRoot();
|
|
3839
4887
|
const paths = buildPaths(root);
|
|
3840
4888
|
const records = listStories(paths, { feature: opts.feature });
|
|
3841
4889
|
const rows = records.map((r) => {
|
|
3842
|
-
const planAbs = r.planFile ?
|
|
4890
|
+
const planAbs = r.planFile ? path20.join(paths.plansDir, r.feature, r.planFile) : void 0;
|
|
3843
4891
|
const meta = planAbs ? readPlanMetadata(planAbs) : void 0;
|
|
3844
4892
|
return {
|
|
3845
4893
|
feature: r.feature,
|
|
@@ -3859,7 +4907,7 @@ async function runList(opts) {
|
|
|
3859
4907
|
r.id,
|
|
3860
4908
|
r.hasIntake ? "yes" : "\u2014",
|
|
3861
4909
|
r.planFile ?? theme_exports.dim("\u2014"),
|
|
3862
|
-
r.meta?.generatedBy ?? (r.planFile ? "
|
|
4910
|
+
r.meta?.generatedBy ?? (r.planFile ? theme_exports.dim("not recorded") : theme_exports.dim("\u2014"))
|
|
3863
4911
|
]);
|
|
3864
4912
|
printTable([header, ...table]);
|
|
3865
4913
|
}
|
|
@@ -3878,9 +4926,9 @@ function stripAnsi(s) {
|
|
|
3878
4926
|
}
|
|
3879
4927
|
|
|
3880
4928
|
// src/commands/tracker-link.ts
|
|
3881
|
-
import
|
|
3882
|
-
import
|
|
3883
|
-
import { input as input4, select as
|
|
4929
|
+
import fs28 from "fs";
|
|
4930
|
+
import path21 from "path";
|
|
4931
|
+
import { input as input4, select as select7 } from "@inquirer/prompts";
|
|
3884
4932
|
async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
3885
4933
|
const root = requireSquadRoot();
|
|
3886
4934
|
const paths = buildPaths(root);
|
|
@@ -3897,7 +4945,7 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
|
3897
4945
|
if (stories.length === 0) {
|
|
3898
4946
|
throw new Error("No stories found under .squad/stories/. Create one with `squad new-story` first.");
|
|
3899
4947
|
}
|
|
3900
|
-
resolvedStoryPath = await
|
|
4948
|
+
resolvedStoryPath = await select7({
|
|
3901
4949
|
message: "Story to link:",
|
|
3902
4950
|
choices: stories.map((s) => ({
|
|
3903
4951
|
name: `${s.feature}/${s.id}`,
|
|
@@ -3926,17 +4974,17 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
|
3926
4974
|
`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.`
|
|
3927
4975
|
);
|
|
3928
4976
|
}
|
|
3929
|
-
const resolved =
|
|
3930
|
-
const intakeFile = resolved.endsWith("intake.md") ? resolved :
|
|
3931
|
-
if (!
|
|
4977
|
+
const resolved = path21.resolve(resolvedStoryPath);
|
|
4978
|
+
const intakeFile = resolved.endsWith("intake.md") ? resolved : path21.join(resolved, "intake.md");
|
|
4979
|
+
if (!fs28.existsSync(intakeFile)) {
|
|
3932
4980
|
throw new Error(
|
|
3933
4981
|
`intake.md not found at ${intakeFile}. Run \`squad list\` to see valid paths, or \`squad new-story\` to create a story first.`
|
|
3934
4982
|
);
|
|
3935
4983
|
}
|
|
3936
|
-
const content =
|
|
4984
|
+
const content = fs28.readFileSync(intakeFile, "utf8");
|
|
3937
4985
|
const updated = upsertTrackerId(content, resolvedTrackerId);
|
|
3938
|
-
|
|
3939
|
-
success(`Linked tracker id ${resolvedTrackerId} \u2192 ${
|
|
4986
|
+
fs28.writeFileSync(intakeFile, updated, "utf8");
|
|
4987
|
+
success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path21.relative(root, intakeFile)}`);
|
|
3940
4988
|
blank();
|
|
3941
4989
|
step("Next:");
|
|
3942
4990
|
info("1) Review the intake \u2014 the Source block at the top now carries the tracker id.");
|
|
@@ -3953,9 +5001,9 @@ function upsertTrackerId(content, id) {
|
|
|
3953
5001
|
`;
|
|
3954
5002
|
}
|
|
3955
5003
|
|
|
3956
|
-
// src/commands/doctor.ts
|
|
3957
|
-
import
|
|
3958
|
-
import
|
|
5004
|
+
// src/commands/doctor-engine.ts
|
|
5005
|
+
import fs29 from "fs";
|
|
5006
|
+
import path22 from "path";
|
|
3959
5007
|
async function gatherContext(paths) {
|
|
3960
5008
|
const ctx = { hasLegacyPromptsDir: false };
|
|
3961
5009
|
try {
|
|
@@ -3964,29 +5012,29 @@ async function gatherContext(paths) {
|
|
|
3964
5012
|
ctx.configError = err;
|
|
3965
5013
|
}
|
|
3966
5014
|
try {
|
|
3967
|
-
ctx.secrets =
|
|
5015
|
+
ctx.secrets = fs29.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
3968
5016
|
} catch (err) {
|
|
3969
5017
|
ctx.secretsError = err;
|
|
3970
5018
|
}
|
|
3971
|
-
ctx.hasLegacyPromptsDir =
|
|
5019
|
+
ctx.hasLegacyPromptsDir = fs29.existsSync(paths.promptsDir);
|
|
3972
5020
|
return ctx;
|
|
3973
5021
|
}
|
|
3974
5022
|
function gitignoreHasManagedBlock(repoRoot) {
|
|
3975
|
-
const gitignore =
|
|
3976
|
-
return
|
|
5023
|
+
const gitignore = path22.join(repoRoot, ".gitignore");
|
|
5024
|
+
return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
|
|
3977
5025
|
}
|
|
3978
5026
|
function gitignoreHasTrashPattern(repoRoot) {
|
|
3979
|
-
const gitignore =
|
|
3980
|
-
return
|
|
5027
|
+
const gitignore = path22.join(repoRoot, ".gitignore");
|
|
5028
|
+
return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
|
|
3981
5029
|
}
|
|
3982
5030
|
async function checkDirStructure(paths, _ctx, fix) {
|
|
3983
|
-
const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !
|
|
5031
|
+
const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs29.existsSync(p));
|
|
3984
5032
|
if (need.length === 0) {
|
|
3985
5033
|
return { id: "dirs", name: ".squad/ directory structure", status: "ok" };
|
|
3986
5034
|
}
|
|
3987
5035
|
if (fix) {
|
|
3988
5036
|
for (const p of need) {
|
|
3989
|
-
|
|
5037
|
+
fs29.mkdirSync(p, { recursive: true });
|
|
3990
5038
|
}
|
|
3991
5039
|
return { id: "dirs", name: ".squad/ directory structure", status: "ok", detail: "repaired" };
|
|
3992
5040
|
}
|
|
@@ -3994,7 +5042,7 @@ async function checkDirStructure(paths, _ctx, fix) {
|
|
|
3994
5042
|
id: "dirs",
|
|
3995
5043
|
name: ".squad/ directory structure",
|
|
3996
5044
|
status: "warn",
|
|
3997
|
-
detail: `missing: ${need.map((p) =>
|
|
5045
|
+
detail: `missing: ${need.map((p) => path22.relative(paths.root, p)).join(", ")}`,
|
|
3998
5046
|
fixable: true,
|
|
3999
5047
|
fixHint: "squad doctor --fix"
|
|
4000
5048
|
};
|
|
@@ -4049,15 +5097,15 @@ async function checkSecretsPermissions(paths, _ctx, fix) {
|
|
|
4049
5097
|
if (process.platform === "win32") {
|
|
4050
5098
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "skip", detail: "Windows" };
|
|
4051
5099
|
}
|
|
4052
|
-
if (!
|
|
5100
|
+
if (!fs29.existsSync(paths.secretsFile)) {
|
|
4053
5101
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "not present" };
|
|
4054
5102
|
}
|
|
4055
|
-
const mode =
|
|
5103
|
+
const mode = fs29.statSync(paths.secretsFile).mode & 511;
|
|
4056
5104
|
if (mode === 384) {
|
|
4057
5105
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok" };
|
|
4058
5106
|
}
|
|
4059
5107
|
if (fix) {
|
|
4060
|
-
|
|
5108
|
+
fs29.chmodSync(paths.secretsFile, 384);
|
|
4061
5109
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "repaired" };
|
|
4062
5110
|
}
|
|
4063
5111
|
return {
|
|
@@ -4078,7 +5126,7 @@ async function checkSecretsParseable(_paths, ctx) {
|
|
|
4078
5126
|
detail: ctx.secretsError.message
|
|
4079
5127
|
};
|
|
4080
5128
|
}
|
|
4081
|
-
if (!
|
|
5129
|
+
if (!fs29.existsSync(_paths.secretsFile)) {
|
|
4082
5130
|
return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok", detail: "not present" };
|
|
4083
5131
|
}
|
|
4084
5132
|
return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok" };
|
|
@@ -4460,6 +5508,8 @@ function summarise(checks) {
|
|
|
4460
5508
|
skip: checks.filter((c) => c.status === "skip").length
|
|
4461
5509
|
};
|
|
4462
5510
|
}
|
|
5511
|
+
|
|
5512
|
+
// src/commands/doctor.ts
|
|
4463
5513
|
function colorForStatus(s) {
|
|
4464
5514
|
switch (s) {
|
|
4465
5515
|
case "ok":
|
|
@@ -4552,23 +5602,23 @@ async function runDoctor(opts) {
|
|
|
4552
5602
|
}
|
|
4553
5603
|
|
|
4554
5604
|
// src/commands/migrate.ts
|
|
4555
|
-
import
|
|
4556
|
-
import
|
|
5605
|
+
import fs30 from "fs";
|
|
5606
|
+
import path23 from "path";
|
|
4557
5607
|
import { confirm as confirm7 } from "@inquirer/prompts";
|
|
4558
5608
|
var LEGACY_PROMPTS_MIGRATION = {
|
|
4559
5609
|
id: "legacy-prompts",
|
|
4560
5610
|
name: "Remove legacy .squad/prompts/",
|
|
4561
|
-
isApplied: (paths) => !
|
|
4562
|
-
describe: (paths) => `delete ${
|
|
5611
|
+
isApplied: (paths) => !fs30.existsSync(paths.promptsDir),
|
|
5612
|
+
describe: (paths) => `delete ${path23.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
|
|
4563
5613
|
apply: (paths) => {
|
|
4564
5614
|
const target = paths.promptsDir;
|
|
4565
|
-
if (!target.includes(
|
|
5615
|
+
if (!target.includes(path23.sep + ".squad" + path23.sep + "prompts")) {
|
|
4566
5616
|
throw new Error(
|
|
4567
5617
|
`Refusing to delete suspicious path: ${target}. This is a safety check \u2014 run \`squad doctor\` if .squad/ paths look wrong.`
|
|
4568
5618
|
);
|
|
4569
5619
|
}
|
|
4570
|
-
if (
|
|
4571
|
-
|
|
5620
|
+
if (fs30.existsSync(target)) {
|
|
5621
|
+
fs30.rmSync(target, { recursive: true, force: true });
|
|
4572
5622
|
}
|
|
4573
5623
|
}
|
|
4574
5624
|
};
|
|
@@ -4576,8 +5626,8 @@ var GITIGNORE_MIGRATION = {
|
|
|
4576
5626
|
id: "gitignore",
|
|
4577
5627
|
name: "Ensure .gitignore managed block",
|
|
4578
5628
|
isApplied: (paths) => {
|
|
4579
|
-
const f =
|
|
4580
|
-
return
|
|
5629
|
+
const f = path23.join(paths.root, ".gitignore");
|
|
5630
|
+
return fs30.existsSync(f) && fs30.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
|
|
4581
5631
|
},
|
|
4582
5632
|
describe: () => "add squad-kit managed block to .gitignore",
|
|
4583
5633
|
apply: (paths) => {
|
|
@@ -4589,23 +5639,23 @@ var SECRETS_PERMS_MIGRATION = {
|
|
|
4589
5639
|
name: "Tighten .squad/secrets.yaml permissions",
|
|
4590
5640
|
isApplied: (paths) => {
|
|
4591
5641
|
if (process.platform === "win32") return true;
|
|
4592
|
-
if (!
|
|
4593
|
-
const st =
|
|
5642
|
+
if (!fs30.existsSync(paths.secretsFile)) return true;
|
|
5643
|
+
const st = fs30.statSync(paths.secretsFile);
|
|
4594
5644
|
return (st.mode & 511) === 384;
|
|
4595
5645
|
},
|
|
4596
|
-
describe: (paths) => `chmod 0600 ${
|
|
5646
|
+
describe: (paths) => `chmod 0600 ${path23.relative(paths.root, paths.secretsFile)}`,
|
|
4597
5647
|
apply: (paths) => {
|
|
4598
5648
|
if (process.platform === "win32") return;
|
|
4599
|
-
if (
|
|
5649
|
+
if (fs30.existsSync(paths.secretsFile)) fs30.chmodSync(paths.secretsFile, 384);
|
|
4600
5650
|
}
|
|
4601
5651
|
};
|
|
4602
5652
|
var PLANNER_BUDGET_DEFAULTS_MIGRATION = {
|
|
4603
5653
|
id: "planner-budget-defaults",
|
|
4604
5654
|
name: "Backfill planner.budget defaults in config.yaml",
|
|
4605
5655
|
isApplied: (paths) => {
|
|
4606
|
-
if (!
|
|
5656
|
+
if (!fs30.existsSync(paths.configFile)) return true;
|
|
4607
5657
|
try {
|
|
4608
|
-
const raw =
|
|
5658
|
+
const raw = fs30.readFileSync(paths.configFile, "utf8");
|
|
4609
5659
|
if (!/^\s*planner:/m.test(raw)) return true;
|
|
4610
5660
|
if (/^\s*budget:/m.test(raw)) return true;
|
|
4611
5661
|
const cfg = loadConfig(paths.configFile);
|
|
@@ -4624,9 +5674,9 @@ var CONFIG_NORMALIZE_MIGRATION = {
|
|
|
4624
5674
|
id: "config-normalize",
|
|
4625
5675
|
name: "Normalise .squad/config.yaml formatting",
|
|
4626
5676
|
isApplied: (paths) => {
|
|
4627
|
-
if (!
|
|
5677
|
+
if (!fs30.existsSync(paths.configFile)) return true;
|
|
4628
5678
|
try {
|
|
4629
|
-
const raw =
|
|
5679
|
+
const raw = fs30.readFileSync(paths.configFile, "utf8");
|
|
4630
5680
|
const cfg = loadConfig(paths.configFile);
|
|
4631
5681
|
return raw.trim() === serializeConfig(cfg).trim();
|
|
4632
5682
|
} catch {
|
|
@@ -4635,7 +5685,7 @@ var CONFIG_NORMALIZE_MIGRATION = {
|
|
|
4635
5685
|
},
|
|
4636
5686
|
describe: () => "rewrite config.yaml with canonical YAML formatting (drops comments)",
|
|
4637
5687
|
apply: (paths) => {
|
|
4638
|
-
if (!
|
|
5688
|
+
if (!fs30.existsSync(paths.configFile)) return;
|
|
4639
5689
|
const cfg = loadConfig(paths.configFile);
|
|
4640
5690
|
saveConfig(paths.configFile, cfg);
|
|
4641
5691
|
}
|
|
@@ -4716,8 +5766,8 @@ import process3 from "process";
|
|
|
4716
5766
|
import { confirm as confirm8 } from "@inquirer/prompts";
|
|
4717
5767
|
|
|
4718
5768
|
// src/core/package-info.ts
|
|
4719
|
-
import
|
|
4720
|
-
import
|
|
5769
|
+
import fs31 from "fs";
|
|
5770
|
+
import path24 from "path";
|
|
4721
5771
|
function changelogBlobUrl(repositoryUrl, version) {
|
|
4722
5772
|
let base = repositoryUrl;
|
|
4723
5773
|
if (!base) base = "https://github.com/AzmSquad/squad-kit";
|
|
@@ -4728,9 +5778,9 @@ function changelogBlobUrl(repositoryUrl, version) {
|
|
|
4728
5778
|
}
|
|
4729
5779
|
function readInstalledPackage() {
|
|
4730
5780
|
const root = packageRoot();
|
|
4731
|
-
const pkgFile =
|
|
4732
|
-
const pkg = JSON.parse(
|
|
4733
|
-
const isDevInstall =
|
|
5781
|
+
const pkgFile = path24.join(root, "package.json");
|
|
5782
|
+
const pkg = JSON.parse(fs31.readFileSync(pkgFile, "utf8"));
|
|
5783
|
+
const isDevInstall = fs31.existsSync(path24.join(root, "src"));
|
|
4734
5784
|
return {
|
|
4735
5785
|
name: pkg.name,
|
|
4736
5786
|
version: pkg.version,
|
|
@@ -4770,7 +5820,7 @@ function isNewer(a, b) {
|
|
|
4770
5820
|
}
|
|
4771
5821
|
|
|
4772
5822
|
// src/core/package-manager.ts
|
|
4773
|
-
import
|
|
5823
|
+
import path25 from "path";
|
|
4774
5824
|
import process2 from "process";
|
|
4775
5825
|
function detectPackageManager(installRoot) {
|
|
4776
5826
|
const ua = process2.env.npm_config_user_agent;
|
|
@@ -4781,11 +5831,11 @@ function detectPackageManager(installRoot) {
|
|
|
4781
5831
|
if (ua.startsWith("npm/")) return { pm: "npm", reason: "npm_config_user_agent" };
|
|
4782
5832
|
}
|
|
4783
5833
|
const root = installRoot.toLowerCase();
|
|
4784
|
-
if (root.includes(`${
|
|
5834
|
+
if (root.includes(`${path25.sep}pnpm${path25.sep}`) || root.includes(`${path25.sep}.pnpm${path25.sep}`))
|
|
4785
5835
|
return { pm: "pnpm", reason: `install path contains pnpm` };
|
|
4786
|
-
if (root.includes(`${
|
|
5836
|
+
if (root.includes(`${path25.sep}.yarn${path25.sep}`) || root.includes(`${path25.sep}yarn${path25.sep}`))
|
|
4787
5837
|
return { pm: "yarn", reason: `install path contains yarn` };
|
|
4788
|
-
if (root.includes(`${
|
|
5838
|
+
if (root.includes(`${path25.sep}.bun${path25.sep}`) || root.includes(`${path25.sep}bun${path25.sep}`))
|
|
4789
5839
|
return { pm: "bun", reason: `install path contains bun` };
|
|
4790
5840
|
return { pm: "npm", reason: "default fallback" };
|
|
4791
5841
|
}
|
|
@@ -4850,244 +5900,1069 @@ async function runUpgrade(opts) {
|
|
|
4850
5900
|
info(`Run to upgrade: ${cmd} ${args.join(" ")}`);
|
|
4851
5901
|
return;
|
|
4852
5902
|
}
|
|
4853
|
-
if (!opts.yes) {
|
|
4854
|
-
const interactive = Boolean(process3.stdin.isTTY) && process3.env.CI !== "true";
|
|
4855
|
-
if (!interactive) {
|
|
4856
|
-
failure(
|
|
4857
|
-
`Refusing to run a global install without --yes. Run \`squad upgrade --yes\` in non-interactive mode, or run manually: ${cmd} ${args.join(" ")}`
|
|
4858
|
-
);
|
|
4859
|
-
process3.exit(1);
|
|
5903
|
+
if (!opts.yes) {
|
|
5904
|
+
const interactive = Boolean(process3.stdin.isTTY) && process3.env.CI !== "true";
|
|
5905
|
+
if (!interactive) {
|
|
5906
|
+
failure(
|
|
5907
|
+
`Refusing to run a global install without --yes. Run \`squad upgrade --yes\` in non-interactive mode, or run manually: ${cmd} ${args.join(" ")}`
|
|
5908
|
+
);
|
|
5909
|
+
process3.exit(1);
|
|
5910
|
+
}
|
|
5911
|
+
const go = await confirm8({ message: `Run "${cmd} ${args.join(" ")}"?`, default: true });
|
|
5912
|
+
if (!go) {
|
|
5913
|
+
info("Cancelled. No changes applied.");
|
|
5914
|
+
return;
|
|
5915
|
+
}
|
|
5916
|
+
}
|
|
5917
|
+
blank();
|
|
5918
|
+
info(`Running: ${cmd} ${args.join(" ")}`);
|
|
5919
|
+
const exitCode = await spawnInherit(cmd, args);
|
|
5920
|
+
if (exitCode !== 0) {
|
|
5921
|
+
failure(
|
|
5922
|
+
`${cmd} exited with code ${exitCode}. Upgrade may be incomplete \u2014 run \`squad doctor\` and re-run \`squad upgrade\` if needed.`
|
|
5923
|
+
);
|
|
5924
|
+
process3.exit(exitCode);
|
|
5925
|
+
}
|
|
5926
|
+
blank();
|
|
5927
|
+
success(`Upgraded to ${latest}.`);
|
|
5928
|
+
info("Run `squad migrate` to update your .squad/ structure if needed.");
|
|
5929
|
+
}
|
|
5930
|
+
function spawnInherit(cmd, args) {
|
|
5931
|
+
return new Promise((resolve2, reject) => {
|
|
5932
|
+
const child = spawn2(cmd, args, { stdio: "inherit", shell: process3.platform === "win32" });
|
|
5933
|
+
child.on("exit", (code) => resolve2(code ?? 0));
|
|
5934
|
+
child.on("error", (err) => reject(err));
|
|
5935
|
+
});
|
|
5936
|
+
}
|
|
5937
|
+
|
|
5938
|
+
// src/commands/console.ts
|
|
5939
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
5940
|
+
import { resolve } from "path";
|
|
5941
|
+
import open from "open";
|
|
5942
|
+
|
|
5943
|
+
// src/console/server.ts
|
|
5944
|
+
import { createServer } from "net";
|
|
5945
|
+
import { serve } from "@hono/node-server";
|
|
5946
|
+
import { Hono } from "hono";
|
|
5947
|
+
import { cors } from "hono/cors";
|
|
5948
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
5949
|
+
|
|
5950
|
+
// src/console/api/stories.ts
|
|
5951
|
+
import fs32 from "fs";
|
|
5952
|
+
import { z } from "zod";
|
|
5953
|
+
|
|
5954
|
+
// src/utils/path-safety.ts
|
|
5955
|
+
import path26 from "path";
|
|
5956
|
+
function isInside(abs, parent) {
|
|
5957
|
+
const a = path26.resolve(abs) + path26.sep;
|
|
5958
|
+
const p = path26.resolve(parent) + path26.sep;
|
|
5959
|
+
return a.startsWith(p);
|
|
5960
|
+
}
|
|
5961
|
+
|
|
5962
|
+
// src/console/api/stories.ts
|
|
5963
|
+
var CreateBody = z.object({
|
|
5964
|
+
feature: z.string().min(1).max(80),
|
|
5965
|
+
title: z.string().max(200).optional(),
|
|
5966
|
+
trackerId: z.string().max(80).optional()
|
|
5967
|
+
});
|
|
5968
|
+
var PatchBody = z.object({
|
|
5969
|
+
intakeContent: z.string().min(1).max(1e6)
|
|
5970
|
+
});
|
|
5971
|
+
function safeFeatureParam(feature) {
|
|
5972
|
+
return feature.length > 0 && !feature.includes("/") && !feature.includes("..") && slugify(feature) === feature;
|
|
5973
|
+
}
|
|
5974
|
+
function mountStoriesApi(app, opts) {
|
|
5975
|
+
app.get("/api/stories", (c) => {
|
|
5976
|
+
const feature = c.req.query("feature") ?? void 0;
|
|
5977
|
+
const stories = listStories(opts.paths, { feature });
|
|
5978
|
+
return c.json(
|
|
5979
|
+
stories.map((s) => ({
|
|
5980
|
+
feature: s.feature,
|
|
5981
|
+
id: s.id,
|
|
5982
|
+
intakePath: s.intakePath,
|
|
5983
|
+
storyDir: s.storyDir,
|
|
5984
|
+
planFile: s.planFile ?? null,
|
|
5985
|
+
titleHint: s.titleHint ?? null
|
|
5986
|
+
}))
|
|
5987
|
+
);
|
|
5988
|
+
});
|
|
5989
|
+
app.get("/api/stories/:feature/:id", (c) => {
|
|
5990
|
+
const { feature, id } = c.req.param();
|
|
5991
|
+
if (!safeFeatureParam(feature)) return c.json({ error: "forbidden" }, 403);
|
|
5992
|
+
const stories = listStories(opts.paths, { feature });
|
|
5993
|
+
const hit = stories.find((s) => s.id === id);
|
|
5994
|
+
if (!hit) return c.json({ error: "not_found" }, 404);
|
|
5995
|
+
if (!isInside(hit.intakePath, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
|
|
5996
|
+
let intakeContent = "";
|
|
5997
|
+
try {
|
|
5998
|
+
intakeContent = fs32.readFileSync(hit.intakePath, "utf8");
|
|
5999
|
+
} catch {
|
|
6000
|
+
}
|
|
6001
|
+
return c.json({
|
|
6002
|
+
feature: hit.feature,
|
|
6003
|
+
id: hit.id,
|
|
6004
|
+
intakePath: hit.intakePath,
|
|
6005
|
+
storyDir: hit.storyDir,
|
|
6006
|
+
planFile: hit.planFile ?? null,
|
|
6007
|
+
titleHint: hit.titleHint ?? null,
|
|
6008
|
+
intakeContent
|
|
6009
|
+
});
|
|
6010
|
+
});
|
|
6011
|
+
app.post("/api/stories", async (c) => {
|
|
6012
|
+
const body = CreateBody.safeParse(await c.req.json());
|
|
6013
|
+
if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
|
|
6014
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6015
|
+
let created;
|
|
6016
|
+
try {
|
|
6017
|
+
created = createStoryRecord({
|
|
6018
|
+
paths: opts.paths,
|
|
6019
|
+
config: cfg,
|
|
6020
|
+
feature: body.data.feature,
|
|
6021
|
+
title: body.data.title,
|
|
6022
|
+
trackerId: body.data.trackerId
|
|
6023
|
+
});
|
|
6024
|
+
} catch (e) {
|
|
6025
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
6026
|
+
if (/already exists|Intake already exists/.test(msg)) {
|
|
6027
|
+
return c.json({ error: "conflict", message: msg }, 409);
|
|
6028
|
+
}
|
|
6029
|
+
return c.json({ error: "invalid_request", message: msg }, 400);
|
|
6030
|
+
}
|
|
6031
|
+
return c.json(created, 201);
|
|
6032
|
+
});
|
|
6033
|
+
app.patch("/api/stories/:feature/:id", async (c) => {
|
|
6034
|
+
const body = PatchBody.safeParse(await c.req.json());
|
|
6035
|
+
if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
|
|
6036
|
+
const { feature, id } = c.req.param();
|
|
6037
|
+
if (!safeFeatureParam(feature)) return c.json({ error: "forbidden" }, 403);
|
|
6038
|
+
const stories = listStories(opts.paths, { feature });
|
|
6039
|
+
const hit = stories.find((s) => s.id === id);
|
|
6040
|
+
if (!hit) return c.json({ error: "not_found" }, 404);
|
|
6041
|
+
if (!isInside(hit.intakePath, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
|
|
6042
|
+
fs32.writeFileSync(hit.intakePath, body.data.intakeContent, "utf8");
|
|
6043
|
+
return c.json({ ok: true });
|
|
6044
|
+
});
|
|
6045
|
+
app.delete("/api/stories/:feature/:id", (c) => {
|
|
6046
|
+
const trash = c.req.query("trash") === "1";
|
|
6047
|
+
const { feature, id } = c.req.param();
|
|
6048
|
+
if (!safeFeatureParam(feature)) return c.json({ error: "forbidden" }, 403);
|
|
6049
|
+
const stories = listStories(opts.paths, { feature });
|
|
6050
|
+
const hit = stories.find((s) => s.id === id);
|
|
6051
|
+
if (!hit) return c.json({ error: "not_found" }, 404);
|
|
6052
|
+
if (!isInside(hit.storyDir, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
|
|
6053
|
+
const result = deleteStoryRecord({ paths: opts.paths, story: hit, trash });
|
|
6054
|
+
return c.json(result);
|
|
6055
|
+
});
|
|
6056
|
+
}
|
|
6057
|
+
|
|
6058
|
+
// src/console/api/plans.ts
|
|
6059
|
+
import fs33 from "fs";
|
|
6060
|
+
import path27 from "path";
|
|
6061
|
+
import { z as z2 } from "zod";
|
|
6062
|
+
import { diffLines } from "diff";
|
|
6063
|
+
var DiffQuery = z2.object({
|
|
6064
|
+
feature: z2.string().min(1).max(80),
|
|
6065
|
+
a: z2.string().min(1).max(200),
|
|
6066
|
+
b: z2.string().min(1).max(200)
|
|
6067
|
+
});
|
|
6068
|
+
function safeFeatureParam2(feature) {
|
|
6069
|
+
return feature.length > 0 && !feature.includes("/") && !feature.includes("..") && slugify(feature) === feature;
|
|
6070
|
+
}
|
|
6071
|
+
function mountPlansApi(app, opts) {
|
|
6072
|
+
app.get("/api/plans", (c) => {
|
|
6073
|
+
const out = [];
|
|
6074
|
+
for (const feature of listPlanFeatureNames(opts.paths)) {
|
|
6075
|
+
const featureDir = path27.join(opts.paths.plansDir, feature);
|
|
6076
|
+
if (!fs33.existsSync(featureDir)) continue;
|
|
6077
|
+
for (const entry of fs33.readdirSync(featureDir)) {
|
|
6078
|
+
if (!entry.endsWith(".md") || entry === "00-index.md") continue;
|
|
6079
|
+
const abs = path27.join(featureDir, entry);
|
|
6080
|
+
out.push({ feature, planFile: entry, metadata: readPlanMetadata(abs) });
|
|
6081
|
+
}
|
|
6082
|
+
}
|
|
6083
|
+
return c.json(out);
|
|
6084
|
+
});
|
|
6085
|
+
app.get("/api/plans/:feature/:planFile", (c) => {
|
|
6086
|
+
const { feature, planFile } = c.req.param();
|
|
6087
|
+
if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
|
|
6088
|
+
const abs = path27.resolve(path27.join(opts.paths.plansDir, feature, planFile));
|
|
6089
|
+
if (!isInside(abs, opts.paths.plansDir)) return c.json({ error: "forbidden" }, 403);
|
|
6090
|
+
if (!fs33.existsSync(abs)) return c.json({ error: "not_found" }, 404);
|
|
6091
|
+
const content = fs33.readFileSync(abs, "utf8");
|
|
6092
|
+
return c.json({
|
|
6093
|
+
feature,
|
|
6094
|
+
planFile,
|
|
6095
|
+
content,
|
|
6096
|
+
absPath: abs,
|
|
6097
|
+
metadata: readPlanMetadata(abs)
|
|
6098
|
+
});
|
|
6099
|
+
});
|
|
6100
|
+
app.get("/api/plans/diff", (c) => {
|
|
6101
|
+
const parse = DiffQuery.safeParse({
|
|
6102
|
+
feature: c.req.query("feature"),
|
|
6103
|
+
a: c.req.query("a"),
|
|
6104
|
+
b: c.req.query("b")
|
|
6105
|
+
});
|
|
6106
|
+
if (!parse.success) return c.json({ error: "invalid_query", issues: parse.error.issues }, 400);
|
|
6107
|
+
const { feature, a, b } = parse.data;
|
|
6108
|
+
if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
|
|
6109
|
+
const featureDir = path27.join(opts.paths.plansDir, feature);
|
|
6110
|
+
const aAbs = path27.join(featureDir, a);
|
|
6111
|
+
const bAbs = path27.join(featureDir, b);
|
|
6112
|
+
if (!isInside(aAbs, opts.paths.plansDir) || !isInside(bAbs, opts.paths.plansDir)) {
|
|
6113
|
+
return c.json({ error: "forbidden" }, 403);
|
|
6114
|
+
}
|
|
6115
|
+
if (!fs33.existsSync(aAbs) || !fs33.existsSync(bAbs)) return c.json({ error: "not_found" }, 404);
|
|
6116
|
+
const aText = fs33.readFileSync(aAbs, "utf8");
|
|
6117
|
+
const bText = fs33.readFileSync(bAbs, "utf8");
|
|
6118
|
+
const changes = diffLines(aText, bText);
|
|
6119
|
+
return c.json({ feature, a, b, changes });
|
|
6120
|
+
});
|
|
6121
|
+
app.delete("/api/plans/:feature/:planFile", (c) => {
|
|
6122
|
+
const trash = c.req.query("trash") === "1";
|
|
6123
|
+
const { feature, planFile } = c.req.param();
|
|
6124
|
+
if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
|
|
6125
|
+
const abs = path27.join(opts.paths.plansDir, feature, planFile);
|
|
6126
|
+
if (!isInside(abs, opts.paths.plansDir)) return c.json({ error: "forbidden" }, 403);
|
|
6127
|
+
if (!fs33.existsSync(abs)) return c.json({ error: "not_found" }, 404);
|
|
6128
|
+
if (trash) {
|
|
6129
|
+
const result = trashOrDelete([abs], opts.paths.trashDir, true);
|
|
6130
|
+
removeOverviewRowForPlanFile(path27.join(opts.paths.plansDir, feature), planFile);
|
|
6131
|
+
return c.json({ ok: true, trashed: result.trashed });
|
|
6132
|
+
}
|
|
6133
|
+
fs33.rmSync(abs);
|
|
6134
|
+
removeOverviewRowForPlanFile(path27.join(opts.paths.plansDir, feature), planFile);
|
|
6135
|
+
return c.json({ ok: true });
|
|
6136
|
+
});
|
|
6137
|
+
}
|
|
6138
|
+
|
|
6139
|
+
// src/console/api/config.ts
|
|
6140
|
+
import { z as z3 } from "zod";
|
|
6141
|
+
var ConfigBody = z3.object({}).passthrough();
|
|
6142
|
+
function mountConfigApi(app, opts) {
|
|
6143
|
+
app.get("/api/config", (c) => {
|
|
6144
|
+
const config2 = loadConfig(opts.paths.configFile);
|
|
6145
|
+
return c.json(config2);
|
|
6146
|
+
});
|
|
6147
|
+
app.put("/api/config", async (c) => {
|
|
6148
|
+
const body = ConfigBody.safeParse(await c.req.json());
|
|
6149
|
+
if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
|
|
6150
|
+
try {
|
|
6151
|
+
saveConfig(opts.paths.configFile, body.data);
|
|
6152
|
+
return c.json({ ok: true });
|
|
6153
|
+
} catch (err) {
|
|
6154
|
+
return c.json({ error: "invalid_config", detail: err.message }, 400);
|
|
6155
|
+
}
|
|
6156
|
+
});
|
|
6157
|
+
}
|
|
6158
|
+
|
|
6159
|
+
// src/console/api/meta.ts
|
|
6160
|
+
function mountMetaApi(app, opts) {
|
|
6161
|
+
app.get("/api/meta", async (c) => {
|
|
6162
|
+
const config2 = loadConfig(opts.paths.configFile);
|
|
6163
|
+
const lastRun = await readLastRun(opts.paths);
|
|
6164
|
+
return c.json({
|
|
6165
|
+
version: "0.6.0",
|
|
6166
|
+
root: opts.paths.root,
|
|
6167
|
+
project: config2.project,
|
|
6168
|
+
planner: config2.planner ? { provider: config2.planner.provider, enabled: config2.planner.enabled } : null,
|
|
6169
|
+
tracker: { type: config2.tracker.type },
|
|
6170
|
+
lastRun
|
|
6171
|
+
});
|
|
6172
|
+
});
|
|
6173
|
+
app.get("/api/last-run", async (c) => {
|
|
6174
|
+
const lastRun = await readLastRun(opts.paths);
|
|
6175
|
+
return c.json(lastRun ?? null);
|
|
6176
|
+
});
|
|
6177
|
+
}
|
|
6178
|
+
|
|
6179
|
+
// src/console/api/runs.ts
|
|
6180
|
+
import { streamSSE } from "hono/streaming";
|
|
6181
|
+
import { z as z4 } from "zod";
|
|
6182
|
+
import path28 from "path";
|
|
6183
|
+
import fs34 from "fs";
|
|
6184
|
+
var active = /* @__PURE__ */ new Map();
|
|
6185
|
+
var EVENT_BUF_CAP = 512;
|
|
6186
|
+
var StartBody = z4.object({
|
|
6187
|
+
feature: z4.string().min(1),
|
|
6188
|
+
storyId: z4.string().min(1)
|
|
6189
|
+
});
|
|
6190
|
+
function emptyStats(durationMs) {
|
|
6191
|
+
return {
|
|
6192
|
+
turns: 0,
|
|
6193
|
+
inputTokens: 0,
|
|
6194
|
+
outputTokens: 0,
|
|
6195
|
+
cacheCreationTokens: 0,
|
|
6196
|
+
cacheReadTokens: 0,
|
|
6197
|
+
cacheHitRatio: 0,
|
|
6198
|
+
durationMs
|
|
6199
|
+
};
|
|
6200
|
+
}
|
|
6201
|
+
function mountRunsApi(app, opts) {
|
|
6202
|
+
app.get("/api/runs", async (c) => c.json(await listRuns(opts.paths)));
|
|
6203
|
+
app.get("/api/runs/active", (c) => {
|
|
6204
|
+
const rows = [...active.values()].map((r) => ({
|
|
6205
|
+
runId: r.runId,
|
|
6206
|
+
feature: r.feature,
|
|
6207
|
+
storyId: r.storyId,
|
|
6208
|
+
startedAt: r.startedAt
|
|
6209
|
+
}));
|
|
6210
|
+
return c.json(rows);
|
|
6211
|
+
});
|
|
6212
|
+
app.post("/api/runs", async (c) => {
|
|
6213
|
+
const body = StartBody.safeParse(await c.req.json());
|
|
6214
|
+
if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
|
|
6215
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6216
|
+
if (!cfg.planner?.enabled) {
|
|
6217
|
+
return c.json({ error: "planner_disabled", detail: "Run `squad config set planner` first." }, 400);
|
|
6218
|
+
}
|
|
6219
|
+
const apiKey = readProviderKeyForPaths(opts.paths, cfg.planner.provider);
|
|
6220
|
+
if (!apiKey) {
|
|
6221
|
+
return c.json(
|
|
6222
|
+
{
|
|
6223
|
+
error: "missing_credentials",
|
|
6224
|
+
detail: `Set ${providerEnvVar(cfg.planner.provider)} or save a key via the Secrets tab.`
|
|
6225
|
+
},
|
|
6226
|
+
400
|
|
6227
|
+
);
|
|
6228
|
+
}
|
|
6229
|
+
const stories = listStories(opts.paths, { feature: body.data.feature });
|
|
6230
|
+
const story = stories.find((s) => s.id === body.data.storyId);
|
|
6231
|
+
if (!story) return c.json({ error: "not_found" }, 404);
|
|
6232
|
+
const runId = newRunId();
|
|
6233
|
+
const eventBuffer = [];
|
|
6234
|
+
const bus = new PlannerEventBus();
|
|
6235
|
+
bus.subscribe((e) => {
|
|
6236
|
+
eventBuffer.push(e);
|
|
6237
|
+
if (eventBuffer.length > EVENT_BUF_CAP) eventBuffer.splice(0, eventBuffer.length - EVENT_BUF_CAP);
|
|
6238
|
+
});
|
|
6239
|
+
const controller = new AbortController();
|
|
6240
|
+
const startedAt = Date.now();
|
|
6241
|
+
const done = (async () => {
|
|
6242
|
+
const cfgFresh = loadConfig(opts.paths.configFile);
|
|
6243
|
+
const planner = cfgFresh.planner;
|
|
6244
|
+
const provider = providerFor(planner.provider);
|
|
6245
|
+
const model = modelFor(planner.provider, "plan", planner.modelOverride);
|
|
6246
|
+
const budget = new Budget(planner.budget);
|
|
6247
|
+
const cacheEnabled = planner.cache?.enabled ?? true;
|
|
6248
|
+
const repoMap = buildRepoMap(opts.paths.root);
|
|
6249
|
+
const systemPrompt = composeSystemPrompt({
|
|
6250
|
+
projectRoots: cfgFresh.project.projectRoots ?? ["."],
|
|
6251
|
+
primaryLanguage: cfgFresh.project.primaryLanguage ?? "",
|
|
6252
|
+
trackerType: cfgFresh.tracker.type,
|
|
6253
|
+
repoMap
|
|
6254
|
+
});
|
|
6255
|
+
const userPrompt = composeUserPrompt({
|
|
6256
|
+
intakeContent: fs34.readFileSync(story.intakePath, "utf8")
|
|
6257
|
+
});
|
|
6258
|
+
let result;
|
|
6259
|
+
try {
|
|
6260
|
+
result = await runPlanner({
|
|
6261
|
+
root: opts.paths.root,
|
|
6262
|
+
provider,
|
|
6263
|
+
model,
|
|
6264
|
+
apiKey,
|
|
6265
|
+
systemPrompt,
|
|
6266
|
+
userPrompt,
|
|
6267
|
+
budget,
|
|
6268
|
+
maxOutputTokens: planner.maxOutputTokens,
|
|
6269
|
+
cacheEnabled,
|
|
6270
|
+
events: bus,
|
|
6271
|
+
runId,
|
|
6272
|
+
abort: controller.signal
|
|
6273
|
+
});
|
|
6274
|
+
} catch (err) {
|
|
6275
|
+
const durationMs = Date.now() - startedAt;
|
|
6276
|
+
bus.emit({ kind: "error", runId, message: err.message });
|
|
6277
|
+
bus.emit({
|
|
6278
|
+
kind: "done",
|
|
6279
|
+
runId,
|
|
6280
|
+
success: false,
|
|
6281
|
+
planFile: null,
|
|
6282
|
+
partial: true,
|
|
6283
|
+
stats: emptyStats(durationMs),
|
|
6284
|
+
durationMs
|
|
6285
|
+
});
|
|
6286
|
+
return;
|
|
6287
|
+
}
|
|
6288
|
+
const runSuccess = result.finishedNormally && !result.timedOut && !result.userCancelled;
|
|
6289
|
+
let planFile = null;
|
|
6290
|
+
if (result.planText.trim()) {
|
|
6291
|
+
const snap = budget.snapshot();
|
|
6292
|
+
const header = buildMetadataHeader({
|
|
6293
|
+
provider: planner.provider,
|
|
6294
|
+
model,
|
|
6295
|
+
reads: snap.reads,
|
|
6296
|
+
bytes: snap.bytes,
|
|
6297
|
+
inputTokens: snap.usage.inputTokens,
|
|
6298
|
+
outputTokens: snap.usage.outputTokens,
|
|
6299
|
+
durationMs: Date.now() - startedAt,
|
|
6300
|
+
costUsd: snap.usage.costUsd,
|
|
6301
|
+
planStatus: runSuccess ? void 0 : "partial"
|
|
6302
|
+
});
|
|
6303
|
+
const out = writePlanFile({
|
|
6304
|
+
paths: opts.paths,
|
|
6305
|
+
config: cfgFresh,
|
|
6306
|
+
story,
|
|
6307
|
+
planBodyMarkdown: result.planText,
|
|
6308
|
+
metadataHeader: header,
|
|
6309
|
+
partial: !runSuccess
|
|
6310
|
+
});
|
|
6311
|
+
planFile = path28.relative(opts.paths.root, out.planFile);
|
|
6312
|
+
}
|
|
6313
|
+
try {
|
|
6314
|
+
await writeLastRun(opts.paths, {
|
|
6315
|
+
stats: result.stats,
|
|
6316
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6317
|
+
provider: planner.provider,
|
|
6318
|
+
model
|
|
6319
|
+
});
|
|
6320
|
+
await appendRun(opts.paths, {
|
|
6321
|
+
runId,
|
|
6322
|
+
provider: planner.provider,
|
|
6323
|
+
model,
|
|
6324
|
+
feature: story.feature,
|
|
6325
|
+
storyId: story.id,
|
|
6326
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
6327
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6328
|
+
success: runSuccess,
|
|
6329
|
+
partial: !runSuccess,
|
|
6330
|
+
planFile,
|
|
6331
|
+
stats: result.stats,
|
|
6332
|
+
cacheEnabled,
|
|
6333
|
+
durationMs: Date.now() - startedAt
|
|
6334
|
+
});
|
|
6335
|
+
} catch {
|
|
6336
|
+
}
|
|
6337
|
+
bus.emit({
|
|
6338
|
+
kind: "done",
|
|
6339
|
+
runId,
|
|
6340
|
+
success: runSuccess,
|
|
6341
|
+
planFile,
|
|
6342
|
+
partial: !runSuccess,
|
|
6343
|
+
stats: result.stats,
|
|
6344
|
+
durationMs: Date.now() - startedAt
|
|
6345
|
+
});
|
|
6346
|
+
})().finally(() => {
|
|
6347
|
+
active.delete(runId);
|
|
6348
|
+
});
|
|
6349
|
+
active.set(runId, { runId, bus, controller, feature: story.feature, storyId: story.id, startedAt, done, eventBuffer });
|
|
6350
|
+
return c.json({ runId, eventStream: `/api/runs/${runId}/stream` }, 202);
|
|
6351
|
+
});
|
|
6352
|
+
app.get("/api/runs/:runId/stream", (c) => {
|
|
6353
|
+
const runId = c.req.param("runId");
|
|
6354
|
+
const run = active.get(runId);
|
|
6355
|
+
return streamSSE(c, async (stream) => {
|
|
6356
|
+
if (!run) {
|
|
6357
|
+
await stream.writeSSE({ event: "error", data: JSON.stringify({ message: "unknown run" }) });
|
|
6358
|
+
await stream.writeSSE({ event: "closed", data: "{}" });
|
|
6359
|
+
return;
|
|
6360
|
+
}
|
|
6361
|
+
let idx = 0;
|
|
6362
|
+
let resolveWaiter = null;
|
|
6363
|
+
const notify = () => resolveWaiter?.();
|
|
6364
|
+
const unsubscribe = run.bus.subscribe(() => notify());
|
|
6365
|
+
const ka = setInterval(() => stream.writeSSE({ event: "ping", data: "{}" }).catch(() => {
|
|
6366
|
+
}), 15e3);
|
|
6367
|
+
let closed = false;
|
|
6368
|
+
stream.onAbort(() => {
|
|
6369
|
+
closed = true;
|
|
6370
|
+
unsubscribe();
|
|
6371
|
+
clearInterval(ka);
|
|
6372
|
+
});
|
|
6373
|
+
try {
|
|
6374
|
+
while (!closed) {
|
|
6375
|
+
while (idx < run.eventBuffer.length) {
|
|
6376
|
+
const e = run.eventBuffer[idx++];
|
|
6377
|
+
await stream.writeSSE({ event: e.kind, data: JSON.stringify(e) });
|
|
6378
|
+
if (e.kind === "done") {
|
|
6379
|
+
await stream.writeSSE({ event: "closed", data: JSON.stringify({ ok: true }) });
|
|
6380
|
+
clearInterval(ka);
|
|
6381
|
+
unsubscribe();
|
|
6382
|
+
return;
|
|
6383
|
+
}
|
|
6384
|
+
}
|
|
6385
|
+
await new Promise((resolve2) => {
|
|
6386
|
+
resolveWaiter = resolve2;
|
|
6387
|
+
});
|
|
6388
|
+
resolveWaiter = null;
|
|
6389
|
+
}
|
|
6390
|
+
} finally {
|
|
6391
|
+
clearInterval(ka);
|
|
6392
|
+
unsubscribe();
|
|
6393
|
+
}
|
|
6394
|
+
});
|
|
6395
|
+
});
|
|
6396
|
+
app.delete("/api/runs/:runId", (c) => {
|
|
6397
|
+
const runId = c.req.param("runId");
|
|
6398
|
+
const run = active.get(runId);
|
|
6399
|
+
if (!run) return c.json({ error: "not_found" }, 404);
|
|
6400
|
+
run.controller.abort();
|
|
6401
|
+
return c.json({ ok: true });
|
|
6402
|
+
});
|
|
6403
|
+
}
|
|
6404
|
+
|
|
6405
|
+
// src/console/api/secrets.ts
|
|
6406
|
+
import { z as z5 } from "zod";
|
|
6407
|
+
|
|
6408
|
+
// src/core/mask-token.ts
|
|
6409
|
+
function maskToken(t) {
|
|
6410
|
+
if (!t) return null;
|
|
6411
|
+
if (t.length <= 8) return "\u2022\u2022\u2022\u2022" + t.slice(-2);
|
|
6412
|
+
return t.slice(0, 4) + "\u2026" + t.slice(-4);
|
|
6413
|
+
}
|
|
6414
|
+
|
|
6415
|
+
// src/console/api/secrets.ts
|
|
6416
|
+
var PROVIDERS = ["anthropic", "openai", "google"];
|
|
6417
|
+
function mountSecretsApi(app, opts) {
|
|
6418
|
+
app.get("/api/secrets", (c) => {
|
|
6419
|
+
const s = loadSecrets(opts.paths.secretsFile);
|
|
6420
|
+
return c.json({
|
|
6421
|
+
planner: {
|
|
6422
|
+
anthropic: maskToken(s.planner?.anthropic),
|
|
6423
|
+
openai: maskToken(s.planner?.openai),
|
|
6424
|
+
google: maskToken(s.planner?.google)
|
|
6425
|
+
},
|
|
6426
|
+
tracker: {
|
|
6427
|
+
jira: {
|
|
6428
|
+
host: s.tracker?.jira?.host ?? null,
|
|
6429
|
+
email: s.tracker?.jira?.email ?? null,
|
|
6430
|
+
token: maskToken(s.tracker?.jira?.token)
|
|
6431
|
+
},
|
|
6432
|
+
azure: {
|
|
6433
|
+
organization: s.tracker?.azure?.organization ?? null,
|
|
6434
|
+
project: s.tracker?.azure?.project ?? null,
|
|
6435
|
+
pat: maskToken(s.tracker?.azure?.pat)
|
|
6436
|
+
}
|
|
6437
|
+
}
|
|
6438
|
+
});
|
|
6439
|
+
});
|
|
6440
|
+
const PutBody = z5.object({
|
|
6441
|
+
planner: z5.object({
|
|
6442
|
+
anthropic: z5.string().optional(),
|
|
6443
|
+
openai: z5.string().optional(),
|
|
6444
|
+
google: z5.string().optional()
|
|
6445
|
+
}).optional(),
|
|
6446
|
+
tracker: z5.object({
|
|
6447
|
+
jira: z5.object({
|
|
6448
|
+
host: z5.string().optional(),
|
|
6449
|
+
email: z5.string().optional(),
|
|
6450
|
+
token: z5.string().optional()
|
|
6451
|
+
}).optional(),
|
|
6452
|
+
azure: z5.object({
|
|
6453
|
+
organization: z5.string().optional(),
|
|
6454
|
+
project: z5.string().optional(),
|
|
6455
|
+
pat: z5.string().optional()
|
|
6456
|
+
}).optional()
|
|
6457
|
+
}).optional()
|
|
6458
|
+
});
|
|
6459
|
+
app.put("/api/secrets", async (c) => {
|
|
6460
|
+
const parse = PutBody.safeParse(await c.req.json());
|
|
6461
|
+
if (!parse.success) return c.json({ error: "invalid_body", issues: parse.error.issues }, 400);
|
|
6462
|
+
const current = loadSecrets(opts.paths.secretsFile);
|
|
6463
|
+
const merged = mergeSecrets(current, parse.data);
|
|
6464
|
+
saveSecrets(opts.paths.secretsFile, merged);
|
|
6465
|
+
return c.json({ ok: true });
|
|
6466
|
+
});
|
|
6467
|
+
for (const p of PROVIDERS) {
|
|
6468
|
+
app.post(`/api/secrets/test/${p}`, async (c) => {
|
|
6469
|
+
const s = loadSecrets(opts.paths.secretsFile);
|
|
6470
|
+
const key = s.planner?.[p];
|
|
6471
|
+
if (!key) return c.json({ ok: false, detail: `${p} key not saved` }, 400);
|
|
6472
|
+
const result = await fetchProviderModelIds(p, key);
|
|
6473
|
+
if (result.ok) return c.json({ ok: true, modelCount: result.ids.size });
|
|
6474
|
+
return c.json({ ok: false, status: result.status, detail: result.body.slice(0, 200) }, 200);
|
|
6475
|
+
});
|
|
6476
|
+
}
|
|
6477
|
+
app.post("/api/secrets/test/jira", async (c) => {
|
|
6478
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6479
|
+
const s = loadSecrets(opts.paths.secretsFile);
|
|
6480
|
+
return c.json(await probeJiraConnectivity(s, cfg));
|
|
6481
|
+
});
|
|
6482
|
+
app.post("/api/secrets/test/azure", async (c) => {
|
|
6483
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6484
|
+
const s = loadSecrets(opts.paths.secretsFile);
|
|
6485
|
+
return c.json(await probeAzureConnectivity(s, cfg));
|
|
6486
|
+
});
|
|
6487
|
+
}
|
|
6488
|
+
|
|
6489
|
+
// src/console/api/tracker.ts
|
|
6490
|
+
import { z as z6 } from "zod";
|
|
6491
|
+
var SearchQuery = z6.object({ q: z6.string().max(500) });
|
|
6492
|
+
var ImportBody = z6.object({
|
|
6493
|
+
issueId: z6.string().min(1),
|
|
6494
|
+
feature: z6.string().min(1).max(80),
|
|
6495
|
+
withAttachments: z6.boolean().optional()
|
|
6496
|
+
});
|
|
6497
|
+
function mountTrackerApi(app, opts) {
|
|
6498
|
+
app.get("/api/tracker/search", async (c) => {
|
|
6499
|
+
const q = SearchQuery.safeParse({ q: c.req.query("q") });
|
|
6500
|
+
if (!q.success) return c.json({ error: "invalid_query" }, 400);
|
|
6501
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6502
|
+
const secrets = overlayTrackerEnv(loadSecrets(opts.paths.secretsFile));
|
|
6503
|
+
const { client, error: error2 } = clientFor(cfg, secrets);
|
|
6504
|
+
if (!client) {
|
|
6505
|
+
return c.json({ error: error2?.kind ?? "unsupported", detail: error2?.detail ?? "" }, 400);
|
|
4860
6506
|
}
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
6507
|
+
try {
|
|
6508
|
+
const results = await client.searchIssues(q.data.q);
|
|
6509
|
+
return c.json({ ok: true, results });
|
|
6510
|
+
} catch (err) {
|
|
6511
|
+
if (err instanceof TrackerError) {
|
|
6512
|
+
return c.json({ ok: false, kind: err.kind, status: err.statusCode, detail: err.message }, 200);
|
|
6513
|
+
}
|
|
6514
|
+
return c.json({ ok: false, detail: err.message }, 200);
|
|
4865
6515
|
}
|
|
4866
|
-
}
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
)
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
6516
|
+
});
|
|
6517
|
+
app.post("/api/tracker/import", async (c) => {
|
|
6518
|
+
const body = ImportBody.safeParse(await c.req.json());
|
|
6519
|
+
if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
|
|
6520
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6521
|
+
const secrets = overlayTrackerEnv(loadSecrets(opts.paths.secretsFile));
|
|
6522
|
+
const { client, error: error2 } = clientFor(cfg, secrets);
|
|
6523
|
+
if (!client) {
|
|
6524
|
+
return c.json({ error: error2?.kind ?? "unsupported", detail: error2?.detail ?? "" }, 400);
|
|
6525
|
+
}
|
|
6526
|
+
try {
|
|
6527
|
+
const issue = await client.fetchIssue(body.data.issueId);
|
|
6528
|
+
const created = await createStoryRecordFromIssue({
|
|
6529
|
+
paths: opts.paths,
|
|
6530
|
+
feature: body.data.feature,
|
|
6531
|
+
issue,
|
|
6532
|
+
withAttachments: body.data.withAttachments ?? true,
|
|
6533
|
+
client,
|
|
6534
|
+
config: cfg
|
|
6535
|
+
});
|
|
6536
|
+
return c.json(created, 201);
|
|
6537
|
+
} catch (err) {
|
|
6538
|
+
if (err instanceof TrackerError) return c.json({ error: err.kind, detail: err.message }, 502);
|
|
6539
|
+
return c.json({ error: "fetch_failed", detail: err.message }, 500);
|
|
6540
|
+
}
|
|
6541
|
+
});
|
|
4879
6542
|
}
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
6543
|
+
|
|
6544
|
+
// src/commands/doctor-api.ts
|
|
6545
|
+
async function gatherContextForApi(paths) {
|
|
6546
|
+
return gatherContext(paths);
|
|
6547
|
+
}
|
|
6548
|
+
async function runAllChecksForApi(paths, ctx, fix) {
|
|
6549
|
+
return runAllChecks(paths, ctx, fix);
|
|
6550
|
+
}
|
|
6551
|
+
|
|
6552
|
+
// src/console/api/doctor.ts
|
|
6553
|
+
function mountDoctorApi(app, opts) {
|
|
6554
|
+
app.get("/api/doctor", async (c) => {
|
|
6555
|
+
const ctx = await gatherContextForApi(opts.paths);
|
|
6556
|
+
const checks = await runAllChecksForApi(opts.paths, ctx, false);
|
|
6557
|
+
return c.json({ root: opts.paths.root, checks, summary: summarise(checks) });
|
|
6558
|
+
});
|
|
6559
|
+
app.post("/api/doctor/fix", async (c) => {
|
|
6560
|
+
const ctx = await gatherContextForApi(opts.paths);
|
|
6561
|
+
const checks = await runAllChecksForApi(opts.paths, ctx, true);
|
|
6562
|
+
return c.json({ root: opts.paths.root, checks, summary: summarise(checks) });
|
|
4885
6563
|
});
|
|
4886
6564
|
}
|
|
4887
6565
|
|
|
4888
|
-
// src/
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
6566
|
+
// src/console/api/copy-plan-prompt.ts
|
|
6567
|
+
function mountCopyPlanPromptApi(app, opts) {
|
|
6568
|
+
app.get("/api/copy-plan-prompt", (c) => {
|
|
6569
|
+
const feature = (c.req.query("feature") ?? "").trim();
|
|
6570
|
+
const storyId = (c.req.query("storyId") ?? "").trim();
|
|
6571
|
+
if (!feature || !storyId) {
|
|
6572
|
+
return c.json({ error: "bad_request", detail: "Query params `feature` and `storyId` are required." }, 400);
|
|
6573
|
+
}
|
|
6574
|
+
const stories = listStories(opts.paths, { feature });
|
|
6575
|
+
const hit = stories.find((s) => s.feature === feature && s.id === storyId);
|
|
6576
|
+
if (!hit) {
|
|
6577
|
+
return c.json({ error: "not_found", detail: `No story intake for feature "${feature}" and id "${storyId}".` }, 404);
|
|
6578
|
+
}
|
|
6579
|
+
const config2 = loadConfig(opts.paths.configFile);
|
|
6580
|
+
const intakeContent = readFile(hit.intakePath);
|
|
6581
|
+
const prompt = buildCopyPlanPromptMarkdown(config2, intakeContent);
|
|
6582
|
+
const bytes = Buffer.byteLength(prompt, "utf8");
|
|
6583
|
+
return c.json({
|
|
6584
|
+
prompt,
|
|
6585
|
+
feature: hit.feature,
|
|
6586
|
+
storyId: hit.id,
|
|
6587
|
+
bytes,
|
|
6588
|
+
estTokensApprox: Math.round(bytes / 4)
|
|
6589
|
+
});
|
|
6590
|
+
});
|
|
4896
6591
|
}
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
6592
|
+
|
|
6593
|
+
// src/console/api/dashboard.ts
|
|
6594
|
+
function mountDashboardApi(app, opts) {
|
|
6595
|
+
app.get("/api/dashboard", async (c) => {
|
|
6596
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6597
|
+
const lastRun = await readLastRun(opts.paths) ?? null;
|
|
6598
|
+
const runs = await listRuns(opts.paths);
|
|
6599
|
+
const stories = listStories(opts.paths);
|
|
6600
|
+
return c.json({
|
|
6601
|
+
version: "0.6.0",
|
|
6602
|
+
root: opts.paths.root,
|
|
6603
|
+
project: cfg.project,
|
|
6604
|
+
planner: cfg.planner ? { provider: cfg.planner.provider, enabled: cfg.planner.enabled } : null,
|
|
6605
|
+
tracker: { type: cfg.tracker.type },
|
|
6606
|
+
lastRun,
|
|
6607
|
+
runs: runs.map((r) => ({
|
|
6608
|
+
runId: r.runId,
|
|
6609
|
+
startedAt: r.startedAt,
|
|
6610
|
+
durationMs: r.durationMs,
|
|
6611
|
+
success: r.success,
|
|
6612
|
+
partial: r.partial,
|
|
6613
|
+
inputTokens: r.stats.inputTokens,
|
|
6614
|
+
outputTokens: r.stats.outputTokens,
|
|
6615
|
+
cacheHitRatio: r.stats.cacheHitRatio
|
|
6616
|
+
})),
|
|
6617
|
+
storyCounts: countStories(stories),
|
|
6618
|
+
stories: stories.map((s) => ({
|
|
6619
|
+
feature: s.feature,
|
|
6620
|
+
id: s.id,
|
|
6621
|
+
intakePath: s.intakePath,
|
|
6622
|
+
storyDir: s.storyDir,
|
|
6623
|
+
planFile: s.planFile ?? null,
|
|
6624
|
+
titleHint: s.titleHint ?? null
|
|
6625
|
+
}))
|
|
6626
|
+
});
|
|
4907
6627
|
});
|
|
4908
|
-
fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
4909
6628
|
}
|
|
4910
|
-
function
|
|
4911
|
-
const
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
6629
|
+
function countStories(stories) {
|
|
6630
|
+
const total = stories.length;
|
|
6631
|
+
const planned = stories.filter((s) => !!s.planFile).length;
|
|
6632
|
+
return { total, planned, unplanned: total - planned };
|
|
6633
|
+
}
|
|
6634
|
+
|
|
6635
|
+
// src/console/api/projects.ts
|
|
6636
|
+
import fs35 from "fs/promises";
|
|
6637
|
+
import path29 from "path";
|
|
6638
|
+
import os from "os";
|
|
6639
|
+
var MAX = 10;
|
|
6640
|
+
function recentFilePath() {
|
|
6641
|
+
return path29.join(os.homedir(), ".squad", "recent-projects.json");
|
|
6642
|
+
}
|
|
6643
|
+
async function loadRecent() {
|
|
6644
|
+
const RECENT_FILE = recentFilePath();
|
|
6645
|
+
try {
|
|
6646
|
+
const raw = await fs35.readFile(RECENT_FILE, "utf8");
|
|
6647
|
+
const parsed = JSON.parse(raw);
|
|
6648
|
+
return Array.isArray(parsed) ? parsed.slice(0, MAX) : [];
|
|
6649
|
+
} catch {
|
|
6650
|
+
return [];
|
|
6651
|
+
}
|
|
6652
|
+
}
|
|
6653
|
+
async function saveRecent(list) {
|
|
6654
|
+
const RECENT_FILE = recentFilePath();
|
|
6655
|
+
await fs35.mkdir(path29.dirname(RECENT_FILE), { recursive: true });
|
|
6656
|
+
await fs35.writeFile(RECENT_FILE, JSON.stringify(list.slice(0, MAX), null, 2) + "\n", "utf8");
|
|
6657
|
+
}
|
|
6658
|
+
function mountProjectsApi(app, opts) {
|
|
6659
|
+
app.get("/api/projects/recent", async (c) => c.json(await loadRecent()));
|
|
6660
|
+
app.post("/api/projects/touch", async (c) => {
|
|
6661
|
+
const list = await loadRecent();
|
|
6662
|
+
const filtered = list.filter((p) => p.root !== opts.paths.root);
|
|
6663
|
+
filtered.unshift({ root: opts.paths.root, lastOpenedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6664
|
+
await saveRecent(filtered);
|
|
6665
|
+
return c.json({ ok: true });
|
|
4921
6666
|
});
|
|
4922
|
-
fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
4923
6667
|
}
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
dst = path22.join(bucket, `${n}_${rel}`);
|
|
4939
|
-
}
|
|
4940
|
-
fs28.renameSync(src, dst);
|
|
4941
|
-
}
|
|
4942
|
-
return { removed: absPaths, trashed: bucket };
|
|
6668
|
+
|
|
6669
|
+
// src/console/api/index.ts
|
|
6670
|
+
function mountApi(app, opts) {
|
|
6671
|
+
mountMetaApi(app, opts);
|
|
6672
|
+
mountDashboardApi(app, opts);
|
|
6673
|
+
mountProjectsApi(app, opts);
|
|
6674
|
+
mountStoriesApi(app, opts);
|
|
6675
|
+
mountPlansApi(app, opts);
|
|
6676
|
+
mountConfigApi(app, opts);
|
|
6677
|
+
mountRunsApi(app, opts);
|
|
6678
|
+
mountSecretsApi(app, opts);
|
|
6679
|
+
mountTrackerApi(app, opts);
|
|
6680
|
+
mountDoctorApi(app, opts);
|
|
6681
|
+
mountCopyPlanPromptApi(app, opts);
|
|
4943
6682
|
}
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
6683
|
+
|
|
6684
|
+
// src/console/static-assets.ts
|
|
6685
|
+
import fs36 from "fs";
|
|
6686
|
+
import path30 from "path";
|
|
6687
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
6688
|
+
import { Readable } from "stream";
|
|
6689
|
+
function pathnameForStatic(c) {
|
|
6690
|
+
const rawUrl = c.env.incoming?.url;
|
|
6691
|
+
if (typeof rawUrl === "string" && rawUrl.startsWith("/")) {
|
|
6692
|
+
const pathOnly = rawUrl.split("?", 1)[0] ?? rawUrl;
|
|
6693
|
+
try {
|
|
6694
|
+
return decodeURIComponent(pathOnly);
|
|
6695
|
+
} catch {
|
|
6696
|
+
return pathOnly;
|
|
6697
|
+
}
|
|
4954
6698
|
}
|
|
6699
|
+
try {
|
|
6700
|
+
return decodeURIComponent(new URL(c.req.url).pathname);
|
|
6701
|
+
} catch {
|
|
6702
|
+
return new URL(c.req.url).pathname;
|
|
6703
|
+
}
|
|
6704
|
+
}
|
|
6705
|
+
function mountStaticAssets(app, _opts) {
|
|
6706
|
+
const distDir = resolveConsoleUiDist();
|
|
6707
|
+
app.get("/logo.svg", (c) => serveFile(c, distDir, "logo.svg", "image/svg+xml"));
|
|
6708
|
+
app.get("/favicon.ico", (c) => serveFile(c, distDir, "favicon.ico", "image/x-icon"));
|
|
6709
|
+
app.get("*", (c) => {
|
|
6710
|
+
const pathname = pathnameForStatic(c);
|
|
6711
|
+
if (pathname.startsWith("/assets/")) {
|
|
6712
|
+
return assetResponse(c, distDir, pathname);
|
|
6713
|
+
}
|
|
6714
|
+
if (c.req.path.startsWith("/api/") || c.req.path.startsWith("/healthz")) return c.notFound();
|
|
6715
|
+
if (!distDir) return c.text("UI not built. Run `pnpm -C console-ui build`.", 404);
|
|
6716
|
+
const indexHtml = path30.join(distDir, "index.html");
|
|
6717
|
+
if (!fs36.existsSync(indexHtml)) return c.text("UI not built", 404);
|
|
6718
|
+
return c.html(fs36.readFileSync(indexHtml, "utf8"), 200, { "cache-control": "no-store" });
|
|
6719
|
+
});
|
|
4955
6720
|
}
|
|
4956
|
-
function
|
|
4957
|
-
if (!
|
|
4958
|
-
const
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
6721
|
+
function assetResponse(c, distDir, pathname) {
|
|
6722
|
+
if (!distDir) return c.text("UI not built", 404);
|
|
6723
|
+
const rel = pathname.replace(/^\//, "");
|
|
6724
|
+
const abs = path30.normalize(path30.join(distDir, rel));
|
|
6725
|
+
if (!isInside2(abs, distDir)) return c.text("forbidden", 403);
|
|
6726
|
+
if (!fs36.existsSync(abs) || !fs36.statSync(abs).isFile()) return c.text("not found", 404);
|
|
6727
|
+
const body = Readable.toWeb(fs36.createReadStream(abs));
|
|
6728
|
+
return new Response(body, {
|
|
6729
|
+
headers: {
|
|
6730
|
+
"content-type": contentTypeFor(abs),
|
|
6731
|
+
"cache-control": "public, max-age=31536000, immutable"
|
|
4966
6732
|
}
|
|
6733
|
+
});
|
|
6734
|
+
}
|
|
6735
|
+
function serveFile(c, distDir, name, ct) {
|
|
6736
|
+
if (!distDir) return c.text("UI not built", 404);
|
|
6737
|
+
const abs = path30.join(distDir, name);
|
|
6738
|
+
if (!fs36.existsSync(abs)) return c.text("not found", 404);
|
|
6739
|
+
return new Response(fs36.readFileSync(abs), {
|
|
6740
|
+
headers: { "content-type": ct, "cache-control": "public, max-age=86400" }
|
|
6741
|
+
});
|
|
6742
|
+
}
|
|
6743
|
+
function contentTypeFor(p) {
|
|
6744
|
+
if (p.endsWith(".js")) return "application/javascript; charset=utf-8";
|
|
6745
|
+
if (p.endsWith(".css")) return "text/css; charset=utf-8";
|
|
6746
|
+
if (p.endsWith(".html")) return "text/html; charset=utf-8";
|
|
6747
|
+
if (p.endsWith(".svg")) return "image/svg+xml";
|
|
6748
|
+
if (p.endsWith(".png")) return "image/png";
|
|
6749
|
+
if (p.endsWith(".woff2")) return "font/woff2";
|
|
6750
|
+
if (p.endsWith(".json")) return "application/json";
|
|
6751
|
+
return "application/octet-stream";
|
|
6752
|
+
}
|
|
6753
|
+
function isInside2(abs, parent) {
|
|
6754
|
+
const a = path30.resolve(abs) + path30.sep;
|
|
6755
|
+
const p = path30.resolve(parent) + path30.sep;
|
|
6756
|
+
return a.startsWith(p);
|
|
6757
|
+
}
|
|
6758
|
+
function resolveConsoleUiDist() {
|
|
6759
|
+
const fromEnv = process.env.SQUAD_CONSOLE_UI_DIST;
|
|
6760
|
+
if (fromEnv && fs36.existsSync(path30.join(fromEnv, "index.html"))) return fromEnv;
|
|
6761
|
+
const candidates = [
|
|
6762
|
+
fileURLToPath2(new URL("./console-ui/", import.meta.url)),
|
|
6763
|
+
// dist/cli.js → dist/console-ui/
|
|
6764
|
+
fileURLToPath2(new URL("../../dist/console-ui/", import.meta.url)),
|
|
6765
|
+
// src/console/*.ts → project/dist/console-ui/
|
|
6766
|
+
fileURLToPath2(new URL("../console-ui/dist/", import.meta.url)),
|
|
6767
|
+
// dist/cli.js → project/console-ui/dist
|
|
6768
|
+
fileURLToPath2(new URL("../../console-ui/dist/", import.meta.url))
|
|
6769
|
+
// src/console/*.ts → project/console-ui/dist
|
|
6770
|
+
];
|
|
6771
|
+
for (const c of candidates) {
|
|
6772
|
+
if (fs36.existsSync(path30.join(c, "index.html"))) return c;
|
|
4967
6773
|
}
|
|
4968
|
-
return
|
|
6774
|
+
return null;
|
|
4969
6775
|
}
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
6776
|
+
|
|
6777
|
+
// src/console/auth.ts
|
|
6778
|
+
function authMiddleware(expected) {
|
|
6779
|
+
return async (c, next) => {
|
|
6780
|
+
const presented = readPresentedToken(c.req.raw);
|
|
6781
|
+
if (!presented || !timingSafeEqualHex(presented, expected)) {
|
|
6782
|
+
return c.json({ error: "unauthorized", detail: "missing or invalid session token" }, 401);
|
|
6783
|
+
}
|
|
6784
|
+
await next();
|
|
6785
|
+
};
|
|
6786
|
+
}
|
|
6787
|
+
function readPresentedToken(req) {
|
|
6788
|
+
const auth = req.headers.get("authorization");
|
|
6789
|
+
if (auth && auth.toLowerCase().startsWith("bearer ")) {
|
|
6790
|
+
return auth.slice("bearer ".length).trim();
|
|
4974
6791
|
}
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
message: "Story to remove:",
|
|
4978
|
-
choices: stories.map((s) => ({
|
|
4979
|
-
name: `${s.feature}/${s.id} \xB7 ${s.titleHint ?? s.id}`,
|
|
4980
|
-
value: s
|
|
4981
|
-
}))
|
|
4982
|
-
});
|
|
4983
|
-
return choice;
|
|
6792
|
+
const url = new URL(req.url);
|
|
6793
|
+
return url.searchParams.get("t");
|
|
4984
6794
|
}
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
6795
|
+
function timingSafeEqualHex(a, b) {
|
|
6796
|
+
if (a.length !== b.length) return false;
|
|
6797
|
+
let mismatch = 0;
|
|
6798
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
6799
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
4989
6800
|
}
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
6801
|
+
return mismatch === 0;
|
|
6802
|
+
}
|
|
6803
|
+
|
|
6804
|
+
// src/console/server.ts
|
|
6805
|
+
async function startConsoleServer(opts) {
|
|
6806
|
+
const bindPort = opts.requestedPort === 0 ? await reserveEphemeralPort() : await findFreePortFromBase(opts.requestedPort);
|
|
6807
|
+
const app = new Hono();
|
|
6808
|
+
app.get(
|
|
6809
|
+
"/healthz",
|
|
6810
|
+
(c) => c.json(
|
|
6811
|
+
{ ok: true, version: "0.6.0", port: bindPort },
|
|
6812
|
+
200,
|
|
6813
|
+
{
|
|
6814
|
+
"cache-control": "no-store"
|
|
6815
|
+
}
|
|
6816
|
+
)
|
|
6817
|
+
);
|
|
6818
|
+
app.use(
|
|
6819
|
+
"*",
|
|
6820
|
+
secureHeaders({
|
|
6821
|
+
contentSecurityPolicy: {
|
|
6822
|
+
defaultSrc: ["'self'"],
|
|
6823
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
6824
|
+
imgSrc: ["'self'", "data:"],
|
|
6825
|
+
connectSrc: ["'self'"],
|
|
6826
|
+
scriptSrc: ["'self'"],
|
|
6827
|
+
frameAncestors: ["'none'"]
|
|
6828
|
+
},
|
|
6829
|
+
referrerPolicy: "no-referrer"
|
|
6830
|
+
})
|
|
6831
|
+
);
|
|
6832
|
+
app.use("*", cors({ origin: "http://127.0.0.1:" + bindPort }));
|
|
6833
|
+
app.use("/api/*", authMiddleware(opts.token));
|
|
6834
|
+
mountApi(app, { paths: opts.paths });
|
|
6835
|
+
mountStaticAssets(app, { paths: opts.paths });
|
|
6836
|
+
const nodeServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: bindPort });
|
|
6837
|
+
let resolveDone = () => {
|
|
6838
|
+
};
|
|
6839
|
+
const done = new Promise((resolve2) => {
|
|
6840
|
+
resolveDone = resolve2;
|
|
4997
6841
|
});
|
|
6842
|
+
const actualPort = (() => {
|
|
6843
|
+
const addr = nodeServer.address();
|
|
6844
|
+
if (addr && typeof addr === "object") return addr.port;
|
|
6845
|
+
return bindPort;
|
|
6846
|
+
})();
|
|
6847
|
+
const shutdown = (onClose) => {
|
|
6848
|
+
nodeServer.close((err) => {
|
|
6849
|
+
onClose(err);
|
|
6850
|
+
});
|
|
6851
|
+
};
|
|
6852
|
+
const stop = () => {
|
|
6853
|
+
shutdown((err) => {
|
|
6854
|
+
if (err) warning(`server close: ${err.message}`);
|
|
6855
|
+
resolveDone();
|
|
6856
|
+
});
|
|
6857
|
+
};
|
|
6858
|
+
process.once("SIGINT", stop);
|
|
6859
|
+
process.once("SIGTERM", stop);
|
|
6860
|
+
return {
|
|
6861
|
+
port: actualPort,
|
|
6862
|
+
done,
|
|
6863
|
+
close: () => new Promise((resolve2, reject) => {
|
|
6864
|
+
shutdown((err) => {
|
|
6865
|
+
if (err) {
|
|
6866
|
+
reject(err);
|
|
6867
|
+
return;
|
|
6868
|
+
}
|
|
6869
|
+
resolve2();
|
|
6870
|
+
resolveDone();
|
|
6871
|
+
});
|
|
6872
|
+
})
|
|
6873
|
+
};
|
|
4998
6874
|
}
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
6875
|
+
function reserveEphemeralPort() {
|
|
6876
|
+
return new Promise((resolve2, reject) => {
|
|
6877
|
+
const s = createServer();
|
|
6878
|
+
s.once("error", reject);
|
|
6879
|
+
s.listen(0, "127.0.0.1", () => {
|
|
6880
|
+
const addr = s.address();
|
|
6881
|
+
s.close((closeErr) => {
|
|
6882
|
+
if (closeErr) {
|
|
6883
|
+
reject(closeErr);
|
|
6884
|
+
return;
|
|
6885
|
+
}
|
|
6886
|
+
if (addr && typeof addr === "object") {
|
|
6887
|
+
resolve2(addr.port);
|
|
6888
|
+
} else {
|
|
6889
|
+
reject(new Error("Could not determine ephemeral port"));
|
|
6890
|
+
}
|
|
6891
|
+
});
|
|
6892
|
+
});
|
|
5008
6893
|
});
|
|
5009
6894
|
}
|
|
5010
|
-
function
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
if (
|
|
5014
|
-
if (`${s.feature}/${s.id}` === t) return s;
|
|
6895
|
+
async function findFreePortFromBase(start, attempts = 20) {
|
|
6896
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
6897
|
+
const candidate = start + i;
|
|
6898
|
+
if (await isPortFree(candidate)) return candidate;
|
|
5015
6899
|
}
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
}
|
|
5020
|
-
function isSequenceToken(s) {
|
|
5021
|
-
return /^\d{1,4}$/.test(s) && s.length > 0;
|
|
6900
|
+
throw new Error(
|
|
6901
|
+
`Could not find a free port in [${start}, ${start + attempts - 1}]. Pass \`--port <n>\` to pick another base.`
|
|
6902
|
+
);
|
|
5022
6903
|
}
|
|
5023
|
-
function
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
if (segs.length < 2) return void 0;
|
|
5031
|
-
const feature = segs[0];
|
|
5032
|
-
const file = segs.slice(1).join(path22.sep);
|
|
5033
|
-
if (featureFilter && feature !== featureFilter) return void 0;
|
|
5034
|
-
return { feature, file, abs: path22.resolve(absPath) };
|
|
6904
|
+
function isPortFree(port) {
|
|
6905
|
+
return new Promise((resolve2) => {
|
|
6906
|
+
const probe = createServer();
|
|
6907
|
+
probe.once("error", () => resolve2(false));
|
|
6908
|
+
probe.once("listening", () => probe.close(() => resolve2(true)));
|
|
6909
|
+
probe.listen(port, "127.0.0.1");
|
|
6910
|
+
});
|
|
5035
6911
|
}
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
const
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
6912
|
+
|
|
6913
|
+
// src/commands/console.ts
|
|
6914
|
+
var DEFAULT_PORT = 4571;
|
|
6915
|
+
async function runConsole(opts) {
|
|
6916
|
+
const root = requireSquadRoot();
|
|
6917
|
+
const paths = buildPaths(root);
|
|
6918
|
+
loadConfig(paths.configFile);
|
|
6919
|
+
const portOpt = opts.port;
|
|
6920
|
+
const requestedPort = portOpt !== void 0 && !Number.isNaN(portOpt) && portOpt >= 0 ? portOpt : DEFAULT_PORT;
|
|
6921
|
+
const token = opts.token ?? randomBytes2(32).toString("hex");
|
|
6922
|
+
const server = await startConsoleServer({ paths, requestedPort, token });
|
|
6923
|
+
const url = `http://127.0.0.1:${server.port}/?t=${token}`;
|
|
6924
|
+
banner();
|
|
6925
|
+
divider("squad console");
|
|
6926
|
+
summaryBox(" squad console ", [
|
|
6927
|
+
{ key: "project", value: resolve(paths.root) },
|
|
6928
|
+
{ key: "url", value: url },
|
|
6929
|
+
{ key: "token", value: `${token.slice(0, 8)}\u2026` },
|
|
6930
|
+
{ key: "port", value: String(server.port) }
|
|
6931
|
+
]);
|
|
6932
|
+
if (server.port !== requestedPort) {
|
|
6933
|
+
warning(`port ${requestedPort} was busy \u2014 bound to ${server.port} instead`);
|
|
5045
6934
|
}
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
`Multiple plans match sequence "${t}". Run \`squad rm plan\` in a TTY, or add \`--feature <slug>\` to disambiguate.`
|
|
5057
|
-
);
|
|
6935
|
+
blank();
|
|
6936
|
+
step("Next:");
|
|
6937
|
+
info("1) Pick a story in the Stories tab.");
|
|
6938
|
+
info('2) Click "Generate plan" to start a planning run live.');
|
|
6939
|
+
info("3) Press Ctrl-C in this terminal to stop the server when you're done.");
|
|
6940
|
+
if (opts.open !== false) {
|
|
6941
|
+
try {
|
|
6942
|
+
await open(url);
|
|
6943
|
+
} catch {
|
|
6944
|
+
warning("Could not open a browser automatically. Copy the URL above into your browser.");
|
|
5058
6945
|
}
|
|
6946
|
+
} else {
|
|
6947
|
+
info("Browser auto-open disabled (--no-open). Copy the URL above into your browser.");
|
|
5059
6948
|
}
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
function countOverviewDataRows(overviewContent) {
|
|
5063
|
-
let n = 0;
|
|
5064
|
-
for (const line2 of overviewContent.split("\n")) {
|
|
5065
|
-
if (!line2.startsWith("|")) continue;
|
|
5066
|
-
if (/^\|\s*-+/.test(line2)) continue;
|
|
5067
|
-
if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) continue;
|
|
5068
|
-
n++;
|
|
5069
|
-
}
|
|
5070
|
-
return n;
|
|
5071
|
-
}
|
|
5072
|
-
function featureStoriesDirEmpty(paths, feature) {
|
|
5073
|
-
const d = path22.join(paths.storiesDir, feature);
|
|
5074
|
-
if (!fs28.existsSync(d)) return true;
|
|
5075
|
-
return fs28.readdirSync(d).length === 0;
|
|
6949
|
+
await server.done;
|
|
6950
|
+
info("console stopped");
|
|
5076
6951
|
}
|
|
5077
6952
|
|
|
5078
6953
|
// src/commands/rm/story.ts
|
|
5079
|
-
import
|
|
5080
|
-
import
|
|
6954
|
+
import fs38 from "fs";
|
|
6955
|
+
import path32 from "path";
|
|
5081
6956
|
import { confirm as confirm10 } from "@inquirer/prompts";
|
|
5082
6957
|
|
|
5083
6958
|
// src/commands/rm/feature.ts
|
|
5084
|
-
import
|
|
5085
|
-
import
|
|
6959
|
+
import fs37 from "fs";
|
|
6960
|
+
import path31 from "path";
|
|
5086
6961
|
import { confirm as confirm9 } from "@inquirer/prompts";
|
|
5087
6962
|
function countStoryDirs(storiesDir, feature) {
|
|
5088
|
-
const d =
|
|
5089
|
-
if (!
|
|
5090
|
-
return
|
|
6963
|
+
const d = path31.join(storiesDir, feature);
|
|
6964
|
+
if (!fs37.existsSync(d)) return 0;
|
|
6965
|
+
return fs37.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
|
|
5091
6966
|
}
|
|
5092
6967
|
async function runRmFeature(featureSlug, opts = {}) {
|
|
5093
6968
|
const root = requireSquadRoot();
|
|
@@ -5121,17 +6996,17 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
5121
6996
|
feature = await pickFeatureInteractive(paths);
|
|
5122
6997
|
}
|
|
5123
6998
|
}
|
|
5124
|
-
const storyDir =
|
|
5125
|
-
const planDir =
|
|
6999
|
+
const storyDir = path31.join(paths.storiesDir, feature);
|
|
7000
|
+
const planDir = path31.join(paths.plansDir, feature);
|
|
5126
7001
|
const nStories = countStoryDirs(paths.storiesDir, feature);
|
|
5127
7002
|
const nPlans = listAllPlanListEntries(paths, feature).length;
|
|
5128
|
-
const hasOverview =
|
|
7003
|
+
const hasOverview = fs37.existsSync(path31.join(planDir, "00-overview.md")) ? 1 : 0;
|
|
5129
7004
|
const targets = [];
|
|
5130
|
-
if (
|
|
5131
|
-
targets.push({ label:
|
|
7005
|
+
if (fs37.existsSync(storyDir)) {
|
|
7006
|
+
targets.push({ label: path31.relative(root, storyDir), absPath: storyDir, kind: "dir" });
|
|
5132
7007
|
}
|
|
5133
|
-
if (
|
|
5134
|
-
targets.push({ label:
|
|
7008
|
+
if (fs37.existsSync(planDir)) {
|
|
7009
|
+
targets.push({ label: path31.relative(root, planDir), absPath: planDir, kind: "dir" });
|
|
5135
7010
|
}
|
|
5136
7011
|
divider("squad rm feature");
|
|
5137
7012
|
info(`Feature: ${feature}`);
|
|
@@ -5146,7 +7021,7 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
5146
7021
|
kv(t.kind, t.label, 6);
|
|
5147
7022
|
}
|
|
5148
7023
|
if (useTrash) {
|
|
5149
|
-
info(`Items will move to ${
|
|
7024
|
+
info(`Items will move to ${path31.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
|
|
5150
7025
|
} else if (!dryRun) {
|
|
5151
7026
|
warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
|
|
5152
7027
|
}
|
|
@@ -5179,7 +7054,7 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
5179
7054
|
useTrash
|
|
5180
7055
|
);
|
|
5181
7056
|
if (result.trashed) {
|
|
5182
|
-
success(`Feature removed. Items moved to ${
|
|
7057
|
+
success(`Feature removed. Items moved to ${path31.relative(root, result.trashed)}.`);
|
|
5183
7058
|
} else {
|
|
5184
7059
|
success("Feature removed (stories and plans directories).");
|
|
5185
7060
|
}
|
|
@@ -5220,11 +7095,11 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
5220
7095
|
}
|
|
5221
7096
|
}
|
|
5222
7097
|
const targets = [
|
|
5223
|
-
{ label:
|
|
7098
|
+
{ label: path32.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
|
|
5224
7099
|
];
|
|
5225
7100
|
if (story.planFile) {
|
|
5226
|
-
const planAbs =
|
|
5227
|
-
targets.push({ label:
|
|
7101
|
+
const planAbs = path32.join(paths.plansDir, story.feature, story.planFile);
|
|
7102
|
+
targets.push({ label: path32.relative(root, planAbs), absPath: planAbs, kind: "file" });
|
|
5228
7103
|
}
|
|
5229
7104
|
renderPreview("story", targets, {
|
|
5230
7105
|
dryRun,
|
|
@@ -5247,18 +7122,16 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
5247
7122
|
return;
|
|
5248
7123
|
}
|
|
5249
7124
|
}
|
|
5250
|
-
const
|
|
5251
|
-
const result = trashOrDelete(absPaths, paths.trashDir, useTrash);
|
|
5252
|
-
removeOverviewRow(path24.join(paths.plansDir, story.feature), story.id);
|
|
7125
|
+
const result = deleteStoryRecord({ paths, story, trash: useTrash });
|
|
5253
7126
|
if (result.trashed) {
|
|
5254
|
-
success(`Removed (moved to ${
|
|
7127
|
+
success(`Removed (moved to ${path32.relative(root, result.trashed)}).`);
|
|
5255
7128
|
} else {
|
|
5256
7129
|
success("Removed story folder, plan file (if any), and overview row.");
|
|
5257
7130
|
}
|
|
5258
|
-
const featurePlanDir =
|
|
5259
|
-
const overviewPath =
|
|
5260
|
-
if (
|
|
5261
|
-
const body =
|
|
7131
|
+
const featurePlanDir = path32.join(paths.plansDir, story.feature);
|
|
7132
|
+
const overviewPath = path32.join(featurePlanDir, "00-overview.md");
|
|
7133
|
+
if (fs38.existsSync(overviewPath)) {
|
|
7134
|
+
const body = fs38.readFileSync(overviewPath, "utf8");
|
|
5262
7135
|
const onlyShell = countOverviewDataRows(body) === 0;
|
|
5263
7136
|
if (onlyShell && featureStoriesDirEmpty(paths, story.feature)) {
|
|
5264
7137
|
if (canPrompt) {
|
|
@@ -5278,7 +7151,7 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
5278
7151
|
}
|
|
5279
7152
|
|
|
5280
7153
|
// src/commands/rm/plan.ts
|
|
5281
|
-
import
|
|
7154
|
+
import path33 from "path";
|
|
5282
7155
|
import { confirm as confirm11 } from "@inquirer/prompts";
|
|
5283
7156
|
async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
5284
7157
|
const root = requireSquadRoot();
|
|
@@ -5314,7 +7187,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
|
5314
7187
|
}
|
|
5315
7188
|
const storyRows = listStories(paths, { feature: plan.feature });
|
|
5316
7189
|
const linked = storyRows.find((s) => s.planFile === plan.file);
|
|
5317
|
-
const targets = [{ label:
|
|
7190
|
+
const targets = [{ label: path33.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
|
|
5318
7191
|
renderPreview("plan", targets, {
|
|
5319
7192
|
dryRun,
|
|
5320
7193
|
trash: useTrash,
|
|
@@ -5337,14 +7210,14 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
|
5337
7210
|
}
|
|
5338
7211
|
}
|
|
5339
7212
|
trashOrDelete([plan.abs], paths.trashDir, useTrash);
|
|
5340
|
-
removeOverviewRowForPlanFile(
|
|
5341
|
-
const intakeHint = linked ?
|
|
7213
|
+
removeOverviewRowForPlanFile(path33.join(paths.plansDir, plan.feature), plan.file);
|
|
7214
|
+
const intakeHint = linked ? path33.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
|
|
5342
7215
|
success(`Plan removed. Run \`squad new-plan ${intakeHint}\` to regenerate.`);
|
|
5343
7216
|
}
|
|
5344
7217
|
|
|
5345
7218
|
// src/cli.ts
|
|
5346
7219
|
var program = new Command();
|
|
5347
|
-
program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.
|
|
7220
|
+
program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.6.0");
|
|
5348
7221
|
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(
|
|
5349
7222
|
"--tracker-workspace <hostOrOrg>",
|
|
5350
7223
|
"Jira host or Azure org (non-interactive default; also written to config for jira/azure)"
|
|
@@ -5377,6 +7250,11 @@ program.command("status").description("Show squad-kit workspace status").action(
|
|
|
5377
7250
|
program.command("doctor").description("Run a health check on the local .squad/ workspace and its external integrations").option("--fix", "Apply non-destructive repairs (gitignore, permissions, missing dirs)", false).option("--json", "Emit results as JSON to stdout (for scripting)", false).action(wrap(runDoctor));
|
|
5378
7251
|
program.command("migrate").description("Apply one-shot structural migrations to bring .squad/ to the latest version").option("--dry-run", "Show what would change without touching the filesystem", false).option("-y, --yes", "Skip the confirmation prompt", false).action(wrap(runMigrate));
|
|
5379
7252
|
program.command("upgrade").description("Check for and install the latest squad-kit release").option("--check", "Only check for updates; do not install", false).option("-y, --yes", "Skip the confirmation prompt", false).action(wrap(runUpgrade));
|
|
7253
|
+
program.command("console").description("Open the squad-kit web console (local, dark-modern UI for stories, plans, runs, config)").option(
|
|
7254
|
+
"--port <number>",
|
|
7255
|
+
"Port to bind on 127.0.0.1 (default 4571; falls back to next free port)",
|
|
7256
|
+
(v) => parseInt(v, 10)
|
|
7257
|
+
).option("--no-open", "Do not auto-open the browser; print the URL instead").option("--token <hex>", "Reuse a specific session token (advanced; default: random per session)").action(wrap(runConsole));
|
|
5380
7258
|
program.command("list").description("List stories and their plan state").option("--feature <slug>", "Filter by feature slug").action(wrap(runList));
|
|
5381
7259
|
var rm = program.command("rm").description("Delete stories, plans, or entire features safely");
|
|
5382
7260
|
rm.command("story [story-path-or-id]").description("Delete a story: intake folder + matching plan file + overview row").option("--feature <slug>", "Scope the picker or lookup to one feature").option("--dry-run", "Show what would be deleted without touching the filesystem", false).option("--trash", "Move into .squad/.trash/<ts>/ instead of permanent delete", false).option("-y, --yes", "Skip the confirmation prompt", false).action(wrapArgs(runRmStory));
|
|
@@ -5398,6 +7276,10 @@ config.command("remove-credential <section>").description('Delete credentials fo
|
|
|
5398
7276
|
)
|
|
5399
7277
|
);
|
|
5400
7278
|
program.parseAsync(process.argv).catch((err) => {
|
|
7279
|
+
if (isSquadExit(err)) {
|
|
7280
|
+
if (err.message.trim()) info(err.message);
|
|
7281
|
+
process.exit(err.exitCode);
|
|
7282
|
+
}
|
|
5401
7283
|
renderError(err);
|
|
5402
7284
|
process.exit(1);
|
|
5403
7285
|
});
|
|
@@ -5406,6 +7288,10 @@ function wrap(fn) {
|
|
|
5406
7288
|
try {
|
|
5407
7289
|
await fn(opts);
|
|
5408
7290
|
} catch (err) {
|
|
7291
|
+
if (isSquadExit(err)) {
|
|
7292
|
+
if (err.message.trim()) info(err.message);
|
|
7293
|
+
process.exit(err.exitCode);
|
|
7294
|
+
}
|
|
5409
7295
|
renderError(err);
|
|
5410
7296
|
process.exit(1);
|
|
5411
7297
|
}
|
|
@@ -5416,6 +7302,10 @@ function wrapArgs(fn) {
|
|
|
5416
7302
|
try {
|
|
5417
7303
|
await fn(...args);
|
|
5418
7304
|
} catch (err) {
|
|
7305
|
+
if (isSquadExit(err)) {
|
|
7306
|
+
if (err.message.trim()) info(err.message);
|
|
7307
|
+
process.exit(err.exitCode);
|
|
7308
|
+
}
|
|
5419
7309
|
renderError(err);
|
|
5420
7310
|
process.exit(1);
|
|
5421
7311
|
}
|