squad-kit 0.4.0 → 0.6.1
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 +81 -1
- package/README.md +27 -4
- package/dist/cli.js +2134 -658
- 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 (q.length === 0) {
|
|
1254
|
+
wiql = "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project ORDER BY [System.ChangedDate] DESC";
|
|
1255
|
+
} else if (/^\d+$/.test(q)) {
|
|
1256
|
+
wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.Id] = ${q}`;
|
|
1257
|
+
} else {
|
|
1258
|
+
const esc = q.replace(/'/g, "''");
|
|
1259
|
+
wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.Title] CONTAINS '${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,15 +1345,26 @@ 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
|
);
|
|
1283
1352
|
}
|
|
1353
|
+
if (status === 400) {
|
|
1354
|
+
return new TrackerError(
|
|
1355
|
+
id === "search" ? `Azure DevOps rejected the search query (HTTP 400). This usually means the WIQL is not scoped to a team project, or the configured organization/project (${this.cfg.organization}/${this.cfg.project}) is wrong.` : `Azure DevOps rejected the request (HTTP 400).`,
|
|
1356
|
+
"other",
|
|
1357
|
+
status
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1284
1360
|
if (status === 429) {
|
|
1285
1361
|
return new TrackerError(`Azure DevOps rate limit hit (HTTP 429). Wait a minute and retry.`, "rate-limited", status);
|
|
1286
1362
|
}
|
|
1287
|
-
return new TrackerError(
|
|
1363
|
+
return new TrackerError(
|
|
1364
|
+
id === "search" ? `Azure DevOps search failed (HTTP ${status}).` : `Azure DevOps request failed (HTTP ${status}).`,
|
|
1365
|
+
"other",
|
|
1366
|
+
status
|
|
1367
|
+
);
|
|
1288
1368
|
}
|
|
1289
1369
|
};
|
|
1290
1370
|
function parseTags(raw) {
|
|
@@ -1381,6 +1461,40 @@ var JiraClient = class {
|
|
|
1381
1461
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1382
1462
|
};
|
|
1383
1463
|
}
|
|
1464
|
+
async searchIssues(query, opts) {
|
|
1465
|
+
const limit = Math.min(50, Math.max(1, opts?.limit ?? 25));
|
|
1466
|
+
const q = query.trim();
|
|
1467
|
+
let jql;
|
|
1468
|
+
if (/^[A-Z][A-Z0-9_]+-\d+$/i.test(q)) {
|
|
1469
|
+
jql = `issuekey = "${q.toUpperCase()}"`;
|
|
1470
|
+
} else if (q.length === 0) {
|
|
1471
|
+
jql = "order by updated DESC";
|
|
1472
|
+
} else {
|
|
1473
|
+
const esc = q.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1474
|
+
jql = `text ~ "${esc}" order by updated DESC`;
|
|
1475
|
+
}
|
|
1476
|
+
const url = `${this.baseUrl}/rest/api/3/search?jql=${encodeURIComponent(jql)}&fields=summary,status,issuetype&maxResults=${limit}`;
|
|
1477
|
+
let res;
|
|
1478
|
+
try {
|
|
1479
|
+
res = await fetch(url, { headers: this.headers() });
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
throw new TrackerError(`Jira search failed: ${err.message}`, "network");
|
|
1482
|
+
}
|
|
1483
|
+
if (!res.ok) throw this.mapHttpError(res.status, "search");
|
|
1484
|
+
const body = await res.json();
|
|
1485
|
+
const issues = body.issues ?? [];
|
|
1486
|
+
return issues.map((it) => {
|
|
1487
|
+
const key = it.key ?? "";
|
|
1488
|
+
const f = it.fields ?? {};
|
|
1489
|
+
return {
|
|
1490
|
+
id: key,
|
|
1491
|
+
title: f.summary ?? "(no title)",
|
|
1492
|
+
type: f.issuetype?.name,
|
|
1493
|
+
status: f.status?.name,
|
|
1494
|
+
url: `${this.baseUrl}/browse/${key}`
|
|
1495
|
+
};
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1384
1498
|
async downloadAttachments(refs, targetDir, opts) {
|
|
1385
1499
|
return downloadAttachmentsWith(refs, targetDir, this.headers(), opts);
|
|
1386
1500
|
}
|
|
@@ -1400,7 +1514,7 @@ var JiraClient = class {
|
|
|
1400
1514
|
}
|
|
1401
1515
|
if (status === 404) {
|
|
1402
1516
|
return new TrackerError(
|
|
1403
|
-
`Jira issue "${id}" not found on ${this.cfg.host} (HTTP 404). Check the id and your workspace host.`,
|
|
1517
|
+
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
1518
|
"not-found",
|
|
1405
1519
|
status
|
|
1406
1520
|
);
|
|
@@ -1408,7 +1522,11 @@ var JiraClient = class {
|
|
|
1408
1522
|
if (status === 429) {
|
|
1409
1523
|
return new TrackerError(`Jira rate limit hit (HTTP 429). Wait a minute and retry.`, "rate-limited", status);
|
|
1410
1524
|
}
|
|
1411
|
-
return new TrackerError(
|
|
1525
|
+
return new TrackerError(
|
|
1526
|
+
id === "search" ? `Jira search failed (HTTP ${status}).` : `Jira request failed (HTTP ${status}).`,
|
|
1527
|
+
"other",
|
|
1528
|
+
status
|
|
1529
|
+
);
|
|
1412
1530
|
}
|
|
1413
1531
|
};
|
|
1414
1532
|
function stripHtml2(html) {
|
|
@@ -1963,9 +2081,9 @@ Plan meta-prompts (\`generate-plan.md\`, \`story-skeleton.md\`) ship inside the
|
|
|
1963
2081
|
}
|
|
1964
2082
|
|
|
1965
2083
|
// src/commands/new-story.ts
|
|
1966
|
-
import
|
|
1967
|
-
import
|
|
1968
|
-
import { input as input3, select as
|
|
2084
|
+
import fs20 from "fs";
|
|
2085
|
+
import path12 from "path";
|
|
2086
|
+
import { input as input3, select as select5 } from "@inquirer/prompts";
|
|
1969
2087
|
|
|
1970
2088
|
// src/core/stories.ts
|
|
1971
2089
|
import fs15 from "fs";
|
|
@@ -1975,6 +2093,15 @@ function listFeatures(paths) {
|
|
|
1975
2093
|
if (!fs15.existsSync(paths.storiesDir)) return [];
|
|
1976
2094
|
return fs15.readdirSync(paths.storiesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
1977
2095
|
}
|
|
2096
|
+
function listPlanFeatureNames(paths) {
|
|
2097
|
+
const names = new Set(listFeatures(paths));
|
|
2098
|
+
if (fs15.existsSync(paths.plansDir)) {
|
|
2099
|
+
for (const e of fs15.readdirSync(paths.plansDir, { withFileTypes: true })) {
|
|
2100
|
+
if (e.isDirectory()) names.add(e.name);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
return [...names].sort();
|
|
2104
|
+
}
|
|
1978
2105
|
function listAllStories(paths) {
|
|
1979
2106
|
return listStories(paths);
|
|
1980
2107
|
}
|
|
@@ -2097,225 +2224,9 @@ function trackerIdForFilename(type, id) {
|
|
|
2097
2224
|
return id;
|
|
2098
2225
|
}
|
|
2099
2226
|
|
|
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
|
-
}
|
|
2227
|
+
// src/core/story-intake-helpers.ts
|
|
2228
|
+
import fs16 from "fs";
|
|
2229
|
+
import path8 from "path";
|
|
2319
2230
|
function buildIntakeTemplateVars(args) {
|
|
2320
2231
|
const e = escapeTemplateValue;
|
|
2321
2232
|
const { config: config2, featureSlug, storyFolderName, trackerId, title, fetchedIssue } = args;
|
|
@@ -2407,10 +2318,570 @@ function ensureFeatureOverview(featurePlanDir, slug) {
|
|
|
2407
2318
|
writeFileSafe(overviewFile, render(template, { featureSlug: slug }), false);
|
|
2408
2319
|
}
|
|
2409
2320
|
|
|
2410
|
-
// src/
|
|
2411
|
-
import
|
|
2412
|
-
import
|
|
2413
|
-
|
|
2321
|
+
// src/core/story-mutations.ts
|
|
2322
|
+
import fs19 from "fs";
|
|
2323
|
+
import path11 from "path";
|
|
2324
|
+
|
|
2325
|
+
// src/commands/rm/shared.ts
|
|
2326
|
+
import fs18 from "fs";
|
|
2327
|
+
import path10 from "path";
|
|
2328
|
+
import { select as select4 } from "@inquirer/prompts";
|
|
2329
|
+
|
|
2330
|
+
// src/core/sequence.ts
|
|
2331
|
+
import fs17 from "fs";
|
|
2332
|
+
import path9 from "path";
|
|
2333
|
+
var STORY_FILE2 = /^(\d{2,})-story-.+\.md$/;
|
|
2334
|
+
function scanPlans(plansDir) {
|
|
2335
|
+
const used = [];
|
|
2336
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2337
|
+
if (fs17.existsSync(plansDir)) {
|
|
2338
|
+
for (const feature of fs17.readdirSync(plansDir, { withFileTypes: true })) {
|
|
2339
|
+
if (!feature.isDirectory()) continue;
|
|
2340
|
+
const featureDir = path9.join(plansDir, feature.name);
|
|
2341
|
+
for (const entry of fs17.readdirSync(featureDir)) {
|
|
2342
|
+
const match = entry.match(STORY_FILE2);
|
|
2343
|
+
if (!match) continue;
|
|
2344
|
+
const n = parseInt(match[1], 10);
|
|
2345
|
+
used.push(n);
|
|
2346
|
+
seen.set(n, (seen.get(n) ?? 0) + 1);
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
const duplicates = [...seen.entries()].filter(([, c]) => c > 1).map(([n]) => n);
|
|
2351
|
+
const maxNumber = used.length ? Math.max(...used) : 0;
|
|
2352
|
+
return { usedNumbers: used.sort((a, b) => a - b), maxNumber, duplicates, nextGlobal: maxNumber + 1 };
|
|
2353
|
+
}
|
|
2354
|
+
function formatSequence(n) {
|
|
2355
|
+
return String(n).padStart(2, "0");
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// src/commands/rm/shared.ts
|
|
2359
|
+
var STORY_PLAN = /^(\d{2,})-story-.+\.md$/;
|
|
2360
|
+
function canPromptForRm(opts) {
|
|
2361
|
+
if (opts.yes) return false;
|
|
2362
|
+
return isInteractive() && Boolean(process.stdin.isTTY);
|
|
2363
|
+
}
|
|
2364
|
+
function removeOverviewRow(featurePlanDir, storyId) {
|
|
2365
|
+
const overview = path10.join(featurePlanDir, "00-overview.md");
|
|
2366
|
+
if (!fs18.existsSync(overview)) return;
|
|
2367
|
+
const src = fs18.readFileSync(overview, "utf8");
|
|
2368
|
+
const lines = src.split("\n");
|
|
2369
|
+
const filtered = lines.filter((line2) => {
|
|
2370
|
+
if (!line2.startsWith("|")) return true;
|
|
2371
|
+
if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) return true;
|
|
2372
|
+
if (/^\|\s*-+/.test(line2)) return true;
|
|
2373
|
+
return !line2.includes(storyId);
|
|
2374
|
+
});
|
|
2375
|
+
fs18.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
2376
|
+
}
|
|
2377
|
+
function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
|
|
2378
|
+
const overview = path10.join(featurePlanDir, "00-overview.md");
|
|
2379
|
+
if (!fs18.existsSync(overview)) return;
|
|
2380
|
+
const src = fs18.readFileSync(overview, "utf8");
|
|
2381
|
+
const token = `\`${planBasename}\``;
|
|
2382
|
+
const lines = src.split("\n");
|
|
2383
|
+
const filtered = lines.filter((line2) => {
|
|
2384
|
+
if (!line2.startsWith("|")) return true;
|
|
2385
|
+
if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) return true;
|
|
2386
|
+
if (/^\|\s*-+/.test(line2)) return true;
|
|
2387
|
+
return !line2.includes(token);
|
|
2388
|
+
});
|
|
2389
|
+
fs18.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
2390
|
+
}
|
|
2391
|
+
function trashOrDelete(absPaths, trashRoot, useTrash) {
|
|
2392
|
+
if (!useTrash) {
|
|
2393
|
+
for (const p of absPaths) fs18.rmSync(p, { recursive: true, force: true });
|
|
2394
|
+
return { removed: absPaths };
|
|
2395
|
+
}
|
|
2396
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2397
|
+
const bucket = path10.join(trashRoot, ts);
|
|
2398
|
+
fs18.mkdirSync(bucket, { recursive: true });
|
|
2399
|
+
for (const src of absPaths) {
|
|
2400
|
+
const rel = path10.basename(src);
|
|
2401
|
+
let dst = path10.join(bucket, rel);
|
|
2402
|
+
let n = 0;
|
|
2403
|
+
while (fs18.existsSync(dst)) {
|
|
2404
|
+
n += 1;
|
|
2405
|
+
dst = path10.join(bucket, `${n}_${rel}`);
|
|
2406
|
+
}
|
|
2407
|
+
fs18.renameSync(src, dst);
|
|
2408
|
+
}
|
|
2409
|
+
return { removed: absPaths, trashed: bucket };
|
|
2410
|
+
}
|
|
2411
|
+
function renderPreview(kind, targets, opts) {
|
|
2412
|
+
divider(`squad rm ${kind}`);
|
|
2413
|
+
step(opts.dryRun ? "Dry run \u2014 nothing will be changed." : "About to remove:");
|
|
2414
|
+
for (const t of targets) {
|
|
2415
|
+
kv(t.kind, t.label, 6);
|
|
2416
|
+
}
|
|
2417
|
+
if (opts.trash) {
|
|
2418
|
+
info(`Items will move to ${path10.relative(process.cwd(), opts.trashRoot)}/<timestamp>/`);
|
|
2419
|
+
} else if (!opts.dryRun) {
|
|
2420
|
+
warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
function listAllPlanListEntries(paths, featureFilter) {
|
|
2424
|
+
if (!fs18.existsSync(paths.plansDir)) return [];
|
|
2425
|
+
const out = [];
|
|
2426
|
+
for (const ent of fs18.readdirSync(paths.plansDir, { withFileTypes: true })) {
|
|
2427
|
+
if (!ent.isDirectory()) continue;
|
|
2428
|
+
if (featureFilter && ent.name !== featureFilter) continue;
|
|
2429
|
+
const featureDir = path10.join(paths.plansDir, ent.name);
|
|
2430
|
+
for (const name of fs18.readdirSync(featureDir)) {
|
|
2431
|
+
if (!STORY_PLAN.test(name)) continue;
|
|
2432
|
+
out.push({ feature: ent.name, file: name, abs: path10.join(featureDir, name) });
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
return out.sort((a, b) => (a.feature + a.file).localeCompare(b.feature + b.file));
|
|
2436
|
+
}
|
|
2437
|
+
async function pickStoryInteractive(paths, featureFilter) {
|
|
2438
|
+
const stories = listAllStories(paths).filter((s) => !featureFilter || s.feature === featureFilter);
|
|
2439
|
+
if (stories.length === 0) {
|
|
2440
|
+
throw new Error("No stories to pick. Run `squad new-story` first, or `squad list` to verify paths.");
|
|
2441
|
+
}
|
|
2442
|
+
if (stories.length === 1) return stories[0];
|
|
2443
|
+
const choice = await select4({
|
|
2444
|
+
message: "Story to remove:",
|
|
2445
|
+
choices: stories.map((s) => ({
|
|
2446
|
+
name: `${s.feature}/${s.id} \xB7 ${s.titleHint ?? s.id}`,
|
|
2447
|
+
value: s
|
|
2448
|
+
}))
|
|
2449
|
+
});
|
|
2450
|
+
return choice;
|
|
2451
|
+
}
|
|
2452
|
+
async function pickPlanInteractive(paths, featureFilter) {
|
|
2453
|
+
const plans = listAllPlanListEntries(paths, featureFilter);
|
|
2454
|
+
if (plans.length === 0) {
|
|
2455
|
+
throw new Error("No plan files to pick. Run `squad new-plan` first, or `squad list` to see plans.");
|
|
2456
|
+
}
|
|
2457
|
+
if (plans.length === 1) return plans[0];
|
|
2458
|
+
return select4({
|
|
2459
|
+
message: "Plan file to remove:",
|
|
2460
|
+
choices: plans.map((p) => ({
|
|
2461
|
+
name: `${p.feature}/${p.file}`,
|
|
2462
|
+
value: p
|
|
2463
|
+
}))
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
async function pickFeatureInteractive(paths) {
|
|
2467
|
+
const feats = listFeatures(paths);
|
|
2468
|
+
if (feats.length === 0) {
|
|
2469
|
+
throw new Error("No features to pick. Run `squad new-story` first, or `squad list` to see features.");
|
|
2470
|
+
}
|
|
2471
|
+
if (feats.length === 1) return feats[0];
|
|
2472
|
+
return select4({
|
|
2473
|
+
message: "Feature to remove:",
|
|
2474
|
+
choices: feats.map((f) => ({ name: f, value: f }))
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
function resolveStoryFromArg(arg, stories, root) {
|
|
2478
|
+
const t = arg.trim();
|
|
2479
|
+
for (const s of stories) {
|
|
2480
|
+
if (s.id === t) return s;
|
|
2481
|
+
if (`${s.feature}/${s.id}` === t) return s;
|
|
2482
|
+
}
|
|
2483
|
+
const abs = path10.isAbsolute(t) ? t : path10.resolve(root, t);
|
|
2484
|
+
const withIntake = t.endsWith("intake.md") ? abs : path10.join(abs, "intake.md");
|
|
2485
|
+
return findStoryByIntake(stories, withIntake) ?? findStoryByIntake(stories, abs);
|
|
2486
|
+
}
|
|
2487
|
+
function isSequenceToken(s) {
|
|
2488
|
+
return /^\d{1,4}$/.test(s) && s.length > 0;
|
|
2489
|
+
}
|
|
2490
|
+
function tryParsePlanFilePath(absPath, paths, featureFilter) {
|
|
2491
|
+
if (!fs18.existsSync(absPath) || !STORY_PLAN.test(path10.basename(absPath))) return void 0;
|
|
2492
|
+
const planRoot = fs18.realpathSync(path10.resolve(paths.plansDir));
|
|
2493
|
+
const fileReal = fs18.realpathSync(path10.resolve(absPath));
|
|
2494
|
+
const rel = path10.relative(planRoot, fileReal);
|
|
2495
|
+
if (rel.startsWith("..") || path10.isAbsolute(rel)) return void 0;
|
|
2496
|
+
const segs = rel.split(path10.sep);
|
|
2497
|
+
if (segs.length < 2) return void 0;
|
|
2498
|
+
const feature = segs[0];
|
|
2499
|
+
const file = segs.slice(1).join(path10.sep);
|
|
2500
|
+
if (featureFilter && feature !== featureFilter) return void 0;
|
|
2501
|
+
return { feature, file, abs: path10.resolve(absPath) };
|
|
2502
|
+
}
|
|
2503
|
+
function resolvePlanFromArg(arg, paths, featureFilter) {
|
|
2504
|
+
const t = arg.trim();
|
|
2505
|
+
if (!t) return void 0;
|
|
2506
|
+
const asPath = path10.isAbsolute(t) ? t : path10.resolve(process.cwd(), t);
|
|
2507
|
+
const a = tryParsePlanFilePath(asPath, paths, featureFilter);
|
|
2508
|
+
if (a) return a;
|
|
2509
|
+
if (!path10.isAbsolute(t)) {
|
|
2510
|
+
const under = tryParsePlanFilePath(path10.join(paths.plansDir, t), paths, featureFilter);
|
|
2511
|
+
if (under) return under;
|
|
2512
|
+
}
|
|
2513
|
+
if (isSequenceToken(t)) {
|
|
2514
|
+
const nn = formatSequence(parseInt(t, 10));
|
|
2515
|
+
const prefix = `${nn}-`;
|
|
2516
|
+
const cands = [];
|
|
2517
|
+
for (const p of listAllPlanListEntries(paths, featureFilter)) {
|
|
2518
|
+
if (p.file.startsWith(prefix)) cands.push(p);
|
|
2519
|
+
}
|
|
2520
|
+
if (cands.length === 1) return cands[0];
|
|
2521
|
+
if (cands.length > 1) {
|
|
2522
|
+
throw new Error(
|
|
2523
|
+
`Multiple plans match sequence "${t}". Run \`squad rm plan\` in a TTY, or add \`--feature <slug>\` to disambiguate.`
|
|
2524
|
+
);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
return void 0;
|
|
2528
|
+
}
|
|
2529
|
+
function countOverviewDataRows(overviewContent) {
|
|
2530
|
+
let n = 0;
|
|
2531
|
+
for (const line2 of overviewContent.split("\n")) {
|
|
2532
|
+
if (!line2.startsWith("|")) continue;
|
|
2533
|
+
if (/^\|\s*-+/.test(line2)) continue;
|
|
2534
|
+
if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) continue;
|
|
2535
|
+
n++;
|
|
2536
|
+
}
|
|
2537
|
+
return n;
|
|
2538
|
+
}
|
|
2539
|
+
function featureStoriesDirEmpty(paths, feature) {
|
|
2540
|
+
const d = path10.join(paths.storiesDir, feature);
|
|
2541
|
+
if (!fs18.existsSync(d)) return true;
|
|
2542
|
+
return fs18.readdirSync(d).length === 0;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// src/core/story-mutations.ts
|
|
2546
|
+
function writeNewStoryToDisk(input5) {
|
|
2547
|
+
const { paths, config: config2, featureSlug, storyFolderName, id, title, noTracker, sourceBlock, fetchedIssue } = input5;
|
|
2548
|
+
const storyDir = path11.join(paths.storiesDir, featureSlug, storyFolderName);
|
|
2549
|
+
const intakeFile = path11.join(storyDir, "intake.md");
|
|
2550
|
+
const attachmentsDir = path11.join(storyDir, "attachments");
|
|
2551
|
+
if (fs19.existsSync(intakeFile)) {
|
|
2552
|
+
const rel = path11.relative(paths.root, intakeFile);
|
|
2553
|
+
throw new Error(
|
|
2554
|
+
`Intake already exists: ${intakeFile}. Run \`squad new-plan ${rel}\` to plan it, or \`squad rm story\` to remove the story first.`
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
fs19.mkdirSync(attachmentsDir, { recursive: true });
|
|
2558
|
+
const template = readBundledPrompt("intake.md");
|
|
2559
|
+
const rendered = render(
|
|
2560
|
+
template,
|
|
2561
|
+
buildIntakeTemplateVars({
|
|
2562
|
+
config: config2,
|
|
2563
|
+
featureSlug,
|
|
2564
|
+
storyFolderName,
|
|
2565
|
+
trackerId: id,
|
|
2566
|
+
title,
|
|
2567
|
+
fetchedIssue
|
|
2568
|
+
})
|
|
2569
|
+
);
|
|
2570
|
+
const skippedPreamble = noTracker && config2.tracker.type !== "none" && !sourceBlock ? buildSkippedTrackerPreamble(config2.tracker.type) : "";
|
|
2571
|
+
writeFileSafe(intakeFile, skippedPreamble + sourceBlock + rendered, false);
|
|
2572
|
+
ensureFeatureOverview(path11.join(paths.plansDir, featureSlug), featureSlug);
|
|
2573
|
+
return { storyDir, intakePath: intakeFile, feature: featureSlug, id: storyFolderName };
|
|
2574
|
+
}
|
|
2575
|
+
function createStoryRecord(input5) {
|
|
2576
|
+
const feature = slugify(input5.feature);
|
|
2577
|
+
if (!feature) {
|
|
2578
|
+
throw new Error(`Invalid feature slug: "${input5.feature}". Use kebab-case (letters, digits, hyphens).`);
|
|
2579
|
+
}
|
|
2580
|
+
const rawId = input5.trackerId?.trim() || void 0;
|
|
2581
|
+
const noTracker = input5.config.tracker.type !== "none" && !rawId;
|
|
2582
|
+
let id = rawId;
|
|
2583
|
+
if (noTracker) id = void 0;
|
|
2584
|
+
if (id && !validateTrackerId(input5.config.tracker.type, id)) {
|
|
2585
|
+
throw new Error(
|
|
2586
|
+
`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.`
|
|
2587
|
+
);
|
|
2588
|
+
}
|
|
2589
|
+
const title = input5.title?.trim() || void 0;
|
|
2590
|
+
const storyFolderName = id ? trackerIdForFilename(input5.config.tracker.type, id) : slugify(title ?? "");
|
|
2591
|
+
if (!storyFolderName) {
|
|
2592
|
+
throw new Error(
|
|
2593
|
+
'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.'
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2596
|
+
return writeNewStoryToDisk({
|
|
2597
|
+
paths: input5.paths,
|
|
2598
|
+
config: input5.config,
|
|
2599
|
+
featureSlug: feature,
|
|
2600
|
+
storyFolderName,
|
|
2601
|
+
id,
|
|
2602
|
+
title,
|
|
2603
|
+
noTracker,
|
|
2604
|
+
sourceBlock: "",
|
|
2605
|
+
fetchedIssue: void 0
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
async function createStoryRecordFromIssue(input5) {
|
|
2609
|
+
const feature = slugify(input5.feature);
|
|
2610
|
+
if (!feature) {
|
|
2611
|
+
throw new Error(`Invalid feature slug: "${input5.feature}". Use kebab-case (letters, digits, hyphens).`);
|
|
2612
|
+
}
|
|
2613
|
+
const id = input5.issue.id.trim();
|
|
2614
|
+
if (!validateTrackerId(input5.config.tracker.type, id)) {
|
|
2615
|
+
throw new Error(
|
|
2616
|
+
`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.`
|
|
2617
|
+
);
|
|
2618
|
+
}
|
|
2619
|
+
const title = input5.issue.title;
|
|
2620
|
+
const storyFolderName = trackerIdForFilename(input5.config.tracker.type, id);
|
|
2621
|
+
const storyDir = path11.join(input5.paths.storiesDir, feature, storyFolderName);
|
|
2622
|
+
const intakeFile = path11.join(storyDir, "intake.md");
|
|
2623
|
+
const attachmentsDir = path11.join(storyDir, "attachments");
|
|
2624
|
+
if (fs19.existsSync(intakeFile)) {
|
|
2625
|
+
const rel = path11.relative(input5.paths.root, intakeFile);
|
|
2626
|
+
throw new Error(
|
|
2627
|
+
`Intake already exists: ${intakeFile}. Run \`squad new-plan ${rel}\` to plan it, or \`squad rm story\` to remove the story first.`
|
|
2628
|
+
);
|
|
2629
|
+
}
|
|
2630
|
+
fs19.mkdirSync(attachmentsDir, { recursive: true });
|
|
2631
|
+
let downloads = [];
|
|
2632
|
+
if (input5.withAttachments && input5.issue.attachments.length > 0) {
|
|
2633
|
+
downloads = await input5.client.downloadAttachments(input5.issue.attachments, attachmentsDir);
|
|
2634
|
+
} else if (!input5.withAttachments && input5.issue.attachments.length > 0) {
|
|
2635
|
+
downloads = input5.issue.attachments.map((source) => ({
|
|
2636
|
+
source,
|
|
2637
|
+
outcome: "skipped-error",
|
|
2638
|
+
bytesWritten: 0,
|
|
2639
|
+
skipReason: "not downloaded"
|
|
2640
|
+
}));
|
|
2641
|
+
}
|
|
2642
|
+
const sourceBlock = buildSourcePreamble(input5.issue, downloads, input5.config.tracker.type);
|
|
2643
|
+
return writeNewStoryToDisk({
|
|
2644
|
+
paths: input5.paths,
|
|
2645
|
+
config: input5.config,
|
|
2646
|
+
featureSlug: feature,
|
|
2647
|
+
storyFolderName,
|
|
2648
|
+
id,
|
|
2649
|
+
title,
|
|
2650
|
+
noTracker: false,
|
|
2651
|
+
sourceBlock,
|
|
2652
|
+
fetchedIssue: input5.issue
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2655
|
+
function deleteStoryRecord(input5) {
|
|
2656
|
+
const targets = [input5.story.storyDir];
|
|
2657
|
+
if (input5.story.planFile) {
|
|
2658
|
+
targets.push(path11.join(input5.paths.plansDir, input5.story.feature, input5.story.planFile));
|
|
2659
|
+
}
|
|
2660
|
+
const result = trashOrDelete(targets, input5.paths.trashDir, input5.trash);
|
|
2661
|
+
removeOverviewRow(path11.join(input5.paths.plansDir, input5.story.feature), input5.story.id);
|
|
2662
|
+
return result;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
// src/commands/new-story.ts
|
|
2666
|
+
function validateFeatureSlugForPrompt(v) {
|
|
2667
|
+
const trimmed = v.trim();
|
|
2668
|
+
if (!trimmed) return "Required.";
|
|
2669
|
+
const s = slugify(trimmed);
|
|
2670
|
+
if (!s) return "Required.";
|
|
2671
|
+
if (s !== trimmed) return "Use lowercase kebab-case (letters, digits, hyphens).";
|
|
2672
|
+
return true;
|
|
2673
|
+
}
|
|
2674
|
+
async function runNewStory(featureSlug, opts) {
|
|
2675
|
+
const interactive = !opts.yes && Boolean(process.stdin.isTTY) && process.env.CI !== "true";
|
|
2676
|
+
let featureSlugInput = featureSlug;
|
|
2677
|
+
if (!featureSlugInput) {
|
|
2678
|
+
if (!interactive) {
|
|
2679
|
+
throw new Error(
|
|
2680
|
+
"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."
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2683
|
+
const rootForPrompt = requireSquadRoot();
|
|
2684
|
+
const pathsForPrompt = buildPaths(rootForPrompt);
|
|
2685
|
+
const existing = listFeatures(pathsForPrompt);
|
|
2686
|
+
if (existing.length > 0) {
|
|
2687
|
+
const choice = await select5({
|
|
2688
|
+
message: "Feature:",
|
|
2689
|
+
choices: [
|
|
2690
|
+
...existing.map((f) => ({ name: f, value: f })),
|
|
2691
|
+
{ name: "\u2500\u2500\u2500 new feature \u2500\u2500\u2500", value: "__NEW__" }
|
|
2692
|
+
]
|
|
2693
|
+
});
|
|
2694
|
+
if (choice !== "__NEW__") {
|
|
2695
|
+
featureSlugInput = choice;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
if (!featureSlugInput) {
|
|
2699
|
+
featureSlugInput = (await input3({
|
|
2700
|
+
message: "New feature slug (kebab-case):",
|
|
2701
|
+
validate: validateFeatureSlugForPrompt
|
|
2702
|
+
})).trim();
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
const slug = slugify(featureSlugInput);
|
|
2706
|
+
if (!slug) {
|
|
2707
|
+
throw new Error(
|
|
2708
|
+
`Invalid feature slug: "${featureSlugInput}". Run \`squad new-story\` and use kebab-case (letters, digits, hyphens).`
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2711
|
+
const root = requireSquadRoot();
|
|
2712
|
+
const paths = buildPaths(root);
|
|
2713
|
+
const config2 = loadConfig(paths.configFile);
|
|
2714
|
+
let noTracker = opts.tracker === false && config2.tracker.type !== "none";
|
|
2715
|
+
let id = opts.id;
|
|
2716
|
+
if (noTracker) id = void 0;
|
|
2717
|
+
let trackerNeedsId = !noTracker && config2.tracker.type !== "none" && config2.naming.includeTrackerId;
|
|
2718
|
+
let title = opts.title;
|
|
2719
|
+
if (!id && trackerNeedsId) {
|
|
2720
|
+
if (!interactive) {
|
|
2721
|
+
throw new Error(
|
|
2722
|
+
`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.`
|
|
2723
|
+
);
|
|
2724
|
+
}
|
|
2725
|
+
const mode = await select5({
|
|
2726
|
+
message: `This workspace requires a ${config2.tracker.type} id for new stories. How do you want to proceed?`,
|
|
2727
|
+
choices: [
|
|
2728
|
+
{ name: `Enter a ${config2.tracker.type} id now`, value: "enter" },
|
|
2729
|
+
{ name: "Skip tracker for this story (same as --no-tracker)", value: "skip" }
|
|
2730
|
+
],
|
|
2731
|
+
default: "enter"
|
|
2732
|
+
});
|
|
2733
|
+
if (mode === "skip") {
|
|
2734
|
+
opts.tracker = false;
|
|
2735
|
+
noTracker = true;
|
|
2736
|
+
trackerNeedsId = false;
|
|
2737
|
+
} else {
|
|
2738
|
+
id = (await input3({
|
|
2739
|
+
message: `${config2.tracker.type} work-item id:`,
|
|
2740
|
+
validate: (v) => validateTrackerId(config2.tracker.type, v.trim()) ? true : `Invalid ${config2.tracker.type} id format.`
|
|
2741
|
+
})).trim();
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
if (id && !validateTrackerId(config2.tracker.type, id)) {
|
|
2745
|
+
throw new Error(
|
|
2746
|
+
`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.`
|
|
2747
|
+
);
|
|
2748
|
+
}
|
|
2749
|
+
if (!id && !title) {
|
|
2750
|
+
if (!interactive) {
|
|
2751
|
+
if (!noTracker) {
|
|
2752
|
+
throw new Error(
|
|
2753
|
+
'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).'
|
|
2754
|
+
);
|
|
2755
|
+
}
|
|
2756
|
+
} else {
|
|
2757
|
+
title = (await input3({ message: "Short story title:" })).trim();
|
|
2758
|
+
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.");
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
if (interactive && !title) {
|
|
2762
|
+
const maybe = (await input3({ message: "Short title (optional, press Enter to skip):", default: "" })).trim();
|
|
2763
|
+
if (maybe) title = maybe;
|
|
2764
|
+
}
|
|
2765
|
+
const storyFolderName = id ? trackerIdForFilename(config2.tracker.type, id) : slugify(title ?? "");
|
|
2766
|
+
if (!storyFolderName) {
|
|
2767
|
+
throw new Error(
|
|
2768
|
+
'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.'
|
|
2769
|
+
);
|
|
2770
|
+
}
|
|
2771
|
+
const storyDir = path12.join(paths.storiesDir, slug, storyFolderName);
|
|
2772
|
+
const intakeFile = path12.join(storyDir, "intake.md");
|
|
2773
|
+
const attachmentsDir = path12.join(storyDir, "attachments");
|
|
2774
|
+
if (fs20.existsSync(intakeFile)) {
|
|
2775
|
+
throw new Error(
|
|
2776
|
+
`Intake already exists: ${intakeFile}. Run \`squad new-plan ${path12.relative(root, intakeFile)}\` to plan it, or \`squad rm story\` to remove the story first.`
|
|
2777
|
+
);
|
|
2778
|
+
}
|
|
2779
|
+
fs20.mkdirSync(attachmentsDir, { recursive: true });
|
|
2780
|
+
let sourceBlock = "";
|
|
2781
|
+
let fetchedSummary;
|
|
2782
|
+
let fetchedIssue;
|
|
2783
|
+
const shouldFetch = !noTracker && opts.fetch !== false && Boolean(id) && config2.tracker.type !== "none";
|
|
2784
|
+
if (shouldFetch) {
|
|
2785
|
+
const outcome = await tryFetchIssue(config2, id, opts, paths, attachmentsDir);
|
|
2786
|
+
if (outcome.outcome === "ok") {
|
|
2787
|
+
fetchedIssue = outcome.issue;
|
|
2788
|
+
sourceBlock = buildSourcePreamble(outcome.issue, outcome.downloads, config2.tracker.type);
|
|
2789
|
+
if (!title) title = outcome.issue.title;
|
|
2790
|
+
const written = outcome.downloads.filter((d) => d.outcome === "written").length;
|
|
2791
|
+
fetchedSummary = { id: outcome.issue.id, written };
|
|
2792
|
+
} else {
|
|
2793
|
+
warning(`Tracker fetch skipped: ${outcome.reason}`);
|
|
2794
|
+
if (outcome.hint) info(outcome.hint);
|
|
2795
|
+
sourceBlock = buildFallbackPreamble(outcome.reason, outcome.hint);
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
writeNewStoryToDisk({
|
|
2799
|
+
paths,
|
|
2800
|
+
config: config2,
|
|
2801
|
+
featureSlug: slug,
|
|
2802
|
+
storyFolderName,
|
|
2803
|
+
id,
|
|
2804
|
+
title,
|
|
2805
|
+
noTracker,
|
|
2806
|
+
sourceBlock,
|
|
2807
|
+
fetchedIssue
|
|
2808
|
+
});
|
|
2809
|
+
const relativePath = path12.relative(root, intakeFile);
|
|
2810
|
+
const relAttachDir = path12.relative(root, attachmentsDir);
|
|
2811
|
+
success("Created intake");
|
|
2812
|
+
if (noTracker && config2.tracker.type !== "none") {
|
|
2813
|
+
warning("Created without a tracker link. Use `squad tracker link` later to attach one.");
|
|
2814
|
+
}
|
|
2815
|
+
kv("intake", relativePath, 11);
|
|
2816
|
+
kv("attachments", relAttachDir + path12.sep, 11);
|
|
2817
|
+
if (fetchedSummary && fetchedSummary.written > 0) {
|
|
2818
|
+
kv("fetched", `${fetchedSummary.id} \xB7 ${fetchedSummary.written} attachments saved`, 11);
|
|
2819
|
+
}
|
|
2820
|
+
blank();
|
|
2821
|
+
step("Next: review the intake (Source block at top is from the tracker), then:");
|
|
2822
|
+
info(`squad new-plan ${relativePath}`);
|
|
2823
|
+
}
|
|
2824
|
+
async function tryFetchIssue(config2, id, opts, paths, attachmentsDir) {
|
|
2825
|
+
const secretsFromFile = fs20.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
2826
|
+
const secrets = overlayTrackerEnv(secretsFromFile);
|
|
2827
|
+
const resolution = clientFor(config2, secrets);
|
|
2828
|
+
if (resolution.error) {
|
|
2829
|
+
return { outcome: "skipped", reason: resolution.error.message, hint: resolution.error.detail };
|
|
2830
|
+
}
|
|
2831
|
+
const client = resolution.client;
|
|
2832
|
+
const spin = spinner(`Fetching ${config2.tracker.type} ${id}\u2026`);
|
|
2833
|
+
let issue;
|
|
2834
|
+
try {
|
|
2835
|
+
issue = await client.fetchIssue(id);
|
|
2836
|
+
} catch (err) {
|
|
2837
|
+
spin.fail("Tracker fetch failed");
|
|
2838
|
+
if (err instanceof TrackerError) {
|
|
2839
|
+
return { outcome: "failed", reason: err.message, hint: explainTrackerErrorHint(err) };
|
|
2840
|
+
}
|
|
2841
|
+
return { outcome: "failed", reason: err.message };
|
|
2842
|
+
}
|
|
2843
|
+
spin.succeed(`Fetched ${issue.id}: ${truncate(issue.title, 60)}`);
|
|
2844
|
+
let downloads = [];
|
|
2845
|
+
if (opts.attachments !== false && issue.attachments.length > 0) {
|
|
2846
|
+
const attachSpin = spinner(`Downloading ${issue.attachments.length} attachment(s)\u2026`);
|
|
2847
|
+
downloads = await client.downloadAttachments(issue.attachments, attachmentsDir, {
|
|
2848
|
+
maxMegabytes: opts.attachmentMb
|
|
2849
|
+
});
|
|
2850
|
+
const written = downloads.filter((d) => d.outcome === "written").length;
|
|
2851
|
+
const skipped = downloads.length - written;
|
|
2852
|
+
attachSpin.succeed(`Attachments: ${written} saved, ${skipped} skipped`);
|
|
2853
|
+
} else if (opts.attachments === false && issue.attachments.length > 0) {
|
|
2854
|
+
downloads = issue.attachments.map((source) => ({
|
|
2855
|
+
source,
|
|
2856
|
+
outcome: "skipped-error",
|
|
2857
|
+
bytesWritten: 0,
|
|
2858
|
+
skipReason: "not downloaded (--no-attachments)"
|
|
2859
|
+
}));
|
|
2860
|
+
}
|
|
2861
|
+
return { outcome: "ok", issue, downloads };
|
|
2862
|
+
}
|
|
2863
|
+
function explainTrackerErrorHint(err) {
|
|
2864
|
+
switch (err.kind) {
|
|
2865
|
+
case "auth":
|
|
2866
|
+
return "Run `squad config set tracker` to re-enter credentials, or `squad doctor` to verify.";
|
|
2867
|
+
case "not-found":
|
|
2868
|
+
return "Run `squad config show` to check workspace settings, or `squad tracker link` with a valid work-item id.";
|
|
2869
|
+
case "rate-limited":
|
|
2870
|
+
return "Wait a minute, then retry, or run `squad new-story` with --no-fetch.";
|
|
2871
|
+
case "network":
|
|
2872
|
+
return "Run `squad doctor` to verify connectivity, or `squad new-story` with --no-fetch to skip.";
|
|
2873
|
+
default:
|
|
2874
|
+
return void 0;
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
function truncate(s, n) {
|
|
2878
|
+
return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
// src/commands/new-plan.ts
|
|
2882
|
+
import fs26 from "fs";
|
|
2883
|
+
import path19 from "path";
|
|
2884
|
+
import { confirm as confirm6, select as select6 } from "@inquirer/prompts";
|
|
2414
2885
|
|
|
2415
2886
|
// src/utils/clipboard.ts
|
|
2416
2887
|
import { spawn, spawnSync } from "child_process";
|
|
@@ -2433,12 +2904,12 @@ async function copyToClipboard(text) {
|
|
|
2433
2904
|
return { ok: false, reason: lastReason ?? "no working clipboard tool found" };
|
|
2434
2905
|
}
|
|
2435
2906
|
function tryCopy(cmd, text) {
|
|
2436
|
-
return new Promise((
|
|
2907
|
+
return new Promise((resolve2) => {
|
|
2437
2908
|
let child;
|
|
2438
2909
|
try {
|
|
2439
2910
|
child = spawn(cmd.command, cmd.args, { stdio: ["pipe", "ignore", "pipe"] });
|
|
2440
2911
|
} catch (err) {
|
|
2441
|
-
|
|
2912
|
+
resolve2({ ok: false, reason: `${cmd.command} failed to spawn: ${err.message}` });
|
|
2442
2913
|
return;
|
|
2443
2914
|
}
|
|
2444
2915
|
let stderr = "";
|
|
@@ -2446,13 +2917,13 @@ function tryCopy(cmd, text) {
|
|
|
2446
2917
|
stderr += chunk.toString();
|
|
2447
2918
|
});
|
|
2448
2919
|
child.on("error", (err) => {
|
|
2449
|
-
|
|
2920
|
+
resolve2({ ok: false, reason: `${cmd.command} spawn error: ${err.message}` });
|
|
2450
2921
|
});
|
|
2451
2922
|
child.on("close", (code) => {
|
|
2452
2923
|
if (code === 0) {
|
|
2453
|
-
|
|
2924
|
+
resolve2({ ok: true, tool: cmd.command });
|
|
2454
2925
|
} else {
|
|
2455
|
-
|
|
2926
|
+
resolve2({
|
|
2456
2927
|
ok: false,
|
|
2457
2928
|
reason: `${cmd.command} exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 200)}` : ""}`
|
|
2458
2929
|
});
|
|
@@ -2501,11 +2972,11 @@ function noCandidateReason() {
|
|
|
2501
2972
|
}
|
|
2502
2973
|
|
|
2503
2974
|
// src/planner/loop.ts
|
|
2504
|
-
import
|
|
2975
|
+
import path15 from "path";
|
|
2505
2976
|
|
|
2506
2977
|
// src/planner/tools.ts
|
|
2507
|
-
import
|
|
2508
|
-
import
|
|
2978
|
+
import fs21 from "fs";
|
|
2979
|
+
import path13 from "path";
|
|
2509
2980
|
var READ_FILE_TOOL = {
|
|
2510
2981
|
name: "read_file",
|
|
2511
2982
|
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 +2995,15 @@ function readFileTool(root, budget, input5) {
|
|
|
2524
2995
|
if (typeof raw !== "string" || raw.length === 0) {
|
|
2525
2996
|
return error('read_file: missing or invalid "path" argument.');
|
|
2526
2997
|
}
|
|
2527
|
-
const resolved =
|
|
2528
|
-
const relCheck =
|
|
2529
|
-
if (relCheck.startsWith("..") ||
|
|
2998
|
+
const resolved = path13.resolve(root, raw);
|
|
2999
|
+
const relCheck = path13.relative(root, resolved);
|
|
3000
|
+
if (relCheck.startsWith("..") || path13.isAbsolute(relCheck)) {
|
|
2530
3001
|
return error(`read_file: path "${raw}" escapes the project root and was refused.`);
|
|
2531
3002
|
}
|
|
2532
|
-
if (!
|
|
3003
|
+
if (!fs21.existsSync(resolved)) {
|
|
2533
3004
|
return error(`read_file: not found "${raw}".`);
|
|
2534
3005
|
}
|
|
2535
|
-
const stat =
|
|
3006
|
+
const stat = fs21.statSync(resolved);
|
|
2536
3007
|
if (stat.isDirectory()) {
|
|
2537
3008
|
return error(`read_file: "${raw}" is a directory. Ask for a specific file path.`);
|
|
2538
3009
|
}
|
|
@@ -2547,7 +3018,7 @@ function readFileTool(root, budget, input5) {
|
|
|
2547
3018
|
}
|
|
2548
3019
|
let buf;
|
|
2549
3020
|
try {
|
|
2550
|
-
buf =
|
|
3021
|
+
buf = fs21.readFileSync(resolved);
|
|
2551
3022
|
} catch (e) {
|
|
2552
3023
|
return error(`read_file: failed to read "${raw}" (${e.message}).`);
|
|
2553
3024
|
}
|
|
@@ -2712,7 +3183,8 @@ async function callAnthropic(req) {
|
|
|
2712
3183
|
"anthropic-version": API_VERSION,
|
|
2713
3184
|
"content-type": "application/json"
|
|
2714
3185
|
},
|
|
2715
|
-
body: JSON.stringify(body)
|
|
3186
|
+
body: JSON.stringify(body),
|
|
3187
|
+
signal: req.abort
|
|
2716
3188
|
});
|
|
2717
3189
|
if (!res.ok) {
|
|
2718
3190
|
const text = await res.text().catch(() => "");
|
|
@@ -2850,7 +3322,8 @@ async function callOpenAI(req) {
|
|
|
2850
3322
|
Authorization: `Bearer ${req.apiKey}`,
|
|
2851
3323
|
"content-type": "application/json"
|
|
2852
3324
|
},
|
|
2853
|
-
body: JSON.stringify(body)
|
|
3325
|
+
body: JSON.stringify(body),
|
|
3326
|
+
signal: req.abort
|
|
2854
3327
|
});
|
|
2855
3328
|
if (!res.ok) {
|
|
2856
3329
|
const text = await res.text().catch(() => "");
|
|
@@ -2968,7 +3441,8 @@ async function callGoogle(req) {
|
|
|
2968
3441
|
const res = await fetch(url, {
|
|
2969
3442
|
method: "POST",
|
|
2970
3443
|
headers: { "content-type": "application/json" },
|
|
2971
|
-
body: JSON.stringify(body)
|
|
3444
|
+
body: JSON.stringify(body),
|
|
3445
|
+
signal: req.abort
|
|
2972
3446
|
});
|
|
2973
3447
|
if (!res.ok) {
|
|
2974
3448
|
const text = await res.text().catch(() => "");
|
|
@@ -3058,13 +3532,91 @@ function prefixOf(provider, req) {
|
|
|
3058
3532
|
return s.length > 0 ? s.slice(0, -1) : s;
|
|
3059
3533
|
}
|
|
3060
3534
|
|
|
3535
|
+
// src/core/runs.ts
|
|
3536
|
+
import fs22 from "fs/promises";
|
|
3537
|
+
import path14 from "path";
|
|
3538
|
+
import { randomBytes } from "crypto";
|
|
3539
|
+
var RING_SIZE = 20;
|
|
3540
|
+
function newRunId() {
|
|
3541
|
+
const ts = Date.now().toString(36).padStart(9, "0");
|
|
3542
|
+
const rnd = randomBytes(8).toString("hex");
|
|
3543
|
+
return `${ts}-${rnd}`;
|
|
3544
|
+
}
|
|
3545
|
+
async function appendRun(paths, rec) {
|
|
3546
|
+
const dir = path14.join(paths.squadDir, "runs");
|
|
3547
|
+
await fs22.mkdir(dir, { recursive: true });
|
|
3548
|
+
const file = path14.join(dir, `${rec.runId}.json`);
|
|
3549
|
+
await fs22.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
|
|
3550
|
+
await pruneOldRuns(dir);
|
|
3551
|
+
}
|
|
3552
|
+
async function pruneOldRuns(dir) {
|
|
3553
|
+
const entries = (await fs22.readdir(dir)).filter((n) => n.endsWith(".json"));
|
|
3554
|
+
if (entries.length <= RING_SIZE) return;
|
|
3555
|
+
entries.sort();
|
|
3556
|
+
const drop = entries.slice(0, entries.length - RING_SIZE);
|
|
3557
|
+
await Promise.all(drop.map((n) => fs22.rm(path14.join(dir, n), { force: true })));
|
|
3558
|
+
}
|
|
3559
|
+
async function listRuns(paths) {
|
|
3560
|
+
const dir = path14.join(paths.squadDir, "runs");
|
|
3561
|
+
let entries;
|
|
3562
|
+
try {
|
|
3563
|
+
entries = await fs22.readdir(dir);
|
|
3564
|
+
} catch {
|
|
3565
|
+
return [];
|
|
3566
|
+
}
|
|
3567
|
+
const files = entries.filter((n) => n.endsWith(".json")).sort().reverse();
|
|
3568
|
+
const out = [];
|
|
3569
|
+
for (const f of files) {
|
|
3570
|
+
try {
|
|
3571
|
+
const raw = await fs22.readFile(path14.join(dir, f), "utf8");
|
|
3572
|
+
const parsed = JSON.parse(raw);
|
|
3573
|
+
if (parsed.version === 1) out.push(parsed);
|
|
3574
|
+
} catch {
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
return out;
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3061
3580
|
// src/planner/session-limits.ts
|
|
3062
3581
|
var DEFAULT_PLANNER_MAX_ITERATIONS = 40;
|
|
3063
3582
|
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
3583
|
|
|
3584
|
+
// src/planner/events.ts
|
|
3585
|
+
var PlannerEventBus = class {
|
|
3586
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3587
|
+
emit(e) {
|
|
3588
|
+
for (const fn of this.listeners) {
|
|
3589
|
+
try {
|
|
3590
|
+
fn(e);
|
|
3591
|
+
} catch {
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
subscribe(fn) {
|
|
3596
|
+
this.listeners.add(fn);
|
|
3597
|
+
return () => this.listeners.delete(fn);
|
|
3598
|
+
}
|
|
3599
|
+
};
|
|
3600
|
+
|
|
3065
3601
|
// src/planner/loop.ts
|
|
3066
3602
|
var MAX_RATE_LIMIT_RETRY_SEC = 90;
|
|
3067
|
-
var defaultSleep = (ms) => new Promise((
|
|
3603
|
+
var defaultSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
3604
|
+
function isAbortError(e) {
|
|
3605
|
+
return e instanceof Error && e.name === "AbortError";
|
|
3606
|
+
}
|
|
3607
|
+
async function sleepWithAbort(ms, sleepFn, abort) {
|
|
3608
|
+
if (!abort) {
|
|
3609
|
+
await sleepFn(ms);
|
|
3610
|
+
return;
|
|
3611
|
+
}
|
|
3612
|
+
if (abort.aborted) throw new DOMException("Aborted", "AbortError");
|
|
3613
|
+
await Promise.race([
|
|
3614
|
+
sleepFn(ms),
|
|
3615
|
+
new Promise((_, reject) => {
|
|
3616
|
+
abort.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
|
3617
|
+
})
|
|
3618
|
+
]);
|
|
3619
|
+
}
|
|
3068
3620
|
function buildPlannerRunStats(budget, turns, runStartedAt) {
|
|
3069
3621
|
const u = budget.snapshot().usage;
|
|
3070
3622
|
const cacheRead = u.cacheReadTokens ?? 0;
|
|
@@ -3091,8 +3643,51 @@ function extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut
|
|
|
3091
3643
|
maxIter.current += baseMaxIter;
|
|
3092
3644
|
maxOut.current += baseMaxOut;
|
|
3093
3645
|
}
|
|
3646
|
+
function recordUsageAndEmit(bus, input5, runId, turn, usage) {
|
|
3647
|
+
input5.budget.recordUsage(usage);
|
|
3648
|
+
input5.onUsage?.(usage);
|
|
3649
|
+
bus.emit({ kind: "usage", runId, turn, usage });
|
|
3650
|
+
const u = input5.budget.snapshot().usage;
|
|
3651
|
+
const cacheRead = u.cacheReadTokens ?? 0;
|
|
3652
|
+
const cacheCreate = u.cacheCreationTokens ?? 0;
|
|
3653
|
+
if (cacheRead > 0 || cacheCreate > 0) {
|
|
3654
|
+
const inTok = u.inputTokens;
|
|
3655
|
+
const totalInput = inTok + cacheRead;
|
|
3656
|
+
const cacheHitRatio = totalInput === 0 ? 0 : Math.round(cacheRead / totalInput * 100) / 100;
|
|
3657
|
+
bus.emit({
|
|
3658
|
+
kind: "cache_summary",
|
|
3659
|
+
runId,
|
|
3660
|
+
turn,
|
|
3661
|
+
cacheHitRatio,
|
|
3662
|
+
cacheReadTokens: cacheRead,
|
|
3663
|
+
cacheCreationTokens: cacheCreate
|
|
3664
|
+
});
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
function cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt) {
|
|
3668
|
+
bus.emit({ kind: "cancelled", runId });
|
|
3669
|
+
return {
|
|
3670
|
+
planText: accumulatedText,
|
|
3671
|
+
budgetExhausted,
|
|
3672
|
+
timedOut: false,
|
|
3673
|
+
finishedNormally: false,
|
|
3674
|
+
iterations,
|
|
3675
|
+
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
|
|
3676
|
+
userCancelled: true
|
|
3677
|
+
};
|
|
3678
|
+
}
|
|
3094
3679
|
async function runPlanner(input5) {
|
|
3095
3680
|
const runStartedAt = Date.now();
|
|
3681
|
+
const bus = input5.events ?? new PlannerEventBus();
|
|
3682
|
+
const runId = input5.runId ?? newRunId();
|
|
3683
|
+
const cacheEnabled = input5.cacheEnabled ?? true;
|
|
3684
|
+
bus.emit({
|
|
3685
|
+
kind: "started",
|
|
3686
|
+
runId,
|
|
3687
|
+
provider: input5.provider.name,
|
|
3688
|
+
model: input5.model,
|
|
3689
|
+
cacheEnabled
|
|
3690
|
+
});
|
|
3096
3691
|
const baseMaxIter = input5.maxIterations ?? DEFAULT_PLANNER_MAX_ITERATIONS;
|
|
3097
3692
|
const maxIter = { current: baseMaxIter };
|
|
3098
3693
|
const baseMaxOut = input5.maxOutputTokens ?? DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
|
|
@@ -3104,6 +3699,7 @@ async function runPlanner(input5) {
|
|
|
3104
3699
|
let finishedNormally = false;
|
|
3105
3700
|
let prevRequestPrefix;
|
|
3106
3701
|
const decide = input5.decideOnLimit;
|
|
3702
|
+
const sleepFn = input5.sleep ?? defaultSleep;
|
|
3107
3703
|
const limitCtx = (kind) => ({
|
|
3108
3704
|
kind,
|
|
3109
3705
|
budgetSnapshot: input5.budget.snapshot(),
|
|
@@ -3112,6 +3708,9 @@ async function runPlanner(input5) {
|
|
|
3112
3708
|
maxOutputTokens: maxOut.current
|
|
3113
3709
|
});
|
|
3114
3710
|
for (; ; ) {
|
|
3711
|
+
if (input5.abort?.aborted) {
|
|
3712
|
+
return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
|
|
3713
|
+
}
|
|
3115
3714
|
if (iterations >= maxIter.current) {
|
|
3116
3715
|
if (decide) {
|
|
3117
3716
|
const d = await decide(limitCtx("max_iterations"));
|
|
@@ -3197,6 +3796,9 @@ async function runPlanner(input5) {
|
|
|
3197
3796
|
stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
|
|
3198
3797
|
};
|
|
3199
3798
|
}
|
|
3799
|
+
if (iterations >= 2) {
|
|
3800
|
+
bus.emit({ kind: "turn_started", runId, turn: iterations });
|
|
3801
|
+
}
|
|
3200
3802
|
const request = {
|
|
3201
3803
|
systemPrompt: input5.systemPrompt,
|
|
3202
3804
|
model: input5.model,
|
|
@@ -3204,8 +3806,10 @@ async function runPlanner(input5) {
|
|
|
3204
3806
|
turns,
|
|
3205
3807
|
apiKey: input5.apiKey,
|
|
3206
3808
|
maxOutputTokens: maxOut.current,
|
|
3207
|
-
cacheEnabled
|
|
3809
|
+
cacheEnabled,
|
|
3810
|
+
abort: input5.abort
|
|
3208
3811
|
};
|
|
3812
|
+
bus.emit({ kind: "request_sent", runId, turn: iterations });
|
|
3209
3813
|
if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test" && prevRequestPrefix !== void 0) {
|
|
3210
3814
|
const currentPrefix = prefixOf(input5.provider.name, request);
|
|
3211
3815
|
if (!currentPrefix.startsWith(prevRequestPrefix)) {
|
|
@@ -3215,10 +3819,17 @@ async function runPlanner(input5) {
|
|
|
3215
3819
|
);
|
|
3216
3820
|
}
|
|
3217
3821
|
}
|
|
3218
|
-
let response
|
|
3822
|
+
let response;
|
|
3823
|
+
try {
|
|
3824
|
+
response = await input5.provider.send(request);
|
|
3825
|
+
} catch (e) {
|
|
3826
|
+
if (isAbortError(e)) {
|
|
3827
|
+
return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
|
|
3828
|
+
}
|
|
3829
|
+
throw e;
|
|
3830
|
+
}
|
|
3219
3831
|
if (response.usage) {
|
|
3220
|
-
input5
|
|
3221
|
-
input5.onUsage?.(response.usage);
|
|
3832
|
+
recordUsageAndEmit(bus, input5, runId, iterations, response.usage);
|
|
3222
3833
|
}
|
|
3223
3834
|
let retriedRateLimit = false;
|
|
3224
3835
|
let retrySkippedReason;
|
|
@@ -3229,11 +3840,25 @@ async function runPlanner(input5) {
|
|
|
3229
3840
|
} else {
|
|
3230
3841
|
const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
|
|
3231
3842
|
input5.onRateLimit?.(waitSec);
|
|
3232
|
-
|
|
3233
|
-
|
|
3843
|
+
bus.emit({ kind: "rate_limit", runId, turn: iterations, waitSec });
|
|
3844
|
+
try {
|
|
3845
|
+
await sleepWithAbort(waitSec * 1e3, sleepFn, input5.abort);
|
|
3846
|
+
} catch (e) {
|
|
3847
|
+
if (isAbortError(e)) {
|
|
3848
|
+
return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
|
|
3849
|
+
}
|
|
3850
|
+
throw e;
|
|
3851
|
+
}
|
|
3852
|
+
try {
|
|
3853
|
+
response = await input5.provider.send(request);
|
|
3854
|
+
} catch (e) {
|
|
3855
|
+
if (isAbortError(e)) {
|
|
3856
|
+
return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
|
|
3857
|
+
}
|
|
3858
|
+
throw e;
|
|
3859
|
+
}
|
|
3234
3860
|
if (response.usage) {
|
|
3235
|
-
input5
|
|
3236
|
-
input5.onUsage?.(response.usage);
|
|
3861
|
+
recordUsageAndEmit(bus, input5, runId, iterations, response.usage);
|
|
3237
3862
|
}
|
|
3238
3863
|
retriedRateLimit = true;
|
|
3239
3864
|
}
|
|
@@ -3245,8 +3870,10 @@ async function runPlanner(input5) {
|
|
|
3245
3870
|
if (response.text) {
|
|
3246
3871
|
accumulatedText += response.text;
|
|
3247
3872
|
input5.onAssistantText?.(response.text);
|
|
3873
|
+
bus.emit({ kind: "assistant_text", runId, turn: iterations, delta: response.text });
|
|
3248
3874
|
}
|
|
3249
3875
|
if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
|
|
3876
|
+
bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
|
|
3250
3877
|
if (decide) {
|
|
3251
3878
|
const d = await decide(limitCtx("max_output_tokens"));
|
|
3252
3879
|
if (d === "cancel") {
|
|
@@ -3278,6 +3905,7 @@ async function runPlanner(input5) {
|
|
|
3278
3905
|
};
|
|
3279
3906
|
}
|
|
3280
3907
|
if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
|
|
3908
|
+
bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
|
|
3281
3909
|
finishedNormally = true;
|
|
3282
3910
|
return {
|
|
3283
3911
|
planText: accumulatedText,
|
|
@@ -3304,7 +3932,16 @@ async function runPlanner(input5) {
|
|
|
3304
3932
|
const before = input5.budget.snapshot().bytes;
|
|
3305
3933
|
let result = readFileTool(input5.root, input5.budget, tc.input);
|
|
3306
3934
|
const after = input5.budget.snapshot().bytes;
|
|
3307
|
-
|
|
3935
|
+
const bytesLoaded = after - before;
|
|
3936
|
+
input5.onToolCall?.(tc, bytesLoaded, after);
|
|
3937
|
+
bus.emit({
|
|
3938
|
+
kind: "tool_call",
|
|
3939
|
+
runId,
|
|
3940
|
+
turn: iterations,
|
|
3941
|
+
toolCall: tc,
|
|
3942
|
+
bytesLoaded,
|
|
3943
|
+
totalBytes: after
|
|
3944
|
+
});
|
|
3308
3945
|
if (readBudgetishError(result)) {
|
|
3309
3946
|
if (decide) {
|
|
3310
3947
|
const d = await decide(limitCtx("file_or_context_reads"));
|
|
@@ -3345,6 +3982,7 @@ async function runPlanner(input5) {
|
|
|
3345
3982
|
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
3983
|
});
|
|
3347
3984
|
}
|
|
3985
|
+
bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
|
|
3348
3986
|
}
|
|
3349
3987
|
}
|
|
3350
3988
|
function firstByteDiff(a, b) {
|
|
@@ -3529,8 +4167,8 @@ var Budget = class {
|
|
|
3529
4167
|
};
|
|
3530
4168
|
|
|
3531
4169
|
// src/core/repo-map.ts
|
|
3532
|
-
import
|
|
3533
|
-
import
|
|
4170
|
+
import fs23 from "fs";
|
|
4171
|
+
import path16 from "path";
|
|
3534
4172
|
import ignore from "ignore";
|
|
3535
4173
|
var DEFAULT_IGNORES = [
|
|
3536
4174
|
"node_modules",
|
|
@@ -3554,10 +4192,10 @@ function buildRepoMap(root, opts = {}) {
|
|
|
3554
4192
|
}
|
|
3555
4193
|
function buildIgnore(root) {
|
|
3556
4194
|
const ig = ignore().add(DEFAULT_IGNORES);
|
|
3557
|
-
const gitignore =
|
|
3558
|
-
if (
|
|
4195
|
+
const gitignore = path16.join(root, ".gitignore");
|
|
4196
|
+
if (fs23.existsSync(gitignore)) {
|
|
3559
4197
|
try {
|
|
3560
|
-
ig.add(
|
|
4198
|
+
ig.add(fs23.readFileSync(gitignore, "utf8"));
|
|
3561
4199
|
} catch {
|
|
3562
4200
|
}
|
|
3563
4201
|
}
|
|
@@ -3567,15 +4205,15 @@ function walk2(root, dir, ig, out, max, includeHidden) {
|
|
|
3567
4205
|
if (out.length >= max) return;
|
|
3568
4206
|
let entries;
|
|
3569
4207
|
try {
|
|
3570
|
-
entries =
|
|
4208
|
+
entries = fs23.readdirSync(dir, { withFileTypes: true });
|
|
3571
4209
|
} catch {
|
|
3572
4210
|
return;
|
|
3573
4211
|
}
|
|
3574
4212
|
for (const entry of entries) {
|
|
3575
4213
|
if (out.length >= max) return;
|
|
3576
4214
|
if (!includeHidden && entry.name.startsWith(".") && entry.name !== ".squad") continue;
|
|
3577
|
-
const abs =
|
|
3578
|
-
const rel =
|
|
4215
|
+
const abs = path16.join(dir, entry.name);
|
|
4216
|
+
const rel = path16.relative(root, abs);
|
|
3579
4217
|
if (!rel || ig.ignores(rel) || entry.isDirectory() && ig.ignores(rel + "/")) continue;
|
|
3580
4218
|
if (entry.isDirectory()) {
|
|
3581
4219
|
walk2(root, abs, ig, out, max, includeHidden);
|
|
@@ -3623,38 +4261,8 @@ function composeUserPrompt(args) {
|
|
|
3623
4261
|
}
|
|
3624
4262
|
|
|
3625
4263
|
// 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
|
|
4264
|
+
import fs24 from "fs";
|
|
4265
|
+
import path17 from "path";
|
|
3658
4266
|
function planFileSlugParts(config2, story) {
|
|
3659
4267
|
const baseSlug = slugify(story.id);
|
|
3660
4268
|
const trackerId = config2.naming.includeTrackerId && config2.tracker.type !== "none" ? trackerIdForFilename(config2.tracker.type, story.id) : "";
|
|
@@ -3663,8 +4271,8 @@ function planFileSlugParts(config2, story) {
|
|
|
3663
4271
|
}
|
|
3664
4272
|
function writePlanFile(input5) {
|
|
3665
4273
|
const { paths, config: config2, story } = input5;
|
|
3666
|
-
const featurePlanDir =
|
|
3667
|
-
|
|
4274
|
+
const featurePlanDir = path17.join(paths.plansDir, story.feature);
|
|
4275
|
+
fs24.mkdirSync(featurePlanDir, { recursive: true });
|
|
3668
4276
|
if (input5.partial) {
|
|
3669
4277
|
return writePartialPlanFile(input5, featurePlanDir, paths, config2, story);
|
|
3670
4278
|
}
|
|
@@ -3679,14 +4287,14 @@ function writePlanFile(input5) {
|
|
|
3679
4287
|
} else {
|
|
3680
4288
|
nn = scanPlans(paths.plansDir).nextGlobal;
|
|
3681
4289
|
const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
|
|
3682
|
-
targetPath =
|
|
4290
|
+
targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
|
|
3683
4291
|
}
|
|
3684
4292
|
const body = `${input5.metadataHeader.trimEnd()}
|
|
3685
4293
|
|
|
3686
4294
|
${input5.planBodyMarkdown.trimStart()}
|
|
3687
4295
|
`;
|
|
3688
|
-
|
|
3689
|
-
upsertOverviewRow(featurePlanDir, story.feature, nn,
|
|
4296
|
+
fs24.writeFileSync(targetPath, body, "utf8");
|
|
4297
|
+
upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, false);
|
|
3690
4298
|
return { planFile: targetPath, sequenceNumber: nn, overwrote };
|
|
3691
4299
|
}
|
|
3692
4300
|
function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
|
|
@@ -3701,42 +4309,42 @@ function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
|
|
|
3701
4309
|
} else {
|
|
3702
4310
|
nn = scanPlans(paths.plansDir).nextGlobal;
|
|
3703
4311
|
const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
|
|
3704
|
-
targetPath =
|
|
4312
|
+
targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.partial.md`);
|
|
3705
4313
|
}
|
|
3706
4314
|
const body = `${input5.metadataHeader.trimEnd()}
|
|
3707
4315
|
|
|
3708
4316
|
${input5.planBodyMarkdown.trimStart()}
|
|
3709
4317
|
`;
|
|
3710
|
-
|
|
3711
|
-
upsertOverviewRow(featurePlanDir, story.feature, nn,
|
|
4318
|
+
fs24.writeFileSync(targetPath, body, "utf8");
|
|
4319
|
+
upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, true);
|
|
3712
4320
|
return { planFile: targetPath, sequenceNumber: nn, overwrote };
|
|
3713
4321
|
}
|
|
3714
4322
|
function findExistingPlan(featureDir, storyId) {
|
|
3715
|
-
if (!
|
|
4323
|
+
if (!fs24.existsSync(featureDir)) return void 0;
|
|
3716
4324
|
const pattern = /^(\d{2,})-story-.+\.md$/;
|
|
3717
|
-
for (const entry of
|
|
4325
|
+
for (const entry of fs24.readdirSync(featureDir)) {
|
|
3718
4326
|
if (entry.endsWith(".partial.md")) continue;
|
|
3719
4327
|
const m = entry.match(pattern);
|
|
3720
4328
|
if (!m) continue;
|
|
3721
|
-
if (entry.includes(storyId)) return { absPath:
|
|
4329
|
+
if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
|
|
3722
4330
|
}
|
|
3723
4331
|
return void 0;
|
|
3724
4332
|
}
|
|
3725
4333
|
function findExistingPartialPlan(featureDir, storyId) {
|
|
3726
|
-
if (!
|
|
4334
|
+
if (!fs24.existsSync(featureDir)) return void 0;
|
|
3727
4335
|
const pattern = /^(\d{2,})-story-.+\.partial\.md$/;
|
|
3728
|
-
for (const entry of
|
|
4336
|
+
for (const entry of fs24.readdirSync(featureDir)) {
|
|
3729
4337
|
const m = entry.match(pattern);
|
|
3730
4338
|
if (!m) continue;
|
|
3731
|
-
if (entry.includes(storyId)) return { absPath:
|
|
4339
|
+
if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
|
|
3732
4340
|
}
|
|
3733
4341
|
return void 0;
|
|
3734
4342
|
}
|
|
3735
4343
|
function upsertOverviewRow(featureDir, feature, nn, filename, story, isPartial) {
|
|
3736
|
-
const overviewPath =
|
|
4344
|
+
const overviewPath = path17.join(featureDir, "00-overview.md");
|
|
3737
4345
|
const titleCell = isPartial ? `${story.titleHint ?? story.id} (partial)` : story.titleHint ?? story.id;
|
|
3738
4346
|
const row = `| ${formatSequence(nn)} | \`${filename}\` | ${titleCell} | ${story.id} | \u2014 |`;
|
|
3739
|
-
if (!
|
|
4347
|
+
if (!fs24.existsSync(overviewPath)) {
|
|
3740
4348
|
const content = `# ${feature} \u2014 plan overview
|
|
3741
4349
|
|
|
3742
4350
|
Entry point for the **${feature}** feature. Stories execute in order by their \`NN\` prefix.
|
|
@@ -3747,10 +4355,10 @@ Entry point for the **${feature}** feature. Stories execute in order by their \`
|
|
|
3747
4355
|
|----|------|-------|------------|------------|
|
|
3748
4356
|
${row}
|
|
3749
4357
|
`;
|
|
3750
|
-
|
|
4358
|
+
fs24.writeFileSync(overviewPath, content, "utf8");
|
|
3751
4359
|
return;
|
|
3752
4360
|
}
|
|
3753
|
-
const current =
|
|
4361
|
+
const current = fs24.readFileSync(overviewPath, "utf8");
|
|
3754
4362
|
const lines = current.split("\n");
|
|
3755
4363
|
const existingIdx = lines.findIndex((l) => l.includes(`\`${filename}\``) || l.includes(`| ${formatSequence(nn)} |`));
|
|
3756
4364
|
if (existingIdx >= 0) {
|
|
@@ -3759,7 +4367,7 @@ ${row}
|
|
|
3759
4367
|
const tableEnd = findTableEnd(lines);
|
|
3760
4368
|
lines.splice(tableEnd, 0, row);
|
|
3761
4369
|
}
|
|
3762
|
-
|
|
4370
|
+
fs24.writeFileSync(overviewPath, lines.join("\n"), "utf8");
|
|
3763
4371
|
}
|
|
3764
4372
|
function findTableEnd(lines) {
|
|
3765
4373
|
let inTable = false;
|
|
@@ -3792,17 +4400,28 @@ squad-kit-plan-status: partial
|
|
|
3792
4400
|
return line1;
|
|
3793
4401
|
}
|
|
3794
4402
|
|
|
4403
|
+
// src/core/copy-plan-prompt.ts
|
|
4404
|
+
function buildCopyPlanPromptMarkdown(config2, intakeContent) {
|
|
4405
|
+
const metaPromptTemplate = readBundledPrompt("generate-plan.md");
|
|
4406
|
+
return render(metaPromptTemplate, {
|
|
4407
|
+
projectRoots: (config2.project.projectRoots ?? ["."]).join(", "),
|
|
4408
|
+
primaryLanguage: config2.project.primaryLanguage ?? "",
|
|
4409
|
+
trackerType: config2.tracker.type,
|
|
4410
|
+
intakeContent
|
|
4411
|
+
});
|
|
4412
|
+
}
|
|
4413
|
+
|
|
3795
4414
|
// src/core/last-run.ts
|
|
3796
|
-
import
|
|
3797
|
-
import
|
|
4415
|
+
import fs25 from "fs/promises";
|
|
4416
|
+
import path18 from "path";
|
|
3798
4417
|
async function writeLastRun(paths, rec) {
|
|
3799
|
-
const file =
|
|
3800
|
-
await
|
|
4418
|
+
const file = path18.join(paths.squadDir, ".last-run.json");
|
|
4419
|
+
await fs25.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
|
|
3801
4420
|
}
|
|
3802
4421
|
async function readLastRun(paths) {
|
|
3803
|
-
const file =
|
|
4422
|
+
const file = path18.join(paths.squadDir, ".last-run.json");
|
|
3804
4423
|
try {
|
|
3805
|
-
const raw = await
|
|
4424
|
+
const raw = await fs25.readFile(file, "utf8");
|
|
3806
4425
|
const parsed = JSON.parse(raw);
|
|
3807
4426
|
if (parsed.version !== 1) return void 0;
|
|
3808
4427
|
return parsed;
|
|
@@ -3862,8 +4481,8 @@ async function runNewPlan(intakePath, opts) {
|
|
|
3862
4481
|
}
|
|
3863
4482
|
}
|
|
3864
4483
|
function resolveFromPath(intakePath, stories, root) {
|
|
3865
|
-
const resolved =
|
|
3866
|
-
if (!
|
|
4484
|
+
const resolved = path19.resolve(intakePath.trim());
|
|
4485
|
+
if (!fs26.existsSync(resolved)) {
|
|
3867
4486
|
throw new Error(
|
|
3868
4487
|
`Intake not found: ${resolved}. Run \`squad list\` to see intakes, or \`squad new-story\` to create one first.`
|
|
3869
4488
|
);
|
|
@@ -3875,7 +4494,7 @@ function resolveFromPath(intakePath, stories, root) {
|
|
|
3875
4494
|
);
|
|
3876
4495
|
}
|
|
3877
4496
|
function relativeStoriesDir(root) {
|
|
3878
|
-
return
|
|
4497
|
+
return path19.relative(root, path19.join(root, ".squad", "stories"));
|
|
3879
4498
|
}
|
|
3880
4499
|
async function pickStory(stories, opts) {
|
|
3881
4500
|
const candidates = opts.all ? stories : stories.filter((s) => !s.planFile);
|
|
@@ -3898,7 +4517,7 @@ async function pickStory(stories, opts) {
|
|
|
3898
4517
|
"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
4518
|
);
|
|
3900
4519
|
}
|
|
3901
|
-
const pick = await
|
|
4520
|
+
const pick = await select6({
|
|
3902
4521
|
message: "Pick a story to plan:",
|
|
3903
4522
|
pageSize: Math.min(10, candidates.length),
|
|
3904
4523
|
choices: candidates.map((s) => ({
|
|
@@ -3906,7 +4525,7 @@ async function pickStory(stories, opts) {
|
|
|
3906
4525
|
value: s.intakePath
|
|
3907
4526
|
}))
|
|
3908
4527
|
});
|
|
3909
|
-
return candidates.find((s) =>
|
|
4528
|
+
return candidates.find((s) => path19.resolve(s.intakePath) === path19.resolve(pick));
|
|
3910
4529
|
}
|
|
3911
4530
|
function formatLabel(s) {
|
|
3912
4531
|
const head = `${s.feature} / ${s.id}`;
|
|
@@ -3984,9 +4603,36 @@ async function emitViaApi(story, paths, config2, opts) {
|
|
|
3984
4603
|
const sessionSpinner = { current: null };
|
|
3985
4604
|
const startedAt = Date.now();
|
|
3986
4605
|
const cacheEnabled = planner.cache?.enabled ?? true;
|
|
4606
|
+
const runId = newRunId();
|
|
4607
|
+
const bus = new PlannerEventBus();
|
|
4608
|
+
const unsubscribe = bus.subscribe((e) => {
|
|
4609
|
+
switch (e.kind) {
|
|
4610
|
+
case "tool_call": {
|
|
4611
|
+
const p = e.toolCall.input.path ?? "<unknown>";
|
|
4612
|
+
sessionSpinner.current?.succeed(
|
|
4613
|
+
`read ${p} (${(e.bytesLoaded / 1024).toFixed(1)} KB \xB7 ${(e.totalBytes / 1024).toFixed(1)} KB / ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB)`
|
|
4614
|
+
);
|
|
4615
|
+
sessionSpinner.current = spinner("reading next file\u2026");
|
|
4616
|
+
break;
|
|
4617
|
+
}
|
|
4618
|
+
case "assistant_text":
|
|
4619
|
+
sessionSpinner.current?.succeed("planner thinking complete (this chunk)");
|
|
4620
|
+
sessionSpinner.current = spinner("thinking\u2026");
|
|
4621
|
+
break;
|
|
4622
|
+
case "rate_limit":
|
|
4623
|
+
sessionSpinner.current?.stop();
|
|
4624
|
+
warning(`${planner.provider} rate limit hit \u2014 retrying in ${e.waitSec}s`);
|
|
4625
|
+
sessionSpinner.current = spinner("waiting for rate limit to reset\u2026");
|
|
4626
|
+
break;
|
|
4627
|
+
case "usage":
|
|
4628
|
+
break;
|
|
4629
|
+
default:
|
|
4630
|
+
break;
|
|
4631
|
+
}
|
|
4632
|
+
});
|
|
3987
4633
|
const decideOnLimit = interactive ? async (ctx) => {
|
|
3988
4634
|
printPlannerLimitExplanation(ctx);
|
|
3989
|
-
const ans = await
|
|
4635
|
+
const ans = await select6({
|
|
3990
4636
|
message: "Continue this planning session? (Continuing sends more API requests and is billed.)",
|
|
3991
4637
|
choices: [
|
|
3992
4638
|
{ name: "Continue \u2014 extend limits for this run", value: "continue" },
|
|
@@ -3996,35 +4642,26 @@ async function emitViaApi(story, paths, config2, opts) {
|
|
|
3996
4642
|
if (ans === "cancel") printPlannerLimitNextSteps();
|
|
3997
4643
|
return ans;
|
|
3998
4644
|
} : 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();
|
|
4645
|
+
let result;
|
|
4646
|
+
try {
|
|
4647
|
+
result = await runPlanner({
|
|
4648
|
+
root: paths.root,
|
|
4649
|
+
provider,
|
|
4650
|
+
model,
|
|
4651
|
+
apiKey,
|
|
4652
|
+
systemPrompt,
|
|
4653
|
+
userPrompt,
|
|
4654
|
+
budget,
|
|
4655
|
+
maxOutputTokens: planner.maxOutputTokens,
|
|
4656
|
+
cacheEnabled,
|
|
4657
|
+
decideOnLimit,
|
|
4658
|
+
events: bus,
|
|
4659
|
+
runId
|
|
4660
|
+
});
|
|
4661
|
+
} finally {
|
|
4662
|
+
unsubscribe();
|
|
4663
|
+
sessionSpinner.current?.stop();
|
|
4664
|
+
}
|
|
4028
4665
|
if (!result.planText.trim()) {
|
|
4029
4666
|
throw new Error(
|
|
4030
4667
|
"Planner returned no plan text. Run `squad doctor` to check provider and models, or `squad new-plan --copy` to avoid the API."
|
|
@@ -4052,6 +4689,7 @@ async function emitViaApi(story, paths, config2, opts) {
|
|
|
4052
4689
|
metadataHeader: header,
|
|
4053
4690
|
partial: !success2
|
|
4054
4691
|
});
|
|
4692
|
+
const relPlan = path19.relative(paths.root, planFile);
|
|
4055
4693
|
try {
|
|
4056
4694
|
await writeLastRun(paths, {
|
|
4057
4695
|
stats: result.stats,
|
|
@@ -4059,9 +4697,23 @@ async function emitViaApi(story, paths, config2, opts) {
|
|
|
4059
4697
|
provider: planner.provider,
|
|
4060
4698
|
model
|
|
4061
4699
|
});
|
|
4700
|
+
await appendRun(paths, {
|
|
4701
|
+
runId,
|
|
4702
|
+
provider: planner.provider,
|
|
4703
|
+
model,
|
|
4704
|
+
feature: story.feature,
|
|
4705
|
+
storyId: story.id,
|
|
4706
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
4707
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4708
|
+
success: success2,
|
|
4709
|
+
partial: !success2,
|
|
4710
|
+
planFile: relPlan,
|
|
4711
|
+
stats: result.stats,
|
|
4712
|
+
cacheEnabled,
|
|
4713
|
+
durationMs: elapsedMs
|
|
4714
|
+
});
|
|
4062
4715
|
} catch {
|
|
4063
4716
|
}
|
|
4064
|
-
const relPlan = path15.relative(paths.root, planFile);
|
|
4065
4717
|
blank();
|
|
4066
4718
|
summaryBox(success2 ? " plan generated " : " plan saved (incomplete) ", [
|
|
4067
4719
|
{ key: "file", value: relPlan },
|
|
@@ -4091,20 +4743,14 @@ async function emitViaApi(story, paths, config2, opts) {
|
|
|
4091
4743
|
info("Note: file-read budget was reached; the model finalised without further reads.");
|
|
4092
4744
|
}
|
|
4093
4745
|
blank();
|
|
4094
|
-
info(`next \u2192 open a new agent chat and attach only ${
|
|
4746
|
+
info(`next \u2192 open a new agent chat and attach only ${path19.basename(planFile)}`);
|
|
4095
4747
|
}
|
|
4096
4748
|
async function emitCopyPrompt(story, paths, config2, clipboard) {
|
|
4097
4749
|
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);
|
|
4750
|
+
const composed = buildCopyPlanPromptMarkdown(config2, intakeContent);
|
|
4751
|
+
const promptFile = path19.join(paths.squadDir, ".last-copy-prompt.md");
|
|
4752
|
+
fs26.writeFileSync(promptFile, composed, "utf8");
|
|
4753
|
+
const relPromptFile = path19.relative(paths.root, promptFile);
|
|
4108
4754
|
const bytes = Buffer.byteLength(composed, "utf8");
|
|
4109
4755
|
const estTokens = Math.round(bytes / 4);
|
|
4110
4756
|
const clipResult = clipboard ? await copyToClipboard(composed) : { ok: false, reason: "clipboard disabled (--no-clipboard)" };
|
|
@@ -4162,7 +4808,7 @@ function printModelBanner(config2) {
|
|
|
4162
4808
|
}
|
|
4163
4809
|
|
|
4164
4810
|
// src/commands/status.ts
|
|
4165
|
-
import
|
|
4811
|
+
import fs27 from "fs";
|
|
4166
4812
|
var kw = 28;
|
|
4167
4813
|
function probeTrackerCredential(config2, overlay) {
|
|
4168
4814
|
const { client, error: error2 } = clientFor(config2, overlay);
|
|
@@ -4188,7 +4834,7 @@ async function runStatus() {
|
|
|
4188
4834
|
const scan = scanPlans(paths.plansDir);
|
|
4189
4835
|
const storyCount = listStories(paths).length;
|
|
4190
4836
|
const planCount = scan.usedNumbers.length;
|
|
4191
|
-
const secrets =
|
|
4837
|
+
const secrets = fs27.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
4192
4838
|
const overlay = overlayTrackerEnv(secrets);
|
|
4193
4839
|
banner();
|
|
4194
4840
|
step("squad-kit status");
|
|
@@ -4242,13 +4888,13 @@ function buildTrackerLine(config2, overlay) {
|
|
|
4242
4888
|
}
|
|
4243
4889
|
|
|
4244
4890
|
// src/commands/list.ts
|
|
4245
|
-
import
|
|
4891
|
+
import path20 from "path";
|
|
4246
4892
|
async function runList(opts) {
|
|
4247
4893
|
const root = requireSquadRoot();
|
|
4248
4894
|
const paths = buildPaths(root);
|
|
4249
4895
|
const records = listStories(paths, { feature: opts.feature });
|
|
4250
4896
|
const rows = records.map((r) => {
|
|
4251
|
-
const planAbs = r.planFile ?
|
|
4897
|
+
const planAbs = r.planFile ? path20.join(paths.plansDir, r.feature, r.planFile) : void 0;
|
|
4252
4898
|
const meta = planAbs ? readPlanMetadata(planAbs) : void 0;
|
|
4253
4899
|
return {
|
|
4254
4900
|
feature: r.feature,
|
|
@@ -4287,9 +4933,9 @@ function stripAnsi(s) {
|
|
|
4287
4933
|
}
|
|
4288
4934
|
|
|
4289
4935
|
// src/commands/tracker-link.ts
|
|
4290
|
-
import
|
|
4291
|
-
import
|
|
4292
|
-
import { input as input4, select as
|
|
4936
|
+
import fs28 from "fs";
|
|
4937
|
+
import path21 from "path";
|
|
4938
|
+
import { input as input4, select as select7 } from "@inquirer/prompts";
|
|
4293
4939
|
async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
4294
4940
|
const root = requireSquadRoot();
|
|
4295
4941
|
const paths = buildPaths(root);
|
|
@@ -4306,7 +4952,7 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
|
4306
4952
|
if (stories.length === 0) {
|
|
4307
4953
|
throw new Error("No stories found under .squad/stories/. Create one with `squad new-story` first.");
|
|
4308
4954
|
}
|
|
4309
|
-
resolvedStoryPath = await
|
|
4955
|
+
resolvedStoryPath = await select7({
|
|
4310
4956
|
message: "Story to link:",
|
|
4311
4957
|
choices: stories.map((s) => ({
|
|
4312
4958
|
name: `${s.feature}/${s.id}`,
|
|
@@ -4335,17 +4981,17 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
|
|
|
4335
4981
|
`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
4982
|
);
|
|
4337
4983
|
}
|
|
4338
|
-
const resolved =
|
|
4339
|
-
const intakeFile = resolved.endsWith("intake.md") ? resolved :
|
|
4340
|
-
if (!
|
|
4984
|
+
const resolved = path21.resolve(resolvedStoryPath);
|
|
4985
|
+
const intakeFile = resolved.endsWith("intake.md") ? resolved : path21.join(resolved, "intake.md");
|
|
4986
|
+
if (!fs28.existsSync(intakeFile)) {
|
|
4341
4987
|
throw new Error(
|
|
4342
4988
|
`intake.md not found at ${intakeFile}. Run \`squad list\` to see valid paths, or \`squad new-story\` to create a story first.`
|
|
4343
4989
|
);
|
|
4344
4990
|
}
|
|
4345
|
-
const content =
|
|
4991
|
+
const content = fs28.readFileSync(intakeFile, "utf8");
|
|
4346
4992
|
const updated = upsertTrackerId(content, resolvedTrackerId);
|
|
4347
|
-
|
|
4348
|
-
success(`Linked tracker id ${resolvedTrackerId} \u2192 ${
|
|
4993
|
+
fs28.writeFileSync(intakeFile, updated, "utf8");
|
|
4994
|
+
success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path21.relative(root, intakeFile)}`);
|
|
4349
4995
|
blank();
|
|
4350
4996
|
step("Next:");
|
|
4351
4997
|
info("1) Review the intake \u2014 the Source block at the top now carries the tracker id.");
|
|
@@ -4362,9 +5008,9 @@ function upsertTrackerId(content, id) {
|
|
|
4362
5008
|
`;
|
|
4363
5009
|
}
|
|
4364
5010
|
|
|
4365
|
-
// src/commands/doctor.ts
|
|
4366
|
-
import
|
|
4367
|
-
import
|
|
5011
|
+
// src/commands/doctor-engine.ts
|
|
5012
|
+
import fs29 from "fs";
|
|
5013
|
+
import path22 from "path";
|
|
4368
5014
|
async function gatherContext(paths) {
|
|
4369
5015
|
const ctx = { hasLegacyPromptsDir: false };
|
|
4370
5016
|
try {
|
|
@@ -4373,29 +5019,29 @@ async function gatherContext(paths) {
|
|
|
4373
5019
|
ctx.configError = err;
|
|
4374
5020
|
}
|
|
4375
5021
|
try {
|
|
4376
|
-
ctx.secrets =
|
|
5022
|
+
ctx.secrets = fs29.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
|
|
4377
5023
|
} catch (err) {
|
|
4378
5024
|
ctx.secretsError = err;
|
|
4379
5025
|
}
|
|
4380
|
-
ctx.hasLegacyPromptsDir =
|
|
5026
|
+
ctx.hasLegacyPromptsDir = fs29.existsSync(paths.promptsDir);
|
|
4381
5027
|
return ctx;
|
|
4382
5028
|
}
|
|
4383
5029
|
function gitignoreHasManagedBlock(repoRoot) {
|
|
4384
|
-
const gitignore =
|
|
4385
|
-
return
|
|
5030
|
+
const gitignore = path22.join(repoRoot, ".gitignore");
|
|
5031
|
+
return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
|
|
4386
5032
|
}
|
|
4387
5033
|
function gitignoreHasTrashPattern(repoRoot) {
|
|
4388
|
-
const gitignore =
|
|
4389
|
-
return
|
|
5034
|
+
const gitignore = path22.join(repoRoot, ".gitignore");
|
|
5035
|
+
return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
|
|
4390
5036
|
}
|
|
4391
5037
|
async function checkDirStructure(paths, _ctx, fix) {
|
|
4392
|
-
const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !
|
|
5038
|
+
const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs29.existsSync(p));
|
|
4393
5039
|
if (need.length === 0) {
|
|
4394
5040
|
return { id: "dirs", name: ".squad/ directory structure", status: "ok" };
|
|
4395
5041
|
}
|
|
4396
5042
|
if (fix) {
|
|
4397
5043
|
for (const p of need) {
|
|
4398
|
-
|
|
5044
|
+
fs29.mkdirSync(p, { recursive: true });
|
|
4399
5045
|
}
|
|
4400
5046
|
return { id: "dirs", name: ".squad/ directory structure", status: "ok", detail: "repaired" };
|
|
4401
5047
|
}
|
|
@@ -4403,7 +5049,7 @@ async function checkDirStructure(paths, _ctx, fix) {
|
|
|
4403
5049
|
id: "dirs",
|
|
4404
5050
|
name: ".squad/ directory structure",
|
|
4405
5051
|
status: "warn",
|
|
4406
|
-
detail: `missing: ${need.map((p) =>
|
|
5052
|
+
detail: `missing: ${need.map((p) => path22.relative(paths.root, p)).join(", ")}`,
|
|
4407
5053
|
fixable: true,
|
|
4408
5054
|
fixHint: "squad doctor --fix"
|
|
4409
5055
|
};
|
|
@@ -4458,15 +5104,15 @@ async function checkSecretsPermissions(paths, _ctx, fix) {
|
|
|
4458
5104
|
if (process.platform === "win32") {
|
|
4459
5105
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "skip", detail: "Windows" };
|
|
4460
5106
|
}
|
|
4461
|
-
if (!
|
|
5107
|
+
if (!fs29.existsSync(paths.secretsFile)) {
|
|
4462
5108
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "not present" };
|
|
4463
5109
|
}
|
|
4464
|
-
const mode =
|
|
5110
|
+
const mode = fs29.statSync(paths.secretsFile).mode & 511;
|
|
4465
5111
|
if (mode === 384) {
|
|
4466
5112
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok" };
|
|
4467
5113
|
}
|
|
4468
5114
|
if (fix) {
|
|
4469
|
-
|
|
5115
|
+
fs29.chmodSync(paths.secretsFile, 384);
|
|
4470
5116
|
return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "repaired" };
|
|
4471
5117
|
}
|
|
4472
5118
|
return {
|
|
@@ -4487,7 +5133,7 @@ async function checkSecretsParseable(_paths, ctx) {
|
|
|
4487
5133
|
detail: ctx.secretsError.message
|
|
4488
5134
|
};
|
|
4489
5135
|
}
|
|
4490
|
-
if (!
|
|
5136
|
+
if (!fs29.existsSync(_paths.secretsFile)) {
|
|
4491
5137
|
return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok", detail: "not present" };
|
|
4492
5138
|
}
|
|
4493
5139
|
return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok" };
|
|
@@ -4869,6 +5515,8 @@ function summarise(checks) {
|
|
|
4869
5515
|
skip: checks.filter((c) => c.status === "skip").length
|
|
4870
5516
|
};
|
|
4871
5517
|
}
|
|
5518
|
+
|
|
5519
|
+
// src/commands/doctor.ts
|
|
4872
5520
|
function colorForStatus(s) {
|
|
4873
5521
|
switch (s) {
|
|
4874
5522
|
case "ok":
|
|
@@ -4961,23 +5609,23 @@ async function runDoctor(opts) {
|
|
|
4961
5609
|
}
|
|
4962
5610
|
|
|
4963
5611
|
// src/commands/migrate.ts
|
|
4964
|
-
import
|
|
4965
|
-
import
|
|
5612
|
+
import fs30 from "fs";
|
|
5613
|
+
import path23 from "path";
|
|
4966
5614
|
import { confirm as confirm7 } from "@inquirer/prompts";
|
|
4967
5615
|
var LEGACY_PROMPTS_MIGRATION = {
|
|
4968
5616
|
id: "legacy-prompts",
|
|
4969
5617
|
name: "Remove legacy .squad/prompts/",
|
|
4970
|
-
isApplied: (paths) => !
|
|
4971
|
-
describe: (paths) => `delete ${
|
|
5618
|
+
isApplied: (paths) => !fs30.existsSync(paths.promptsDir),
|
|
5619
|
+
describe: (paths) => `delete ${path23.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
|
|
4972
5620
|
apply: (paths) => {
|
|
4973
5621
|
const target = paths.promptsDir;
|
|
4974
|
-
if (!target.includes(
|
|
5622
|
+
if (!target.includes(path23.sep + ".squad" + path23.sep + "prompts")) {
|
|
4975
5623
|
throw new Error(
|
|
4976
5624
|
`Refusing to delete suspicious path: ${target}. This is a safety check \u2014 run \`squad doctor\` if .squad/ paths look wrong.`
|
|
4977
5625
|
);
|
|
4978
5626
|
}
|
|
4979
|
-
if (
|
|
4980
|
-
|
|
5627
|
+
if (fs30.existsSync(target)) {
|
|
5628
|
+
fs30.rmSync(target, { recursive: true, force: true });
|
|
4981
5629
|
}
|
|
4982
5630
|
}
|
|
4983
5631
|
};
|
|
@@ -4985,8 +5633,8 @@ var GITIGNORE_MIGRATION = {
|
|
|
4985
5633
|
id: "gitignore",
|
|
4986
5634
|
name: "Ensure .gitignore managed block",
|
|
4987
5635
|
isApplied: (paths) => {
|
|
4988
|
-
const f =
|
|
4989
|
-
return
|
|
5636
|
+
const f = path23.join(paths.root, ".gitignore");
|
|
5637
|
+
return fs30.existsSync(f) && fs30.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
|
|
4990
5638
|
},
|
|
4991
5639
|
describe: () => "add squad-kit managed block to .gitignore",
|
|
4992
5640
|
apply: (paths) => {
|
|
@@ -4998,23 +5646,23 @@ var SECRETS_PERMS_MIGRATION = {
|
|
|
4998
5646
|
name: "Tighten .squad/secrets.yaml permissions",
|
|
4999
5647
|
isApplied: (paths) => {
|
|
5000
5648
|
if (process.platform === "win32") return true;
|
|
5001
|
-
if (!
|
|
5002
|
-
const st =
|
|
5649
|
+
if (!fs30.existsSync(paths.secretsFile)) return true;
|
|
5650
|
+
const st = fs30.statSync(paths.secretsFile);
|
|
5003
5651
|
return (st.mode & 511) === 384;
|
|
5004
5652
|
},
|
|
5005
|
-
describe: (paths) => `chmod 0600 ${
|
|
5653
|
+
describe: (paths) => `chmod 0600 ${path23.relative(paths.root, paths.secretsFile)}`,
|
|
5006
5654
|
apply: (paths) => {
|
|
5007
5655
|
if (process.platform === "win32") return;
|
|
5008
|
-
if (
|
|
5656
|
+
if (fs30.existsSync(paths.secretsFile)) fs30.chmodSync(paths.secretsFile, 384);
|
|
5009
5657
|
}
|
|
5010
5658
|
};
|
|
5011
5659
|
var PLANNER_BUDGET_DEFAULTS_MIGRATION = {
|
|
5012
5660
|
id: "planner-budget-defaults",
|
|
5013
5661
|
name: "Backfill planner.budget defaults in config.yaml",
|
|
5014
5662
|
isApplied: (paths) => {
|
|
5015
|
-
if (!
|
|
5663
|
+
if (!fs30.existsSync(paths.configFile)) return true;
|
|
5016
5664
|
try {
|
|
5017
|
-
const raw =
|
|
5665
|
+
const raw = fs30.readFileSync(paths.configFile, "utf8");
|
|
5018
5666
|
if (!/^\s*planner:/m.test(raw)) return true;
|
|
5019
5667
|
if (/^\s*budget:/m.test(raw)) return true;
|
|
5020
5668
|
const cfg = loadConfig(paths.configFile);
|
|
@@ -5033,9 +5681,9 @@ var CONFIG_NORMALIZE_MIGRATION = {
|
|
|
5033
5681
|
id: "config-normalize",
|
|
5034
5682
|
name: "Normalise .squad/config.yaml formatting",
|
|
5035
5683
|
isApplied: (paths) => {
|
|
5036
|
-
if (!
|
|
5684
|
+
if (!fs30.existsSync(paths.configFile)) return true;
|
|
5037
5685
|
try {
|
|
5038
|
-
const raw =
|
|
5686
|
+
const raw = fs30.readFileSync(paths.configFile, "utf8");
|
|
5039
5687
|
const cfg = loadConfig(paths.configFile);
|
|
5040
5688
|
return raw.trim() === serializeConfig(cfg).trim();
|
|
5041
5689
|
} catch {
|
|
@@ -5044,7 +5692,7 @@ var CONFIG_NORMALIZE_MIGRATION = {
|
|
|
5044
5692
|
},
|
|
5045
5693
|
describe: () => "rewrite config.yaml with canonical YAML formatting (drops comments)",
|
|
5046
5694
|
apply: (paths) => {
|
|
5047
|
-
if (!
|
|
5695
|
+
if (!fs30.existsSync(paths.configFile)) return;
|
|
5048
5696
|
const cfg = loadConfig(paths.configFile);
|
|
5049
5697
|
saveConfig(paths.configFile, cfg);
|
|
5050
5698
|
}
|
|
@@ -5125,8 +5773,8 @@ import process3 from "process";
|
|
|
5125
5773
|
import { confirm as confirm8 } from "@inquirer/prompts";
|
|
5126
5774
|
|
|
5127
5775
|
// src/core/package-info.ts
|
|
5128
|
-
import
|
|
5129
|
-
import
|
|
5776
|
+
import fs31 from "fs";
|
|
5777
|
+
import path24 from "path";
|
|
5130
5778
|
function changelogBlobUrl(repositoryUrl, version) {
|
|
5131
5779
|
let base = repositoryUrl;
|
|
5132
5780
|
if (!base) base = "https://github.com/AzmSquad/squad-kit";
|
|
@@ -5137,9 +5785,9 @@ function changelogBlobUrl(repositoryUrl, version) {
|
|
|
5137
5785
|
}
|
|
5138
5786
|
function readInstalledPackage() {
|
|
5139
5787
|
const root = packageRoot();
|
|
5140
|
-
const pkgFile =
|
|
5141
|
-
const pkg = JSON.parse(
|
|
5142
|
-
const isDevInstall =
|
|
5788
|
+
const pkgFile = path24.join(root, "package.json");
|
|
5789
|
+
const pkg = JSON.parse(fs31.readFileSync(pkgFile, "utf8"));
|
|
5790
|
+
const isDevInstall = fs31.existsSync(path24.join(root, "src"));
|
|
5143
5791
|
return {
|
|
5144
5792
|
name: pkg.name,
|
|
5145
5793
|
version: pkg.version,
|
|
@@ -5179,7 +5827,7 @@ function isNewer(a, b) {
|
|
|
5179
5827
|
}
|
|
5180
5828
|
|
|
5181
5829
|
// src/core/package-manager.ts
|
|
5182
|
-
import
|
|
5830
|
+
import path25 from "path";
|
|
5183
5831
|
import process2 from "process";
|
|
5184
5832
|
function detectPackageManager(installRoot) {
|
|
5185
5833
|
const ua = process2.env.npm_config_user_agent;
|
|
@@ -5190,11 +5838,11 @@ function detectPackageManager(installRoot) {
|
|
|
5190
5838
|
if (ua.startsWith("npm/")) return { pm: "npm", reason: "npm_config_user_agent" };
|
|
5191
5839
|
}
|
|
5192
5840
|
const root = installRoot.toLowerCase();
|
|
5193
|
-
if (root.includes(`${
|
|
5841
|
+
if (root.includes(`${path25.sep}pnpm${path25.sep}`) || root.includes(`${path25.sep}.pnpm${path25.sep}`))
|
|
5194
5842
|
return { pm: "pnpm", reason: `install path contains pnpm` };
|
|
5195
|
-
if (root.includes(`${
|
|
5843
|
+
if (root.includes(`${path25.sep}.yarn${path25.sep}`) || root.includes(`${path25.sep}yarn${path25.sep}`))
|
|
5196
5844
|
return { pm: "yarn", reason: `install path contains yarn` };
|
|
5197
|
-
if (root.includes(`${
|
|
5845
|
+
if (root.includes(`${path25.sep}.bun${path25.sep}`) || root.includes(`${path25.sep}bun${path25.sep}`))
|
|
5198
5846
|
return { pm: "bun", reason: `install path contains bun` };
|
|
5199
5847
|
return { pm: "npm", reason: "default fallback" };
|
|
5200
5848
|
}
|
|
@@ -5259,244 +5907,1069 @@ async function runUpgrade(opts) {
|
|
|
5259
5907
|
info(`Run to upgrade: ${cmd} ${args.join(" ")}`);
|
|
5260
5908
|
return;
|
|
5261
5909
|
}
|
|
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);
|
|
5910
|
+
if (!opts.yes) {
|
|
5911
|
+
const interactive = Boolean(process3.stdin.isTTY) && process3.env.CI !== "true";
|
|
5912
|
+
if (!interactive) {
|
|
5913
|
+
failure(
|
|
5914
|
+
`Refusing to run a global install without --yes. Run \`squad upgrade --yes\` in non-interactive mode, or run manually: ${cmd} ${args.join(" ")}`
|
|
5915
|
+
);
|
|
5916
|
+
process3.exit(1);
|
|
5917
|
+
}
|
|
5918
|
+
const go = await confirm8({ message: `Run "${cmd} ${args.join(" ")}"?`, default: true });
|
|
5919
|
+
if (!go) {
|
|
5920
|
+
info("Cancelled. No changes applied.");
|
|
5921
|
+
return;
|
|
5922
|
+
}
|
|
5923
|
+
}
|
|
5924
|
+
blank();
|
|
5925
|
+
info(`Running: ${cmd} ${args.join(" ")}`);
|
|
5926
|
+
const exitCode = await spawnInherit(cmd, args);
|
|
5927
|
+
if (exitCode !== 0) {
|
|
5928
|
+
failure(
|
|
5929
|
+
`${cmd} exited with code ${exitCode}. Upgrade may be incomplete \u2014 run \`squad doctor\` and re-run \`squad upgrade\` if needed.`
|
|
5930
|
+
);
|
|
5931
|
+
process3.exit(exitCode);
|
|
5932
|
+
}
|
|
5933
|
+
blank();
|
|
5934
|
+
success(`Upgraded to ${latest}.`);
|
|
5935
|
+
info("Run `squad migrate` to update your .squad/ structure if needed.");
|
|
5936
|
+
}
|
|
5937
|
+
function spawnInherit(cmd, args) {
|
|
5938
|
+
return new Promise((resolve2, reject) => {
|
|
5939
|
+
const child = spawn2(cmd, args, { stdio: "inherit", shell: process3.platform === "win32" });
|
|
5940
|
+
child.on("exit", (code) => resolve2(code ?? 0));
|
|
5941
|
+
child.on("error", (err) => reject(err));
|
|
5942
|
+
});
|
|
5943
|
+
}
|
|
5944
|
+
|
|
5945
|
+
// src/commands/console.ts
|
|
5946
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
5947
|
+
import { resolve } from "path";
|
|
5948
|
+
import open from "open";
|
|
5949
|
+
|
|
5950
|
+
// src/console/server.ts
|
|
5951
|
+
import { createServer } from "net";
|
|
5952
|
+
import { serve } from "@hono/node-server";
|
|
5953
|
+
import { Hono } from "hono";
|
|
5954
|
+
import { cors } from "hono/cors";
|
|
5955
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
5956
|
+
|
|
5957
|
+
// src/console/api/stories.ts
|
|
5958
|
+
import fs32 from "fs";
|
|
5959
|
+
import { z } from "zod";
|
|
5960
|
+
|
|
5961
|
+
// src/utils/path-safety.ts
|
|
5962
|
+
import path26 from "path";
|
|
5963
|
+
function isInside(abs, parent) {
|
|
5964
|
+
const a = path26.resolve(abs) + path26.sep;
|
|
5965
|
+
const p = path26.resolve(parent) + path26.sep;
|
|
5966
|
+
return a.startsWith(p);
|
|
5967
|
+
}
|
|
5968
|
+
|
|
5969
|
+
// src/console/api/stories.ts
|
|
5970
|
+
var CreateBody = z.object({
|
|
5971
|
+
feature: z.string().min(1).max(80),
|
|
5972
|
+
title: z.string().max(200).optional(),
|
|
5973
|
+
trackerId: z.string().max(80).optional()
|
|
5974
|
+
});
|
|
5975
|
+
var PatchBody = z.object({
|
|
5976
|
+
intakeContent: z.string().min(1).max(1e6)
|
|
5977
|
+
});
|
|
5978
|
+
function safeFeatureParam(feature) {
|
|
5979
|
+
return feature.length > 0 && !feature.includes("/") && !feature.includes("..") && slugify(feature) === feature;
|
|
5980
|
+
}
|
|
5981
|
+
function mountStoriesApi(app, opts) {
|
|
5982
|
+
app.get("/api/stories", (c) => {
|
|
5983
|
+
const feature = c.req.query("feature") ?? void 0;
|
|
5984
|
+
const stories = listStories(opts.paths, { feature });
|
|
5985
|
+
return c.json(
|
|
5986
|
+
stories.map((s) => ({
|
|
5987
|
+
feature: s.feature,
|
|
5988
|
+
id: s.id,
|
|
5989
|
+
intakePath: s.intakePath,
|
|
5990
|
+
storyDir: s.storyDir,
|
|
5991
|
+
planFile: s.planFile ?? null,
|
|
5992
|
+
titleHint: s.titleHint ?? null
|
|
5993
|
+
}))
|
|
5994
|
+
);
|
|
5995
|
+
});
|
|
5996
|
+
app.get("/api/stories/:feature/:id", (c) => {
|
|
5997
|
+
const { feature, id } = c.req.param();
|
|
5998
|
+
if (!safeFeatureParam(feature)) return c.json({ error: "forbidden" }, 403);
|
|
5999
|
+
const stories = listStories(opts.paths, { feature });
|
|
6000
|
+
const hit = stories.find((s) => s.id === id);
|
|
6001
|
+
if (!hit) return c.json({ error: "not_found" }, 404);
|
|
6002
|
+
if (!isInside(hit.intakePath, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
|
|
6003
|
+
let intakeContent = "";
|
|
6004
|
+
try {
|
|
6005
|
+
intakeContent = fs32.readFileSync(hit.intakePath, "utf8");
|
|
6006
|
+
} catch {
|
|
6007
|
+
}
|
|
6008
|
+
return c.json({
|
|
6009
|
+
feature: hit.feature,
|
|
6010
|
+
id: hit.id,
|
|
6011
|
+
intakePath: hit.intakePath,
|
|
6012
|
+
storyDir: hit.storyDir,
|
|
6013
|
+
planFile: hit.planFile ?? null,
|
|
6014
|
+
titleHint: hit.titleHint ?? null,
|
|
6015
|
+
intakeContent
|
|
6016
|
+
});
|
|
6017
|
+
});
|
|
6018
|
+
app.post("/api/stories", async (c) => {
|
|
6019
|
+
const body = CreateBody.safeParse(await c.req.json());
|
|
6020
|
+
if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
|
|
6021
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6022
|
+
let created;
|
|
6023
|
+
try {
|
|
6024
|
+
created = createStoryRecord({
|
|
6025
|
+
paths: opts.paths,
|
|
6026
|
+
config: cfg,
|
|
6027
|
+
feature: body.data.feature,
|
|
6028
|
+
title: body.data.title,
|
|
6029
|
+
trackerId: body.data.trackerId
|
|
6030
|
+
});
|
|
6031
|
+
} catch (e) {
|
|
6032
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
6033
|
+
if (/already exists|Intake already exists/.test(msg)) {
|
|
6034
|
+
return c.json({ error: "conflict", message: msg }, 409);
|
|
6035
|
+
}
|
|
6036
|
+
return c.json({ error: "invalid_request", message: msg }, 400);
|
|
6037
|
+
}
|
|
6038
|
+
return c.json(created, 201);
|
|
6039
|
+
});
|
|
6040
|
+
app.patch("/api/stories/:feature/:id", async (c) => {
|
|
6041
|
+
const body = PatchBody.safeParse(await c.req.json());
|
|
6042
|
+
if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
|
|
6043
|
+
const { feature, id } = c.req.param();
|
|
6044
|
+
if (!safeFeatureParam(feature)) return c.json({ error: "forbidden" }, 403);
|
|
6045
|
+
const stories = listStories(opts.paths, { feature });
|
|
6046
|
+
const hit = stories.find((s) => s.id === id);
|
|
6047
|
+
if (!hit) return c.json({ error: "not_found" }, 404);
|
|
6048
|
+
if (!isInside(hit.intakePath, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
|
|
6049
|
+
fs32.writeFileSync(hit.intakePath, body.data.intakeContent, "utf8");
|
|
6050
|
+
return c.json({ ok: true });
|
|
6051
|
+
});
|
|
6052
|
+
app.delete("/api/stories/:feature/:id", (c) => {
|
|
6053
|
+
const trash = c.req.query("trash") === "1";
|
|
6054
|
+
const { feature, id } = c.req.param();
|
|
6055
|
+
if (!safeFeatureParam(feature)) return c.json({ error: "forbidden" }, 403);
|
|
6056
|
+
const stories = listStories(opts.paths, { feature });
|
|
6057
|
+
const hit = stories.find((s) => s.id === id);
|
|
6058
|
+
if (!hit) return c.json({ error: "not_found" }, 404);
|
|
6059
|
+
if (!isInside(hit.storyDir, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
|
|
6060
|
+
const result = deleteStoryRecord({ paths: opts.paths, story: hit, trash });
|
|
6061
|
+
return c.json(result);
|
|
6062
|
+
});
|
|
6063
|
+
}
|
|
6064
|
+
|
|
6065
|
+
// src/console/api/plans.ts
|
|
6066
|
+
import fs33 from "fs";
|
|
6067
|
+
import path27 from "path";
|
|
6068
|
+
import { z as z2 } from "zod";
|
|
6069
|
+
import { diffLines } from "diff";
|
|
6070
|
+
var DiffQuery = z2.object({
|
|
6071
|
+
feature: z2.string().min(1).max(80),
|
|
6072
|
+
a: z2.string().min(1).max(200),
|
|
6073
|
+
b: z2.string().min(1).max(200)
|
|
6074
|
+
});
|
|
6075
|
+
function safeFeatureParam2(feature) {
|
|
6076
|
+
return feature.length > 0 && !feature.includes("/") && !feature.includes("..") && slugify(feature) === feature;
|
|
6077
|
+
}
|
|
6078
|
+
function mountPlansApi(app, opts) {
|
|
6079
|
+
app.get("/api/plans", (c) => {
|
|
6080
|
+
const out = [];
|
|
6081
|
+
for (const feature of listPlanFeatureNames(opts.paths)) {
|
|
6082
|
+
const featureDir = path27.join(opts.paths.plansDir, feature);
|
|
6083
|
+
if (!fs33.existsSync(featureDir)) continue;
|
|
6084
|
+
for (const entry of fs33.readdirSync(featureDir)) {
|
|
6085
|
+
if (!entry.endsWith(".md") || entry === "00-index.md") continue;
|
|
6086
|
+
const abs = path27.join(featureDir, entry);
|
|
6087
|
+
out.push({ feature, planFile: entry, metadata: readPlanMetadata(abs) });
|
|
6088
|
+
}
|
|
6089
|
+
}
|
|
6090
|
+
return c.json(out);
|
|
6091
|
+
});
|
|
6092
|
+
app.get("/api/plans/:feature/:planFile", (c) => {
|
|
6093
|
+
const { feature, planFile } = c.req.param();
|
|
6094
|
+
if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
|
|
6095
|
+
const abs = path27.resolve(path27.join(opts.paths.plansDir, feature, planFile));
|
|
6096
|
+
if (!isInside(abs, opts.paths.plansDir)) return c.json({ error: "forbidden" }, 403);
|
|
6097
|
+
if (!fs33.existsSync(abs)) return c.json({ error: "not_found" }, 404);
|
|
6098
|
+
const content = fs33.readFileSync(abs, "utf8");
|
|
6099
|
+
return c.json({
|
|
6100
|
+
feature,
|
|
6101
|
+
planFile,
|
|
6102
|
+
content,
|
|
6103
|
+
absPath: abs,
|
|
6104
|
+
metadata: readPlanMetadata(abs)
|
|
6105
|
+
});
|
|
6106
|
+
});
|
|
6107
|
+
app.get("/api/plans/diff", (c) => {
|
|
6108
|
+
const parse = DiffQuery.safeParse({
|
|
6109
|
+
feature: c.req.query("feature"),
|
|
6110
|
+
a: c.req.query("a"),
|
|
6111
|
+
b: c.req.query("b")
|
|
6112
|
+
});
|
|
6113
|
+
if (!parse.success) return c.json({ error: "invalid_query", issues: parse.error.issues }, 400);
|
|
6114
|
+
const { feature, a, b } = parse.data;
|
|
6115
|
+
if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
|
|
6116
|
+
const featureDir = path27.join(opts.paths.plansDir, feature);
|
|
6117
|
+
const aAbs = path27.join(featureDir, a);
|
|
6118
|
+
const bAbs = path27.join(featureDir, b);
|
|
6119
|
+
if (!isInside(aAbs, opts.paths.plansDir) || !isInside(bAbs, opts.paths.plansDir)) {
|
|
6120
|
+
return c.json({ error: "forbidden" }, 403);
|
|
6121
|
+
}
|
|
6122
|
+
if (!fs33.existsSync(aAbs) || !fs33.existsSync(bAbs)) return c.json({ error: "not_found" }, 404);
|
|
6123
|
+
const aText = fs33.readFileSync(aAbs, "utf8");
|
|
6124
|
+
const bText = fs33.readFileSync(bAbs, "utf8");
|
|
6125
|
+
const changes = diffLines(aText, bText);
|
|
6126
|
+
return c.json({ feature, a, b, changes });
|
|
6127
|
+
});
|
|
6128
|
+
app.delete("/api/plans/:feature/:planFile", (c) => {
|
|
6129
|
+
const trash = c.req.query("trash") === "1";
|
|
6130
|
+
const { feature, planFile } = c.req.param();
|
|
6131
|
+
if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
|
|
6132
|
+
const abs = path27.join(opts.paths.plansDir, feature, planFile);
|
|
6133
|
+
if (!isInside(abs, opts.paths.plansDir)) return c.json({ error: "forbidden" }, 403);
|
|
6134
|
+
if (!fs33.existsSync(abs)) return c.json({ error: "not_found" }, 404);
|
|
6135
|
+
if (trash) {
|
|
6136
|
+
const result = trashOrDelete([abs], opts.paths.trashDir, true);
|
|
6137
|
+
removeOverviewRowForPlanFile(path27.join(opts.paths.plansDir, feature), planFile);
|
|
6138
|
+
return c.json({ ok: true, trashed: result.trashed });
|
|
6139
|
+
}
|
|
6140
|
+
fs33.rmSync(abs);
|
|
6141
|
+
removeOverviewRowForPlanFile(path27.join(opts.paths.plansDir, feature), planFile);
|
|
6142
|
+
return c.json({ ok: true });
|
|
6143
|
+
});
|
|
6144
|
+
}
|
|
6145
|
+
|
|
6146
|
+
// src/console/api/config.ts
|
|
6147
|
+
import { z as z3 } from "zod";
|
|
6148
|
+
var ConfigBody = z3.object({}).passthrough();
|
|
6149
|
+
function mountConfigApi(app, opts) {
|
|
6150
|
+
app.get("/api/config", (c) => {
|
|
6151
|
+
const config2 = loadConfig(opts.paths.configFile);
|
|
6152
|
+
return c.json(config2);
|
|
6153
|
+
});
|
|
6154
|
+
app.put("/api/config", async (c) => {
|
|
6155
|
+
const body = ConfigBody.safeParse(await c.req.json());
|
|
6156
|
+
if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
|
|
6157
|
+
try {
|
|
6158
|
+
saveConfig(opts.paths.configFile, body.data);
|
|
6159
|
+
return c.json({ ok: true });
|
|
6160
|
+
} catch (err) {
|
|
6161
|
+
return c.json({ error: "invalid_config", detail: err.message }, 400);
|
|
6162
|
+
}
|
|
6163
|
+
});
|
|
6164
|
+
}
|
|
6165
|
+
|
|
6166
|
+
// src/console/api/meta.ts
|
|
6167
|
+
function mountMetaApi(app, opts) {
|
|
6168
|
+
app.get("/api/meta", async (c) => {
|
|
6169
|
+
const config2 = loadConfig(opts.paths.configFile);
|
|
6170
|
+
const lastRun = await readLastRun(opts.paths);
|
|
6171
|
+
return c.json({
|
|
6172
|
+
version: "0.6.0",
|
|
6173
|
+
root: opts.paths.root,
|
|
6174
|
+
project: config2.project,
|
|
6175
|
+
planner: config2.planner ? { provider: config2.planner.provider, enabled: config2.planner.enabled } : null,
|
|
6176
|
+
tracker: { type: config2.tracker.type },
|
|
6177
|
+
lastRun
|
|
6178
|
+
});
|
|
6179
|
+
});
|
|
6180
|
+
app.get("/api/last-run", async (c) => {
|
|
6181
|
+
const lastRun = await readLastRun(opts.paths);
|
|
6182
|
+
return c.json(lastRun ?? null);
|
|
6183
|
+
});
|
|
6184
|
+
}
|
|
6185
|
+
|
|
6186
|
+
// src/console/api/runs.ts
|
|
6187
|
+
import { streamSSE } from "hono/streaming";
|
|
6188
|
+
import { z as z4 } from "zod";
|
|
6189
|
+
import path28 from "path";
|
|
6190
|
+
import fs34 from "fs";
|
|
6191
|
+
var active = /* @__PURE__ */ new Map();
|
|
6192
|
+
var EVENT_BUF_CAP = 512;
|
|
6193
|
+
var StartBody = z4.object({
|
|
6194
|
+
feature: z4.string().min(1),
|
|
6195
|
+
storyId: z4.string().min(1)
|
|
6196
|
+
});
|
|
6197
|
+
function emptyStats(durationMs) {
|
|
6198
|
+
return {
|
|
6199
|
+
turns: 0,
|
|
6200
|
+
inputTokens: 0,
|
|
6201
|
+
outputTokens: 0,
|
|
6202
|
+
cacheCreationTokens: 0,
|
|
6203
|
+
cacheReadTokens: 0,
|
|
6204
|
+
cacheHitRatio: 0,
|
|
6205
|
+
durationMs
|
|
6206
|
+
};
|
|
6207
|
+
}
|
|
6208
|
+
function mountRunsApi(app, opts) {
|
|
6209
|
+
app.get("/api/runs", async (c) => c.json(await listRuns(opts.paths)));
|
|
6210
|
+
app.get("/api/runs/active", (c) => {
|
|
6211
|
+
const rows = [...active.values()].map((r) => ({
|
|
6212
|
+
runId: r.runId,
|
|
6213
|
+
feature: r.feature,
|
|
6214
|
+
storyId: r.storyId,
|
|
6215
|
+
startedAt: r.startedAt
|
|
6216
|
+
}));
|
|
6217
|
+
return c.json(rows);
|
|
6218
|
+
});
|
|
6219
|
+
app.post("/api/runs", async (c) => {
|
|
6220
|
+
const body = StartBody.safeParse(await c.req.json());
|
|
6221
|
+
if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
|
|
6222
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6223
|
+
if (!cfg.planner?.enabled) {
|
|
6224
|
+
return c.json({ error: "planner_disabled", detail: "Run `squad config set planner` first." }, 400);
|
|
6225
|
+
}
|
|
6226
|
+
const apiKey = readProviderKeyForPaths(opts.paths, cfg.planner.provider);
|
|
6227
|
+
if (!apiKey) {
|
|
6228
|
+
return c.json(
|
|
6229
|
+
{
|
|
6230
|
+
error: "missing_credentials",
|
|
6231
|
+
detail: `Set ${providerEnvVar(cfg.planner.provider)} or save a key via the Secrets tab.`
|
|
6232
|
+
},
|
|
6233
|
+
400
|
|
6234
|
+
);
|
|
6235
|
+
}
|
|
6236
|
+
const stories = listStories(opts.paths, { feature: body.data.feature });
|
|
6237
|
+
const story = stories.find((s) => s.id === body.data.storyId);
|
|
6238
|
+
if (!story) return c.json({ error: "not_found" }, 404);
|
|
6239
|
+
const runId = newRunId();
|
|
6240
|
+
const eventBuffer = [];
|
|
6241
|
+
const bus = new PlannerEventBus();
|
|
6242
|
+
bus.subscribe((e) => {
|
|
6243
|
+
eventBuffer.push(e);
|
|
6244
|
+
if (eventBuffer.length > EVENT_BUF_CAP) eventBuffer.splice(0, eventBuffer.length - EVENT_BUF_CAP);
|
|
6245
|
+
});
|
|
6246
|
+
const controller = new AbortController();
|
|
6247
|
+
const startedAt = Date.now();
|
|
6248
|
+
const done = (async () => {
|
|
6249
|
+
const cfgFresh = loadConfig(opts.paths.configFile);
|
|
6250
|
+
const planner = cfgFresh.planner;
|
|
6251
|
+
const provider = providerFor(planner.provider);
|
|
6252
|
+
const model = modelFor(planner.provider, "plan", planner.modelOverride);
|
|
6253
|
+
const budget = new Budget(planner.budget);
|
|
6254
|
+
const cacheEnabled = planner.cache?.enabled ?? true;
|
|
6255
|
+
const repoMap = buildRepoMap(opts.paths.root);
|
|
6256
|
+
const systemPrompt = composeSystemPrompt({
|
|
6257
|
+
projectRoots: cfgFresh.project.projectRoots ?? ["."],
|
|
6258
|
+
primaryLanguage: cfgFresh.project.primaryLanguage ?? "",
|
|
6259
|
+
trackerType: cfgFresh.tracker.type,
|
|
6260
|
+
repoMap
|
|
6261
|
+
});
|
|
6262
|
+
const userPrompt = composeUserPrompt({
|
|
6263
|
+
intakeContent: fs34.readFileSync(story.intakePath, "utf8")
|
|
6264
|
+
});
|
|
6265
|
+
let result;
|
|
6266
|
+
try {
|
|
6267
|
+
result = await runPlanner({
|
|
6268
|
+
root: opts.paths.root,
|
|
6269
|
+
provider,
|
|
6270
|
+
model,
|
|
6271
|
+
apiKey,
|
|
6272
|
+
systemPrompt,
|
|
6273
|
+
userPrompt,
|
|
6274
|
+
budget,
|
|
6275
|
+
maxOutputTokens: planner.maxOutputTokens,
|
|
6276
|
+
cacheEnabled,
|
|
6277
|
+
events: bus,
|
|
6278
|
+
runId,
|
|
6279
|
+
abort: controller.signal
|
|
6280
|
+
});
|
|
6281
|
+
} catch (err) {
|
|
6282
|
+
const durationMs = Date.now() - startedAt;
|
|
6283
|
+
bus.emit({ kind: "error", runId, message: err.message });
|
|
6284
|
+
bus.emit({
|
|
6285
|
+
kind: "done",
|
|
6286
|
+
runId,
|
|
6287
|
+
success: false,
|
|
6288
|
+
planFile: null,
|
|
6289
|
+
partial: true,
|
|
6290
|
+
stats: emptyStats(durationMs),
|
|
6291
|
+
durationMs
|
|
6292
|
+
});
|
|
6293
|
+
return;
|
|
6294
|
+
}
|
|
6295
|
+
const runSuccess = result.finishedNormally && !result.timedOut && !result.userCancelled;
|
|
6296
|
+
let planFile = null;
|
|
6297
|
+
if (result.planText.trim()) {
|
|
6298
|
+
const snap = budget.snapshot();
|
|
6299
|
+
const header = buildMetadataHeader({
|
|
6300
|
+
provider: planner.provider,
|
|
6301
|
+
model,
|
|
6302
|
+
reads: snap.reads,
|
|
6303
|
+
bytes: snap.bytes,
|
|
6304
|
+
inputTokens: snap.usage.inputTokens,
|
|
6305
|
+
outputTokens: snap.usage.outputTokens,
|
|
6306
|
+
durationMs: Date.now() - startedAt,
|
|
6307
|
+
costUsd: snap.usage.costUsd,
|
|
6308
|
+
planStatus: runSuccess ? void 0 : "partial"
|
|
6309
|
+
});
|
|
6310
|
+
const out = writePlanFile({
|
|
6311
|
+
paths: opts.paths,
|
|
6312
|
+
config: cfgFresh,
|
|
6313
|
+
story,
|
|
6314
|
+
planBodyMarkdown: result.planText,
|
|
6315
|
+
metadataHeader: header,
|
|
6316
|
+
partial: !runSuccess
|
|
6317
|
+
});
|
|
6318
|
+
planFile = path28.relative(opts.paths.root, out.planFile);
|
|
6319
|
+
}
|
|
6320
|
+
try {
|
|
6321
|
+
await writeLastRun(opts.paths, {
|
|
6322
|
+
stats: result.stats,
|
|
6323
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6324
|
+
provider: planner.provider,
|
|
6325
|
+
model
|
|
6326
|
+
});
|
|
6327
|
+
await appendRun(opts.paths, {
|
|
6328
|
+
runId,
|
|
6329
|
+
provider: planner.provider,
|
|
6330
|
+
model,
|
|
6331
|
+
feature: story.feature,
|
|
6332
|
+
storyId: story.id,
|
|
6333
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
6334
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6335
|
+
success: runSuccess,
|
|
6336
|
+
partial: !runSuccess,
|
|
6337
|
+
planFile,
|
|
6338
|
+
stats: result.stats,
|
|
6339
|
+
cacheEnabled,
|
|
6340
|
+
durationMs: Date.now() - startedAt
|
|
6341
|
+
});
|
|
6342
|
+
} catch {
|
|
6343
|
+
}
|
|
6344
|
+
bus.emit({
|
|
6345
|
+
kind: "done",
|
|
6346
|
+
runId,
|
|
6347
|
+
success: runSuccess,
|
|
6348
|
+
planFile,
|
|
6349
|
+
partial: !runSuccess,
|
|
6350
|
+
stats: result.stats,
|
|
6351
|
+
durationMs: Date.now() - startedAt
|
|
6352
|
+
});
|
|
6353
|
+
})().finally(() => {
|
|
6354
|
+
active.delete(runId);
|
|
6355
|
+
});
|
|
6356
|
+
active.set(runId, { runId, bus, controller, feature: story.feature, storyId: story.id, startedAt, done, eventBuffer });
|
|
6357
|
+
return c.json({ runId, eventStream: `/api/runs/${runId}/stream` }, 202);
|
|
6358
|
+
});
|
|
6359
|
+
app.get("/api/runs/:runId/stream", (c) => {
|
|
6360
|
+
const runId = c.req.param("runId");
|
|
6361
|
+
const run = active.get(runId);
|
|
6362
|
+
return streamSSE(c, async (stream) => {
|
|
6363
|
+
if (!run) {
|
|
6364
|
+
await stream.writeSSE({ event: "error", data: JSON.stringify({ message: "unknown run" }) });
|
|
6365
|
+
await stream.writeSSE({ event: "closed", data: "{}" });
|
|
6366
|
+
return;
|
|
6367
|
+
}
|
|
6368
|
+
let idx = 0;
|
|
6369
|
+
let resolveWaiter = null;
|
|
6370
|
+
const notify = () => resolveWaiter?.();
|
|
6371
|
+
const unsubscribe = run.bus.subscribe(() => notify());
|
|
6372
|
+
const ka = setInterval(() => stream.writeSSE({ event: "ping", data: "{}" }).catch(() => {
|
|
6373
|
+
}), 15e3);
|
|
6374
|
+
let closed = false;
|
|
6375
|
+
stream.onAbort(() => {
|
|
6376
|
+
closed = true;
|
|
6377
|
+
unsubscribe();
|
|
6378
|
+
clearInterval(ka);
|
|
6379
|
+
});
|
|
6380
|
+
try {
|
|
6381
|
+
while (!closed) {
|
|
6382
|
+
while (idx < run.eventBuffer.length) {
|
|
6383
|
+
const e = run.eventBuffer[idx++];
|
|
6384
|
+
await stream.writeSSE({ event: e.kind, data: JSON.stringify(e) });
|
|
6385
|
+
if (e.kind === "done") {
|
|
6386
|
+
await stream.writeSSE({ event: "closed", data: JSON.stringify({ ok: true }) });
|
|
6387
|
+
clearInterval(ka);
|
|
6388
|
+
unsubscribe();
|
|
6389
|
+
return;
|
|
6390
|
+
}
|
|
6391
|
+
}
|
|
6392
|
+
await new Promise((resolve2) => {
|
|
6393
|
+
resolveWaiter = resolve2;
|
|
6394
|
+
});
|
|
6395
|
+
resolveWaiter = null;
|
|
6396
|
+
}
|
|
6397
|
+
} finally {
|
|
6398
|
+
clearInterval(ka);
|
|
6399
|
+
unsubscribe();
|
|
6400
|
+
}
|
|
6401
|
+
});
|
|
6402
|
+
});
|
|
6403
|
+
app.delete("/api/runs/:runId", (c) => {
|
|
6404
|
+
const runId = c.req.param("runId");
|
|
6405
|
+
const run = active.get(runId);
|
|
6406
|
+
if (!run) return c.json({ error: "not_found" }, 404);
|
|
6407
|
+
run.controller.abort();
|
|
6408
|
+
return c.json({ ok: true });
|
|
6409
|
+
});
|
|
6410
|
+
}
|
|
6411
|
+
|
|
6412
|
+
// src/console/api/secrets.ts
|
|
6413
|
+
import { z as z5 } from "zod";
|
|
6414
|
+
|
|
6415
|
+
// src/core/mask-token.ts
|
|
6416
|
+
function maskToken(t) {
|
|
6417
|
+
if (!t) return null;
|
|
6418
|
+
if (t.length <= 8) return "\u2022\u2022\u2022\u2022" + t.slice(-2);
|
|
6419
|
+
return t.slice(0, 4) + "\u2026" + t.slice(-4);
|
|
6420
|
+
}
|
|
6421
|
+
|
|
6422
|
+
// src/console/api/secrets.ts
|
|
6423
|
+
var PROVIDERS = ["anthropic", "openai", "google"];
|
|
6424
|
+
function mountSecretsApi(app, opts) {
|
|
6425
|
+
app.get("/api/secrets", (c) => {
|
|
6426
|
+
const s = loadSecrets(opts.paths.secretsFile);
|
|
6427
|
+
return c.json({
|
|
6428
|
+
planner: {
|
|
6429
|
+
anthropic: maskToken(s.planner?.anthropic),
|
|
6430
|
+
openai: maskToken(s.planner?.openai),
|
|
6431
|
+
google: maskToken(s.planner?.google)
|
|
6432
|
+
},
|
|
6433
|
+
tracker: {
|
|
6434
|
+
jira: {
|
|
6435
|
+
host: s.tracker?.jira?.host ?? null,
|
|
6436
|
+
email: s.tracker?.jira?.email ?? null,
|
|
6437
|
+
token: maskToken(s.tracker?.jira?.token)
|
|
6438
|
+
},
|
|
6439
|
+
azure: {
|
|
6440
|
+
organization: s.tracker?.azure?.organization ?? null,
|
|
6441
|
+
project: s.tracker?.azure?.project ?? null,
|
|
6442
|
+
pat: maskToken(s.tracker?.azure?.pat)
|
|
6443
|
+
}
|
|
6444
|
+
}
|
|
6445
|
+
});
|
|
6446
|
+
});
|
|
6447
|
+
const PutBody = z5.object({
|
|
6448
|
+
planner: z5.object({
|
|
6449
|
+
anthropic: z5.string().optional(),
|
|
6450
|
+
openai: z5.string().optional(),
|
|
6451
|
+
google: z5.string().optional()
|
|
6452
|
+
}).optional(),
|
|
6453
|
+
tracker: z5.object({
|
|
6454
|
+
jira: z5.object({
|
|
6455
|
+
host: z5.string().optional(),
|
|
6456
|
+
email: z5.string().optional(),
|
|
6457
|
+
token: z5.string().optional()
|
|
6458
|
+
}).optional(),
|
|
6459
|
+
azure: z5.object({
|
|
6460
|
+
organization: z5.string().optional(),
|
|
6461
|
+
project: z5.string().optional(),
|
|
6462
|
+
pat: z5.string().optional()
|
|
6463
|
+
}).optional()
|
|
6464
|
+
}).optional()
|
|
6465
|
+
});
|
|
6466
|
+
app.put("/api/secrets", async (c) => {
|
|
6467
|
+
const parse = PutBody.safeParse(await c.req.json());
|
|
6468
|
+
if (!parse.success) return c.json({ error: "invalid_body", issues: parse.error.issues }, 400);
|
|
6469
|
+
const current = loadSecrets(opts.paths.secretsFile);
|
|
6470
|
+
const merged = mergeSecrets(current, parse.data);
|
|
6471
|
+
saveSecrets(opts.paths.secretsFile, merged);
|
|
6472
|
+
return c.json({ ok: true });
|
|
6473
|
+
});
|
|
6474
|
+
for (const p of PROVIDERS) {
|
|
6475
|
+
app.post(`/api/secrets/test/${p}`, async (c) => {
|
|
6476
|
+
const s = loadSecrets(opts.paths.secretsFile);
|
|
6477
|
+
const key = s.planner?.[p];
|
|
6478
|
+
if (!key) return c.json({ ok: false, detail: `${p} key not saved` }, 400);
|
|
6479
|
+
const result = await fetchProviderModelIds(p, key);
|
|
6480
|
+
if (result.ok) return c.json({ ok: true, modelCount: result.ids.size });
|
|
6481
|
+
return c.json({ ok: false, status: result.status, detail: result.body.slice(0, 200) }, 200);
|
|
6482
|
+
});
|
|
6483
|
+
}
|
|
6484
|
+
app.post("/api/secrets/test/jira", async (c) => {
|
|
6485
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6486
|
+
const s = loadSecrets(opts.paths.secretsFile);
|
|
6487
|
+
return c.json(await probeJiraConnectivity(s, cfg));
|
|
6488
|
+
});
|
|
6489
|
+
app.post("/api/secrets/test/azure", async (c) => {
|
|
6490
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6491
|
+
const s = loadSecrets(opts.paths.secretsFile);
|
|
6492
|
+
return c.json(await probeAzureConnectivity(s, cfg));
|
|
6493
|
+
});
|
|
6494
|
+
}
|
|
6495
|
+
|
|
6496
|
+
// src/console/api/tracker.ts
|
|
6497
|
+
import { z as z6 } from "zod";
|
|
6498
|
+
var SearchQuery = z6.object({ q: z6.string().max(500) });
|
|
6499
|
+
var ImportBody = z6.object({
|
|
6500
|
+
issueId: z6.string().min(1),
|
|
6501
|
+
feature: z6.string().min(1).max(80),
|
|
6502
|
+
withAttachments: z6.boolean().optional()
|
|
6503
|
+
});
|
|
6504
|
+
function mountTrackerApi(app, opts) {
|
|
6505
|
+
app.get("/api/tracker/search", async (c) => {
|
|
6506
|
+
const q = SearchQuery.safeParse({ q: c.req.query("q") });
|
|
6507
|
+
if (!q.success) return c.json({ error: "invalid_query" }, 400);
|
|
6508
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6509
|
+
const secrets = overlayTrackerEnv(loadSecrets(opts.paths.secretsFile));
|
|
6510
|
+
const { client, error: error2 } = clientFor(cfg, secrets);
|
|
6511
|
+
if (!client) {
|
|
6512
|
+
return c.json({ error: error2?.kind ?? "unsupported", detail: error2?.detail ?? "" }, 400);
|
|
5269
6513
|
}
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
6514
|
+
try {
|
|
6515
|
+
const results = await client.searchIssues(q.data.q);
|
|
6516
|
+
return c.json({ ok: true, results });
|
|
6517
|
+
} catch (err) {
|
|
6518
|
+
if (err instanceof TrackerError) {
|
|
6519
|
+
return c.json({ ok: false, kind: err.kind, status: err.statusCode, detail: err.message }, 200);
|
|
6520
|
+
}
|
|
6521
|
+
return c.json({ ok: false, detail: err.message }, 200);
|
|
5274
6522
|
}
|
|
5275
|
-
}
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
)
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
6523
|
+
});
|
|
6524
|
+
app.post("/api/tracker/import", async (c) => {
|
|
6525
|
+
const body = ImportBody.safeParse(await c.req.json());
|
|
6526
|
+
if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
|
|
6527
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6528
|
+
const secrets = overlayTrackerEnv(loadSecrets(opts.paths.secretsFile));
|
|
6529
|
+
const { client, error: error2 } = clientFor(cfg, secrets);
|
|
6530
|
+
if (!client) {
|
|
6531
|
+
return c.json({ error: error2?.kind ?? "unsupported", detail: error2?.detail ?? "" }, 400);
|
|
6532
|
+
}
|
|
6533
|
+
try {
|
|
6534
|
+
const issue = await client.fetchIssue(body.data.issueId);
|
|
6535
|
+
const created = await createStoryRecordFromIssue({
|
|
6536
|
+
paths: opts.paths,
|
|
6537
|
+
feature: body.data.feature,
|
|
6538
|
+
issue,
|
|
6539
|
+
withAttachments: body.data.withAttachments ?? true,
|
|
6540
|
+
client,
|
|
6541
|
+
config: cfg
|
|
6542
|
+
});
|
|
6543
|
+
return c.json(created, 201);
|
|
6544
|
+
} catch (err) {
|
|
6545
|
+
if (err instanceof TrackerError) return c.json({ error: err.kind, detail: err.message }, 502);
|
|
6546
|
+
return c.json({ error: "fetch_failed", detail: err.message }, 500);
|
|
6547
|
+
}
|
|
6548
|
+
});
|
|
5288
6549
|
}
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
6550
|
+
|
|
6551
|
+
// src/commands/doctor-api.ts
|
|
6552
|
+
async function gatherContextForApi(paths) {
|
|
6553
|
+
return gatherContext(paths);
|
|
6554
|
+
}
|
|
6555
|
+
async function runAllChecksForApi(paths, ctx, fix) {
|
|
6556
|
+
return runAllChecks(paths, ctx, fix);
|
|
6557
|
+
}
|
|
6558
|
+
|
|
6559
|
+
// src/console/api/doctor.ts
|
|
6560
|
+
function mountDoctorApi(app, opts) {
|
|
6561
|
+
app.get("/api/doctor", async (c) => {
|
|
6562
|
+
const ctx = await gatherContextForApi(opts.paths);
|
|
6563
|
+
const checks = await runAllChecksForApi(opts.paths, ctx, false);
|
|
6564
|
+
return c.json({ root: opts.paths.root, checks, summary: summarise(checks) });
|
|
6565
|
+
});
|
|
6566
|
+
app.post("/api/doctor/fix", async (c) => {
|
|
6567
|
+
const ctx = await gatherContextForApi(opts.paths);
|
|
6568
|
+
const checks = await runAllChecksForApi(opts.paths, ctx, true);
|
|
6569
|
+
return c.json({ root: opts.paths.root, checks, summary: summarise(checks) });
|
|
5294
6570
|
});
|
|
5295
6571
|
}
|
|
5296
6572
|
|
|
5297
|
-
// src/
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
6573
|
+
// src/console/api/copy-plan-prompt.ts
|
|
6574
|
+
function mountCopyPlanPromptApi(app, opts) {
|
|
6575
|
+
app.get("/api/copy-plan-prompt", (c) => {
|
|
6576
|
+
const feature = (c.req.query("feature") ?? "").trim();
|
|
6577
|
+
const storyId = (c.req.query("storyId") ?? "").trim();
|
|
6578
|
+
if (!feature || !storyId) {
|
|
6579
|
+
return c.json({ error: "bad_request", detail: "Query params `feature` and `storyId` are required." }, 400);
|
|
6580
|
+
}
|
|
6581
|
+
const stories = listStories(opts.paths, { feature });
|
|
6582
|
+
const hit = stories.find((s) => s.feature === feature && s.id === storyId);
|
|
6583
|
+
if (!hit) {
|
|
6584
|
+
return c.json({ error: "not_found", detail: `No story intake for feature "${feature}" and id "${storyId}".` }, 404);
|
|
6585
|
+
}
|
|
6586
|
+
const config2 = loadConfig(opts.paths.configFile);
|
|
6587
|
+
const intakeContent = readFile(hit.intakePath);
|
|
6588
|
+
const prompt = buildCopyPlanPromptMarkdown(config2, intakeContent);
|
|
6589
|
+
const bytes = Buffer.byteLength(prompt, "utf8");
|
|
6590
|
+
return c.json({
|
|
6591
|
+
prompt,
|
|
6592
|
+
feature: hit.feature,
|
|
6593
|
+
storyId: hit.id,
|
|
6594
|
+
bytes,
|
|
6595
|
+
estTokensApprox: Math.round(bytes / 4)
|
|
6596
|
+
});
|
|
6597
|
+
});
|
|
5305
6598
|
}
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
6599
|
+
|
|
6600
|
+
// src/console/api/dashboard.ts
|
|
6601
|
+
function mountDashboardApi(app, opts) {
|
|
6602
|
+
app.get("/api/dashboard", async (c) => {
|
|
6603
|
+
const cfg = loadConfig(opts.paths.configFile);
|
|
6604
|
+
const lastRun = await readLastRun(opts.paths) ?? null;
|
|
6605
|
+
const runs = await listRuns(opts.paths);
|
|
6606
|
+
const stories = listStories(opts.paths);
|
|
6607
|
+
return c.json({
|
|
6608
|
+
version: "0.6.0",
|
|
6609
|
+
root: opts.paths.root,
|
|
6610
|
+
project: cfg.project,
|
|
6611
|
+
planner: cfg.planner ? { provider: cfg.planner.provider, enabled: cfg.planner.enabled } : null,
|
|
6612
|
+
tracker: { type: cfg.tracker.type },
|
|
6613
|
+
lastRun,
|
|
6614
|
+
runs: runs.map((r) => ({
|
|
6615
|
+
runId: r.runId,
|
|
6616
|
+
startedAt: r.startedAt,
|
|
6617
|
+
durationMs: r.durationMs,
|
|
6618
|
+
success: r.success,
|
|
6619
|
+
partial: r.partial,
|
|
6620
|
+
inputTokens: r.stats.inputTokens,
|
|
6621
|
+
outputTokens: r.stats.outputTokens,
|
|
6622
|
+
cacheHitRatio: r.stats.cacheHitRatio
|
|
6623
|
+
})),
|
|
6624
|
+
storyCounts: countStories(stories),
|
|
6625
|
+
stories: stories.map((s) => ({
|
|
6626
|
+
feature: s.feature,
|
|
6627
|
+
id: s.id,
|
|
6628
|
+
intakePath: s.intakePath,
|
|
6629
|
+
storyDir: s.storyDir,
|
|
6630
|
+
planFile: s.planFile ?? null,
|
|
6631
|
+
titleHint: s.titleHint ?? null
|
|
6632
|
+
}))
|
|
6633
|
+
});
|
|
5316
6634
|
});
|
|
5317
|
-
fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
5318
6635
|
}
|
|
5319
|
-
function
|
|
5320
|
-
const
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
6636
|
+
function countStories(stories) {
|
|
6637
|
+
const total = stories.length;
|
|
6638
|
+
const planned = stories.filter((s) => !!s.planFile).length;
|
|
6639
|
+
return { total, planned, unplanned: total - planned };
|
|
6640
|
+
}
|
|
6641
|
+
|
|
6642
|
+
// src/console/api/projects.ts
|
|
6643
|
+
import fs35 from "fs/promises";
|
|
6644
|
+
import path29 from "path";
|
|
6645
|
+
import os from "os";
|
|
6646
|
+
var MAX = 10;
|
|
6647
|
+
function recentFilePath() {
|
|
6648
|
+
return path29.join(os.homedir(), ".squad", "recent-projects.json");
|
|
6649
|
+
}
|
|
6650
|
+
async function loadRecent() {
|
|
6651
|
+
const RECENT_FILE = recentFilePath();
|
|
6652
|
+
try {
|
|
6653
|
+
const raw = await fs35.readFile(RECENT_FILE, "utf8");
|
|
6654
|
+
const parsed = JSON.parse(raw);
|
|
6655
|
+
return Array.isArray(parsed) ? parsed.slice(0, MAX) : [];
|
|
6656
|
+
} catch {
|
|
6657
|
+
return [];
|
|
6658
|
+
}
|
|
6659
|
+
}
|
|
6660
|
+
async function saveRecent(list) {
|
|
6661
|
+
const RECENT_FILE = recentFilePath();
|
|
6662
|
+
await fs35.mkdir(path29.dirname(RECENT_FILE), { recursive: true });
|
|
6663
|
+
await fs35.writeFile(RECENT_FILE, JSON.stringify(list.slice(0, MAX), null, 2) + "\n", "utf8");
|
|
6664
|
+
}
|
|
6665
|
+
function mountProjectsApi(app, opts) {
|
|
6666
|
+
app.get("/api/projects/recent", async (c) => c.json(await loadRecent()));
|
|
6667
|
+
app.post("/api/projects/touch", async (c) => {
|
|
6668
|
+
const list = await loadRecent();
|
|
6669
|
+
const filtered = list.filter((p) => p.root !== opts.paths.root);
|
|
6670
|
+
filtered.unshift({ root: opts.paths.root, lastOpenedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6671
|
+
await saveRecent(filtered);
|
|
6672
|
+
return c.json({ ok: true });
|
|
5330
6673
|
});
|
|
5331
|
-
fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
|
|
5332
6674
|
}
|
|
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 };
|
|
6675
|
+
|
|
6676
|
+
// src/console/api/index.ts
|
|
6677
|
+
function mountApi(app, opts) {
|
|
6678
|
+
mountMetaApi(app, opts);
|
|
6679
|
+
mountDashboardApi(app, opts);
|
|
6680
|
+
mountProjectsApi(app, opts);
|
|
6681
|
+
mountStoriesApi(app, opts);
|
|
6682
|
+
mountPlansApi(app, opts);
|
|
6683
|
+
mountConfigApi(app, opts);
|
|
6684
|
+
mountRunsApi(app, opts);
|
|
6685
|
+
mountSecretsApi(app, opts);
|
|
6686
|
+
mountTrackerApi(app, opts);
|
|
6687
|
+
mountDoctorApi(app, opts);
|
|
6688
|
+
mountCopyPlanPromptApi(app, opts);
|
|
5352
6689
|
}
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
6690
|
+
|
|
6691
|
+
// src/console/static-assets.ts
|
|
6692
|
+
import fs36 from "fs";
|
|
6693
|
+
import path30 from "path";
|
|
6694
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
6695
|
+
import { Readable } from "stream";
|
|
6696
|
+
function pathnameForStatic(c) {
|
|
6697
|
+
const rawUrl = c.env.incoming?.url;
|
|
6698
|
+
if (typeof rawUrl === "string" && rawUrl.startsWith("/")) {
|
|
6699
|
+
const pathOnly = rawUrl.split("?", 1)[0] ?? rawUrl;
|
|
6700
|
+
try {
|
|
6701
|
+
return decodeURIComponent(pathOnly);
|
|
6702
|
+
} catch {
|
|
6703
|
+
return pathOnly;
|
|
6704
|
+
}
|
|
5363
6705
|
}
|
|
6706
|
+
try {
|
|
6707
|
+
return decodeURIComponent(new URL(c.req.url).pathname);
|
|
6708
|
+
} catch {
|
|
6709
|
+
return new URL(c.req.url).pathname;
|
|
6710
|
+
}
|
|
6711
|
+
}
|
|
6712
|
+
function mountStaticAssets(app, _opts) {
|
|
6713
|
+
const distDir = resolveConsoleUiDist();
|
|
6714
|
+
app.get("/logo.svg", (c) => serveFile(c, distDir, "logo.svg", "image/svg+xml"));
|
|
6715
|
+
app.get("/favicon.ico", (c) => serveFile(c, distDir, "favicon.ico", "image/x-icon"));
|
|
6716
|
+
app.get("*", (c) => {
|
|
6717
|
+
const pathname = pathnameForStatic(c);
|
|
6718
|
+
if (pathname.startsWith("/assets/")) {
|
|
6719
|
+
return assetResponse(c, distDir, pathname);
|
|
6720
|
+
}
|
|
6721
|
+
if (c.req.path.startsWith("/api/") || c.req.path.startsWith("/healthz")) return c.notFound();
|
|
6722
|
+
if (!distDir) return c.text("UI not built. Run `pnpm -C console-ui build`.", 404);
|
|
6723
|
+
const indexHtml = path30.join(distDir, "index.html");
|
|
6724
|
+
if (!fs36.existsSync(indexHtml)) return c.text("UI not built", 404);
|
|
6725
|
+
return c.html(fs36.readFileSync(indexHtml, "utf8"), 200, { "cache-control": "no-store" });
|
|
6726
|
+
});
|
|
5364
6727
|
}
|
|
5365
|
-
function
|
|
5366
|
-
if (!
|
|
5367
|
-
const
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
6728
|
+
function assetResponse(c, distDir, pathname) {
|
|
6729
|
+
if (!distDir) return c.text("UI not built", 404);
|
|
6730
|
+
const rel = pathname.replace(/^\//, "");
|
|
6731
|
+
const abs = path30.normalize(path30.join(distDir, rel));
|
|
6732
|
+
if (!isInside2(abs, distDir)) return c.text("forbidden", 403);
|
|
6733
|
+
if (!fs36.existsSync(abs) || !fs36.statSync(abs).isFile()) return c.text("not found", 404);
|
|
6734
|
+
const body = Readable.toWeb(fs36.createReadStream(abs));
|
|
6735
|
+
return new Response(body, {
|
|
6736
|
+
headers: {
|
|
6737
|
+
"content-type": contentTypeFor(abs),
|
|
6738
|
+
"cache-control": "public, max-age=31536000, immutable"
|
|
5375
6739
|
}
|
|
6740
|
+
});
|
|
6741
|
+
}
|
|
6742
|
+
function serveFile(c, distDir, name, ct) {
|
|
6743
|
+
if (!distDir) return c.text("UI not built", 404);
|
|
6744
|
+
const abs = path30.join(distDir, name);
|
|
6745
|
+
if (!fs36.existsSync(abs)) return c.text("not found", 404);
|
|
6746
|
+
return new Response(fs36.readFileSync(abs), {
|
|
6747
|
+
headers: { "content-type": ct, "cache-control": "public, max-age=86400" }
|
|
6748
|
+
});
|
|
6749
|
+
}
|
|
6750
|
+
function contentTypeFor(p) {
|
|
6751
|
+
if (p.endsWith(".js")) return "application/javascript; charset=utf-8";
|
|
6752
|
+
if (p.endsWith(".css")) return "text/css; charset=utf-8";
|
|
6753
|
+
if (p.endsWith(".html")) return "text/html; charset=utf-8";
|
|
6754
|
+
if (p.endsWith(".svg")) return "image/svg+xml";
|
|
6755
|
+
if (p.endsWith(".png")) return "image/png";
|
|
6756
|
+
if (p.endsWith(".woff2")) return "font/woff2";
|
|
6757
|
+
if (p.endsWith(".json")) return "application/json";
|
|
6758
|
+
return "application/octet-stream";
|
|
6759
|
+
}
|
|
6760
|
+
function isInside2(abs, parent) {
|
|
6761
|
+
const a = path30.resolve(abs) + path30.sep;
|
|
6762
|
+
const p = path30.resolve(parent) + path30.sep;
|
|
6763
|
+
return a.startsWith(p);
|
|
6764
|
+
}
|
|
6765
|
+
function resolveConsoleUiDist() {
|
|
6766
|
+
const fromEnv = process.env.SQUAD_CONSOLE_UI_DIST;
|
|
6767
|
+
if (fromEnv && fs36.existsSync(path30.join(fromEnv, "index.html"))) return fromEnv;
|
|
6768
|
+
const candidates = [
|
|
6769
|
+
fileURLToPath2(new URL("./console-ui/", import.meta.url)),
|
|
6770
|
+
// dist/cli.js → dist/console-ui/
|
|
6771
|
+
fileURLToPath2(new URL("../../dist/console-ui/", import.meta.url)),
|
|
6772
|
+
// src/console/*.ts → project/dist/console-ui/
|
|
6773
|
+
fileURLToPath2(new URL("../console-ui/dist/", import.meta.url)),
|
|
6774
|
+
// dist/cli.js → project/console-ui/dist
|
|
6775
|
+
fileURLToPath2(new URL("../../console-ui/dist/", import.meta.url))
|
|
6776
|
+
// src/console/*.ts → project/console-ui/dist
|
|
6777
|
+
];
|
|
6778
|
+
for (const c of candidates) {
|
|
6779
|
+
if (fs36.existsSync(path30.join(c, "index.html"))) return c;
|
|
5376
6780
|
}
|
|
5377
|
-
return
|
|
6781
|
+
return null;
|
|
5378
6782
|
}
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
6783
|
+
|
|
6784
|
+
// src/console/auth.ts
|
|
6785
|
+
function authMiddleware(expected) {
|
|
6786
|
+
return async (c, next) => {
|
|
6787
|
+
const presented = readPresentedToken(c.req.raw);
|
|
6788
|
+
if (!presented || !timingSafeEqualHex(presented, expected)) {
|
|
6789
|
+
return c.json({ error: "unauthorized", detail: "missing or invalid session token" }, 401);
|
|
6790
|
+
}
|
|
6791
|
+
await next();
|
|
6792
|
+
};
|
|
6793
|
+
}
|
|
6794
|
+
function readPresentedToken(req) {
|
|
6795
|
+
const auth = req.headers.get("authorization");
|
|
6796
|
+
if (auth && auth.toLowerCase().startsWith("bearer ")) {
|
|
6797
|
+
return auth.slice("bearer ".length).trim();
|
|
5383
6798
|
}
|
|
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;
|
|
6799
|
+
const url = new URL(req.url);
|
|
6800
|
+
return url.searchParams.get("t");
|
|
5393
6801
|
}
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
6802
|
+
function timingSafeEqualHex(a, b) {
|
|
6803
|
+
if (a.length !== b.length) return false;
|
|
6804
|
+
let mismatch = 0;
|
|
6805
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
6806
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
5398
6807
|
}
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
6808
|
+
return mismatch === 0;
|
|
6809
|
+
}
|
|
6810
|
+
|
|
6811
|
+
// src/console/server.ts
|
|
6812
|
+
async function startConsoleServer(opts) {
|
|
6813
|
+
const bindPort = opts.requestedPort === 0 ? await reserveEphemeralPort() : await findFreePortFromBase(opts.requestedPort);
|
|
6814
|
+
const app = new Hono();
|
|
6815
|
+
app.get(
|
|
6816
|
+
"/healthz",
|
|
6817
|
+
(c) => c.json(
|
|
6818
|
+
{ ok: true, version: "0.6.0", port: bindPort },
|
|
6819
|
+
200,
|
|
6820
|
+
{
|
|
6821
|
+
"cache-control": "no-store"
|
|
6822
|
+
}
|
|
6823
|
+
)
|
|
6824
|
+
);
|
|
6825
|
+
app.use(
|
|
6826
|
+
"*",
|
|
6827
|
+
secureHeaders({
|
|
6828
|
+
contentSecurityPolicy: {
|
|
6829
|
+
defaultSrc: ["'self'"],
|
|
6830
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
6831
|
+
imgSrc: ["'self'", "data:"],
|
|
6832
|
+
connectSrc: ["'self'"],
|
|
6833
|
+
scriptSrc: ["'self'"],
|
|
6834
|
+
frameAncestors: ["'none'"]
|
|
6835
|
+
},
|
|
6836
|
+
referrerPolicy: "no-referrer"
|
|
6837
|
+
})
|
|
6838
|
+
);
|
|
6839
|
+
app.use("*", cors({ origin: "http://127.0.0.1:" + bindPort }));
|
|
6840
|
+
app.use("/api/*", authMiddleware(opts.token));
|
|
6841
|
+
mountApi(app, { paths: opts.paths });
|
|
6842
|
+
mountStaticAssets(app, { paths: opts.paths });
|
|
6843
|
+
const nodeServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: bindPort });
|
|
6844
|
+
let resolveDone = () => {
|
|
6845
|
+
};
|
|
6846
|
+
const done = new Promise((resolve2) => {
|
|
6847
|
+
resolveDone = resolve2;
|
|
5406
6848
|
});
|
|
6849
|
+
const actualPort = (() => {
|
|
6850
|
+
const addr = nodeServer.address();
|
|
6851
|
+
if (addr && typeof addr === "object") return addr.port;
|
|
6852
|
+
return bindPort;
|
|
6853
|
+
})();
|
|
6854
|
+
const shutdown = (onClose) => {
|
|
6855
|
+
nodeServer.close((err) => {
|
|
6856
|
+
onClose(err);
|
|
6857
|
+
});
|
|
6858
|
+
};
|
|
6859
|
+
const stop = () => {
|
|
6860
|
+
shutdown((err) => {
|
|
6861
|
+
if (err) warning(`server close: ${err.message}`);
|
|
6862
|
+
resolveDone();
|
|
6863
|
+
});
|
|
6864
|
+
};
|
|
6865
|
+
process.once("SIGINT", stop);
|
|
6866
|
+
process.once("SIGTERM", stop);
|
|
6867
|
+
return {
|
|
6868
|
+
port: actualPort,
|
|
6869
|
+
done,
|
|
6870
|
+
close: () => new Promise((resolve2, reject) => {
|
|
6871
|
+
shutdown((err) => {
|
|
6872
|
+
if (err) {
|
|
6873
|
+
reject(err);
|
|
6874
|
+
return;
|
|
6875
|
+
}
|
|
6876
|
+
resolve2();
|
|
6877
|
+
resolveDone();
|
|
6878
|
+
});
|
|
6879
|
+
})
|
|
6880
|
+
};
|
|
5407
6881
|
}
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
6882
|
+
function reserveEphemeralPort() {
|
|
6883
|
+
return new Promise((resolve2, reject) => {
|
|
6884
|
+
const s = createServer();
|
|
6885
|
+
s.once("error", reject);
|
|
6886
|
+
s.listen(0, "127.0.0.1", () => {
|
|
6887
|
+
const addr = s.address();
|
|
6888
|
+
s.close((closeErr) => {
|
|
6889
|
+
if (closeErr) {
|
|
6890
|
+
reject(closeErr);
|
|
6891
|
+
return;
|
|
6892
|
+
}
|
|
6893
|
+
if (addr && typeof addr === "object") {
|
|
6894
|
+
resolve2(addr.port);
|
|
6895
|
+
} else {
|
|
6896
|
+
reject(new Error("Could not determine ephemeral port"));
|
|
6897
|
+
}
|
|
6898
|
+
});
|
|
6899
|
+
});
|
|
5417
6900
|
});
|
|
5418
6901
|
}
|
|
5419
|
-
function
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
if (
|
|
5423
|
-
if (`${s.feature}/${s.id}` === t) return s;
|
|
6902
|
+
async function findFreePortFromBase(start, attempts = 20) {
|
|
6903
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
6904
|
+
const candidate = start + i;
|
|
6905
|
+
if (await isPortFree(candidate)) return candidate;
|
|
5424
6906
|
}
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
}
|
|
5429
|
-
function isSequenceToken(s) {
|
|
5430
|
-
return /^\d{1,4}$/.test(s) && s.length > 0;
|
|
6907
|
+
throw new Error(
|
|
6908
|
+
`Could not find a free port in [${start}, ${start + attempts - 1}]. Pass \`--port <n>\` to pick another base.`
|
|
6909
|
+
);
|
|
5431
6910
|
}
|
|
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) };
|
|
6911
|
+
function isPortFree(port) {
|
|
6912
|
+
return new Promise((resolve2) => {
|
|
6913
|
+
const probe = createServer();
|
|
6914
|
+
probe.once("error", () => resolve2(false));
|
|
6915
|
+
probe.once("listening", () => probe.close(() => resolve2(true)));
|
|
6916
|
+
probe.listen(port, "127.0.0.1");
|
|
6917
|
+
});
|
|
5444
6918
|
}
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
const
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
6919
|
+
|
|
6920
|
+
// src/commands/console.ts
|
|
6921
|
+
var DEFAULT_PORT = 4571;
|
|
6922
|
+
async function runConsole(opts) {
|
|
6923
|
+
const root = requireSquadRoot();
|
|
6924
|
+
const paths = buildPaths(root);
|
|
6925
|
+
loadConfig(paths.configFile);
|
|
6926
|
+
const portOpt = opts.port;
|
|
6927
|
+
const requestedPort = portOpt !== void 0 && !Number.isNaN(portOpt) && portOpt >= 0 ? portOpt : DEFAULT_PORT;
|
|
6928
|
+
const token = opts.token ?? randomBytes2(32).toString("hex");
|
|
6929
|
+
const server = await startConsoleServer({ paths, requestedPort, token });
|
|
6930
|
+
const url = `http://127.0.0.1:${server.port}/?t=${token}`;
|
|
6931
|
+
banner();
|
|
6932
|
+
divider("squad console");
|
|
6933
|
+
summaryBox(" squad console ", [
|
|
6934
|
+
{ key: "project", value: resolve(paths.root) },
|
|
6935
|
+
{ key: "url", value: url },
|
|
6936
|
+
{ key: "token", value: `${token.slice(0, 8)}\u2026` },
|
|
6937
|
+
{ key: "port", value: String(server.port) }
|
|
6938
|
+
]);
|
|
6939
|
+
if (server.port !== requestedPort) {
|
|
6940
|
+
warning(`port ${requestedPort} was busy \u2014 bound to ${server.port} instead`);
|
|
5454
6941
|
}
|
|
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
|
-
);
|
|
6942
|
+
blank();
|
|
6943
|
+
step("Next:");
|
|
6944
|
+
info("1) Pick a story in the Stories tab.");
|
|
6945
|
+
info('2) Click "Generate plan" to start a planning run live.');
|
|
6946
|
+
info("3) Press Ctrl-C in this terminal to stop the server when you're done.");
|
|
6947
|
+
if (opts.open !== false) {
|
|
6948
|
+
try {
|
|
6949
|
+
await open(url);
|
|
6950
|
+
} catch {
|
|
6951
|
+
warning("Could not open a browser automatically. Copy the URL above into your browser.");
|
|
5467
6952
|
}
|
|
6953
|
+
} else {
|
|
6954
|
+
info("Browser auto-open disabled (--no-open). Copy the URL above into your browser.");
|
|
5468
6955
|
}
|
|
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;
|
|
6956
|
+
await server.done;
|
|
6957
|
+
info("console stopped");
|
|
5485
6958
|
}
|
|
5486
6959
|
|
|
5487
6960
|
// src/commands/rm/story.ts
|
|
5488
|
-
import
|
|
5489
|
-
import
|
|
6961
|
+
import fs38 from "fs";
|
|
6962
|
+
import path32 from "path";
|
|
5490
6963
|
import { confirm as confirm10 } from "@inquirer/prompts";
|
|
5491
6964
|
|
|
5492
6965
|
// src/commands/rm/feature.ts
|
|
5493
|
-
import
|
|
5494
|
-
import
|
|
6966
|
+
import fs37 from "fs";
|
|
6967
|
+
import path31 from "path";
|
|
5495
6968
|
import { confirm as confirm9 } from "@inquirer/prompts";
|
|
5496
6969
|
function countStoryDirs(storiesDir, feature) {
|
|
5497
|
-
const d =
|
|
5498
|
-
if (!
|
|
5499
|
-
return
|
|
6970
|
+
const d = path31.join(storiesDir, feature);
|
|
6971
|
+
if (!fs37.existsSync(d)) return 0;
|
|
6972
|
+
return fs37.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
|
|
5500
6973
|
}
|
|
5501
6974
|
async function runRmFeature(featureSlug, opts = {}) {
|
|
5502
6975
|
const root = requireSquadRoot();
|
|
@@ -5530,17 +7003,17 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
5530
7003
|
feature = await pickFeatureInteractive(paths);
|
|
5531
7004
|
}
|
|
5532
7005
|
}
|
|
5533
|
-
const storyDir =
|
|
5534
|
-
const planDir =
|
|
7006
|
+
const storyDir = path31.join(paths.storiesDir, feature);
|
|
7007
|
+
const planDir = path31.join(paths.plansDir, feature);
|
|
5535
7008
|
const nStories = countStoryDirs(paths.storiesDir, feature);
|
|
5536
7009
|
const nPlans = listAllPlanListEntries(paths, feature).length;
|
|
5537
|
-
const hasOverview =
|
|
7010
|
+
const hasOverview = fs37.existsSync(path31.join(planDir, "00-overview.md")) ? 1 : 0;
|
|
5538
7011
|
const targets = [];
|
|
5539
|
-
if (
|
|
5540
|
-
targets.push({ label:
|
|
7012
|
+
if (fs37.existsSync(storyDir)) {
|
|
7013
|
+
targets.push({ label: path31.relative(root, storyDir), absPath: storyDir, kind: "dir" });
|
|
5541
7014
|
}
|
|
5542
|
-
if (
|
|
5543
|
-
targets.push({ label:
|
|
7015
|
+
if (fs37.existsSync(planDir)) {
|
|
7016
|
+
targets.push({ label: path31.relative(root, planDir), absPath: planDir, kind: "dir" });
|
|
5544
7017
|
}
|
|
5545
7018
|
divider("squad rm feature");
|
|
5546
7019
|
info(`Feature: ${feature}`);
|
|
@@ -5555,7 +7028,7 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
5555
7028
|
kv(t.kind, t.label, 6);
|
|
5556
7029
|
}
|
|
5557
7030
|
if (useTrash) {
|
|
5558
|
-
info(`Items will move to ${
|
|
7031
|
+
info(`Items will move to ${path31.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
|
|
5559
7032
|
} else if (!dryRun) {
|
|
5560
7033
|
warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
|
|
5561
7034
|
}
|
|
@@ -5588,7 +7061,7 @@ async function runRmFeature(featureSlug, opts = {}) {
|
|
|
5588
7061
|
useTrash
|
|
5589
7062
|
);
|
|
5590
7063
|
if (result.trashed) {
|
|
5591
|
-
success(`Feature removed. Items moved to ${
|
|
7064
|
+
success(`Feature removed. Items moved to ${path31.relative(root, result.trashed)}.`);
|
|
5592
7065
|
} else {
|
|
5593
7066
|
success("Feature removed (stories and plans directories).");
|
|
5594
7067
|
}
|
|
@@ -5629,11 +7102,11 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
5629
7102
|
}
|
|
5630
7103
|
}
|
|
5631
7104
|
const targets = [
|
|
5632
|
-
{ label:
|
|
7105
|
+
{ label: path32.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
|
|
5633
7106
|
];
|
|
5634
7107
|
if (story.planFile) {
|
|
5635
|
-
const planAbs =
|
|
5636
|
-
targets.push({ label:
|
|
7108
|
+
const planAbs = path32.join(paths.plansDir, story.feature, story.planFile);
|
|
7109
|
+
targets.push({ label: path32.relative(root, planAbs), absPath: planAbs, kind: "file" });
|
|
5637
7110
|
}
|
|
5638
7111
|
renderPreview("story", targets, {
|
|
5639
7112
|
dryRun,
|
|
@@ -5656,18 +7129,16 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
5656
7129
|
return;
|
|
5657
7130
|
}
|
|
5658
7131
|
}
|
|
5659
|
-
const
|
|
5660
|
-
const result = trashOrDelete(absPaths, paths.trashDir, useTrash);
|
|
5661
|
-
removeOverviewRow(path24.join(paths.plansDir, story.feature), story.id);
|
|
7132
|
+
const result = deleteStoryRecord({ paths, story, trash: useTrash });
|
|
5662
7133
|
if (result.trashed) {
|
|
5663
|
-
success(`Removed (moved to ${
|
|
7134
|
+
success(`Removed (moved to ${path32.relative(root, result.trashed)}).`);
|
|
5664
7135
|
} else {
|
|
5665
7136
|
success("Removed story folder, plan file (if any), and overview row.");
|
|
5666
7137
|
}
|
|
5667
|
-
const featurePlanDir =
|
|
5668
|
-
const overviewPath =
|
|
5669
|
-
if (
|
|
5670
|
-
const body =
|
|
7138
|
+
const featurePlanDir = path32.join(paths.plansDir, story.feature);
|
|
7139
|
+
const overviewPath = path32.join(featurePlanDir, "00-overview.md");
|
|
7140
|
+
if (fs38.existsSync(overviewPath)) {
|
|
7141
|
+
const body = fs38.readFileSync(overviewPath, "utf8");
|
|
5671
7142
|
const onlyShell = countOverviewDataRows(body) === 0;
|
|
5672
7143
|
if (onlyShell && featureStoriesDirEmpty(paths, story.feature)) {
|
|
5673
7144
|
if (canPrompt) {
|
|
@@ -5687,7 +7158,7 @@ async function runRmStory(storyPathOrId, opts = {}) {
|
|
|
5687
7158
|
}
|
|
5688
7159
|
|
|
5689
7160
|
// src/commands/rm/plan.ts
|
|
5690
|
-
import
|
|
7161
|
+
import path33 from "path";
|
|
5691
7162
|
import { confirm as confirm11 } from "@inquirer/prompts";
|
|
5692
7163
|
async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
5693
7164
|
const root = requireSquadRoot();
|
|
@@ -5723,7 +7194,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
|
5723
7194
|
}
|
|
5724
7195
|
const storyRows = listStories(paths, { feature: plan.feature });
|
|
5725
7196
|
const linked = storyRows.find((s) => s.planFile === plan.file);
|
|
5726
|
-
const targets = [{ label:
|
|
7197
|
+
const targets = [{ label: path33.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
|
|
5727
7198
|
renderPreview("plan", targets, {
|
|
5728
7199
|
dryRun,
|
|
5729
7200
|
trash: useTrash,
|
|
@@ -5746,14 +7217,14 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
|
|
|
5746
7217
|
}
|
|
5747
7218
|
}
|
|
5748
7219
|
trashOrDelete([plan.abs], paths.trashDir, useTrash);
|
|
5749
|
-
removeOverviewRowForPlanFile(
|
|
5750
|
-
const intakeHint = linked ?
|
|
7220
|
+
removeOverviewRowForPlanFile(path33.join(paths.plansDir, plan.feature), plan.file);
|
|
7221
|
+
const intakeHint = linked ? path33.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
|
|
5751
7222
|
success(`Plan removed. Run \`squad new-plan ${intakeHint}\` to regenerate.`);
|
|
5752
7223
|
}
|
|
5753
7224
|
|
|
5754
7225
|
// src/cli.ts
|
|
5755
7226
|
var program = new Command();
|
|
5756
|
-
program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.
|
|
7227
|
+
program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.6.0");
|
|
5757
7228
|
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
7229
|
"--tracker-workspace <hostOrOrg>",
|
|
5759
7230
|
"Jira host or Azure org (non-interactive default; also written to config for jira/azure)"
|
|
@@ -5786,6 +7257,11 @@ program.command("status").description("Show squad-kit workspace status").action(
|
|
|
5786
7257
|
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
7258
|
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
7259
|
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));
|
|
7260
|
+
program.command("console").description("Open the squad-kit web console (local, dark-modern UI for stories, plans, runs, config)").option(
|
|
7261
|
+
"--port <number>",
|
|
7262
|
+
"Port to bind on 127.0.0.1 (default 4571; falls back to next free port)",
|
|
7263
|
+
(v) => parseInt(v, 10)
|
|
7264
|
+
).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
7265
|
program.command("list").description("List stories and their plan state").option("--feature <slug>", "Filter by feature slug").action(wrap(runList));
|
|
5790
7266
|
var rm = program.command("rm").description("Delete stories, plans, or entire features safely");
|
|
5791
7267
|
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));
|