squad-kit 0.4.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 +72 -1
- package/README.md +27 -4
- package/dist/cli.js +2129 -660
- 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/dist/cli.js
CHANGED
|
@@ -237,6 +237,7 @@ var SQUAD_ATTACHMENTS_PATTERN = ".squad/stories/**/attachments/";
|
|
|
237
237
|
var SQUAD_TRASH_PATTERN = ".squad/.trash/";
|
|
238
238
|
var SQUAD_LAST_RUN_PATTERN = ".squad/.last-run.json";
|
|
239
239
|
var SQUAD_LAST_COPY_PROMPT_PATTERN = ".squad/.last-copy-prompt.md";
|
|
240
|
+
var SQUAD_RUNS_PATTERN = ".squad/runs/";
|
|
240
241
|
var MANAGED_BLOCK_HEADER = "# Managed by squad-kit \u2014 do not edit this block";
|
|
241
242
|
var MANAGED_BLOCK_FOOTER = "# End squad-kit block";
|
|
242
243
|
function ensureGitignore(repoRoot) {
|
|
@@ -247,7 +248,8 @@ function ensureGitignore(repoRoot) {
|
|
|
247
248
|
SQUAD_ATTACHMENTS_PATTERN,
|
|
248
249
|
SQUAD_TRASH_PATTERN,
|
|
249
250
|
SQUAD_LAST_RUN_PATTERN,
|
|
250
|
-
SQUAD_LAST_COPY_PROMPT_PATTERN
|
|
251
|
+
SQUAD_LAST_COPY_PROMPT_PATTERN,
|
|
252
|
+
SQUAD_RUNS_PATTERN
|
|
251
253
|
];
|
|
252
254
|
const allPresent = requiredPatterns.every((p) => text.includes(p));
|
|
253
255
|
if (allPresent) return false;
|
|
@@ -301,15 +303,15 @@ function clampPlannerMaxOut(raw) {
|
|
|
301
303
|
Math.max(MIN_PLANNER_MAX_OUTPUT_TOKENS, Math.round(n))
|
|
302
304
|
);
|
|
303
305
|
}
|
|
304
|
-
function rejectSecretsInYaml(node, configFile,
|
|
306
|
+
function rejectSecretsInYaml(node, configFile, path34 = []) {
|
|
305
307
|
if (node === null || typeof node !== "object") return;
|
|
306
308
|
for (const [key, value] of Object.entries(node)) {
|
|
307
309
|
if (FORBIDDEN_KEYS.includes(key.toLowerCase())) {
|
|
308
310
|
throw new Error(
|
|
309
|
-
`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.`
|
|
310
312
|
);
|
|
311
313
|
}
|
|
312
|
-
if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...
|
|
314
|
+
if (typeof value === "object") rejectSecretsInYaml(value, configFile, [...path34, key]);
|
|
313
315
|
}
|
|
314
316
|
}
|
|
315
317
|
function mergePlanner(base, override) {
|
|
@@ -357,8 +359,7 @@ function validateModelOverride(mo, configFile) {
|
|
|
357
359
|
}
|
|
358
360
|
}
|
|
359
361
|
}
|
|
360
|
-
function
|
|
361
|
-
const raw = fs3.readFileSync(configFile, "utf8");
|
|
362
|
+
function parseConfig(raw, configFile) {
|
|
362
363
|
let parsed;
|
|
363
364
|
try {
|
|
364
365
|
parsed = yaml.load(raw);
|
|
@@ -413,11 +414,17 @@ function loadConfig(configFile) {
|
|
|
413
414
|
}
|
|
414
415
|
return merged;
|
|
415
416
|
}
|
|
417
|
+
function loadConfig(configFile) {
|
|
418
|
+
const raw = fs3.readFileSync(configFile, "utf8");
|
|
419
|
+
return parseConfig(raw, configFile);
|
|
420
|
+
}
|
|
416
421
|
function serializeConfig(config2) {
|
|
417
422
|
return yaml.dump(config2, { lineWidth: 100, noRefs: true, sortKeys: false });
|
|
418
423
|
}
|
|
419
424
|
function saveConfig(configFile, config2) {
|
|
420
|
-
|
|
425
|
+
const body = serializeConfig(config2);
|
|
426
|
+
parseConfig(body, configFile);
|
|
427
|
+
fs3.writeFileSync(configFile, body, "utf8");
|
|
421
428
|
}
|
|
422
429
|
function mergeConfig(base, override) {
|
|
423
430
|
return {
|
|
@@ -551,6 +558,19 @@ function resolveProviderKey(provider) {
|
|
|
551
558
|
function readProviderKey(provider) {
|
|
552
559
|
return resolveProviderKey(provider)?.value;
|
|
553
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
|
+
}
|
|
554
574
|
|
|
555
575
|
// src/utils/fs.ts
|
|
556
576
|
import fs6 from "fs";
|
|
@@ -1226,6 +1246,55 @@ var AzureDevOpsClient = class {
|
|
|
1226
1246
|
baseUrl;
|
|
1227
1247
|
apiVersion;
|
|
1228
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
|
+
}
|
|
1229
1298
|
async fetchIssue(id) {
|
|
1230
1299
|
const url = `${this.baseUrl}/workitems/${encodeURIComponent(id)}?$expand=all&api-version=${this.apiVersion}`;
|
|
1231
1300
|
let res;
|
|
@@ -1276,7 +1345,7 @@ var AzureDevOpsClient = class {
|
|
|
1276
1345
|
}
|
|
1277
1346
|
if (status === 404) {
|
|
1278
1347
|
return new TrackerError(
|
|
1279
|
-
`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.`,
|
|
1280
1349
|
"not-found",
|
|
1281
1350
|
status
|
|
1282
1351
|
);
|
|
@@ -1284,7 +1353,11 @@ var AzureDevOpsClient = class {
|
|
|
1284
1353
|
if (status === 429) {
|
|
1285
1354
|
return new TrackerError(`Azure DevOps rate limit hit (HTTP 429). Wait a minute and retry.`, "rate-limited", status);
|
|
1286
1355
|
}
|
|
1287
|
-
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
|
+
);
|
|
1288
1361
|
}
|
|
1289
1362
|
};
|
|
1290
1363
|
function parseTags(raw) {
|
|
@@ -1381,6 +1454,40 @@ var JiraClient = class {
|
|
|
1381
1454
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1382
1455
|
};
|
|
1383
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
|
+
}
|
|
1384
1491
|
async downloadAttachments(refs, targetDir, opts) {
|
|
1385
1492
|
return downloadAttachmentsWith(refs, targetDir, this.headers(), opts);
|
|
1386
1493
|
}
|
|
@@ -1400,7 +1507,7 @@ var JiraClient = class {
|
|
|
1400
1507
|
}
|
|
1401
1508
|
if (status === 404) {
|
|
1402
1509
|
return new TrackerError(
|
|
1403
|
-
`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.`,
|
|
1404
1511
|
"not-found",
|
|
1405
1512
|
status
|
|
1406
1513
|
);
|
|
@@ -1408,7 +1515,11 @@ var JiraClient = class {
|
|
|
1408
1515
|
if (status === 429) {
|
|
1409
1516
|
return new TrackerError(`Jira rate limit hit (HTTP 429). Wait a minute and retry.`, "rate-limited", status);
|
|
1410
1517
|
}
|
|
1411
|
-
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
|
+
);
|
|
1412
1523
|
}
|
|
1413
1524
|
};
|
|
1414
1525
|
function stripHtml2(html) {
|
|
@@ -1963,9 +2074,9 @@ Plan meta-prompts (\`generate-plan.md\`, \`story-skeleton.md\`) ship inside the
|
|
|
1963
2074
|
}
|
|
1964
2075
|
|
|
1965
2076
|
// src/commands/new-story.ts
|
|
1966
|
-
import
|
|
1967
|
-
import
|
|
1968
|
-
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";
|
|
1969
2080
|
|
|
1970
2081
|
// src/core/stories.ts
|
|
1971
2082
|
import fs15 from "fs";
|
|
@@ -1975,6 +2086,15 @@ function listFeatures(paths) {
|
|
|
1975
2086
|
if (!fs15.existsSync(paths.storiesDir)) return [];
|
|
1976
2087
|
return fs15.readdirSync(paths.storiesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
1977
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
|
+
}
|
|
1978
2098
|
function listAllStories(paths) {
|
|
1979
2099
|
return listStories(paths);
|
|
1980
2100
|
}
|
|
@@ -2097,225 +2217,9 @@ function trackerIdForFilename(type, id) {
|
|
|
2097
2217
|
return id;
|
|
2098
2218
|
}
|
|
2099
2219
|
|
|
2100
|
-
// src/
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
if (!trimmed) return "Required.";
|
|
2104
|
-
const s = slugify(trimmed);
|
|
2105
|
-
if (!s) return "Required.";
|
|
2106
|
-
if (s !== trimmed) return "Use lowercase kebab-case (letters, digits, hyphens).";
|
|
2107
|
-
return true;
|
|
2108
|
-
}
|
|
2109
|
-
async function runNewStory(featureSlug, opts) {
|
|
2110
|
-
const interactive = !opts.yes && Boolean(process.stdin.isTTY) && process.env.CI !== "true";
|
|
2111
|
-
let featureSlugInput = featureSlug;
|
|
2112
|
-
if (!featureSlugInput) {
|
|
2113
|
-
if (!interactive) {
|
|
2114
|
-
throw new Error(
|
|
2115
|
-
"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."
|
|
2116
|
-
);
|
|
2117
|
-
}
|
|
2118
|
-
const rootForPrompt = requireSquadRoot();
|
|
2119
|
-
const pathsForPrompt = buildPaths(rootForPrompt);
|
|
2120
|
-
const existing = listFeatures(pathsForPrompt);
|
|
2121
|
-
if (existing.length > 0) {
|
|
2122
|
-
const choice = await select4({
|
|
2123
|
-
message: "Feature:",
|
|
2124
|
-
choices: [
|
|
2125
|
-
...existing.map((f) => ({ name: f, value: f })),
|
|
2126
|
-
{ name: "\u2500\u2500\u2500 new feature \u2500\u2500\u2500", value: "__NEW__" }
|
|
2127
|
-
]
|
|
2128
|
-
});
|
|
2129
|
-
if (choice !== "__NEW__") {
|
|
2130
|
-
featureSlugInput = choice;
|
|
2131
|
-
}
|
|
2132
|
-
}
|
|
2133
|
-
if (!featureSlugInput) {
|
|
2134
|
-
featureSlugInput = (await input3({
|
|
2135
|
-
message: "New feature slug (kebab-case):",
|
|
2136
|
-
validate: validateFeatureSlugForPrompt
|
|
2137
|
-
})).trim();
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
const slug = slugify(featureSlugInput);
|
|
2141
|
-
if (!slug) {
|
|
2142
|
-
throw new Error(
|
|
2143
|
-
`Invalid feature slug: "${featureSlugInput}". Run \`squad new-story\` and use kebab-case (letters, digits, hyphens).`
|
|
2144
|
-
);
|
|
2145
|
-
}
|
|
2146
|
-
const root = requireSquadRoot();
|
|
2147
|
-
const paths = buildPaths(root);
|
|
2148
|
-
const config2 = loadConfig(paths.configFile);
|
|
2149
|
-
let noTracker = opts.tracker === false && config2.tracker.type !== "none";
|
|
2150
|
-
let id = opts.id;
|
|
2151
|
-
if (noTracker) id = void 0;
|
|
2152
|
-
let trackerNeedsId = !noTracker && config2.tracker.type !== "none" && config2.naming.includeTrackerId;
|
|
2153
|
-
let title = opts.title;
|
|
2154
|
-
if (!id && trackerNeedsId) {
|
|
2155
|
-
if (!interactive) {
|
|
2156
|
-
throw new Error(
|
|
2157
|
-
`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.`
|
|
2158
|
-
);
|
|
2159
|
-
}
|
|
2160
|
-
const mode = await select4({
|
|
2161
|
-
message: `This workspace requires a ${config2.tracker.type} id for new stories. How do you want to proceed?`,
|
|
2162
|
-
choices: [
|
|
2163
|
-
{ name: `Enter a ${config2.tracker.type} id now`, value: "enter" },
|
|
2164
|
-
{ name: "Skip tracker for this story (same as --no-tracker)", value: "skip" }
|
|
2165
|
-
],
|
|
2166
|
-
default: "enter"
|
|
2167
|
-
});
|
|
2168
|
-
if (mode === "skip") {
|
|
2169
|
-
opts.tracker = false;
|
|
2170
|
-
noTracker = true;
|
|
2171
|
-
trackerNeedsId = false;
|
|
2172
|
-
} else {
|
|
2173
|
-
id = (await input3({
|
|
2174
|
-
message: `${config2.tracker.type} work-item id:`,
|
|
2175
|
-
validate: (v) => validateTrackerId(config2.tracker.type, v.trim()) ? true : `Invalid ${config2.tracker.type} id format.`
|
|
2176
|
-
})).trim();
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
if (id && !validateTrackerId(config2.tracker.type, id)) {
|
|
2180
|
-
throw new Error(
|
|
2181
|
-
`Invalid tracker id "${id}" for tracker type "${config2.tracker.type}". Run \`squad config show\` to review the expected format, or \`squad config set tracker\` if the tracker type is wrong.`
|
|
2182
|
-
);
|
|
2183
|
-
}
|
|
2184
|
-
if (!id && !title) {
|
|
2185
|
-
if (!interactive) {
|
|
2186
|
-
if (!noTracker) {
|
|
2187
|
-
throw new Error(
|
|
2188
|
-
'Provide either --id <tracker-id> or --title "..." so the story folder has a distinct name. Run `squad new-story <feature-slug> --id ...` or `squad new-story <feature-slug> --title "..."` (or `--no-tracker` with a title).'
|
|
2189
|
-
);
|
|
2190
|
-
}
|
|
2191
|
-
} else {
|
|
2192
|
-
title = (await input3({ message: "Short story title:" })).trim();
|
|
2193
|
-
if (!title) throw new Error("Title cannot be empty. Run `squad new-story` again and enter a short title, or add `--yes` to fail fast in scripts.");
|
|
2194
|
-
}
|
|
2195
|
-
}
|
|
2196
|
-
if (interactive && !title) {
|
|
2197
|
-
const maybe = (await input3({ message: "Short title (optional, press Enter to skip):", default: "" })).trim();
|
|
2198
|
-
if (maybe) title = maybe;
|
|
2199
|
-
}
|
|
2200
|
-
const storyFolderName = id ? trackerIdForFilename(config2.tracker.type, id) : slugify(title ?? "");
|
|
2201
|
-
if (!storyFolderName) {
|
|
2202
|
-
throw new Error(
|
|
2203
|
-
'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.'
|
|
2204
|
-
);
|
|
2205
|
-
}
|
|
2206
|
-
const storyDir = path8.join(paths.storiesDir, slug, storyFolderName);
|
|
2207
|
-
const intakeFile = path8.join(storyDir, "intake.md");
|
|
2208
|
-
const attachmentsDir = path8.join(storyDir, "attachments");
|
|
2209
|
-
if (fs16.existsSync(intakeFile)) {
|
|
2210
|
-
throw new Error(
|
|
2211
|
-
`Intake already exists: ${intakeFile}. Run \`squad new-plan ${path8.relative(root, intakeFile)}\` to plan it, or \`squad rm story\` to remove the story first.`
|
|
2212
|
-
);
|
|
2213
|
-
}
|
|
2214
|
-
fs16.mkdirSync(attachmentsDir, { recursive: true });
|
|
2215
|
-
let sourceBlock = "";
|
|
2216
|
-
let fetchedSummary;
|
|
2217
|
-
let fetchedIssue;
|
|
2218
|
-
const shouldFetch = !noTracker && opts.fetch !== false && Boolean(id) && config2.tracker.type !== "none";
|
|
2219
|
-
if (shouldFetch) {
|
|
2220
|
-
const outcome = await tryFetchIssue(config2, id, opts, paths, attachmentsDir);
|
|
2221
|
-
if (outcome.outcome === "ok") {
|
|
2222
|
-
fetchedIssue = outcome.issue;
|
|
2223
|
-
sourceBlock = buildSourcePreamble(outcome.issue, outcome.downloads, config2.tracker.type);
|
|
2224
|
-
if (!title) title = outcome.issue.title;
|
|
2225
|
-
const written = outcome.downloads.filter((d) => d.outcome === "written").length;
|
|
2226
|
-
fetchedSummary = { id: outcome.issue.id, written };
|
|
2227
|
-
} else {
|
|
2228
|
-
warning(`Tracker fetch skipped: ${outcome.reason}`);
|
|
2229
|
-
if (outcome.hint) info(outcome.hint);
|
|
2230
|
-
sourceBlock = buildFallbackPreamble(outcome.reason, outcome.hint);
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2233
|
-
const template = readBundledPrompt("intake.md");
|
|
2234
|
-
const rendered = render(
|
|
2235
|
-
template,
|
|
2236
|
-
buildIntakeTemplateVars({
|
|
2237
|
-
config: config2,
|
|
2238
|
-
featureSlug: slug,
|
|
2239
|
-
storyFolderName,
|
|
2240
|
-
trackerId: id,
|
|
2241
|
-
title,
|
|
2242
|
-
fetchedIssue
|
|
2243
|
-
})
|
|
2244
|
-
);
|
|
2245
|
-
const skippedPreamble = noTracker && config2.tracker.type !== "none" && !sourceBlock ? buildSkippedTrackerPreamble(config2.tracker.type) : "";
|
|
2246
|
-
writeFileSafe(intakeFile, skippedPreamble + sourceBlock + rendered, false);
|
|
2247
|
-
ensureFeatureOverview(path8.join(paths.plansDir, slug), slug);
|
|
2248
|
-
const relativePath = path8.relative(root, intakeFile);
|
|
2249
|
-
const relAttachDir = path8.relative(root, attachmentsDir);
|
|
2250
|
-
success("Created intake");
|
|
2251
|
-
if (noTracker && config2.tracker.type !== "none") {
|
|
2252
|
-
warning("Created without a tracker link. Use `squad tracker link` later to attach one.");
|
|
2253
|
-
}
|
|
2254
|
-
kv("intake", relativePath, 11);
|
|
2255
|
-
kv("attachments", relAttachDir + path8.sep, 11);
|
|
2256
|
-
if (fetchedSummary && fetchedSummary.written > 0) {
|
|
2257
|
-
kv("fetched", `${fetchedSummary.id} \xB7 ${fetchedSummary.written} attachments saved`, 11);
|
|
2258
|
-
}
|
|
2259
|
-
blank();
|
|
2260
|
-
step("Next: review the intake (Source block at top is from the tracker), then:");
|
|
2261
|
-
info(`squad new-plan ${relativePath}`);
|
|
2262
|
-
}
|
|
2263
|
-
async function tryFetchIssue(config2, id, opts, paths, attachmentsDir) {
|
|
2264
|
-
const secretsFromFile = fs16.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
2265
|
-
const secrets = overlayTrackerEnv(secretsFromFile);
|
|
2266
|
-
const resolution = clientFor(config2, secrets);
|
|
2267
|
-
if (resolution.error) {
|
|
2268
|
-
return { outcome: "skipped", reason: resolution.error.message, hint: resolution.error.detail };
|
|
2269
|
-
}
|
|
2270
|
-
const client = resolution.client;
|
|
2271
|
-
const spin = spinner(`Fetching ${config2.tracker.type} ${id}\u2026`);
|
|
2272
|
-
let issue;
|
|
2273
|
-
try {
|
|
2274
|
-
issue = await client.fetchIssue(id);
|
|
2275
|
-
} catch (err) {
|
|
2276
|
-
spin.fail("Tracker fetch failed");
|
|
2277
|
-
if (err instanceof TrackerError) {
|
|
2278
|
-
return { outcome: "failed", reason: err.message, hint: explainTrackerErrorHint(err) };
|
|
2279
|
-
}
|
|
2280
|
-
return { outcome: "failed", reason: err.message };
|
|
2281
|
-
}
|
|
2282
|
-
spin.succeed(`Fetched ${issue.id}: ${truncate(issue.title, 60)}`);
|
|
2283
|
-
let downloads = [];
|
|
2284
|
-
if (opts.attachments !== false && issue.attachments.length > 0) {
|
|
2285
|
-
const attachSpin = spinner(`Downloading ${issue.attachments.length} attachment(s)\u2026`);
|
|
2286
|
-
downloads = await client.downloadAttachments(issue.attachments, attachmentsDir, {
|
|
2287
|
-
maxMegabytes: opts.attachmentMb
|
|
2288
|
-
});
|
|
2289
|
-
const written = downloads.filter((d) => d.outcome === "written").length;
|
|
2290
|
-
const skipped = downloads.length - written;
|
|
2291
|
-
attachSpin.succeed(`Attachments: ${written} saved, ${skipped} skipped`);
|
|
2292
|
-
} else if (opts.attachments === false && issue.attachments.length > 0) {
|
|
2293
|
-
downloads = issue.attachments.map((source) => ({
|
|
2294
|
-
source,
|
|
2295
|
-
outcome: "skipped-error",
|
|
2296
|
-
bytesWritten: 0,
|
|
2297
|
-
skipReason: "not downloaded (--no-attachments)"
|
|
2298
|
-
}));
|
|
2299
|
-
}
|
|
2300
|
-
return { outcome: "ok", issue, downloads };
|
|
2301
|
-
}
|
|
2302
|
-
function explainTrackerErrorHint(err) {
|
|
2303
|
-
switch (err.kind) {
|
|
2304
|
-
case "auth":
|
|
2305
|
-
return "Run `squad config set tracker` to re-enter credentials, or `squad doctor` to verify.";
|
|
2306
|
-
case "not-found":
|
|
2307
|
-
return "Run `squad config show` to check workspace settings, or `squad tracker link` with a valid work-item id.";
|
|
2308
|
-
case "rate-limited":
|
|
2309
|
-
return "Wait a minute, then retry, or run `squad new-story` with --no-fetch.";
|
|
2310
|
-
case "network":
|
|
2311
|
-
return "Run `squad doctor` to verify connectivity, or `squad new-story` with --no-fetch to skip.";
|
|
2312
|
-
default:
|
|
2313
|
-
return void 0;
|
|
2314
|
-
}
|
|
2315
|
-
}
|
|
2316
|
-
function truncate(s, n) {
|
|
2317
|
-
return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
|
|
2318
|
-
}
|
|
2220
|
+
// src/core/story-intake-helpers.ts
|
|
2221
|
+
import fs16 from "fs";
|
|
2222
|
+
import path8 from "path";
|
|
2319
2223
|
function buildIntakeTemplateVars(args) {
|
|
2320
2224
|
const e = escapeTemplateValue;
|
|
2321
2225
|
const { config: config2, featureSlug, storyFolderName, trackerId, title, fetchedIssue } = args;
|
|
@@ -2407,13 +2311,573 @@ function ensureFeatureOverview(featurePlanDir, slug) {
|
|
|
2407
2311
|
writeFileSafe(overviewFile, render(template, { featureSlug: slug }), false);
|
|
2408
2312
|
}
|
|
2409
2313
|
|
|
2410
|
-
// src/
|
|
2411
|
-
import
|
|
2412
|
-
import
|
|
2413
|
-
import { confirm as confirm6, select as select5 } from "@inquirer/prompts";
|
|
2314
|
+
// src/core/story-mutations.ts
|
|
2315
|
+
import fs19 from "fs";
|
|
2316
|
+
import path11 from "path";
|
|
2414
2317
|
|
|
2415
|
-
// src/
|
|
2416
|
-
import
|
|
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);
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
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}`);
|
|
2399
|
+
}
|
|
2400
|
+
fs18.renameSync(src, dst);
|
|
2401
|
+
}
|
|
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);
|
|
2409
|
+
}
|
|
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";
|
|
2708
|
+
let id = opts.id;
|
|
2709
|
+
if (noTracker) id = void 0;
|
|
2710
|
+
let trackerNeedsId = !noTracker && config2.tracker.type !== "none" && config2.naming.includeTrackerId;
|
|
2711
|
+
let title = opts.title;
|
|
2712
|
+
if (!id && trackerNeedsId) {
|
|
2713
|
+
if (!interactive) {
|
|
2714
|
+
throw new Error(
|
|
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.`
|
|
2716
|
+
);
|
|
2717
|
+
}
|
|
2718
|
+
const mode = await select5({
|
|
2719
|
+
message: `This workspace requires a ${config2.tracker.type} id for new stories. How do you want to proceed?`,
|
|
2720
|
+
choices: [
|
|
2721
|
+
{ name: `Enter a ${config2.tracker.type} id now`, value: "enter" },
|
|
2722
|
+
{ name: "Skip tracker for this story (same as --no-tracker)", value: "skip" }
|
|
2723
|
+
],
|
|
2724
|
+
default: "enter"
|
|
2725
|
+
});
|
|
2726
|
+
if (mode === "skip") {
|
|
2727
|
+
opts.tracker = false;
|
|
2728
|
+
noTracker = true;
|
|
2729
|
+
trackerNeedsId = false;
|
|
2730
|
+
} else {
|
|
2731
|
+
id = (await input3({
|
|
2732
|
+
message: `${config2.tracker.type} work-item id:`,
|
|
2733
|
+
validate: (v) => validateTrackerId(config2.tracker.type, v.trim()) ? true : `Invalid ${config2.tracker.type} id format.`
|
|
2734
|
+
})).trim();
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
if (id && !validateTrackerId(config2.tracker.type, id)) {
|
|
2738
|
+
throw new Error(
|
|
2739
|
+
`Invalid tracker id "${id}" for tracker type "${config2.tracker.type}". Run \`squad config show\` to review the expected format, or \`squad config set tracker\` if the tracker type is wrong.`
|
|
2740
|
+
);
|
|
2741
|
+
}
|
|
2742
|
+
if (!id && !title) {
|
|
2743
|
+
if (!interactive) {
|
|
2744
|
+
if (!noTracker) {
|
|
2745
|
+
throw new Error(
|
|
2746
|
+
'Provide either --id <tracker-id> or --title "..." so the story folder has a distinct name. Run `squad new-story <feature-slug> --id ...` or `squad new-story <feature-slug> --title "..."` (or `--no-tracker` with a title).'
|
|
2747
|
+
);
|
|
2748
|
+
}
|
|
2749
|
+
} else {
|
|
2750
|
+
title = (await input3({ message: "Short story title:" })).trim();
|
|
2751
|
+
if (!title) throw new Error("Title cannot be empty. Run `squad new-story` again and enter a short title, or add `--yes` to fail fast in scripts.");
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
if (interactive && !title) {
|
|
2755
|
+
const maybe = (await input3({ message: "Short title (optional, press Enter to skip):", default: "" })).trim();
|
|
2756
|
+
if (maybe) title = maybe;
|
|
2757
|
+
}
|
|
2758
|
+
const storyFolderName = id ? trackerIdForFilename(config2.tracker.type, id) : slugify(title ?? "");
|
|
2759
|
+
if (!storyFolderName) {
|
|
2760
|
+
throw new Error(
|
|
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.'
|
|
2762
|
+
);
|
|
2763
|
+
}
|
|
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)) {
|
|
2768
|
+
throw new Error(
|
|
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.`
|
|
2770
|
+
);
|
|
2771
|
+
}
|
|
2772
|
+
fs20.mkdirSync(attachmentsDir, { recursive: true });
|
|
2773
|
+
let sourceBlock = "";
|
|
2774
|
+
let fetchedSummary;
|
|
2775
|
+
let fetchedIssue;
|
|
2776
|
+
const shouldFetch = !noTracker && opts.fetch !== false && Boolean(id) && config2.tracker.type !== "none";
|
|
2777
|
+
if (shouldFetch) {
|
|
2778
|
+
const outcome = await tryFetchIssue(config2, id, opts, paths, attachmentsDir);
|
|
2779
|
+
if (outcome.outcome === "ok") {
|
|
2780
|
+
fetchedIssue = outcome.issue;
|
|
2781
|
+
sourceBlock = buildSourcePreamble(outcome.issue, outcome.downloads, config2.tracker.type);
|
|
2782
|
+
if (!title) title = outcome.issue.title;
|
|
2783
|
+
const written = outcome.downloads.filter((d) => d.outcome === "written").length;
|
|
2784
|
+
fetchedSummary = { id: outcome.issue.id, written };
|
|
2785
|
+
} else {
|
|
2786
|
+
warning(`Tracker fetch skipped: ${outcome.reason}`);
|
|
2787
|
+
if (outcome.hint) info(outcome.hint);
|
|
2788
|
+
sourceBlock = buildFallbackPreamble(outcome.reason, outcome.hint);
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
writeNewStoryToDisk({
|
|
2792
|
+
paths,
|
|
2793
|
+
config: config2,
|
|
2794
|
+
featureSlug: slug,
|
|
2795
|
+
storyFolderName,
|
|
2796
|
+
id,
|
|
2797
|
+
title,
|
|
2798
|
+
noTracker,
|
|
2799
|
+
sourceBlock,
|
|
2800
|
+
fetchedIssue
|
|
2801
|
+
});
|
|
2802
|
+
const relativePath = path12.relative(root, intakeFile);
|
|
2803
|
+
const relAttachDir = path12.relative(root, attachmentsDir);
|
|
2804
|
+
success("Created intake");
|
|
2805
|
+
if (noTracker && config2.tracker.type !== "none") {
|
|
2806
|
+
warning("Created without a tracker link. Use `squad tracker link` later to attach one.");
|
|
2807
|
+
}
|
|
2808
|
+
kv("intake", relativePath, 11);
|
|
2809
|
+
kv("attachments", relAttachDir + path12.sep, 11);
|
|
2810
|
+
if (fetchedSummary && fetchedSummary.written > 0) {
|
|
2811
|
+
kv("fetched", `${fetchedSummary.id} \xB7 ${fetchedSummary.written} attachments saved`, 11);
|
|
2812
|
+
}
|
|
2813
|
+
blank();
|
|
2814
|
+
step("Next: review the intake (Source block at top is from the tracker), then:");
|
|
2815
|
+
info(`squad new-plan ${relativePath}`);
|
|
2816
|
+
}
|
|
2817
|
+
async function tryFetchIssue(config2, id, opts, paths, attachmentsDir) {
|
|
2818
|
+
const secretsFromFile = fs20.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
2819
|
+
const secrets = overlayTrackerEnv(secretsFromFile);
|
|
2820
|
+
const resolution = clientFor(config2, secrets);
|
|
2821
|
+
if (resolution.error) {
|
|
2822
|
+
return { outcome: "skipped", reason: resolution.error.message, hint: resolution.error.detail };
|
|
2823
|
+
}
|
|
2824
|
+
const client = resolution.client;
|
|
2825
|
+
const spin = spinner(`Fetching ${config2.tracker.type} ${id}\u2026`);
|
|
2826
|
+
let issue;
|
|
2827
|
+
try {
|
|
2828
|
+
issue = await client.fetchIssue(id);
|
|
2829
|
+
} catch (err) {
|
|
2830
|
+
spin.fail("Tracker fetch failed");
|
|
2831
|
+
if (err instanceof TrackerError) {
|
|
2832
|
+
return { outcome: "failed", reason: err.message, hint: explainTrackerErrorHint(err) };
|
|
2833
|
+
}
|
|
2834
|
+
return { outcome: "failed", reason: err.message };
|
|
2835
|
+
}
|
|
2836
|
+
spin.succeed(`Fetched ${issue.id}: ${truncate(issue.title, 60)}`);
|
|
2837
|
+
let downloads = [];
|
|
2838
|
+
if (opts.attachments !== false && issue.attachments.length > 0) {
|
|
2839
|
+
const attachSpin = spinner(`Downloading ${issue.attachments.length} attachment(s)\u2026`);
|
|
2840
|
+
downloads = await client.downloadAttachments(issue.attachments, attachmentsDir, {
|
|
2841
|
+
maxMegabytes: opts.attachmentMb
|
|
2842
|
+
});
|
|
2843
|
+
const written = downloads.filter((d) => d.outcome === "written").length;
|
|
2844
|
+
const skipped = downloads.length - written;
|
|
2845
|
+
attachSpin.succeed(`Attachments: ${written} saved, ${skipped} skipped`);
|
|
2846
|
+
} else if (opts.attachments === false && issue.attachments.length > 0) {
|
|
2847
|
+
downloads = issue.attachments.map((source) => ({
|
|
2848
|
+
source,
|
|
2849
|
+
outcome: "skipped-error",
|
|
2850
|
+
bytesWritten: 0,
|
|
2851
|
+
skipReason: "not downloaded (--no-attachments)"
|
|
2852
|
+
}));
|
|
2853
|
+
}
|
|
2854
|
+
return { outcome: "ok", issue, downloads };
|
|
2855
|
+
}
|
|
2856
|
+
function explainTrackerErrorHint(err) {
|
|
2857
|
+
switch (err.kind) {
|
|
2858
|
+
case "auth":
|
|
2859
|
+
return "Run `squad config set tracker` to re-enter credentials, or `squad doctor` to verify.";
|
|
2860
|
+
case "not-found":
|
|
2861
|
+
return "Run `squad config show` to check workspace settings, or `squad tracker link` with a valid work-item id.";
|
|
2862
|
+
case "rate-limited":
|
|
2863
|
+
return "Wait a minute, then retry, or run `squad new-story` with --no-fetch.";
|
|
2864
|
+
case "network":
|
|
2865
|
+
return "Run `squad doctor` to verify connectivity, or `squad new-story` with --no-fetch to skip.";
|
|
2866
|
+
default:
|
|
2867
|
+
return void 0;
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
function truncate(s, n) {
|
|
2871
|
+
return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
// src/commands/new-plan.ts
|
|
2875
|
+
import fs26 from "fs";
|
|
2876
|
+
import path19 from "path";
|
|
2877
|
+
import { confirm as confirm6, select as select6 } from "@inquirer/prompts";
|
|
2878
|
+
|
|
2879
|
+
// src/utils/clipboard.ts
|
|
2880
|
+
import { spawn, spawnSync } from "child_process";
|
|
2417
2881
|
async function copyToClipboard(text) {
|
|
2418
2882
|
const candidates = clipboardCandidates();
|
|
2419
2883
|
if (candidates.length === 0) {
|
|
@@ -2433,12 +2897,12 @@ async function copyToClipboard(text) {
|
|
|
2433
2897
|
return { ok: false, reason: lastReason ?? "no working clipboard tool found" };
|
|
2434
2898
|
}
|
|
2435
2899
|
function tryCopy(cmd, text) {
|
|
2436
|
-
return new Promise((
|
|
2900
|
+
return new Promise((resolve2) => {
|
|
2437
2901
|
let child;
|
|
2438
2902
|
try {
|
|
2439
2903
|
child = spawn(cmd.command, cmd.args, { stdio: ["pipe", "ignore", "pipe"] });
|
|
2440
2904
|
} catch (err) {
|
|
2441
|
-
|
|
2905
|
+
resolve2({ ok: false, reason: `${cmd.command} failed to spawn: ${err.message}` });
|
|
2442
2906
|
return;
|
|
2443
2907
|
}
|
|
2444
2908
|
let stderr = "";
|
|
@@ -2446,13 +2910,13 @@ function tryCopy(cmd, text) {
|
|
|
2446
2910
|
stderr += chunk.toString();
|
|
2447
2911
|
});
|
|
2448
2912
|
child.on("error", (err) => {
|
|
2449
|
-
|
|
2913
|
+
resolve2({ ok: false, reason: `${cmd.command} spawn error: ${err.message}` });
|
|
2450
2914
|
});
|
|
2451
2915
|
child.on("close", (code) => {
|
|
2452
2916
|
if (code === 0) {
|
|
2453
|
-
|
|
2917
|
+
resolve2({ ok: true, tool: cmd.command });
|
|
2454
2918
|
} else {
|
|
2455
|
-
|
|
2919
|
+
resolve2({
|
|
2456
2920
|
ok: false,
|
|
2457
2921
|
reason: `${cmd.command} exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 200)}` : ""}`
|
|
2458
2922
|
});
|
|
@@ -2501,11 +2965,11 @@ function noCandidateReason() {
|
|
|
2501
2965
|
}
|
|
2502
2966
|
|
|
2503
2967
|
// src/planner/loop.ts
|
|
2504
|
-
import
|
|
2968
|
+
import path15 from "path";
|
|
2505
2969
|
|
|
2506
2970
|
// src/planner/tools.ts
|
|
2507
|
-
import
|
|
2508
|
-
import
|
|
2971
|
+
import fs21 from "fs";
|
|
2972
|
+
import path13 from "path";
|
|
2509
2973
|
var READ_FILE_TOOL = {
|
|
2510
2974
|
name: "read_file",
|
|
2511
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.",
|
|
@@ -2524,15 +2988,15 @@ function readFileTool(root, budget, input5) {
|
|
|
2524
2988
|
if (typeof raw !== "string" || raw.length === 0) {
|
|
2525
2989
|
return error('read_file: missing or invalid "path" argument.');
|
|
2526
2990
|
}
|
|
2527
|
-
const resolved =
|
|
2528
|
-
const relCheck =
|
|
2529
|
-
if (relCheck.startsWith("..") ||
|
|
2991
|
+
const resolved = path13.resolve(root, raw);
|
|
2992
|
+
const relCheck = path13.relative(root, resolved);
|
|
2993
|
+
if (relCheck.startsWith("..") || path13.isAbsolute(relCheck)) {
|
|
2530
2994
|
return error(`read_file: path "${raw}" escapes the project root and was refused.`);
|
|
2531
2995
|
}
|
|
2532
|
-
if (!
|
|
2996
|
+
if (!fs21.existsSync(resolved)) {
|
|
2533
2997
|
return error(`read_file: not found "${raw}".`);
|
|
2534
2998
|
}
|
|
2535
|
-
const stat =
|
|
2999
|
+
const stat = fs21.statSync(resolved);
|
|
2536
3000
|
if (stat.isDirectory()) {
|
|
2537
3001
|
return error(`read_file: "${raw}" is a directory. Ask for a specific file path.`);
|
|
2538
3002
|
}
|
|
@@ -2547,7 +3011,7 @@ function readFileTool(root, budget, input5) {
|
|
|
2547
3011
|
}
|
|
2548
3012
|
let buf;
|
|
2549
3013
|
try {
|
|
2550
|
-
buf =
|
|
3014
|
+
buf = fs21.readFileSync(resolved);
|
|
2551
3015
|
} catch (e) {
|
|
2552
3016
|
return error(`read_file: failed to read "${raw}" (${e.message}).`);
|
|
2553
3017
|
}
|
|
@@ -2712,7 +3176,8 @@ async function callAnthropic(req) {
|
|
|
2712
3176
|
"anthropic-version": API_VERSION,
|
|
2713
3177
|
"content-type": "application/json"
|
|
2714
3178
|
},
|
|
2715
|
-
body: JSON.stringify(body)
|
|
3179
|
+
body: JSON.stringify(body),
|
|
3180
|
+
signal: req.abort
|
|
2716
3181
|
});
|
|
2717
3182
|
if (!res.ok) {
|
|
2718
3183
|
const text = await res.text().catch(() => "");
|
|
@@ -2850,7 +3315,8 @@ async function callOpenAI(req) {
|
|
|
2850
3315
|
Authorization: `Bearer ${req.apiKey}`,
|
|
2851
3316
|
"content-type": "application/json"
|
|
2852
3317
|
},
|
|
2853
|
-
body: JSON.stringify(body)
|
|
3318
|
+
body: JSON.stringify(body),
|
|
3319
|
+
signal: req.abort
|
|
2854
3320
|
});
|
|
2855
3321
|
if (!res.ok) {
|
|
2856
3322
|
const text = await res.text().catch(() => "");
|
|
@@ -2968,7 +3434,8 @@ async function callGoogle(req) {
|
|
|
2968
3434
|
const res = await fetch(url, {
|
|
2969
3435
|
method: "POST",
|
|
2970
3436
|
headers: { "content-type": "application/json" },
|
|
2971
|
-
body: JSON.stringify(body)
|
|
3437
|
+
body: JSON.stringify(body),
|
|
3438
|
+
signal: req.abort
|
|
2972
3439
|
});
|
|
2973
3440
|
if (!res.ok) {
|
|
2974
3441
|
const text = await res.text().catch(() => "");
|
|
@@ -3058,13 +3525,91 @@ function prefixOf(provider, req) {
|
|
|
3058
3525
|
return s.length > 0 ? s.slice(0, -1) : s;
|
|
3059
3526
|
}
|
|
3060
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
|
+
|
|
3061
3573
|
// src/planner/session-limits.ts
|
|
3062
3574
|
var DEFAULT_PLANNER_MAX_ITERATIONS = 40;
|
|
3063
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.";
|
|
3064
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
|
+
|
|
3065
3594
|
// src/planner/loop.ts
|
|
3066
3595
|
var MAX_RATE_LIMIT_RETRY_SEC = 90;
|
|
3067
|
-
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
|
+
}
|
|
3068
3613
|
function buildPlannerRunStats(budget, turns, runStartedAt) {
|
|
3069
3614
|
const u = budget.snapshot().usage;
|
|
3070
3615
|
const cacheRead = u.cacheReadTokens ?? 0;
|
|
@@ -3091,8 +3636,51 @@ function extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut
|
|
|
3091
3636
|
maxIter.current += baseMaxIter;
|
|
3092
3637
|
maxOut.current += baseMaxOut;
|
|
3093
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
|
+
}
|
|
3094
3672
|
async function runPlanner(input5) {
|
|
3095
3673
|
const runStartedAt = Date.now();
|
|
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
|
+
});
|
|
3096
3684
|
const baseMaxIter = input5.maxIterations ?? DEFAULT_PLANNER_MAX_ITERATIONS;
|
|
3097
3685
|
const maxIter = { current: baseMaxIter };
|
|
3098
3686
|
const baseMaxOut = input5.maxOutputTokens ?? DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
|
|
@@ -3104,6 +3692,7 @@ async function runPlanner(input5) {
|
|
|
3104
3692
|
let finishedNormally = false;
|
|
3105
3693
|
let prevRequestPrefix;
|
|
3106
3694
|
const decide = input5.decideOnLimit;
|
|
3695
|
+
const sleepFn = input5.sleep ?? defaultSleep;
|
|
3107
3696
|
const limitCtx = (kind) => ({
|
|
3108
3697
|
kind,
|
|
3109
3698
|
budgetSnapshot: input5.budget.snapshot(),
|
|
@@ -3112,6 +3701,9 @@ async function runPlanner(input5) {
|
|
|
3112
3701
|
maxOutputTokens: maxOut.current
|
|
3113
3702
|
});
|
|
3114
3703
|
for (; ; ) {
|
|
3704
|
+
if (input5.abort?.aborted) {
|
|
3705
|
+
return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
|
|
3706
|
+
}
|
|
3115
3707
|
if (iterations >= maxIter.current) {
|
|
3116
3708
|
if (decide) {
|
|
3117
3709
|
const d = await decide(limitCtx("max_iterations"));
|
|
@@ -3197,6 +3789,9 @@ async function runPlanner(input5) {
|
|
|
3197
3789
|
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
|
|
3198
3790
|
};
|
|
3199
3791
|
}
|
|
3792
|
+
if (iterations >= 2) {
|
|
3793
|
+
bus.emit({ kind: "turn_started", runId, turn: iterations });
|
|
3794
|
+
}
|
|
3200
3795
|
const request = {
|
|
3201
3796
|
systemPrompt: input5.systemPrompt,
|
|
3202
3797
|
model: input5.model,
|
|
@@ -3204,8 +3799,10 @@ async function runPlanner(input5) {
|
|
|
3204
3799
|
turns,
|
|
3205
3800
|
apiKey: input5.apiKey,
|
|
3206
3801
|
maxOutputTokens: maxOut.current,
|
|
3207
|
-
cacheEnabled
|
|
3802
|
+
cacheEnabled,
|
|
3803
|
+
abort: input5.abort
|
|
3208
3804
|
};
|
|
3805
|
+
bus.emit({ kind: "request_sent", runId, turn: iterations });
|
|
3209
3806
|
if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test" && prevRequestPrefix !== void 0) {
|
|
3210
3807
|
const currentPrefix = prefixOf(input5.provider.name, request);
|
|
3211
3808
|
if (!currentPrefix.startsWith(prevRequestPrefix)) {
|
|
@@ -3215,10 +3812,17 @@ async function runPlanner(input5) {
|
|
|
3215
3812
|
);
|
|
3216
3813
|
}
|
|
3217
3814
|
}
|
|
3218
|
-
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
|
+
}
|
|
3219
3824
|
if (response.usage) {
|
|
3220
|
-
input5
|
|
3221
|
-
input5.onUsage?.(response.usage);
|
|
3825
|
+
recordUsageAndEmit(bus, input5, runId, iterations, response.usage);
|
|
3222
3826
|
}
|
|
3223
3827
|
let retriedRateLimit = false;
|
|
3224
3828
|
let retrySkippedReason;
|
|
@@ -3229,11 +3833,25 @@ async function runPlanner(input5) {
|
|
|
3229
3833
|
} else {
|
|
3230
3834
|
const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
|
|
3231
3835
|
input5.onRateLimit?.(waitSec);
|
|
3232
|
-
|
|
3233
|
-
|
|
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
|
+
}
|
|
3234
3853
|
if (response.usage) {
|
|
3235
|
-
input5
|
|
3236
|
-
input5.onUsage?.(response.usage);
|
|
3854
|
+
recordUsageAndEmit(bus, input5, runId, iterations, response.usage);
|
|
3237
3855
|
}
|
|
3238
3856
|
retriedRateLimit = true;
|
|
3239
3857
|
}
|
|
@@ -3245,8 +3863,10 @@ async function runPlanner(input5) {
|
|
|
3245
3863
|
if (response.text) {
|
|
3246
3864
|
accumulatedText += response.text;
|
|
3247
3865
|
input5.onAssistantText?.(response.text);
|
|
3866
|
+
bus.emit({ kind: "assistant_text", runId, turn: iterations, delta: response.text });
|
|
3248
3867
|
}
|
|
3249
3868
|
if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
|
|
3869
|
+
bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
|
|
3250
3870
|
if (decide) {
|
|
3251
3871
|
const d = await decide(limitCtx("max_output_tokens"));
|
|
3252
3872
|
if (d === "cancel") {
|
|
@@ -3278,6 +3898,7 @@ async function runPlanner(input5) {
|
|
|
3278
3898
|
};
|
|
3279
3899
|
}
|
|
3280
3900
|
if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
|
|
3901
|
+
bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
|
|
3281
3902
|
finishedNormally = true;
|
|
3282
3903
|
return {
|
|
3283
3904
|
planText: accumulatedText,
|
|
@@ -3304,7 +3925,16 @@ async function runPlanner(input5) {
|
|
|
3304
3925
|
const before = input5.budget.snapshot().bytes;
|
|
3305
3926
|
let result = readFileTool(input5.root, input5.budget, tc.input);
|
|
3306
3927
|
const after = input5.budget.snapshot().bytes;
|
|
3307
|
-
|
|
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
|
+
});
|
|
3308
3938
|
if (readBudgetishError(result)) {
|
|
3309
3939
|
if (decide) {
|
|
3310
3940
|
const d = await decide(limitCtx("file_or_context_reads"));
|
|
@@ -3345,6 +3975,7 @@ async function runPlanner(input5) {
|
|
|
3345
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."
|
|
3346
3976
|
});
|
|
3347
3977
|
}
|
|
3978
|
+
bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
|
|
3348
3979
|
}
|
|
3349
3980
|
}
|
|
3350
3981
|
function firstByteDiff(a, b) {
|
|
@@ -3529,8 +4160,8 @@ var Budget = class {
|
|
|
3529
4160
|
};
|
|
3530
4161
|
|
|
3531
4162
|
// src/core/repo-map.ts
|
|
3532
|
-
import
|
|
3533
|
-
import
|
|
4163
|
+
import fs23 from "fs";
|
|
4164
|
+
import path16 from "path";
|
|
3534
4165
|
import ignore from "ignore";
|
|
3535
4166
|
var DEFAULT_IGNORES = [
|
|
3536
4167
|
"node_modules",
|
|
@@ -3554,10 +4185,10 @@ function buildRepoMap(root, opts = {}) {
|
|
|
3554
4185
|
}
|
|
3555
4186
|
function buildIgnore(root) {
|
|
3556
4187
|
const ig = ignore().add(DEFAULT_IGNORES);
|
|
3557
|
-
const gitignore =
|
|
3558
|
-
if (
|
|
4188
|
+
const gitignore = path16.join(root, ".gitignore");
|
|
4189
|
+
if (fs23.existsSync(gitignore)) {
|
|
3559
4190
|
try {
|
|
3560
|
-
ig.add(
|
|
4191
|
+
ig.add(fs23.readFileSync(gitignore, "utf8"));
|
|
3561
4192
|
} catch {
|
|
3562
4193
|
}
|
|
3563
4194
|
}
|
|
@@ -3567,15 +4198,15 @@ function walk2(root, dir, ig, out, max, includeHidden) {
|
|
|
3567
4198
|
if (out.length >= max) return;
|
|
3568
4199
|
let entries;
|
|
3569
4200
|
try {
|
|
3570
|
-
entries =
|
|
4201
|
+
entries = fs23.readdirSync(dir, { withFileTypes: true });
|
|
3571
4202
|
} catch {
|
|
3572
4203
|
return;
|
|
3573
4204
|
}
|
|
3574
4205
|
for (const entry of entries) {
|
|
3575
4206
|
if (out.length >= max) return;
|
|
3576
4207
|
if (!includeHidden && entry.name.startsWith(".") && entry.name !== ".squad") continue;
|
|
3577
|
-
const abs =
|
|
3578
|
-
const rel =
|
|
4208
|
+
const abs = path16.join(dir, entry.name);
|
|
4209
|
+
const rel = path16.relative(root, abs);
|
|
3579
4210
|
if (!rel || ig.ignores(rel) || entry.isDirectory() && ig.ignores(rel + "/")) continue;
|
|
3580
4211
|
if (entry.isDirectory()) {
|
|
3581
4212
|
walk2(root, abs, ig, out, max, includeHidden);
|
|
@@ -3623,38 +4254,8 @@ function composeUserPrompt(args) {
|
|
|
3623
4254
|
}
|
|
3624
4255
|
|
|
3625
4256
|
// src/planner/writer.ts
|
|
3626
|
-
import
|
|
3627
|
-
import
|
|
3628
|
-
|
|
3629
|
-
// src/core/sequence.ts
|
|
3630
|
-
import fs19 from "fs";
|
|
3631
|
-
import path12 from "path";
|
|
3632
|
-
var STORY_FILE2 = /^(\d{2,})-story-.+\.md$/;
|
|
3633
|
-
function scanPlans(plansDir) {
|
|
3634
|
-
const used = [];
|
|
3635
|
-
const seen = /* @__PURE__ */ new Map();
|
|
3636
|
-
if (fs19.existsSync(plansDir)) {
|
|
3637
|
-
for (const feature of fs19.readdirSync(plansDir, { withFileTypes: true })) {
|
|
3638
|
-
if (!feature.isDirectory()) continue;
|
|
3639
|
-
const featureDir = path12.join(plansDir, feature.name);
|
|
3640
|
-
for (const entry of fs19.readdirSync(featureDir)) {
|
|
3641
|
-
const match = entry.match(STORY_FILE2);
|
|
3642
|
-
if (!match) continue;
|
|
3643
|
-
const n = parseInt(match[1], 10);
|
|
3644
|
-
used.push(n);
|
|
3645
|
-
seen.set(n, (seen.get(n) ?? 0) + 1);
|
|
3646
|
-
}
|
|
3647
|
-
}
|
|
3648
|
-
}
|
|
3649
|
-
const duplicates = [...seen.entries()].filter(([, c]) => c > 1).map(([n]) => n);
|
|
3650
|
-
const maxNumber = used.length ? Math.max(...used) : 0;
|
|
3651
|
-
return { usedNumbers: used.sort((a, b) => a - b), maxNumber, duplicates, nextGlobal: maxNumber + 1 };
|
|
3652
|
-
}
|
|
3653
|
-
function formatSequence(n) {
|
|
3654
|
-
return String(n).padStart(2, "0");
|
|
3655
|
-
}
|
|
3656
|
-
|
|
3657
|
-
// src/planner/writer.ts
|
|
4257
|
+
import fs24 from "fs";
|
|
4258
|
+
import path17 from "path";
|
|
3658
4259
|
function planFileSlugParts(config2, story) {
|
|
3659
4260
|
const baseSlug = slugify(story.id);
|
|
3660
4261
|
const trackerId = config2.naming.includeTrackerId && config2.tracker.type !== "none" ? trackerIdForFilename(config2.tracker.type, story.id) : "";
|
|
@@ -3663,8 +4264,8 @@ function planFileSlugParts(config2, story) {
|
|
|
3663
4264
|
}
|
|
3664
4265
|
function writePlanFile(input5) {
|
|
3665
4266
|
const { paths, config: config2, story } = input5;
|
|
3666
|
-
const featurePlanDir =
|
|
3667
|
-
|
|
4267
|
+
const featurePlanDir = path17.join(paths.plansDir, story.feature);
|
|
4268
|
+
fs24.mkdirSync(featurePlanDir, { recursive: true });
|
|
3668
4269
|
if (input5.partial) {
|
|
3669
4270
|
return writePartialPlanFile(input5, featurePlanDir, paths, config2, story);
|
|
3670
4271
|
}
|
|
@@ -3679,14 +4280,14 @@ function writePlanFile(input5) {
|
|
|
3679
4280
|
} else {
|
|
3680
4281
|
nn = scanPlans(paths.plansDir).nextGlobal;
|
|
3681
4282
|
const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
|
|
3682
|
-
targetPath =
|
|
4283
|
+
targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
|
|
3683
4284
|
}
|
|
3684
4285
|
const body = `${input5.metadataHeader.trimEnd()}
|
|
3685
4286
|
|
|
3686
4287
|
${input5.planBodyMarkdown.trimStart()}
|
|
3687
4288
|
`;
|
|
3688
|
-
|
|
3689
|
-
upsertOverviewRow(featurePlanDir, story.feature, nn,
|
|
4289
|
+
fs24.writeFileSync(targetPath, body, "utf8");
|
|
4290
|
+
upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, false);
|
|
3690
4291
|
return { planFile: targetPath, sequenceNumber: nn, overwrote };
|
|
3691
4292
|
}
|
|
3692
4293
|
function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
|
|
@@ -3701,42 +4302,42 @@ function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
|
|
|
3701
4302
|
} else {
|
|
3702
4303
|
nn = scanPlans(paths.plansDir).nextGlobal;
|
|
3703
4304
|
const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
|
|
3704
|
-
targetPath =
|
|
4305
|
+
targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.partial.md`);
|
|
3705
4306
|
}
|
|
3706
4307
|
const body = `${input5.metadataHeader.trimEnd()}
|
|
3707
4308
|
|
|
3708
4309
|
${input5.planBodyMarkdown.trimStart()}
|
|
3709
4310
|
`;
|
|
3710
|
-
|
|
3711
|
-
upsertOverviewRow(featurePlanDir, story.feature, nn,
|
|
4311
|
+
fs24.writeFileSync(targetPath, body, "utf8");
|
|
4312
|
+
upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, true);
|
|
3712
4313
|
return { planFile: targetPath, sequenceNumber: nn, overwrote };
|
|
3713
4314
|
}
|
|
3714
4315
|
function findExistingPlan(featureDir, storyId) {
|
|
3715
|
-
if (!
|
|
4316
|
+
if (!fs24.existsSync(featureDir)) return void 0;
|
|
3716
4317
|
const pattern = /^(\d{2,})-story-.+\.md$/;
|
|
3717
|
-
for (const entry of
|
|
4318
|
+
for (const entry of fs24.readdirSync(featureDir)) {
|
|
3718
4319
|
if (entry.endsWith(".partial.md")) continue;
|
|
3719
4320
|
const m = entry.match(pattern);
|
|
3720
4321
|
if (!m) continue;
|
|
3721
|
-
if (entry.includes(storyId)) return { absPath:
|
|
4322
|
+
if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
|
|
3722
4323
|
}
|
|
3723
4324
|
return void 0;
|
|
3724
4325
|
}
|
|
3725
4326
|
function findExistingPartialPlan(featureDir, storyId) {
|
|
3726
|
-
if (!
|
|
4327
|
+
if (!fs24.existsSync(featureDir)) return void 0;
|
|
3727
4328
|
const pattern = /^(\d{2,})-story-.+\.partial\.md$/;
|
|
3728
|
-
for (const entry of
|
|
4329
|
+
for (const entry of fs24.readdirSync(featureDir)) {
|
|
3729
4330
|
const m = entry.match(pattern);
|
|
3730
4331
|
if (!m) continue;
|
|
3731
|
-
if (entry.includes(storyId)) return { absPath:
|
|
4332
|
+
if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
|
|
3732
4333
|
}
|
|
3733
4334
|
return void 0;
|
|
3734
4335
|
}
|
|
3735
4336
|
function upsertOverviewRow(featureDir, feature, nn, filename, story, isPartial) {
|
|
3736
|
-
const overviewPath =
|
|
4337
|
+
const overviewPath = path17.join(featureDir, "00-overview.md");
|
|
3737
4338
|
const titleCell = isPartial ? `${story.titleHint ?? story.id} (partial)` : story.titleHint ?? story.id;
|
|
3738
4339
|
const row = `| ${formatSequence(nn)} | \`${filename}\` | ${titleCell} | ${story.id} | \u2014 |`;
|
|
3739
|
-
if (!
|
|
4340
|
+
if (!fs24.existsSync(overviewPath)) {
|
|
3740
4341
|
const content = `# ${feature} \u2014 plan overview
|
|
3741
4342
|
|
|
3742
4343
|
Entry point for the **${feature}** feature. Stories execute in order by their \`NN\` prefix.
|
|
@@ -3747,10 +4348,10 @@ Entry point for the **${feature}** feature. Stories execute in order by their \`
|
|
|
3747
4348
|
|----|------|-------|------------|------------|
|
|
3748
4349
|
${row}
|
|
3749
4350
|
`;
|
|
3750
|
-
|
|
4351
|
+
fs24.writeFileSync(overviewPath, content, "utf8");
|
|
3751
4352
|
return;
|
|
3752
4353
|
}
|
|
3753
|
-
const current =
|
|
4354
|
+
const current = fs24.readFileSync(overviewPath, "utf8");
|
|
3754
4355
|
const lines = current.split("\n");
|
|
3755
4356
|
const existingIdx = lines.findIndex((l) => l.includes(`\`${filename}\``) || l.includes(`| ${formatSequence(nn)} |`));
|
|
3756
4357
|
if (existingIdx >= 0) {
|
|
@@ -3759,7 +4360,7 @@ ${row}
|
|
|
3759
4360
|
const tableEnd = findTableEnd(lines);
|
|
3760
4361
|
lines.splice(tableEnd, 0, row);
|
|
3761
4362
|
}
|
|
3762
|
-
|
|
4363
|
+
fs24.writeFileSync(overviewPath, lines.join("\n"), "utf8");
|
|
3763
4364
|
}
|
|
3764
4365
|
function findTableEnd(lines) {
|
|
3765
4366
|
let inTable = false;
|
|
@@ -3792,17 +4393,28 @@ squad-kit-plan-status: partial
|
|
|
3792
4393
|
return line1;
|
|
3793
4394
|
}
|
|
3794
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
|
+
});
|
|
4405
|
+
}
|
|
4406
|
+
|
|
3795
4407
|
// src/core/last-run.ts
|
|
3796
|
-
import
|
|
3797
|
-
import
|
|
4408
|
+
import fs25 from "fs/promises";
|
|
4409
|
+
import path18 from "path";
|
|
3798
4410
|
async function writeLastRun(paths, rec) {
|
|
3799
|
-
const file =
|
|
3800
|
-
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");
|
|
3801
4413
|
}
|
|
3802
4414
|
async function readLastRun(paths) {
|
|
3803
|
-
const file =
|
|
4415
|
+
const file = path18.join(paths.squadDir, ".last-run.json");
|
|
3804
4416
|
try {
|
|
3805
|
-
const raw = await
|
|
4417
|
+
const raw = await fs25.readFile(file, "utf8");
|
|
3806
4418
|
const parsed = JSON.parse(raw);
|
|
3807
4419
|
if (parsed.version !== 1) return void 0;
|
|
3808
4420
|
return parsed;
|
|
@@ -3862,8 +4474,8 @@ async function runNewPlan(intakePath, opts) {
|
|
|
3862
4474
|
}
|
|
3863
4475
|
}
|
|
3864
4476
|
function resolveFromPath(intakePath, stories, root) {
|
|
3865
|
-
const resolved =
|
|
3866
|
-
if (!
|
|
4477
|
+
const resolved = path19.resolve(intakePath.trim());
|
|
4478
|
+
if (!fs26.existsSync(resolved)) {
|
|
3867
4479
|
throw new Error(
|
|
3868
4480
|
`Intake not found: ${resolved}. Run \`squad list\` to see intakes, or \`squad new-story\` to create one first.`
|
|
3869
4481
|
);
|
|
@@ -3875,7 +4487,7 @@ function resolveFromPath(intakePath, stories, root) {
|
|
|
3875
4487
|
);
|
|
3876
4488
|
}
|
|
3877
4489
|
function relativeStoriesDir(root) {
|
|
3878
|
-
return
|
|
4490
|
+
return path19.relative(root, path19.join(root, ".squad", "stories"));
|
|
3879
4491
|
}
|
|
3880
4492
|
async function pickStory(stories, opts) {
|
|
3881
4493
|
const candidates = opts.all ? stories : stories.filter((s) => !s.planFile);
|
|
@@ -3898,7 +4510,7 @@ async function pickStory(stories, opts) {
|
|
|
3898
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."
|
|
3899
4511
|
);
|
|
3900
4512
|
}
|
|
3901
|
-
const pick = await
|
|
4513
|
+
const pick = await select6({
|
|
3902
4514
|
message: "Pick a story to plan:",
|
|
3903
4515
|
pageSize: Math.min(10, candidates.length),
|
|
3904
4516
|
choices: candidates.map((s) => ({
|
|
@@ -3906,7 +4518,7 @@ async function pickStory(stories, opts) {
|
|
|
3906
4518
|
value: s.intakePath
|
|
3907
4519
|
}))
|
|
3908
4520
|
});
|
|
3909
|
-
return candidates.find((s) =>
|
|
4521
|
+
return candidates.find((s) => path19.resolve(s.intakePath) === path19.resolve(pick));
|
|
3910
4522
|
}
|
|
3911
4523
|
function formatLabel(s) {
|
|
3912
4524
|
const head = `${s.feature} / ${s.id}`;
|
|
@@ -3984,9 +4596,36 @@ async function emitViaApi(story, paths, config2, opts) {
|
|
|
3984
4596
|
const sessionSpinner = { current: null };
|
|
3985
4597
|
const startedAt = Date.now();
|
|
3986
4598
|
const cacheEnabled = planner.cache?.enabled ?? true;
|
|
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;
|
|
4624
|
+
}
|
|
4625
|
+
});
|
|
3987
4626
|
const decideOnLimit = interactive ? async (ctx) => {
|
|
3988
4627
|
printPlannerLimitExplanation(ctx);
|
|
3989
|
-
const ans = await
|
|
4628
|
+
const ans = await select6({
|
|
3990
4629
|
message: "Continue this planning session? (Continuing sends more API requests and is billed.)",
|
|
3991
4630
|
choices: [
|
|
3992
4631
|
{ name: "Continue \u2014 extend limits for this run", value: "continue" },
|
|
@@ -3996,35 +4635,26 @@ async function emitViaApi(story, paths, config2, opts) {
|
|
|
3996
4635
|
if (ans === "cancel") printPlannerLimitNextSteps();
|
|
3997
4636
|
return ans;
|
|
3998
4637
|
} : void 0;
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
sessionSpinner.current = spinner("thinking\u2026");
|
|
4020
|
-
},
|
|
4021
|
-
onRateLimit: (waitSec) => {
|
|
4022
|
-
sessionSpinner.current?.stop();
|
|
4023
|
-
warning(`${planner.provider} rate limit hit \u2014 retrying in ${waitSec}s`);
|
|
4024
|
-
sessionSpinner.current = spinner("waiting for rate limit to reset\u2026");
|
|
4025
|
-
}
|
|
4026
|
-
});
|
|
4027
|
-
sessionSpinner.current?.stop();
|
|
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
|
+
}
|
|
4028
4658
|
if (!result.planText.trim()) {
|
|
4029
4659
|
throw new Error(
|
|
4030
4660
|
"Planner returned no plan text. Run `squad doctor` to check provider and models, or `squad new-plan --copy` to avoid the API."
|
|
@@ -4052,6 +4682,7 @@ async function emitViaApi(story, paths, config2, opts) {
|
|
|
4052
4682
|
metadataHeader: header,
|
|
4053
4683
|
partial: !success2
|
|
4054
4684
|
});
|
|
4685
|
+
const relPlan = path19.relative(paths.root, planFile);
|
|
4055
4686
|
try {
|
|
4056
4687
|
await writeLastRun(paths, {
|
|
4057
4688
|
stats: result.stats,
|
|
@@ -4059,9 +4690,23 @@ async function emitViaApi(story, paths, config2, opts) {
|
|
|
4059
4690
|
provider: planner.provider,
|
|
4060
4691
|
model
|
|
4061
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
|
+
});
|
|
4062
4708
|
} catch {
|
|
4063
4709
|
}
|
|
4064
|
-
const relPlan = path15.relative(paths.root, planFile);
|
|
4065
4710
|
blank();
|
|
4066
4711
|
summaryBox(success2 ? " plan generated " : " plan saved (incomplete) ", [
|
|
4067
4712
|
{ key: "file", value: relPlan },
|
|
@@ -4091,20 +4736,14 @@ async function emitViaApi(story, paths, config2, opts) {
|
|
|
4091
4736
|
info("Note: file-read budget was reached; the model finalised without further reads.");
|
|
4092
4737
|
}
|
|
4093
4738
|
blank();
|
|
4094
|
-
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)}`);
|
|
4095
4740
|
}
|
|
4096
4741
|
async function emitCopyPrompt(story, paths, config2, clipboard) {
|
|
4097
4742
|
const intakeContent = readFile(story.intakePath);
|
|
4098
|
-
const
|
|
4099
|
-
const
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
trackerType: config2.tracker.type,
|
|
4103
|
-
intakeContent
|
|
4104
|
-
});
|
|
4105
|
-
const promptFile = path15.join(paths.squadDir, ".last-copy-prompt.md");
|
|
4106
|
-
fs22.writeFileSync(promptFile, composed, "utf8");
|
|
4107
|
-
const relPromptFile = path15.relative(paths.root, promptFile);
|
|
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);
|
|
4108
4747
|
const bytes = Buffer.byteLength(composed, "utf8");
|
|
4109
4748
|
const estTokens = Math.round(bytes / 4);
|
|
4110
4749
|
const clipResult = clipboard ? await copyToClipboard(composed) : { ok: false, reason: "clipboard disabled (--no-clipboard)" };
|
|
@@ -4162,7 +4801,7 @@ function printModelBanner(config2) {
|
|
|
4162
4801
|
}
|
|
4163
4802
|
|
|
4164
4803
|
// src/commands/status.ts
|
|
4165
|
-
import
|
|
4804
|
+
import fs27 from "fs";
|
|
4166
4805
|
var kw = 28;
|
|
4167
4806
|
function probeTrackerCredential(config2, overlay) {
|
|
4168
4807
|
const { client, error: error2 } = clientFor(config2, overlay);
|
|
@@ -4188,7 +4827,7 @@ async function runStatus() {
|
|
|
4188
4827
|
const scan = scanPlans(paths.plansDir);
|
|
4189
4828
|
const storyCount = listStories(paths).length;
|
|
4190
4829
|
const planCount = scan.usedNumbers.length;
|
|
4191
|
-
const secrets =
|
|
4830
|
+
const secrets = fs27.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
4192
4831
|
const overlay = overlayTrackerEnv(secrets);
|
|
4193
4832
|
banner();
|
|
4194
4833
|
step("squad-kit status");
|
|
@@ -4242,13 +4881,13 @@ function buildTrackerLine(config2, overlay) {
|
|
|
4242
4881
|
}
|
|
4243
4882
|
|
|
4244
4883
|
// src/commands/list.ts
|
|
4245
|
-
import
|
|
4884
|
+
import path20 from "path";
|
|
4246
4885
|
async function runList(opts) {
|
|
4247
4886
|
const root = requireSquadRoot();
|
|
4248
4887
|
const paths = buildPaths(root);
|
|
4249
4888
|
const records = listStories(paths, { feature: opts.feature });
|
|
4250
4889
|
const rows = records.map((r) => {
|
|
4251
|
-
const planAbs = r.planFile ?
|
|
4890
|
+
const planAbs = r.planFile ? path20.join(paths.plansDir, r.feature, r.planFile) : void 0;
|
|
4252
4891
|
const meta = planAbs ? readPlanMetadata(planAbs) : void 0;
|
|
4253
4892
|
return {
|
|
4254
4893
|
feature: r.feature,
|
|
@@ -4287,9 +4926,9 @@ function stripAnsi(s) {
|
|
|
4287
4926
|
}
|
|
4288
4927
|
|
|
4289
4928
|
// src/commands/tracker-link.ts
|
|
4290
|
-
import
|
|
4291
|
-
import
|
|
4292
|
-
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";
|
|
4293
4932
|
async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
4294
4933
|
const root = requireSquadRoot();
|
|
4295
4934
|
const paths = buildPaths(root);
|
|
@@ -4306,7 +4945,7 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
|
4306
4945
|
if (stories.length === 0) {
|
|
4307
4946
|
throw new Error("No stories found under .squad/stories/. Create one with `squad new-story` first.");
|
|
4308
4947
|
}
|
|
4309
|
-
resolvedStoryPath = await
|
|
4948
|
+
resolvedStoryPath = await select7({
|
|
4310
4949
|
message: "Story to link:",
|
|
4311
4950
|
choices: stories.map((s) => ({
|
|
4312
4951
|
name: `${s.feature}/${s.id}`,
|
|
@@ -4335,17 +4974,17 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
|
4335
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.`
|
|
4336
4975
|
);
|
|
4337
4976
|
}
|
|
4338
|
-
const resolved =
|
|
4339
|
-
const intakeFile = resolved.endsWith("intake.md") ? resolved :
|
|
4340
|
-
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)) {
|
|
4341
4980
|
throw new Error(
|
|
4342
4981
|
`intake.md not found at ${intakeFile}. Run \`squad list\` to see valid paths, or \`squad new-story\` to create a story first.`
|
|
4343
4982
|
);
|
|
4344
4983
|
}
|
|
4345
|
-
const content =
|
|
4984
|
+
const content = fs28.readFileSync(intakeFile, "utf8");
|
|
4346
4985
|
const updated = upsertTrackerId(content, resolvedTrackerId);
|
|
4347
|
-
|
|
4348
|
-
success(`Linked tracker id ${resolvedTrackerId} \u2192 ${
|
|
4986
|
+
fs28.writeFileSync(intakeFile, updated, "utf8");
|
|
4987
|
+
success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path21.relative(root, intakeFile)}`);
|
|
4349
4988
|
blank();
|
|
4350
4989
|
step("Next:");
|
|
4351
4990
|
info("1) Review the intake \u2014 the Source block at the top now carries the tracker id.");
|
|
@@ -4362,9 +5001,9 @@ function upsertTrackerId(content, id) {
|
|
|
4362
5001
|
`;
|
|
4363
5002
|
}
|
|
4364
5003
|
|
|
4365
|
-
// src/commands/doctor.ts
|
|
4366
|
-
import
|
|
4367
|
-
import
|
|
5004
|
+
// src/commands/doctor-engine.ts
|
|
5005
|
+
import fs29 from "fs";
|
|
5006
|
+
import path22 from "path";
|
|
4368
5007
|
async function gatherContext(paths) {
|
|
4369
5008
|
const ctx = { hasLegacyPromptsDir: false };
|
|
4370
5009
|
try {
|
|
@@ -4373,29 +5012,29 @@ async function gatherContext(paths) {
|
|
|
4373
5012
|
ctx.configError = err;
|
|
4374
5013
|
}
|
|
4375
5014
|
try {
|
|
4376
|
-
ctx.secrets =
|
|
5015
|
+
ctx.secrets = fs29.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
4377
5016
|
} catch (err) {
|
|
4378
5017
|
ctx.secretsError = err;
|
|
4379
5018
|
}
|
|
4380
|
-
ctx.hasLegacyPromptsDir =
|
|
5019
|
+
ctx.hasLegacyPromptsDir = fs29.existsSync(paths.promptsDir);
|
|
4381
5020
|
return ctx;
|
|
4382
5021
|
}
|
|
4383
5022
|
function gitignoreHasManagedBlock(repoRoot) {
|
|
4384
|
-
const gitignore =
|
|
4385
|
-
return
|
|
5023
|
+
const gitignore = path22.join(repoRoot, ".gitignore");
|
|
5024
|
+
return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
|
|
4386
5025
|
}
|
|
4387
5026
|
function gitignoreHasTrashPattern(repoRoot) {
|
|
4388
|
-
const gitignore =
|
|
4389
|
-
return
|
|
5027
|
+
const gitignore = path22.join(repoRoot, ".gitignore");
|
|
5028
|
+
return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
|
|
4390
5029
|
}
|
|
4391
5030
|
async function checkDirStructure(paths, _ctx, fix) {
|
|
4392
|
-
const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !
|
|
5031
|
+
const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs29.existsSync(p));
|
|
4393
5032
|
if (need.length === 0) {
|
|
4394
5033
|
return { id: "dirs", name: ".squad/ directory structure", status: "ok" };
|
|
4395
5034
|
}
|
|
4396
5035
|
if (fix) {
|
|
4397
5036
|
for (const p of need) {
|
|
4398
|
-
|
|
5037
|
+
fs29.mkdirSync(p, { recursive: true });
|
|
4399
5038
|
}
|
|
4400
5039
|
return { id: "dirs", name: ".squad/ directory structure", status: "ok", detail: "repaired" };
|
|
4401
5040
|
}
|
|
@@ -4403,7 +5042,7 @@ async function checkDirStructure(paths, _ctx, fix) {
|
|
|
4403
5042
|
id: "dirs",
|
|
4404
5043
|
name: ".squad/ directory structure",
|
|
4405
5044
|
status: "warn",
|
|
4406
|
-
detail: `missing: ${need.map((p) =>
|
|
5045
|
+
detail: `missing: ${need.map((p) => path22.relative(paths.root, p)).join(", ")}`,
|
|
4407
5046
|
fixable: true,
|
|
4408
5047
|
fixHint: "squad doctor --fix"
|
|
4409
5048
|
};
|
|
@@ -4458,15 +5097,15 @@ async function checkSecretsPermissions(paths, _ctx, fix) {
|
|
|
4458
5097
|
if (process.platform === "win32") {
|
|
4459
5098
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "skip", detail: "Windows" };
|
|
4460
5099
|
}
|
|
4461
|
-
if (!
|
|
5100
|
+
if (!fs29.existsSync(paths.secretsFile)) {
|
|
4462
5101
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "not present" };
|
|
4463
5102
|
}
|
|
4464
|
-
const mode =
|
|
5103
|
+
const mode = fs29.statSync(paths.secretsFile).mode & 511;
|
|
4465
5104
|
if (mode === 384) {
|
|
4466
5105
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok" };
|
|
4467
5106
|
}
|
|
4468
5107
|
if (fix) {
|
|
4469
|
-
|
|
5108
|
+
fs29.chmodSync(paths.secretsFile, 384);
|
|
4470
5109
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "repaired" };
|
|
4471
5110
|
}
|
|
4472
5111
|
return {
|
|
@@ -4487,7 +5126,7 @@ async function checkSecretsParseable(_paths, ctx) {
|
|
|
4487
5126
|
detail: ctx.secretsError.message
|
|
4488
5127
|
};
|
|
4489
5128
|
}
|
|
4490
|
-
if (!
|
|
5129
|
+
if (!fs29.existsSync(_paths.secretsFile)) {
|
|
4491
5130
|
return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok", detail: "not present" };
|
|
4492
5131
|
}
|
|
4493
5132
|
return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok" };
|
|
@@ -4869,6 +5508,8 @@ function summarise(checks) {
|
|
|
4869
5508
|
skip: checks.filter((c) => c.status === "skip").length
|
|
4870
5509
|
};
|
|
4871
5510
|
}
|
|
5511
|
+
|
|
5512
|
+
// src/commands/doctor.ts
|
|
4872
5513
|
function colorForStatus(s) {
|
|
4873
5514
|
switch (s) {
|
|
4874
5515
|
case "ok":
|
|
@@ -4961,23 +5602,23 @@ async function runDoctor(opts) {
|
|
|
4961
5602
|
}
|
|
4962
5603
|
|
|
4963
5604
|
// src/commands/migrate.ts
|
|
4964
|
-
import
|
|
4965
|
-
import
|
|
5605
|
+
import fs30 from "fs";
|
|
5606
|
+
import path23 from "path";
|
|
4966
5607
|
import { confirm as confirm7 } from "@inquirer/prompts";
|
|
4967
5608
|
var LEGACY_PROMPTS_MIGRATION = {
|
|
4968
5609
|
id: "legacy-prompts",
|
|
4969
5610
|
name: "Remove legacy .squad/prompts/",
|
|
4970
|
-
isApplied: (paths) => !
|
|
4971
|
-
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)`,
|
|
4972
5613
|
apply: (paths) => {
|
|
4973
5614
|
const target = paths.promptsDir;
|
|
4974
|
-
if (!target.includes(
|
|
5615
|
+
if (!target.includes(path23.sep + ".squad" + path23.sep + "prompts")) {
|
|
4975
5616
|
throw new Error(
|
|
4976
5617
|
`Refusing to delete suspicious path: ${target}. This is a safety check \u2014 run \`squad doctor\` if .squad/ paths look wrong.`
|
|
4977
5618
|
);
|
|
4978
5619
|
}
|
|
4979
|
-
if (
|
|
4980
|
-
|
|
5620
|
+
if (fs30.existsSync(target)) {
|
|
5621
|
+
fs30.rmSync(target, { recursive: true, force: true });
|
|
4981
5622
|
}
|
|
4982
5623
|
}
|
|
4983
5624
|
};
|
|
@@ -4985,8 +5626,8 @@ var GITIGNORE_MIGRATION = {
|
|
|
4985
5626
|
id: "gitignore",
|
|
4986
5627
|
name: "Ensure .gitignore managed block",
|
|
4987
5628
|
isApplied: (paths) => {
|
|
4988
|
-
const f =
|
|
4989
|
-
return
|
|
5629
|
+
const f = path23.join(paths.root, ".gitignore");
|
|
5630
|
+
return fs30.existsSync(f) && fs30.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
|
|
4990
5631
|
},
|
|
4991
5632
|
describe: () => "add squad-kit managed block to .gitignore",
|
|
4992
5633
|
apply: (paths) => {
|
|
@@ -4998,23 +5639,23 @@ var SECRETS_PERMS_MIGRATION = {
|
|
|
4998
5639
|
name: "Tighten .squad/secrets.yaml permissions",
|
|
4999
5640
|
isApplied: (paths) => {
|
|
5000
5641
|
if (process.platform === "win32") return true;
|
|
5001
|
-
if (!
|
|
5002
|
-
const st =
|
|
5642
|
+
if (!fs30.existsSync(paths.secretsFile)) return true;
|
|
5643
|
+
const st = fs30.statSync(paths.secretsFile);
|
|
5003
5644
|
return (st.mode & 511) === 384;
|
|
5004
5645
|
},
|
|
5005
|
-
describe: (paths) => `chmod 0600 ${
|
|
5646
|
+
describe: (paths) => `chmod 0600 ${path23.relative(paths.root, paths.secretsFile)}`,
|
|
5006
5647
|
apply: (paths) => {
|
|
5007
5648
|
if (process.platform === "win32") return;
|
|
5008
|
-
if (
|
|
5649
|
+
if (fs30.existsSync(paths.secretsFile)) fs30.chmodSync(paths.secretsFile, 384);
|
|
5009
5650
|
}
|
|
5010
5651
|
};
|
|
5011
5652
|
var PLANNER_BUDGET_DEFAULTS_MIGRATION = {
|
|
5012
5653
|
id: "planner-budget-defaults",
|
|
5013
5654
|
name: "Backfill planner.budget defaults in config.yaml",
|
|
5014
5655
|
isApplied: (paths) => {
|
|
5015
|
-
if (!
|
|
5656
|
+
if (!fs30.existsSync(paths.configFile)) return true;
|
|
5016
5657
|
try {
|
|
5017
|
-
const raw =
|
|
5658
|
+
const raw = fs30.readFileSync(paths.configFile, "utf8");
|
|
5018
5659
|
if (!/^\s*planner:/m.test(raw)) return true;
|
|
5019
5660
|
if (/^\s*budget:/m.test(raw)) return true;
|
|
5020
5661
|
const cfg = loadConfig(paths.configFile);
|
|
@@ -5033,9 +5674,9 @@ var CONFIG_NORMALIZE_MIGRATION = {
|
|
|
5033
5674
|
id: "config-normalize",
|
|
5034
5675
|
name: "Normalise .squad/config.yaml formatting",
|
|
5035
5676
|
isApplied: (paths) => {
|
|
5036
|
-
if (!
|
|
5677
|
+
if (!fs30.existsSync(paths.configFile)) return true;
|
|
5037
5678
|
try {
|
|
5038
|
-
const raw =
|
|
5679
|
+
const raw = fs30.readFileSync(paths.configFile, "utf8");
|
|
5039
5680
|
const cfg = loadConfig(paths.configFile);
|
|
5040
5681
|
return raw.trim() === serializeConfig(cfg).trim();
|
|
5041
5682
|
} catch {
|
|
@@ -5044,7 +5685,7 @@ var CONFIG_NORMALIZE_MIGRATION = {
|
|
|
5044
5685
|
},
|
|
5045
5686
|
describe: () => "rewrite config.yaml with canonical YAML formatting (drops comments)",
|
|
5046
5687
|
apply: (paths) => {
|
|
5047
|
-
if (!
|
|
5688
|
+
if (!fs30.existsSync(paths.configFile)) return;
|
|
5048
5689
|
const cfg = loadConfig(paths.configFile);
|
|
5049
5690
|
saveConfig(paths.configFile, cfg);
|
|
5050
5691
|
}
|
|
@@ -5125,8 +5766,8 @@ import process3 from "process";
|
|
|
5125
5766
|
import { confirm as confirm8 } from "@inquirer/prompts";
|
|
5126
5767
|
|
|
5127
5768
|
// src/core/package-info.ts
|
|
5128
|
-
import
|
|
5129
|
-
import
|
|
5769
|
+
import fs31 from "fs";
|
|
5770
|
+
import path24 from "path";
|
|
5130
5771
|
function changelogBlobUrl(repositoryUrl, version) {
|
|
5131
5772
|
let base = repositoryUrl;
|
|
5132
5773
|
if (!base) base = "https://github.com/AzmSquad/squad-kit";
|
|
@@ -5137,9 +5778,9 @@ function changelogBlobUrl(repositoryUrl, version) {
|
|
|
5137
5778
|
}
|
|
5138
5779
|
function readInstalledPackage() {
|
|
5139
5780
|
const root = packageRoot();
|
|
5140
|
-
const pkgFile =
|
|
5141
|
-
const pkg = JSON.parse(
|
|
5142
|
-
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"));
|
|
5143
5784
|
return {
|
|
5144
5785
|
name: pkg.name,
|
|
5145
5786
|
version: pkg.version,
|
|
@@ -5179,7 +5820,7 @@ function isNewer(a, b) {
|
|
|
5179
5820
|
}
|
|
5180
5821
|
|
|
5181
5822
|
// src/core/package-manager.ts
|
|
5182
|
-
import
|
|
5823
|
+
import path25 from "path";
|
|
5183
5824
|
import process2 from "process";
|
|
5184
5825
|
function detectPackageManager(installRoot) {
|
|
5185
5826
|
const ua = process2.env.npm_config_user_agent;
|
|
@@ -5190,11 +5831,11 @@ function detectPackageManager(installRoot) {
|
|
|
5190
5831
|
if (ua.startsWith("npm/")) return { pm: "npm", reason: "npm_config_user_agent" };
|
|
5191
5832
|
}
|
|
5192
5833
|
const root = installRoot.toLowerCase();
|
|
5193
|
-
if (root.includes(`${
|
|
5834
|
+
if (root.includes(`${path25.sep}pnpm${path25.sep}`) || root.includes(`${path25.sep}.pnpm${path25.sep}`))
|
|
5194
5835
|
return { pm: "pnpm", reason: `install path contains pnpm` };
|
|
5195
|
-
if (root.includes(`${
|
|
5836
|
+
if (root.includes(`${path25.sep}.yarn${path25.sep}`) || root.includes(`${path25.sep}yarn${path25.sep}`))
|
|
5196
5837
|
return { pm: "yarn", reason: `install path contains yarn` };
|
|
5197
|
-
if (root.includes(`${
|
|
5838
|
+
if (root.includes(`${path25.sep}.bun${path25.sep}`) || root.includes(`${path25.sep}bun${path25.sep}`))
|
|
5198
5839
|
return { pm: "bun", reason: `install path contains bun` };
|
|
5199
5840
|
return { pm: "npm", reason: "default fallback" };
|
|
5200
5841
|
}
|
|
@@ -5259,244 +5900,1069 @@ async function runUpgrade(opts) {
|
|
|
5259
5900
|
info(`Run to upgrade: ${cmd} ${args.join(" ")}`);
|
|
5260
5901
|
return;
|
|
5261
5902
|
}
|
|
5262
|
-
if (!opts.yes) {
|
|
5263
|
-
const interactive = Boolean(process3.stdin.isTTY) && process3.env.CI !== "true";
|
|
5264
|
-
if (!interactive) {
|
|
5265
|
-
failure(
|
|
5266
|
-
`Refusing to run a global install without --yes. Run \`squad upgrade --yes\` in non-interactive mode, or run manually: ${cmd} ${args.join(" ")}`
|
|
5267
|
-
);
|
|
5268
|
-
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);
|
|
5269
6506
|
}
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
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);
|
|
5274
6515
|
}
|
|
5275
|
-
}
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
)
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
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
|
+
});
|
|
5288
6542
|
}
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
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) });
|
|
5294
6563
|
});
|
|
5295
6564
|
}
|
|
5296
6565
|
|
|
5297
|
-
// src/
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
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
|
+
});
|
|
5305
6591
|
}
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
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
|
+
});
|
|
5316
6627
|
});
|
|
5317
|
-
fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
5318
6628
|
}
|
|
5319
|
-
function
|
|
5320
|
-
const
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
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 });
|
|
5330
6666
|
});
|
|
5331
|
-
fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
5332
6667
|
}
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
dst = path22.join(bucket, `${n}_${rel}`);
|
|
5348
|
-
}
|
|
5349
|
-
fs28.renameSync(src, dst);
|
|
5350
|
-
}
|
|
5351
|
-
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);
|
|
5352
6682
|
}
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
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
|
+
}
|
|
5363
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
|
+
});
|
|
5364
6720
|
}
|
|
5365
|
-
function
|
|
5366
|
-
if (!
|
|
5367
|
-
const
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
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"
|
|
5375
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;
|
|
5376
6773
|
}
|
|
5377
|
-
return
|
|
6774
|
+
return null;
|
|
5378
6775
|
}
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
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();
|
|
5383
6791
|
}
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
message: "Story to remove:",
|
|
5387
|
-
choices: stories.map((s) => ({
|
|
5388
|
-
name: `${s.feature}/${s.id} \xB7 ${s.titleHint ?? s.id}`,
|
|
5389
|
-
value: s
|
|
5390
|
-
}))
|
|
5391
|
-
});
|
|
5392
|
-
return choice;
|
|
6792
|
+
const url = new URL(req.url);
|
|
6793
|
+
return url.searchParams.get("t");
|
|
5393
6794
|
}
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
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);
|
|
5398
6800
|
}
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
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;
|
|
5406
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
|
+
};
|
|
5407
6874
|
}
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
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
|
+
});
|
|
5417
6893
|
});
|
|
5418
6894
|
}
|
|
5419
|
-
function
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
if (
|
|
5423
|
-
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;
|
|
5424
6899
|
}
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
}
|
|
5429
|
-
function isSequenceToken(s) {
|
|
5430
|
-
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
|
+
);
|
|
5431
6903
|
}
|
|
5432
|
-
function
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
if (segs.length < 2) return void 0;
|
|
5440
|
-
const feature = segs[0];
|
|
5441
|
-
const file = segs.slice(1).join(path22.sep);
|
|
5442
|
-
if (featureFilter && feature !== featureFilter) return void 0;
|
|
5443
|
-
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
|
+
});
|
|
5444
6911
|
}
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
const
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
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`);
|
|
5454
6934
|
}
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
`Multiple plans match sequence "${t}". Run \`squad rm plan\` in a TTY, or add \`--feature <slug>\` to disambiguate.`
|
|
5466
|
-
);
|
|
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.");
|
|
5467
6945
|
}
|
|
6946
|
+
} else {
|
|
6947
|
+
info("Browser auto-open disabled (--no-open). Copy the URL above into your browser.");
|
|
5468
6948
|
}
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
function countOverviewDataRows(overviewContent) {
|
|
5472
|
-
let n = 0;
|
|
5473
|
-
for (const line2 of overviewContent.split("\n")) {
|
|
5474
|
-
if (!line2.startsWith("|")) continue;
|
|
5475
|
-
if (/^\|\s*-+/.test(line2)) continue;
|
|
5476
|
-
if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) continue;
|
|
5477
|
-
n++;
|
|
5478
|
-
}
|
|
5479
|
-
return n;
|
|
5480
|
-
}
|
|
5481
|
-
function featureStoriesDirEmpty(paths, feature) {
|
|
5482
|
-
const d = path22.join(paths.storiesDir, feature);
|
|
5483
|
-
if (!fs28.existsSync(d)) return true;
|
|
5484
|
-
return fs28.readdirSync(d).length === 0;
|
|
6949
|
+
await server.done;
|
|
6950
|
+
info("console stopped");
|
|
5485
6951
|
}
|
|
5486
6952
|
|
|
5487
6953
|
// src/commands/rm/story.ts
|
|
5488
|
-
import
|
|
5489
|
-
import
|
|
6954
|
+
import fs38 from "fs";
|
|
6955
|
+
import path32 from "path";
|
|
5490
6956
|
import { confirm as confirm10 } from "@inquirer/prompts";
|
|
5491
6957
|
|
|
5492
6958
|
// src/commands/rm/feature.ts
|
|
5493
|
-
import
|
|
5494
|
-
import
|
|
6959
|
+
import fs37 from "fs";
|
|
6960
|
+
import path31 from "path";
|
|
5495
6961
|
import { confirm as confirm9 } from "@inquirer/prompts";
|
|
5496
6962
|
function countStoryDirs(storiesDir, feature) {
|
|
5497
|
-
const d =
|
|
5498
|
-
if (!
|
|
5499
|
-
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;
|
|
5500
6966
|
}
|
|
5501
6967
|
async function runRmFeature(featureSlug, opts = {}) {
|
|
5502
6968
|
const root = requireSquadRoot();
|
|
@@ -5530,17 +6996,17 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
5530
6996
|
feature = await pickFeatureInteractive(paths);
|
|
5531
6997
|
}
|
|
5532
6998
|
}
|
|
5533
|
-
const storyDir =
|
|
5534
|
-
const planDir =
|
|
6999
|
+
const storyDir = path31.join(paths.storiesDir, feature);
|
|
7000
|
+
const planDir = path31.join(paths.plansDir, feature);
|
|
5535
7001
|
const nStories = countStoryDirs(paths.storiesDir, feature);
|
|
5536
7002
|
const nPlans = listAllPlanListEntries(paths, feature).length;
|
|
5537
|
-
const hasOverview =
|
|
7003
|
+
const hasOverview = fs37.existsSync(path31.join(planDir, "00-overview.md")) ? 1 : 0;
|
|
5538
7004
|
const targets = [];
|
|
5539
|
-
if (
|
|
5540
|
-
targets.push({ label:
|
|
7005
|
+
if (fs37.existsSync(storyDir)) {
|
|
7006
|
+
targets.push({ label: path31.relative(root, storyDir), absPath: storyDir, kind: "dir" });
|
|
5541
7007
|
}
|
|
5542
|
-
if (
|
|
5543
|
-
targets.push({ label:
|
|
7008
|
+
if (fs37.existsSync(planDir)) {
|
|
7009
|
+
targets.push({ label: path31.relative(root, planDir), absPath: planDir, kind: "dir" });
|
|
5544
7010
|
}
|
|
5545
7011
|
divider("squad rm feature");
|
|
5546
7012
|
info(`Feature: ${feature}`);
|
|
@@ -5555,7 +7021,7 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
5555
7021
|
kv(t.kind, t.label, 6);
|
|
5556
7022
|
}
|
|
5557
7023
|
if (useTrash) {
|
|
5558
|
-
info(`Items will move to ${
|
|
7024
|
+
info(`Items will move to ${path31.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
|
|
5559
7025
|
} else if (!dryRun) {
|
|
5560
7026
|
warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
|
|
5561
7027
|
}
|
|
@@ -5588,7 +7054,7 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
5588
7054
|
useTrash
|
|
5589
7055
|
);
|
|
5590
7056
|
if (result.trashed) {
|
|
5591
|
-
success(`Feature removed. Items moved to ${
|
|
7057
|
+
success(`Feature removed. Items moved to ${path31.relative(root, result.trashed)}.`);
|
|
5592
7058
|
} else {
|
|
5593
7059
|
success("Feature removed (stories and plans directories).");
|
|
5594
7060
|
}
|
|
@@ -5629,11 +7095,11 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
5629
7095
|
}
|
|
5630
7096
|
}
|
|
5631
7097
|
const targets = [
|
|
5632
|
-
{ label:
|
|
7098
|
+
{ label: path32.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
|
|
5633
7099
|
];
|
|
5634
7100
|
if (story.planFile) {
|
|
5635
|
-
const planAbs =
|
|
5636
|
-
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" });
|
|
5637
7103
|
}
|
|
5638
7104
|
renderPreview("story", targets, {
|
|
5639
7105
|
dryRun,
|
|
@@ -5656,18 +7122,16 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
5656
7122
|
return;
|
|
5657
7123
|
}
|
|
5658
7124
|
}
|
|
5659
|
-
const
|
|
5660
|
-
const result = trashOrDelete(absPaths, paths.trashDir, useTrash);
|
|
5661
|
-
removeOverviewRow(path24.join(paths.plansDir, story.feature), story.id);
|
|
7125
|
+
const result = deleteStoryRecord({ paths, story, trash: useTrash });
|
|
5662
7126
|
if (result.trashed) {
|
|
5663
|
-
success(`Removed (moved to ${
|
|
7127
|
+
success(`Removed (moved to ${path32.relative(root, result.trashed)}).`);
|
|
5664
7128
|
} else {
|
|
5665
7129
|
success("Removed story folder, plan file (if any), and overview row.");
|
|
5666
7130
|
}
|
|
5667
|
-
const featurePlanDir =
|
|
5668
|
-
const overviewPath =
|
|
5669
|
-
if (
|
|
5670
|
-
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");
|
|
5671
7135
|
const onlyShell = countOverviewDataRows(body) === 0;
|
|
5672
7136
|
if (onlyShell && featureStoriesDirEmpty(paths, story.feature)) {
|
|
5673
7137
|
if (canPrompt) {
|
|
@@ -5687,7 +7151,7 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
5687
7151
|
}
|
|
5688
7152
|
|
|
5689
7153
|
// src/commands/rm/plan.ts
|
|
5690
|
-
import
|
|
7154
|
+
import path33 from "path";
|
|
5691
7155
|
import { confirm as confirm11 } from "@inquirer/prompts";
|
|
5692
7156
|
async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
5693
7157
|
const root = requireSquadRoot();
|
|
@@ -5723,7 +7187,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
|
5723
7187
|
}
|
|
5724
7188
|
const storyRows = listStories(paths, { feature: plan.feature });
|
|
5725
7189
|
const linked = storyRows.find((s) => s.planFile === plan.file);
|
|
5726
|
-
const targets = [{ label:
|
|
7190
|
+
const targets = [{ label: path33.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
|
|
5727
7191
|
renderPreview("plan", targets, {
|
|
5728
7192
|
dryRun,
|
|
5729
7193
|
trash: useTrash,
|
|
@@ -5746,14 +7210,14 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
|
5746
7210
|
}
|
|
5747
7211
|
}
|
|
5748
7212
|
trashOrDelete([plan.abs], paths.trashDir, useTrash);
|
|
5749
|
-
removeOverviewRowForPlanFile(
|
|
5750
|
-
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}/`;
|
|
5751
7215
|
success(`Plan removed. Run \`squad new-plan ${intakeHint}\` to regenerate.`);
|
|
5752
7216
|
}
|
|
5753
7217
|
|
|
5754
7218
|
// src/cli.ts
|
|
5755
7219
|
var program = new Command();
|
|
5756
|
-
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");
|
|
5757
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(
|
|
5758
7222
|
"--tracker-workspace <hostOrOrg>",
|
|
5759
7223
|
"Jira host or Azure org (non-interactive default; also written to config for jira/azure)"
|
|
@@ -5786,6 +7250,11 @@ program.command("status").description("Show squad-kit workspace status").action(
|
|
|
5786
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));
|
|
5787
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));
|
|
5788
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));
|
|
5789
7258
|
program.command("list").description("List stories and their plan state").option("--feature <slug>", "Filter by feature slug").action(wrap(runList));
|
|
5790
7259
|
var rm = program.command("rm").description("Delete stories, plans, or entire features safely");
|
|
5791
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));
|