squad-kit 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, path26 = []) {
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 "${[...path26, key].join(".")}" looks like a secret. Run \`squad config set planner\` or \`squad config set tracker\` to save it to .squad/secrets.yaml, then remove the key from config.yaml. Provider keys also work via ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY.`
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, [...path26, key]);
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 loadConfig(configFile) {
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
- fs3.writeFileSync(configFile, serializeConfig(config2), "utf8");
425
+ const body = serializeConfig(config2);
426
+ parseConfig(body, configFile);
427
+ fs3.writeFileSync(configFile, body, "utf8");
421
428
  }
422
429
  function mergeConfig(base, override) {
423
430
  return {
@@ -551,6 +558,19 @@ function resolveProviderKey(provider) {
551
558
  function readProviderKey(provider) {
552
559
  return resolveProviderKey(provider)?.value;
553
560
  }
561
+ function readProviderKeyForPaths(paths, provider) {
562
+ const envVar = providerEnvVar(provider);
563
+ const envValue = process.env[envVar];
564
+ if (envValue) return envValue;
565
+ const fallback = process.env.SQUAD_PLANNER_API_KEY;
566
+ if (fallback) return fallback;
567
+ if (fs5.existsSync(paths.secretsFile)) {
568
+ const secrets = loadSecrets(paths.secretsFile);
569
+ const fromFile = secrets.planner?.[provider];
570
+ if (fromFile) return fromFile;
571
+ }
572
+ return void 0;
573
+ }
554
574
 
555
575
  // src/utils/fs.ts
556
576
  import fs6 from "fs";
@@ -1226,6 +1246,55 @@ var AzureDevOpsClient = class {
1226
1246
  baseUrl;
1227
1247
  apiVersion;
1228
1248
  webBase;
1249
+ async searchIssues(query, opts) {
1250
+ const limit = Math.min(50, Math.max(1, opts?.limit ?? 25));
1251
+ const q = query.trim();
1252
+ let wiql;
1253
+ if (/^\d+$/.test(q)) {
1254
+ wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Id] = ${q}`;
1255
+ } else if (q.length === 0) {
1256
+ wiql = "SELECT [System.Id] FROM WorkItems ORDER BY [System.ChangedDate] DESC";
1257
+ } else {
1258
+ const esc = q.replace(/'/g, "''");
1259
+ wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Title] CONTAINS STRING '${esc}' ORDER BY [System.ChangedDate] DESC`;
1260
+ }
1261
+ const wiqlUrl = `${this.baseUrl}/wiql?api-version=${this.apiVersion}`;
1262
+ let wiqlRes;
1263
+ try {
1264
+ wiqlRes = await fetch(wiqlUrl, {
1265
+ method: "POST",
1266
+ headers: { ...this.headers(), "content-type": "application/json" },
1267
+ body: JSON.stringify({ query: `${wiql}` })
1268
+ });
1269
+ } catch (err) {
1270
+ throw new TrackerError(`Azure DevOps search failed: ${err.message}`, "network");
1271
+ }
1272
+ if (!wiqlRes.ok) throw this.mapHttpError(wiqlRes.status, "search");
1273
+ const wiqlBody = await wiqlRes.json();
1274
+ const ids = (wiqlBody.workItems ?? []).slice(0, limit).map((w) => w.id);
1275
+ if (ids.length === 0) return [];
1276
+ const batchUrl = `${this.baseUrl}/workitems?ids=${ids.map(String).join(",")}&$expand=all&api-version=${this.apiVersion}`;
1277
+ let batchRes;
1278
+ try {
1279
+ batchRes = await fetch(batchUrl, { headers: this.headers() });
1280
+ } catch (err) {
1281
+ throw new TrackerError(`Azure DevOps work items fetch failed: ${err.message}`, "network");
1282
+ }
1283
+ if (!batchRes.ok) throw this.mapHttpError(batchRes.status, "search");
1284
+ const batchBody = await batchRes.json();
1285
+ const value = batchBody.value ?? [];
1286
+ return value.map((body) => {
1287
+ const id = String(body.id ?? "");
1288
+ const fields = body.fields ?? {};
1289
+ return {
1290
+ id,
1291
+ title: fields["System.Title"] ?? "(no title)",
1292
+ type: fields["System.WorkItemType"],
1293
+ status: fields["System.State"],
1294
+ url: `${this.webBase}/${id}`
1295
+ };
1296
+ });
1297
+ }
1229
1298
  async fetchIssue(id) {
1230
1299
  const url = `${this.baseUrl}/workitems/${encodeURIComponent(id)}?$expand=all&api-version=${this.apiVersion}`;
1231
1300
  let res;
@@ -1276,7 +1345,7 @@ var AzureDevOpsClient = class {
1276
1345
  }
1277
1346
  if (status === 404) {
1278
1347
  return new TrackerError(
1279
- `Azure DevOps work item "${id}" not found in ${this.cfg.organization}/${this.cfg.project} (HTTP 404). Check the id and the organization/project in .squad/config.yaml.`,
1348
+ id === "search" ? `Azure DevOps search failed (HTTP 404) in ${this.cfg.organization}/${this.cfg.project}.` : `Azure DevOps work item "${id}" not found in ${this.cfg.organization}/${this.cfg.project} (HTTP 404). Check the id and the organization/project in .squad/config.yaml.`,
1280
1349
  "not-found",
1281
1350
  status
1282
1351
  );
@@ -1284,7 +1353,11 @@ var AzureDevOpsClient = class {
1284
1353
  if (status === 429) {
1285
1354
  return new TrackerError(`Azure DevOps rate limit hit (HTTP 429). Wait a minute and retry.`, "rate-limited", status);
1286
1355
  }
1287
- return new TrackerError(`Azure DevOps request failed (HTTP ${status}).`, "other", status);
1356
+ return new TrackerError(
1357
+ id === "search" ? `Azure DevOps search failed (HTTP ${status}).` : `Azure DevOps request failed (HTTP ${status}).`,
1358
+ "other",
1359
+ status
1360
+ );
1288
1361
  }
1289
1362
  };
1290
1363
  function parseTags(raw) {
@@ -1381,6 +1454,40 @@ var JiraClient = class {
1381
1454
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
1382
1455
  };
1383
1456
  }
1457
+ async searchIssues(query, opts) {
1458
+ const limit = Math.min(50, Math.max(1, opts?.limit ?? 25));
1459
+ const q = query.trim();
1460
+ let jql;
1461
+ if (/^[A-Z][A-Z0-9_]+-\d+$/i.test(q)) {
1462
+ jql = `issuekey = "${q.toUpperCase()}"`;
1463
+ } else if (q.length === 0) {
1464
+ jql = "order by updated DESC";
1465
+ } else {
1466
+ const esc = q.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1467
+ jql = `text ~ "${esc}" order by updated DESC`;
1468
+ }
1469
+ const url = `${this.baseUrl}/rest/api/3/search?jql=${encodeURIComponent(jql)}&fields=summary,status,issuetype&maxResults=${limit}`;
1470
+ let res;
1471
+ try {
1472
+ res = await fetch(url, { headers: this.headers() });
1473
+ } catch (err) {
1474
+ throw new TrackerError(`Jira search failed: ${err.message}`, "network");
1475
+ }
1476
+ if (!res.ok) throw this.mapHttpError(res.status, "search");
1477
+ const body = await res.json();
1478
+ const issues = body.issues ?? [];
1479
+ return issues.map((it) => {
1480
+ const key = it.key ?? "";
1481
+ const f = it.fields ?? {};
1482
+ return {
1483
+ id: key,
1484
+ title: f.summary ?? "(no title)",
1485
+ type: f.issuetype?.name,
1486
+ status: f.status?.name,
1487
+ url: `${this.baseUrl}/browse/${key}`
1488
+ };
1489
+ });
1490
+ }
1384
1491
  async downloadAttachments(refs, targetDir, opts) {
1385
1492
  return downloadAttachmentsWith(refs, targetDir, this.headers(), opts);
1386
1493
  }
@@ -1400,7 +1507,7 @@ var JiraClient = class {
1400
1507
  }
1401
1508
  if (status === 404) {
1402
1509
  return new TrackerError(
1403
- `Jira issue "${id}" not found on ${this.cfg.host} (HTTP 404). Check the id and your workspace host.`,
1510
+ id === "search" ? `Jira search not found (HTTP 404) on ${this.cfg.host}.` : `Jira issue "${id}" not found on ${this.cfg.host} (HTTP 404). Check the id and your workspace host.`,
1404
1511
  "not-found",
1405
1512
  status
1406
1513
  );
@@ -1408,7 +1515,11 @@ var JiraClient = class {
1408
1515
  if (status === 429) {
1409
1516
  return new TrackerError(`Jira rate limit hit (HTTP 429). Wait a minute and retry.`, "rate-limited", status);
1410
1517
  }
1411
- return new TrackerError(`Jira request failed (HTTP ${status}).`, "other", status);
1518
+ return new TrackerError(
1519
+ id === "search" ? `Jira search failed (HTTP ${status}).` : `Jira request failed (HTTP ${status}).`,
1520
+ "other",
1521
+ status
1522
+ );
1412
1523
  }
1413
1524
  };
1414
1525
  function stripHtml2(html) {
@@ -1963,9 +2074,9 @@ Plan meta-prompts (\`generate-plan.md\`, \`story-skeleton.md\`) ship inside the
1963
2074
  }
1964
2075
 
1965
2076
  // src/commands/new-story.ts
1966
- import fs16 from "fs";
1967
- import path8 from "path";
1968
- import { input as input3, select as select4 } from "@inquirer/prompts";
2077
+ import fs20 from "fs";
2078
+ import path12 from "path";
2079
+ import { input as input3, select as select5 } from "@inquirer/prompts";
1969
2080
 
1970
2081
  // src/core/stories.ts
1971
2082
  import fs15 from "fs";
@@ -1975,6 +2086,15 @@ function listFeatures(paths) {
1975
2086
  if (!fs15.existsSync(paths.storiesDir)) return [];
1976
2087
  return fs15.readdirSync(paths.storiesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
1977
2088
  }
2089
+ function listPlanFeatureNames(paths) {
2090
+ const names = new Set(listFeatures(paths));
2091
+ if (fs15.existsSync(paths.plansDir)) {
2092
+ for (const e of fs15.readdirSync(paths.plansDir, { withFileTypes: true })) {
2093
+ if (e.isDirectory()) names.add(e.name);
2094
+ }
2095
+ }
2096
+ return [...names].sort();
2097
+ }
1978
2098
  function listAllStories(paths) {
1979
2099
  return listStories(paths);
1980
2100
  }
@@ -2097,225 +2217,9 @@ function trackerIdForFilename(type, id) {
2097
2217
  return id;
2098
2218
  }
2099
2219
 
2100
- // src/commands/new-story.ts
2101
- function validateFeatureSlugForPrompt(v) {
2102
- const trimmed = v.trim();
2103
- if (!trimmed) return "Required.";
2104
- const s = slugify(trimmed);
2105
- if (!s) return "Required.";
2106
- if (s !== trimmed) return "Use lowercase kebab-case (letters, digits, hyphens).";
2107
- return true;
2108
- }
2109
- async function runNewStory(featureSlug, opts) {
2110
- const interactive = !opts.yes && Boolean(process.stdin.isTTY) && process.env.CI !== "true";
2111
- let featureSlugInput = featureSlug;
2112
- if (!featureSlugInput) {
2113
- if (!interactive) {
2114
- throw new Error(
2115
- "feature-slug is required. Run `squad new-story` in a TTY to pick interactively, or pass a slug like `squad new-story <feature-slug>`, or add `--yes` to fail fast in scripts."
2116
- );
2117
- }
2118
- const rootForPrompt = requireSquadRoot();
2119
- const pathsForPrompt = buildPaths(rootForPrompt);
2120
- const existing = listFeatures(pathsForPrompt);
2121
- if (existing.length > 0) {
2122
- const choice = await select4({
2123
- message: "Feature:",
2124
- choices: [
2125
- ...existing.map((f) => ({ name: f, value: f })),
2126
- { name: "\u2500\u2500\u2500 new feature \u2500\u2500\u2500", value: "__NEW__" }
2127
- ]
2128
- });
2129
- if (choice !== "__NEW__") {
2130
- featureSlugInput = choice;
2131
- }
2132
- }
2133
- if (!featureSlugInput) {
2134
- featureSlugInput = (await input3({
2135
- message: "New feature slug (kebab-case):",
2136
- validate: validateFeatureSlugForPrompt
2137
- })).trim();
2138
- }
2139
- }
2140
- const slug = slugify(featureSlugInput);
2141
- if (!slug) {
2142
- throw new Error(
2143
- `Invalid feature slug: "${featureSlugInput}". Run \`squad new-story\` and use kebab-case (letters, digits, hyphens).`
2144
- );
2145
- }
2146
- const root = requireSquadRoot();
2147
- const paths = buildPaths(root);
2148
- const config2 = loadConfig(paths.configFile);
2149
- let noTracker = opts.tracker === false && config2.tracker.type !== "none";
2150
- let id = opts.id;
2151
- if (noTracker) id = void 0;
2152
- let trackerNeedsId = !noTracker && config2.tracker.type !== "none" && config2.naming.includeTrackerId;
2153
- let title = opts.title;
2154
- if (!id && trackerNeedsId) {
2155
- if (!interactive) {
2156
- throw new Error(
2157
- `Tracker "${config2.tracker.type}" is configured and naming.includeTrackerId is true. Run \`squad new-story <feature-slug> --id <tracker-id>\` to link now, or \`squad new-story <feature-slug> --no-tracker\` to skip the tracker for this story only.`
2158
- );
2159
- }
2160
- const mode = await select4({
2161
- message: `This workspace requires a ${config2.tracker.type} id for new stories. How do you want to proceed?`,
2162
- choices: [
2163
- { name: `Enter a ${config2.tracker.type} id now`, value: "enter" },
2164
- { name: "Skip tracker for this story (same as --no-tracker)", value: "skip" }
2165
- ],
2166
- default: "enter"
2167
- });
2168
- if (mode === "skip") {
2169
- opts.tracker = false;
2170
- noTracker = true;
2171
- trackerNeedsId = false;
2172
- } else {
2173
- id = (await input3({
2174
- message: `${config2.tracker.type} work-item id:`,
2175
- validate: (v) => validateTrackerId(config2.tracker.type, v.trim()) ? true : `Invalid ${config2.tracker.type} id format.`
2176
- })).trim();
2177
- }
2178
- }
2179
- if (id && !validateTrackerId(config2.tracker.type, id)) {
2180
- throw new Error(
2181
- `Invalid tracker id "${id}" for tracker type "${config2.tracker.type}". Run \`squad config show\` to review the expected format, or \`squad config set tracker\` if the tracker type is wrong.`
2182
- );
2183
- }
2184
- if (!id && !title) {
2185
- if (!interactive) {
2186
- if (!noTracker) {
2187
- throw new Error(
2188
- 'Provide either --id <tracker-id> or --title "..." so the story folder has a distinct name. Run `squad new-story <feature-slug> --id ...` or `squad new-story <feature-slug> --title "..."` (or `--no-tracker` with a title).'
2189
- );
2190
- }
2191
- } else {
2192
- title = (await input3({ message: "Short story title:" })).trim();
2193
- if (!title) throw new Error("Title cannot be empty. Run `squad new-story` again and enter a short title, or add `--yes` to fail fast in scripts.");
2194
- }
2195
- }
2196
- if (interactive && !title) {
2197
- const maybe = (await input3({ message: "Short title (optional, press Enter to skip):", default: "" })).trim();
2198
- if (maybe) title = maybe;
2199
- }
2200
- const storyFolderName = id ? trackerIdForFilename(config2.tracker.type, id) : slugify(title ?? "");
2201
- if (!storyFolderName) {
2202
- throw new Error(
2203
- 'Could not derive a story folder name. Pass `--title "<hint>"` when using `--no-tracker`, or `--id <tracker-id>` to name the folder after the tracker id.'
2204
- );
2205
- }
2206
- const storyDir = path8.join(paths.storiesDir, slug, storyFolderName);
2207
- const intakeFile = path8.join(storyDir, "intake.md");
2208
- const attachmentsDir = path8.join(storyDir, "attachments");
2209
- if (fs16.existsSync(intakeFile)) {
2210
- throw new Error(
2211
- `Intake already exists: ${intakeFile}. Run \`squad new-plan ${path8.relative(root, intakeFile)}\` to plan it, or \`squad rm story\` to remove the story first.`
2212
- );
2213
- }
2214
- fs16.mkdirSync(attachmentsDir, { recursive: true });
2215
- let sourceBlock = "";
2216
- let fetchedSummary;
2217
- let fetchedIssue;
2218
- const shouldFetch = !noTracker && opts.fetch !== false && Boolean(id) && config2.tracker.type !== "none";
2219
- if (shouldFetch) {
2220
- const outcome = await tryFetchIssue(config2, id, opts, paths, attachmentsDir);
2221
- if (outcome.outcome === "ok") {
2222
- fetchedIssue = outcome.issue;
2223
- sourceBlock = buildSourcePreamble(outcome.issue, outcome.downloads, config2.tracker.type);
2224
- if (!title) title = outcome.issue.title;
2225
- const written = outcome.downloads.filter((d) => d.outcome === "written").length;
2226
- fetchedSummary = { id: outcome.issue.id, written };
2227
- } else {
2228
- warning(`Tracker fetch skipped: ${outcome.reason}`);
2229
- if (outcome.hint) info(outcome.hint);
2230
- sourceBlock = buildFallbackPreamble(outcome.reason, outcome.hint);
2231
- }
2232
- }
2233
- const template = readBundledPrompt("intake.md");
2234
- const rendered = render(
2235
- template,
2236
- buildIntakeTemplateVars({
2237
- config: config2,
2238
- featureSlug: slug,
2239
- storyFolderName,
2240
- trackerId: id,
2241
- title,
2242
- fetchedIssue
2243
- })
2244
- );
2245
- const skippedPreamble = noTracker && config2.tracker.type !== "none" && !sourceBlock ? buildSkippedTrackerPreamble(config2.tracker.type) : "";
2246
- writeFileSafe(intakeFile, skippedPreamble + sourceBlock + rendered, false);
2247
- ensureFeatureOverview(path8.join(paths.plansDir, slug), slug);
2248
- const relativePath = path8.relative(root, intakeFile);
2249
- const relAttachDir = path8.relative(root, attachmentsDir);
2250
- success("Created intake");
2251
- if (noTracker && config2.tracker.type !== "none") {
2252
- warning("Created without a tracker link. Use `squad tracker link` later to attach one.");
2253
- }
2254
- kv("intake", relativePath, 11);
2255
- kv("attachments", relAttachDir + path8.sep, 11);
2256
- if (fetchedSummary && fetchedSummary.written > 0) {
2257
- kv("fetched", `${fetchedSummary.id} \xB7 ${fetchedSummary.written} attachments saved`, 11);
2258
- }
2259
- blank();
2260
- step("Next: review the intake (Source block at top is from the tracker), then:");
2261
- info(`squad new-plan ${relativePath}`);
2262
- }
2263
- async function tryFetchIssue(config2, id, opts, paths, attachmentsDir) {
2264
- const secretsFromFile = fs16.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
2265
- const secrets = overlayTrackerEnv(secretsFromFile);
2266
- const resolution = clientFor(config2, secrets);
2267
- if (resolution.error) {
2268
- return { outcome: "skipped", reason: resolution.error.message, hint: resolution.error.detail };
2269
- }
2270
- const client = resolution.client;
2271
- const spin = spinner(`Fetching ${config2.tracker.type} ${id}\u2026`);
2272
- let issue;
2273
- try {
2274
- issue = await client.fetchIssue(id);
2275
- } catch (err) {
2276
- spin.fail("Tracker fetch failed");
2277
- if (err instanceof TrackerError) {
2278
- return { outcome: "failed", reason: err.message, hint: explainTrackerErrorHint(err) };
2279
- }
2280
- return { outcome: "failed", reason: err.message };
2281
- }
2282
- spin.succeed(`Fetched ${issue.id}: ${truncate(issue.title, 60)}`);
2283
- let downloads = [];
2284
- if (opts.attachments !== false && issue.attachments.length > 0) {
2285
- const attachSpin = spinner(`Downloading ${issue.attachments.length} attachment(s)\u2026`);
2286
- downloads = await client.downloadAttachments(issue.attachments, attachmentsDir, {
2287
- maxMegabytes: opts.attachmentMb
2288
- });
2289
- const written = downloads.filter((d) => d.outcome === "written").length;
2290
- const skipped = downloads.length - written;
2291
- attachSpin.succeed(`Attachments: ${written} saved, ${skipped} skipped`);
2292
- } else if (opts.attachments === false && issue.attachments.length > 0) {
2293
- downloads = issue.attachments.map((source) => ({
2294
- source,
2295
- outcome: "skipped-error",
2296
- bytesWritten: 0,
2297
- skipReason: "not downloaded (--no-attachments)"
2298
- }));
2299
- }
2300
- return { outcome: "ok", issue, downloads };
2301
- }
2302
- function explainTrackerErrorHint(err) {
2303
- switch (err.kind) {
2304
- case "auth":
2305
- return "Run `squad config set tracker` to re-enter credentials, or `squad doctor` to verify.";
2306
- case "not-found":
2307
- return "Run `squad config show` to check workspace settings, or `squad tracker link` with a valid work-item id.";
2308
- case "rate-limited":
2309
- return "Wait a minute, then retry, or run `squad new-story` with --no-fetch.";
2310
- case "network":
2311
- return "Run `squad doctor` to verify connectivity, or `squad new-story` with --no-fetch to skip.";
2312
- default:
2313
- return void 0;
2314
- }
2315
- }
2316
- function truncate(s, n) {
2317
- return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
2318
- }
2220
+ // src/core/story-intake-helpers.ts
2221
+ import fs16 from "fs";
2222
+ import path8 from "path";
2319
2223
  function buildIntakeTemplateVars(args) {
2320
2224
  const e = escapeTemplateValue;
2321
2225
  const { config: config2, featureSlug, storyFolderName, trackerId, title, fetchedIssue } = args;
@@ -2407,13 +2311,573 @@ function ensureFeatureOverview(featurePlanDir, slug) {
2407
2311
  writeFileSafe(overviewFile, render(template, { featureSlug: slug }), false);
2408
2312
  }
2409
2313
 
2410
- // src/commands/new-plan.ts
2411
- import fs22 from "fs";
2412
- import path15 from "path";
2413
- import { confirm as confirm6, select as select5 } from "@inquirer/prompts";
2314
+ // src/core/story-mutations.ts
2315
+ import fs19 from "fs";
2316
+ import path11 from "path";
2414
2317
 
2415
- // src/utils/clipboard.ts
2416
- import { spawn, spawnSync } from "child_process";
2318
+ // src/commands/rm/shared.ts
2319
+ import fs18 from "fs";
2320
+ import path10 from "path";
2321
+ import { select as select4 } from "@inquirer/prompts";
2322
+
2323
+ // src/core/sequence.ts
2324
+ import fs17 from "fs";
2325
+ import path9 from "path";
2326
+ var STORY_FILE2 = /^(\d{2,})-story-.+\.md$/;
2327
+ function scanPlans(plansDir) {
2328
+ const used = [];
2329
+ const seen = /* @__PURE__ */ new Map();
2330
+ if (fs17.existsSync(plansDir)) {
2331
+ for (const feature of fs17.readdirSync(plansDir, { withFileTypes: true })) {
2332
+ if (!feature.isDirectory()) continue;
2333
+ const featureDir = path9.join(plansDir, feature.name);
2334
+ for (const entry of fs17.readdirSync(featureDir)) {
2335
+ const match = entry.match(STORY_FILE2);
2336
+ if (!match) continue;
2337
+ const n = parseInt(match[1], 10);
2338
+ used.push(n);
2339
+ seen.set(n, (seen.get(n) ?? 0) + 1);
2340
+ }
2341
+ }
2342
+ }
2343
+ const duplicates = [...seen.entries()].filter(([, c]) => c > 1).map(([n]) => n);
2344
+ const maxNumber = used.length ? Math.max(...used) : 0;
2345
+ return { usedNumbers: used.sort((a, b) => a - b), maxNumber, duplicates, nextGlobal: maxNumber + 1 };
2346
+ }
2347
+ function formatSequence(n) {
2348
+ return String(n).padStart(2, "0");
2349
+ }
2350
+
2351
+ // src/commands/rm/shared.ts
2352
+ var STORY_PLAN = /^(\d{2,})-story-.+\.md$/;
2353
+ function canPromptForRm(opts) {
2354
+ if (opts.yes) return false;
2355
+ return isInteractive() && Boolean(process.stdin.isTTY);
2356
+ }
2357
+ function removeOverviewRow(featurePlanDir, storyId) {
2358
+ const overview = path10.join(featurePlanDir, "00-overview.md");
2359
+ if (!fs18.existsSync(overview)) return;
2360
+ const src = fs18.readFileSync(overview, "utf8");
2361
+ const lines = src.split("\n");
2362
+ const filtered = lines.filter((line2) => {
2363
+ if (!line2.startsWith("|")) return true;
2364
+ if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) return true;
2365
+ if (/^\|\s*-+/.test(line2)) return true;
2366
+ return !line2.includes(storyId);
2367
+ });
2368
+ fs18.writeFileSync(overview, filtered.join("\n"), "utf8");
2369
+ }
2370
+ function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
2371
+ const overview = path10.join(featurePlanDir, "00-overview.md");
2372
+ if (!fs18.existsSync(overview)) return;
2373
+ const src = fs18.readFileSync(overview, "utf8");
2374
+ const token = `\`${planBasename}\``;
2375
+ const lines = src.split("\n");
2376
+ const filtered = lines.filter((line2) => {
2377
+ if (!line2.startsWith("|")) return true;
2378
+ if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) return true;
2379
+ if (/^\|\s*-+/.test(line2)) return true;
2380
+ return !line2.includes(token);
2381
+ });
2382
+ fs18.writeFileSync(overview, filtered.join("\n"), "utf8");
2383
+ }
2384
+ function trashOrDelete(absPaths, trashRoot, useTrash) {
2385
+ if (!useTrash) {
2386
+ for (const p of absPaths) fs18.rmSync(p, { recursive: true, force: true });
2387
+ return { removed: absPaths };
2388
+ }
2389
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2390
+ const bucket = path10.join(trashRoot, ts);
2391
+ fs18.mkdirSync(bucket, { recursive: true });
2392
+ for (const src of absPaths) {
2393
+ const rel = path10.basename(src);
2394
+ let dst = path10.join(bucket, rel);
2395
+ let n = 0;
2396
+ while (fs18.existsSync(dst)) {
2397
+ n += 1;
2398
+ dst = path10.join(bucket, `${n}_${rel}`);
2399
+ }
2400
+ fs18.renameSync(src, dst);
2401
+ }
2402
+ return { removed: absPaths, trashed: bucket };
2403
+ }
2404
+ function renderPreview(kind, targets, opts) {
2405
+ divider(`squad rm ${kind}`);
2406
+ step(opts.dryRun ? "Dry run \u2014 nothing will be changed." : "About to remove:");
2407
+ for (const t of targets) {
2408
+ kv(t.kind, t.label, 6);
2409
+ }
2410
+ if (opts.trash) {
2411
+ info(`Items will move to ${path10.relative(process.cwd(), opts.trashRoot)}/<timestamp>/`);
2412
+ } else if (!opts.dryRun) {
2413
+ warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
2414
+ }
2415
+ }
2416
+ function listAllPlanListEntries(paths, featureFilter) {
2417
+ if (!fs18.existsSync(paths.plansDir)) return [];
2418
+ const out = [];
2419
+ for (const ent of fs18.readdirSync(paths.plansDir, { withFileTypes: true })) {
2420
+ if (!ent.isDirectory()) continue;
2421
+ if (featureFilter && ent.name !== featureFilter) continue;
2422
+ const featureDir = path10.join(paths.plansDir, ent.name);
2423
+ for (const name of fs18.readdirSync(featureDir)) {
2424
+ if (!STORY_PLAN.test(name)) continue;
2425
+ out.push({ feature: ent.name, file: name, abs: path10.join(featureDir, name) });
2426
+ }
2427
+ }
2428
+ return out.sort((a, b) => (a.feature + a.file).localeCompare(b.feature + b.file));
2429
+ }
2430
+ async function pickStoryInteractive(paths, featureFilter) {
2431
+ const stories = listAllStories(paths).filter((s) => !featureFilter || s.feature === featureFilter);
2432
+ if (stories.length === 0) {
2433
+ throw new Error("No stories to pick. Run `squad new-story` first, or `squad list` to verify paths.");
2434
+ }
2435
+ if (stories.length === 1) return stories[0];
2436
+ const choice = await select4({
2437
+ message: "Story to remove:",
2438
+ choices: stories.map((s) => ({
2439
+ name: `${s.feature}/${s.id} \xB7 ${s.titleHint ?? s.id}`,
2440
+ value: s
2441
+ }))
2442
+ });
2443
+ return choice;
2444
+ }
2445
+ async function pickPlanInteractive(paths, featureFilter) {
2446
+ const plans = listAllPlanListEntries(paths, featureFilter);
2447
+ if (plans.length === 0) {
2448
+ throw new Error("No plan files to pick. Run `squad new-plan` first, or `squad list` to see plans.");
2449
+ }
2450
+ if (plans.length === 1) return plans[0];
2451
+ return select4({
2452
+ message: "Plan file to remove:",
2453
+ choices: plans.map((p) => ({
2454
+ name: `${p.feature}/${p.file}`,
2455
+ value: p
2456
+ }))
2457
+ });
2458
+ }
2459
+ async function pickFeatureInteractive(paths) {
2460
+ const feats = listFeatures(paths);
2461
+ if (feats.length === 0) {
2462
+ throw new Error("No features to pick. Run `squad new-story` first, or `squad list` to see features.");
2463
+ }
2464
+ if (feats.length === 1) return feats[0];
2465
+ return select4({
2466
+ message: "Feature to remove:",
2467
+ choices: feats.map((f) => ({ name: f, value: f }))
2468
+ });
2469
+ }
2470
+ function resolveStoryFromArg(arg, stories, root) {
2471
+ const t = arg.trim();
2472
+ for (const s of stories) {
2473
+ if (s.id === t) return s;
2474
+ if (`${s.feature}/${s.id}` === t) return s;
2475
+ }
2476
+ const abs = path10.isAbsolute(t) ? t : path10.resolve(root, t);
2477
+ const withIntake = t.endsWith("intake.md") ? abs : path10.join(abs, "intake.md");
2478
+ return findStoryByIntake(stories, withIntake) ?? findStoryByIntake(stories, abs);
2479
+ }
2480
+ function isSequenceToken(s) {
2481
+ return /^\d{1,4}$/.test(s) && s.length > 0;
2482
+ }
2483
+ function tryParsePlanFilePath(absPath, paths, featureFilter) {
2484
+ if (!fs18.existsSync(absPath) || !STORY_PLAN.test(path10.basename(absPath))) return void 0;
2485
+ const planRoot = fs18.realpathSync(path10.resolve(paths.plansDir));
2486
+ const fileReal = fs18.realpathSync(path10.resolve(absPath));
2487
+ const rel = path10.relative(planRoot, fileReal);
2488
+ if (rel.startsWith("..") || path10.isAbsolute(rel)) return void 0;
2489
+ const segs = rel.split(path10.sep);
2490
+ if (segs.length < 2) return void 0;
2491
+ const feature = segs[0];
2492
+ const file = segs.slice(1).join(path10.sep);
2493
+ if (featureFilter && feature !== featureFilter) return void 0;
2494
+ return { feature, file, abs: path10.resolve(absPath) };
2495
+ }
2496
+ function resolvePlanFromArg(arg, paths, featureFilter) {
2497
+ const t = arg.trim();
2498
+ if (!t) return void 0;
2499
+ const asPath = path10.isAbsolute(t) ? t : path10.resolve(process.cwd(), t);
2500
+ const a = tryParsePlanFilePath(asPath, paths, featureFilter);
2501
+ if (a) return a;
2502
+ if (!path10.isAbsolute(t)) {
2503
+ const under = tryParsePlanFilePath(path10.join(paths.plansDir, t), paths, featureFilter);
2504
+ if (under) return under;
2505
+ }
2506
+ if (isSequenceToken(t)) {
2507
+ const nn = formatSequence(parseInt(t, 10));
2508
+ const prefix = `${nn}-`;
2509
+ const cands = [];
2510
+ for (const p of listAllPlanListEntries(paths, featureFilter)) {
2511
+ if (p.file.startsWith(prefix)) cands.push(p);
2512
+ }
2513
+ if (cands.length === 1) return cands[0];
2514
+ if (cands.length > 1) {
2515
+ throw new Error(
2516
+ `Multiple plans match sequence "${t}". Run \`squad rm plan\` in a TTY, or add \`--feature <slug>\` to disambiguate.`
2517
+ );
2518
+ }
2519
+ }
2520
+ return void 0;
2521
+ }
2522
+ function countOverviewDataRows(overviewContent) {
2523
+ let n = 0;
2524
+ for (const line2 of overviewContent.split("\n")) {
2525
+ if (!line2.startsWith("|")) continue;
2526
+ if (/^\|\s*-+/.test(line2)) continue;
2527
+ if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) continue;
2528
+ n++;
2529
+ }
2530
+ return n;
2531
+ }
2532
+ function featureStoriesDirEmpty(paths, feature) {
2533
+ const d = path10.join(paths.storiesDir, feature);
2534
+ if (!fs18.existsSync(d)) return true;
2535
+ return fs18.readdirSync(d).length === 0;
2536
+ }
2537
+
2538
+ // src/core/story-mutations.ts
2539
+ function writeNewStoryToDisk(input5) {
2540
+ const { paths, config: config2, featureSlug, storyFolderName, id, title, noTracker, sourceBlock, fetchedIssue } = input5;
2541
+ const storyDir = path11.join(paths.storiesDir, featureSlug, storyFolderName);
2542
+ const intakeFile = path11.join(storyDir, "intake.md");
2543
+ const attachmentsDir = path11.join(storyDir, "attachments");
2544
+ if (fs19.existsSync(intakeFile)) {
2545
+ const rel = path11.relative(paths.root, intakeFile);
2546
+ throw new Error(
2547
+ `Intake already exists: ${intakeFile}. Run \`squad new-plan ${rel}\` to plan it, or \`squad rm story\` to remove the story first.`
2548
+ );
2549
+ }
2550
+ fs19.mkdirSync(attachmentsDir, { recursive: true });
2551
+ const template = readBundledPrompt("intake.md");
2552
+ const rendered = render(
2553
+ template,
2554
+ buildIntakeTemplateVars({
2555
+ config: config2,
2556
+ featureSlug,
2557
+ storyFolderName,
2558
+ trackerId: id,
2559
+ title,
2560
+ fetchedIssue
2561
+ })
2562
+ );
2563
+ const skippedPreamble = noTracker && config2.tracker.type !== "none" && !sourceBlock ? buildSkippedTrackerPreamble(config2.tracker.type) : "";
2564
+ writeFileSafe(intakeFile, skippedPreamble + sourceBlock + rendered, false);
2565
+ ensureFeatureOverview(path11.join(paths.plansDir, featureSlug), featureSlug);
2566
+ return { storyDir, intakePath: intakeFile, feature: featureSlug, id: storyFolderName };
2567
+ }
2568
+ function createStoryRecord(input5) {
2569
+ const feature = slugify(input5.feature);
2570
+ if (!feature) {
2571
+ throw new Error(`Invalid feature slug: "${input5.feature}". Use kebab-case (letters, digits, hyphens).`);
2572
+ }
2573
+ const rawId = input5.trackerId?.trim() || void 0;
2574
+ const noTracker = input5.config.tracker.type !== "none" && !rawId;
2575
+ let id = rawId;
2576
+ if (noTracker) id = void 0;
2577
+ if (id && !validateTrackerId(input5.config.tracker.type, id)) {
2578
+ throw new Error(
2579
+ `Invalid tracker id "${id}" for tracker type "${input5.config.tracker.type}". Check the expected format, or \`squad config set tracker\` if the tracker type is wrong.`
2580
+ );
2581
+ }
2582
+ const title = input5.title?.trim() || void 0;
2583
+ const storyFolderName = id ? trackerIdForFilename(input5.config.tracker.type, id) : slugify(title ?? "");
2584
+ if (!storyFolderName) {
2585
+ throw new Error(
2586
+ 'Could not derive a story folder name. Pass a "title" when not linking a tracker id, or provide a "trackerId" to name the folder after it.'
2587
+ );
2588
+ }
2589
+ return writeNewStoryToDisk({
2590
+ paths: input5.paths,
2591
+ config: input5.config,
2592
+ featureSlug: feature,
2593
+ storyFolderName,
2594
+ id,
2595
+ title,
2596
+ noTracker,
2597
+ sourceBlock: "",
2598
+ fetchedIssue: void 0
2599
+ });
2600
+ }
2601
+ async function createStoryRecordFromIssue(input5) {
2602
+ const feature = slugify(input5.feature);
2603
+ if (!feature) {
2604
+ throw new Error(`Invalid feature slug: "${input5.feature}". Use kebab-case (letters, digits, hyphens).`);
2605
+ }
2606
+ const id = input5.issue.id.trim();
2607
+ if (!validateTrackerId(input5.config.tracker.type, id)) {
2608
+ throw new Error(
2609
+ `Invalid tracker id "${id}" for tracker type "${input5.config.tracker.type}". Check the expected format, or \`squad config set tracker\` if the tracker type is wrong.`
2610
+ );
2611
+ }
2612
+ const title = input5.issue.title;
2613
+ const storyFolderName = trackerIdForFilename(input5.config.tracker.type, id);
2614
+ const storyDir = path11.join(input5.paths.storiesDir, feature, storyFolderName);
2615
+ const intakeFile = path11.join(storyDir, "intake.md");
2616
+ const attachmentsDir = path11.join(storyDir, "attachments");
2617
+ if (fs19.existsSync(intakeFile)) {
2618
+ const rel = path11.relative(input5.paths.root, intakeFile);
2619
+ throw new Error(
2620
+ `Intake already exists: ${intakeFile}. Run \`squad new-plan ${rel}\` to plan it, or \`squad rm story\` to remove the story first.`
2621
+ );
2622
+ }
2623
+ fs19.mkdirSync(attachmentsDir, { recursive: true });
2624
+ let downloads = [];
2625
+ if (input5.withAttachments && input5.issue.attachments.length > 0) {
2626
+ downloads = await input5.client.downloadAttachments(input5.issue.attachments, attachmentsDir);
2627
+ } else if (!input5.withAttachments && input5.issue.attachments.length > 0) {
2628
+ downloads = input5.issue.attachments.map((source) => ({
2629
+ source,
2630
+ outcome: "skipped-error",
2631
+ bytesWritten: 0,
2632
+ skipReason: "not downloaded"
2633
+ }));
2634
+ }
2635
+ const sourceBlock = buildSourcePreamble(input5.issue, downloads, input5.config.tracker.type);
2636
+ return writeNewStoryToDisk({
2637
+ paths: input5.paths,
2638
+ config: input5.config,
2639
+ featureSlug: feature,
2640
+ storyFolderName,
2641
+ id,
2642
+ title,
2643
+ noTracker: false,
2644
+ sourceBlock,
2645
+ fetchedIssue: input5.issue
2646
+ });
2647
+ }
2648
+ function deleteStoryRecord(input5) {
2649
+ const targets = [input5.story.storyDir];
2650
+ if (input5.story.planFile) {
2651
+ targets.push(path11.join(input5.paths.plansDir, input5.story.feature, input5.story.planFile));
2652
+ }
2653
+ const result = trashOrDelete(targets, input5.paths.trashDir, input5.trash);
2654
+ removeOverviewRow(path11.join(input5.paths.plansDir, input5.story.feature), input5.story.id);
2655
+ return result;
2656
+ }
2657
+
2658
+ // src/commands/new-story.ts
2659
+ function validateFeatureSlugForPrompt(v) {
2660
+ const trimmed = v.trim();
2661
+ if (!trimmed) return "Required.";
2662
+ const s = slugify(trimmed);
2663
+ if (!s) return "Required.";
2664
+ if (s !== trimmed) return "Use lowercase kebab-case (letters, digits, hyphens).";
2665
+ return true;
2666
+ }
2667
+ async function runNewStory(featureSlug, opts) {
2668
+ const interactive = !opts.yes && Boolean(process.stdin.isTTY) && process.env.CI !== "true";
2669
+ let featureSlugInput = featureSlug;
2670
+ if (!featureSlugInput) {
2671
+ if (!interactive) {
2672
+ throw new Error(
2673
+ "feature-slug is required. Run `squad new-story` in a TTY to pick interactively, or pass a slug like `squad new-story <feature-slug>`, or add `--yes` to fail fast in scripts."
2674
+ );
2675
+ }
2676
+ const rootForPrompt = requireSquadRoot();
2677
+ const pathsForPrompt = buildPaths(rootForPrompt);
2678
+ const existing = listFeatures(pathsForPrompt);
2679
+ if (existing.length > 0) {
2680
+ const choice = await select5({
2681
+ message: "Feature:",
2682
+ choices: [
2683
+ ...existing.map((f) => ({ name: f, value: f })),
2684
+ { name: "\u2500\u2500\u2500 new feature \u2500\u2500\u2500", value: "__NEW__" }
2685
+ ]
2686
+ });
2687
+ if (choice !== "__NEW__") {
2688
+ featureSlugInput = choice;
2689
+ }
2690
+ }
2691
+ if (!featureSlugInput) {
2692
+ featureSlugInput = (await input3({
2693
+ message: "New feature slug (kebab-case):",
2694
+ validate: validateFeatureSlugForPrompt
2695
+ })).trim();
2696
+ }
2697
+ }
2698
+ const slug = slugify(featureSlugInput);
2699
+ if (!slug) {
2700
+ throw new Error(
2701
+ `Invalid feature slug: "${featureSlugInput}". Run \`squad new-story\` and use kebab-case (letters, digits, hyphens).`
2702
+ );
2703
+ }
2704
+ const root = requireSquadRoot();
2705
+ const paths = buildPaths(root);
2706
+ const config2 = loadConfig(paths.configFile);
2707
+ let noTracker = opts.tracker === false && config2.tracker.type !== "none";
2708
+ let id = opts.id;
2709
+ if (noTracker) id = void 0;
2710
+ let trackerNeedsId = !noTracker && config2.tracker.type !== "none" && config2.naming.includeTrackerId;
2711
+ let title = opts.title;
2712
+ if (!id && trackerNeedsId) {
2713
+ if (!interactive) {
2714
+ throw new Error(
2715
+ `Tracker "${config2.tracker.type}" is configured and naming.includeTrackerId is true. Run \`squad new-story <feature-slug> --id <tracker-id>\` to link now, or \`squad new-story <feature-slug> --no-tracker\` to skip the tracker for this story only.`
2716
+ );
2717
+ }
2718
+ const mode = await select5({
2719
+ message: `This workspace requires a ${config2.tracker.type} id for new stories. How do you want to proceed?`,
2720
+ choices: [
2721
+ { name: `Enter a ${config2.tracker.type} id now`, value: "enter" },
2722
+ { name: "Skip tracker for this story (same as --no-tracker)", value: "skip" }
2723
+ ],
2724
+ default: "enter"
2725
+ });
2726
+ if (mode === "skip") {
2727
+ opts.tracker = false;
2728
+ noTracker = true;
2729
+ trackerNeedsId = false;
2730
+ } else {
2731
+ id = (await input3({
2732
+ message: `${config2.tracker.type} work-item id:`,
2733
+ validate: (v) => validateTrackerId(config2.tracker.type, v.trim()) ? true : `Invalid ${config2.tracker.type} id format.`
2734
+ })).trim();
2735
+ }
2736
+ }
2737
+ if (id && !validateTrackerId(config2.tracker.type, id)) {
2738
+ throw new Error(
2739
+ `Invalid tracker id "${id}" for tracker type "${config2.tracker.type}". Run \`squad config show\` to review the expected format, or \`squad config set tracker\` if the tracker type is wrong.`
2740
+ );
2741
+ }
2742
+ if (!id && !title) {
2743
+ if (!interactive) {
2744
+ if (!noTracker) {
2745
+ throw new Error(
2746
+ 'Provide either --id <tracker-id> or --title "..." so the story folder has a distinct name. Run `squad new-story <feature-slug> --id ...` or `squad new-story <feature-slug> --title "..."` (or `--no-tracker` with a title).'
2747
+ );
2748
+ }
2749
+ } else {
2750
+ title = (await input3({ message: "Short story title:" })).trim();
2751
+ if (!title) throw new Error("Title cannot be empty. Run `squad new-story` again and enter a short title, or add `--yes` to fail fast in scripts.");
2752
+ }
2753
+ }
2754
+ if (interactive && !title) {
2755
+ const maybe = (await input3({ message: "Short title (optional, press Enter to skip):", default: "" })).trim();
2756
+ if (maybe) title = maybe;
2757
+ }
2758
+ const storyFolderName = id ? trackerIdForFilename(config2.tracker.type, id) : slugify(title ?? "");
2759
+ if (!storyFolderName) {
2760
+ throw new Error(
2761
+ 'Could not derive a story folder name. Pass `--title "<hint>"` when using `--no-tracker`, or `--id <tracker-id>` to name the folder after the tracker id.'
2762
+ );
2763
+ }
2764
+ const storyDir = path12.join(paths.storiesDir, slug, storyFolderName);
2765
+ const intakeFile = path12.join(storyDir, "intake.md");
2766
+ const attachmentsDir = path12.join(storyDir, "attachments");
2767
+ if (fs20.existsSync(intakeFile)) {
2768
+ throw new Error(
2769
+ `Intake already exists: ${intakeFile}. Run \`squad new-plan ${path12.relative(root, intakeFile)}\` to plan it, or \`squad rm story\` to remove the story first.`
2770
+ );
2771
+ }
2772
+ fs20.mkdirSync(attachmentsDir, { recursive: true });
2773
+ let sourceBlock = "";
2774
+ let fetchedSummary;
2775
+ let fetchedIssue;
2776
+ const shouldFetch = !noTracker && opts.fetch !== false && Boolean(id) && config2.tracker.type !== "none";
2777
+ if (shouldFetch) {
2778
+ const outcome = await tryFetchIssue(config2, id, opts, paths, attachmentsDir);
2779
+ if (outcome.outcome === "ok") {
2780
+ fetchedIssue = outcome.issue;
2781
+ sourceBlock = buildSourcePreamble(outcome.issue, outcome.downloads, config2.tracker.type);
2782
+ if (!title) title = outcome.issue.title;
2783
+ const written = outcome.downloads.filter((d) => d.outcome === "written").length;
2784
+ fetchedSummary = { id: outcome.issue.id, written };
2785
+ } else {
2786
+ warning(`Tracker fetch skipped: ${outcome.reason}`);
2787
+ if (outcome.hint) info(outcome.hint);
2788
+ sourceBlock = buildFallbackPreamble(outcome.reason, outcome.hint);
2789
+ }
2790
+ }
2791
+ writeNewStoryToDisk({
2792
+ paths,
2793
+ config: config2,
2794
+ featureSlug: slug,
2795
+ storyFolderName,
2796
+ id,
2797
+ title,
2798
+ noTracker,
2799
+ sourceBlock,
2800
+ fetchedIssue
2801
+ });
2802
+ const relativePath = path12.relative(root, intakeFile);
2803
+ const relAttachDir = path12.relative(root, attachmentsDir);
2804
+ success("Created intake");
2805
+ if (noTracker && config2.tracker.type !== "none") {
2806
+ warning("Created without a tracker link. Use `squad tracker link` later to attach one.");
2807
+ }
2808
+ kv("intake", relativePath, 11);
2809
+ kv("attachments", relAttachDir + path12.sep, 11);
2810
+ if (fetchedSummary && fetchedSummary.written > 0) {
2811
+ kv("fetched", `${fetchedSummary.id} \xB7 ${fetchedSummary.written} attachments saved`, 11);
2812
+ }
2813
+ blank();
2814
+ step("Next: review the intake (Source block at top is from the tracker), then:");
2815
+ info(`squad new-plan ${relativePath}`);
2816
+ }
2817
+ async function tryFetchIssue(config2, id, opts, paths, attachmentsDir) {
2818
+ const secretsFromFile = fs20.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
2819
+ const secrets = overlayTrackerEnv(secretsFromFile);
2820
+ const resolution = clientFor(config2, secrets);
2821
+ if (resolution.error) {
2822
+ return { outcome: "skipped", reason: resolution.error.message, hint: resolution.error.detail };
2823
+ }
2824
+ const client = resolution.client;
2825
+ const spin = spinner(`Fetching ${config2.tracker.type} ${id}\u2026`);
2826
+ let issue;
2827
+ try {
2828
+ issue = await client.fetchIssue(id);
2829
+ } catch (err) {
2830
+ spin.fail("Tracker fetch failed");
2831
+ if (err instanceof TrackerError) {
2832
+ return { outcome: "failed", reason: err.message, hint: explainTrackerErrorHint(err) };
2833
+ }
2834
+ return { outcome: "failed", reason: err.message };
2835
+ }
2836
+ spin.succeed(`Fetched ${issue.id}: ${truncate(issue.title, 60)}`);
2837
+ let downloads = [];
2838
+ if (opts.attachments !== false && issue.attachments.length > 0) {
2839
+ const attachSpin = spinner(`Downloading ${issue.attachments.length} attachment(s)\u2026`);
2840
+ downloads = await client.downloadAttachments(issue.attachments, attachmentsDir, {
2841
+ maxMegabytes: opts.attachmentMb
2842
+ });
2843
+ const written = downloads.filter((d) => d.outcome === "written").length;
2844
+ const skipped = downloads.length - written;
2845
+ attachSpin.succeed(`Attachments: ${written} saved, ${skipped} skipped`);
2846
+ } else if (opts.attachments === false && issue.attachments.length > 0) {
2847
+ downloads = issue.attachments.map((source) => ({
2848
+ source,
2849
+ outcome: "skipped-error",
2850
+ bytesWritten: 0,
2851
+ skipReason: "not downloaded (--no-attachments)"
2852
+ }));
2853
+ }
2854
+ return { outcome: "ok", issue, downloads };
2855
+ }
2856
+ function explainTrackerErrorHint(err) {
2857
+ switch (err.kind) {
2858
+ case "auth":
2859
+ return "Run `squad config set tracker` to re-enter credentials, or `squad doctor` to verify.";
2860
+ case "not-found":
2861
+ return "Run `squad config show` to check workspace settings, or `squad tracker link` with a valid work-item id.";
2862
+ case "rate-limited":
2863
+ return "Wait a minute, then retry, or run `squad new-story` with --no-fetch.";
2864
+ case "network":
2865
+ return "Run `squad doctor` to verify connectivity, or `squad new-story` with --no-fetch to skip.";
2866
+ default:
2867
+ return void 0;
2868
+ }
2869
+ }
2870
+ function truncate(s, n) {
2871
+ return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
2872
+ }
2873
+
2874
+ // src/commands/new-plan.ts
2875
+ import fs26 from "fs";
2876
+ import path19 from "path";
2877
+ import { confirm as confirm6, select as select6 } from "@inquirer/prompts";
2878
+
2879
+ // src/utils/clipboard.ts
2880
+ import { spawn, spawnSync } from "child_process";
2417
2881
  async function copyToClipboard(text) {
2418
2882
  const candidates = clipboardCandidates();
2419
2883
  if (candidates.length === 0) {
@@ -2433,12 +2897,12 @@ async function copyToClipboard(text) {
2433
2897
  return { ok: false, reason: lastReason ?? "no working clipboard tool found" };
2434
2898
  }
2435
2899
  function tryCopy(cmd, text) {
2436
- return new Promise((resolve) => {
2900
+ return new Promise((resolve2) => {
2437
2901
  let child;
2438
2902
  try {
2439
2903
  child = spawn(cmd.command, cmd.args, { stdio: ["pipe", "ignore", "pipe"] });
2440
2904
  } catch (err) {
2441
- resolve({ ok: false, reason: `${cmd.command} failed to spawn: ${err.message}` });
2905
+ resolve2({ ok: false, reason: `${cmd.command} failed to spawn: ${err.message}` });
2442
2906
  return;
2443
2907
  }
2444
2908
  let stderr = "";
@@ -2446,13 +2910,13 @@ function tryCopy(cmd, text) {
2446
2910
  stderr += chunk.toString();
2447
2911
  });
2448
2912
  child.on("error", (err) => {
2449
- resolve({ ok: false, reason: `${cmd.command} spawn error: ${err.message}` });
2913
+ resolve2({ ok: false, reason: `${cmd.command} spawn error: ${err.message}` });
2450
2914
  });
2451
2915
  child.on("close", (code) => {
2452
2916
  if (code === 0) {
2453
- resolve({ ok: true, tool: cmd.command });
2917
+ resolve2({ ok: true, tool: cmd.command });
2454
2918
  } else {
2455
- resolve({
2919
+ resolve2({
2456
2920
  ok: false,
2457
2921
  reason: `${cmd.command} exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 200)}` : ""}`
2458
2922
  });
@@ -2501,11 +2965,11 @@ function noCandidateReason() {
2501
2965
  }
2502
2966
 
2503
2967
  // src/planner/loop.ts
2504
- import path10 from "path";
2968
+ import path15 from "path";
2505
2969
 
2506
2970
  // src/planner/tools.ts
2507
- import fs17 from "fs";
2508
- import path9 from "path";
2971
+ import fs21 from "fs";
2972
+ import path13 from "path";
2509
2973
  var READ_FILE_TOOL = {
2510
2974
  name: "read_file",
2511
2975
  description: "Read a UTF-8 text file from the project, returning its contents so you can plan against real code. Paths must be relative to the project root. Binary files and files outside the project are refused. You have a bounded context budget; prefer small, targeted reads.",
@@ -2524,15 +2988,15 @@ function readFileTool(root, budget, input5) {
2524
2988
  if (typeof raw !== "string" || raw.length === 0) {
2525
2989
  return error('read_file: missing or invalid "path" argument.');
2526
2990
  }
2527
- const resolved = path9.resolve(root, raw);
2528
- const relCheck = path9.relative(root, resolved);
2529
- if (relCheck.startsWith("..") || path9.isAbsolute(relCheck)) {
2991
+ const resolved = path13.resolve(root, raw);
2992
+ const relCheck = path13.relative(root, resolved);
2993
+ if (relCheck.startsWith("..") || path13.isAbsolute(relCheck)) {
2530
2994
  return error(`read_file: path "${raw}" escapes the project root and was refused.`);
2531
2995
  }
2532
- if (!fs17.existsSync(resolved)) {
2996
+ if (!fs21.existsSync(resolved)) {
2533
2997
  return error(`read_file: not found "${raw}".`);
2534
2998
  }
2535
- const stat = fs17.statSync(resolved);
2999
+ const stat = fs21.statSync(resolved);
2536
3000
  if (stat.isDirectory()) {
2537
3001
  return error(`read_file: "${raw}" is a directory. Ask for a specific file path.`);
2538
3002
  }
@@ -2547,7 +3011,7 @@ function readFileTool(root, budget, input5) {
2547
3011
  }
2548
3012
  let buf;
2549
3013
  try {
2550
- buf = fs17.readFileSync(resolved);
3014
+ buf = fs21.readFileSync(resolved);
2551
3015
  } catch (e) {
2552
3016
  return error(`read_file: failed to read "${raw}" (${e.message}).`);
2553
3017
  }
@@ -2712,7 +3176,8 @@ async function callAnthropic(req) {
2712
3176
  "anthropic-version": API_VERSION,
2713
3177
  "content-type": "application/json"
2714
3178
  },
2715
- body: JSON.stringify(body)
3179
+ body: JSON.stringify(body),
3180
+ signal: req.abort
2716
3181
  });
2717
3182
  if (!res.ok) {
2718
3183
  const text = await res.text().catch(() => "");
@@ -2850,7 +3315,8 @@ async function callOpenAI(req) {
2850
3315
  Authorization: `Bearer ${req.apiKey}`,
2851
3316
  "content-type": "application/json"
2852
3317
  },
2853
- body: JSON.stringify(body)
3318
+ body: JSON.stringify(body),
3319
+ signal: req.abort
2854
3320
  });
2855
3321
  if (!res.ok) {
2856
3322
  const text = await res.text().catch(() => "");
@@ -2968,7 +3434,8 @@ async function callGoogle(req) {
2968
3434
  const res = await fetch(url, {
2969
3435
  method: "POST",
2970
3436
  headers: { "content-type": "application/json" },
2971
- body: JSON.stringify(body)
3437
+ body: JSON.stringify(body),
3438
+ signal: req.abort
2972
3439
  });
2973
3440
  if (!res.ok) {
2974
3441
  const text = await res.text().catch(() => "");
@@ -3058,13 +3525,91 @@ function prefixOf(provider, req) {
3058
3525
  return s.length > 0 ? s.slice(0, -1) : s;
3059
3526
  }
3060
3527
 
3528
+ // src/core/runs.ts
3529
+ import fs22 from "fs/promises";
3530
+ import path14 from "path";
3531
+ import { randomBytes } from "crypto";
3532
+ var RING_SIZE = 20;
3533
+ function newRunId() {
3534
+ const ts = Date.now().toString(36).padStart(9, "0");
3535
+ const rnd = randomBytes(8).toString("hex");
3536
+ return `${ts}-${rnd}`;
3537
+ }
3538
+ async function appendRun(paths, rec) {
3539
+ const dir = path14.join(paths.squadDir, "runs");
3540
+ await fs22.mkdir(dir, { recursive: true });
3541
+ const file = path14.join(dir, `${rec.runId}.json`);
3542
+ await fs22.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
3543
+ await pruneOldRuns(dir);
3544
+ }
3545
+ async function pruneOldRuns(dir) {
3546
+ const entries = (await fs22.readdir(dir)).filter((n) => n.endsWith(".json"));
3547
+ if (entries.length <= RING_SIZE) return;
3548
+ entries.sort();
3549
+ const drop = entries.slice(0, entries.length - RING_SIZE);
3550
+ await Promise.all(drop.map((n) => fs22.rm(path14.join(dir, n), { force: true })));
3551
+ }
3552
+ async function listRuns(paths) {
3553
+ const dir = path14.join(paths.squadDir, "runs");
3554
+ let entries;
3555
+ try {
3556
+ entries = await fs22.readdir(dir);
3557
+ } catch {
3558
+ return [];
3559
+ }
3560
+ const files = entries.filter((n) => n.endsWith(".json")).sort().reverse();
3561
+ const out = [];
3562
+ for (const f of files) {
3563
+ try {
3564
+ const raw = await fs22.readFile(path14.join(dir, f), "utf8");
3565
+ const parsed = JSON.parse(raw);
3566
+ if (parsed.version === 1) out.push(parsed);
3567
+ } catch {
3568
+ }
3569
+ }
3570
+ return out;
3571
+ }
3572
+
3061
3573
  // src/planner/session-limits.ts
3062
3574
  var DEFAULT_PLANNER_MAX_ITERATIONS = 40;
3063
3575
  var PLANNER_MARKDOWN_CONTINUATION_USER = "Your previous reply was cut off because it hit the per-response output token limit for this request. Continue the Markdown plan from the exact cut-off only: write the remainder, do not repeat headings or paragraphs already sent. When the plan is complete, use end_turn without unnecessary tool calls.";
3064
3576
 
3577
+ // src/planner/events.ts
3578
+ var PlannerEventBus = class {
3579
+ listeners = /* @__PURE__ */ new Set();
3580
+ emit(e) {
3581
+ for (const fn of this.listeners) {
3582
+ try {
3583
+ fn(e);
3584
+ } catch {
3585
+ }
3586
+ }
3587
+ }
3588
+ subscribe(fn) {
3589
+ this.listeners.add(fn);
3590
+ return () => this.listeners.delete(fn);
3591
+ }
3592
+ };
3593
+
3065
3594
  // src/planner/loop.ts
3066
3595
  var MAX_RATE_LIMIT_RETRY_SEC = 90;
3067
- var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3596
+ var defaultSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
3597
+ function isAbortError(e) {
3598
+ return e instanceof Error && e.name === "AbortError";
3599
+ }
3600
+ async function sleepWithAbort(ms, sleepFn, abort) {
3601
+ if (!abort) {
3602
+ await sleepFn(ms);
3603
+ return;
3604
+ }
3605
+ if (abort.aborted) throw new DOMException("Aborted", "AbortError");
3606
+ await Promise.race([
3607
+ sleepFn(ms),
3608
+ new Promise((_, reject) => {
3609
+ abort.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
3610
+ })
3611
+ ]);
3612
+ }
3068
3613
  function buildPlannerRunStats(budget, turns, runStartedAt) {
3069
3614
  const u = budget.snapshot().usage;
3070
3615
  const cacheRead = u.cacheReadTokens ?? 0;
@@ -3091,8 +3636,51 @@ function extendAllSessionLimits(input5, maxIter, maxOut, baseMaxIter, baseMaxOut
3091
3636
  maxIter.current += baseMaxIter;
3092
3637
  maxOut.current += baseMaxOut;
3093
3638
  }
3639
+ function recordUsageAndEmit(bus, input5, runId, turn, usage) {
3640
+ input5.budget.recordUsage(usage);
3641
+ input5.onUsage?.(usage);
3642
+ bus.emit({ kind: "usage", runId, turn, usage });
3643
+ const u = input5.budget.snapshot().usage;
3644
+ const cacheRead = u.cacheReadTokens ?? 0;
3645
+ const cacheCreate = u.cacheCreationTokens ?? 0;
3646
+ if (cacheRead > 0 || cacheCreate > 0) {
3647
+ const inTok = u.inputTokens;
3648
+ const totalInput = inTok + cacheRead;
3649
+ const cacheHitRatio = totalInput === 0 ? 0 : Math.round(cacheRead / totalInput * 100) / 100;
3650
+ bus.emit({
3651
+ kind: "cache_summary",
3652
+ runId,
3653
+ turn,
3654
+ cacheHitRatio,
3655
+ cacheReadTokens: cacheRead,
3656
+ cacheCreationTokens: cacheCreate
3657
+ });
3658
+ }
3659
+ }
3660
+ function cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt) {
3661
+ bus.emit({ kind: "cancelled", runId });
3662
+ return {
3663
+ planText: accumulatedText,
3664
+ budgetExhausted,
3665
+ timedOut: false,
3666
+ finishedNormally: false,
3667
+ iterations,
3668
+ stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt),
3669
+ userCancelled: true
3670
+ };
3671
+ }
3094
3672
  async function runPlanner(input5) {
3095
3673
  const runStartedAt = Date.now();
3674
+ const bus = input5.events ?? new PlannerEventBus();
3675
+ const runId = input5.runId ?? newRunId();
3676
+ const cacheEnabled = input5.cacheEnabled ?? true;
3677
+ bus.emit({
3678
+ kind: "started",
3679
+ runId,
3680
+ provider: input5.provider.name,
3681
+ model: input5.model,
3682
+ cacheEnabled
3683
+ });
3096
3684
  const baseMaxIter = input5.maxIterations ?? DEFAULT_PLANNER_MAX_ITERATIONS;
3097
3685
  const maxIter = { current: baseMaxIter };
3098
3686
  const baseMaxOut = input5.maxOutputTokens ?? DEFAULT_PLANNER_MAX_OUTPUT_TOKENS;
@@ -3104,6 +3692,7 @@ async function runPlanner(input5) {
3104
3692
  let finishedNormally = false;
3105
3693
  let prevRequestPrefix;
3106
3694
  const decide = input5.decideOnLimit;
3695
+ const sleepFn = input5.sleep ?? defaultSleep;
3107
3696
  const limitCtx = (kind) => ({
3108
3697
  kind,
3109
3698
  budgetSnapshot: input5.budget.snapshot(),
@@ -3112,6 +3701,9 @@ async function runPlanner(input5) {
3112
3701
  maxOutputTokens: maxOut.current
3113
3702
  });
3114
3703
  for (; ; ) {
3704
+ if (input5.abort?.aborted) {
3705
+ return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
3706
+ }
3115
3707
  if (iterations >= maxIter.current) {
3116
3708
  if (decide) {
3117
3709
  const d = await decide(limitCtx("max_iterations"));
@@ -3197,6 +3789,9 @@ async function runPlanner(input5) {
3197
3789
  stats: buildPlannerRunStats(input5.budget, iterations, runStartedAt)
3198
3790
  };
3199
3791
  }
3792
+ if (iterations >= 2) {
3793
+ bus.emit({ kind: "turn_started", runId, turn: iterations });
3794
+ }
3200
3795
  const request = {
3201
3796
  systemPrompt: input5.systemPrompt,
3202
3797
  model: input5.model,
@@ -3204,8 +3799,10 @@ async function runPlanner(input5) {
3204
3799
  turns,
3205
3800
  apiKey: input5.apiKey,
3206
3801
  maxOutputTokens: maxOut.current,
3207
- cacheEnabled: input5.cacheEnabled ?? true
3802
+ cacheEnabled,
3803
+ abort: input5.abort
3208
3804
  };
3805
+ bus.emit({ kind: "request_sent", runId, turn: iterations });
3209
3806
  if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test" && prevRequestPrefix !== void 0) {
3210
3807
  const currentPrefix = prefixOf(input5.provider.name, request);
3211
3808
  if (!currentPrefix.startsWith(prevRequestPrefix)) {
@@ -3215,10 +3812,17 @@ async function runPlanner(input5) {
3215
3812
  );
3216
3813
  }
3217
3814
  }
3218
- let response = await input5.provider.send(request);
3815
+ let response;
3816
+ try {
3817
+ response = await input5.provider.send(request);
3818
+ } catch (e) {
3819
+ if (isAbortError(e)) {
3820
+ return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
3821
+ }
3822
+ throw e;
3823
+ }
3219
3824
  if (response.usage) {
3220
- input5.budget.recordUsage(response.usage);
3221
- input5.onUsage?.(response.usage);
3825
+ recordUsageAndEmit(bus, input5, runId, iterations, response.usage);
3222
3826
  }
3223
3827
  let retriedRateLimit = false;
3224
3828
  let retrySkippedReason;
@@ -3229,11 +3833,25 @@ async function runPlanner(input5) {
3229
3833
  } else {
3230
3834
  const waitSec = Math.min(asked ?? 10, MAX_RATE_LIMIT_RETRY_SEC);
3231
3835
  input5.onRateLimit?.(waitSec);
3232
- await (input5.sleep ?? defaultSleep)(waitSec * 1e3);
3233
- response = await input5.provider.send(request);
3836
+ bus.emit({ kind: "rate_limit", runId, turn: iterations, waitSec });
3837
+ try {
3838
+ await sleepWithAbort(waitSec * 1e3, sleepFn, input5.abort);
3839
+ } catch (e) {
3840
+ if (isAbortError(e)) {
3841
+ return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
3842
+ }
3843
+ throw e;
3844
+ }
3845
+ try {
3846
+ response = await input5.provider.send(request);
3847
+ } catch (e) {
3848
+ if (isAbortError(e)) {
3849
+ return cancelledOutput(input5, bus, runId, accumulatedText, budgetExhausted, iterations, runStartedAt);
3850
+ }
3851
+ throw e;
3852
+ }
3234
3853
  if (response.usage) {
3235
- input5.budget.recordUsage(response.usage);
3236
- input5.onUsage?.(response.usage);
3854
+ recordUsageAndEmit(bus, input5, runId, iterations, response.usage);
3237
3855
  }
3238
3856
  retriedRateLimit = true;
3239
3857
  }
@@ -3245,8 +3863,10 @@ async function runPlanner(input5) {
3245
3863
  if (response.text) {
3246
3864
  accumulatedText += response.text;
3247
3865
  input5.onAssistantText?.(response.text);
3866
+ bus.emit({ kind: "assistant_text", runId, turn: iterations, delta: response.text });
3248
3867
  }
3249
3868
  if (response.stopReason === "max_tokens" && !response.toolCalls?.length) {
3869
+ bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
3250
3870
  if (decide) {
3251
3871
  const d = await decide(limitCtx("max_output_tokens"));
3252
3872
  if (d === "cancel") {
@@ -3278,6 +3898,7 @@ async function runPlanner(input5) {
3278
3898
  };
3279
3899
  }
3280
3900
  if (response.stopReason === "end_turn" || !response.toolCalls?.length) {
3901
+ bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
3281
3902
  finishedNormally = true;
3282
3903
  return {
3283
3904
  planText: accumulatedText,
@@ -3304,7 +3925,16 @@ async function runPlanner(input5) {
3304
3925
  const before = input5.budget.snapshot().bytes;
3305
3926
  let result = readFileTool(input5.root, input5.budget, tc.input);
3306
3927
  const after = input5.budget.snapshot().bytes;
3307
- input5.onToolCall?.(tc, after - before, after);
3928
+ const bytesLoaded = after - before;
3929
+ input5.onToolCall?.(tc, bytesLoaded, after);
3930
+ bus.emit({
3931
+ kind: "tool_call",
3932
+ runId,
3933
+ turn: iterations,
3934
+ toolCall: tc,
3935
+ bytesLoaded,
3936
+ totalBytes: after
3937
+ });
3308
3938
  if (readBudgetishError(result)) {
3309
3939
  if (decide) {
3310
3940
  const d = await decide(limitCtx("file_or_context_reads"));
@@ -3345,6 +3975,7 @@ async function runPlanner(input5) {
3345
3975
  text: "Budget is exhausted. Finalise the plan with the information you already have. Do not call any more tools. Output the complete plan markdown now."
3346
3976
  });
3347
3977
  }
3978
+ bus.emit({ kind: "turn_complete", runId, turn: iterations, stopReason: response.stopReason });
3348
3979
  }
3349
3980
  }
3350
3981
  function firstByteDiff(a, b) {
@@ -3529,8 +4160,8 @@ var Budget = class {
3529
4160
  };
3530
4161
 
3531
4162
  // src/core/repo-map.ts
3532
- import fs18 from "fs";
3533
- import path11 from "path";
4163
+ import fs23 from "fs";
4164
+ import path16 from "path";
3534
4165
  import ignore from "ignore";
3535
4166
  var DEFAULT_IGNORES = [
3536
4167
  "node_modules",
@@ -3554,10 +4185,10 @@ function buildRepoMap(root, opts = {}) {
3554
4185
  }
3555
4186
  function buildIgnore(root) {
3556
4187
  const ig = ignore().add(DEFAULT_IGNORES);
3557
- const gitignore = path11.join(root, ".gitignore");
3558
- if (fs18.existsSync(gitignore)) {
4188
+ const gitignore = path16.join(root, ".gitignore");
4189
+ if (fs23.existsSync(gitignore)) {
3559
4190
  try {
3560
- ig.add(fs18.readFileSync(gitignore, "utf8"));
4191
+ ig.add(fs23.readFileSync(gitignore, "utf8"));
3561
4192
  } catch {
3562
4193
  }
3563
4194
  }
@@ -3567,15 +4198,15 @@ function walk2(root, dir, ig, out, max, includeHidden) {
3567
4198
  if (out.length >= max) return;
3568
4199
  let entries;
3569
4200
  try {
3570
- entries = fs18.readdirSync(dir, { withFileTypes: true });
4201
+ entries = fs23.readdirSync(dir, { withFileTypes: true });
3571
4202
  } catch {
3572
4203
  return;
3573
4204
  }
3574
4205
  for (const entry of entries) {
3575
4206
  if (out.length >= max) return;
3576
4207
  if (!includeHidden && entry.name.startsWith(".") && entry.name !== ".squad") continue;
3577
- const abs = path11.join(dir, entry.name);
3578
- const rel = path11.relative(root, abs);
4208
+ const abs = path16.join(dir, entry.name);
4209
+ const rel = path16.relative(root, abs);
3579
4210
  if (!rel || ig.ignores(rel) || entry.isDirectory() && ig.ignores(rel + "/")) continue;
3580
4211
  if (entry.isDirectory()) {
3581
4212
  walk2(root, abs, ig, out, max, includeHidden);
@@ -3623,38 +4254,8 @@ function composeUserPrompt(args) {
3623
4254
  }
3624
4255
 
3625
4256
  // src/planner/writer.ts
3626
- import fs20 from "fs";
3627
- import path13 from "path";
3628
-
3629
- // src/core/sequence.ts
3630
- import fs19 from "fs";
3631
- import path12 from "path";
3632
- var STORY_FILE2 = /^(\d{2,})-story-.+\.md$/;
3633
- function scanPlans(plansDir) {
3634
- const used = [];
3635
- const seen = /* @__PURE__ */ new Map();
3636
- if (fs19.existsSync(plansDir)) {
3637
- for (const feature of fs19.readdirSync(plansDir, { withFileTypes: true })) {
3638
- if (!feature.isDirectory()) continue;
3639
- const featureDir = path12.join(plansDir, feature.name);
3640
- for (const entry of fs19.readdirSync(featureDir)) {
3641
- const match = entry.match(STORY_FILE2);
3642
- if (!match) continue;
3643
- const n = parseInt(match[1], 10);
3644
- used.push(n);
3645
- seen.set(n, (seen.get(n) ?? 0) + 1);
3646
- }
3647
- }
3648
- }
3649
- const duplicates = [...seen.entries()].filter(([, c]) => c > 1).map(([n]) => n);
3650
- const maxNumber = used.length ? Math.max(...used) : 0;
3651
- return { usedNumbers: used.sort((a, b) => a - b), maxNumber, duplicates, nextGlobal: maxNumber + 1 };
3652
- }
3653
- function formatSequence(n) {
3654
- return String(n).padStart(2, "0");
3655
- }
3656
-
3657
- // src/planner/writer.ts
4257
+ import fs24 from "fs";
4258
+ import path17 from "path";
3658
4259
  function planFileSlugParts(config2, story) {
3659
4260
  const baseSlug = slugify(story.id);
3660
4261
  const trackerId = config2.naming.includeTrackerId && config2.tracker.type !== "none" ? trackerIdForFilename(config2.tracker.type, story.id) : "";
@@ -3663,8 +4264,8 @@ function planFileSlugParts(config2, story) {
3663
4264
  }
3664
4265
  function writePlanFile(input5) {
3665
4266
  const { paths, config: config2, story } = input5;
3666
- const featurePlanDir = path13.join(paths.plansDir, story.feature);
3667
- fs20.mkdirSync(featurePlanDir, { recursive: true });
4267
+ const featurePlanDir = path17.join(paths.plansDir, story.feature);
4268
+ fs24.mkdirSync(featurePlanDir, { recursive: true });
3668
4269
  if (input5.partial) {
3669
4270
  return writePartialPlanFile(input5, featurePlanDir, paths, config2, story);
3670
4271
  }
@@ -3679,14 +4280,14 @@ function writePlanFile(input5) {
3679
4280
  } else {
3680
4281
  nn = scanPlans(paths.plansDir).nextGlobal;
3681
4282
  const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
3682
- targetPath = path13.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
4283
+ targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.md`);
3683
4284
  }
3684
4285
  const body = `${input5.metadataHeader.trimEnd()}
3685
4286
 
3686
4287
  ${input5.planBodyMarkdown.trimStart()}
3687
4288
  `;
3688
- fs20.writeFileSync(targetPath, body, "utf8");
3689
- upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story, false);
4289
+ fs24.writeFileSync(targetPath, body, "utf8");
4290
+ upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, false);
3690
4291
  return { planFile: targetPath, sequenceNumber: nn, overwrote };
3691
4292
  }
3692
4293
  function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
@@ -3701,42 +4302,42 @@ function writePartialPlanFile(input5, featurePlanDir, paths, config2, story) {
3701
4302
  } else {
3702
4303
  nn = scanPlans(paths.plansDir).nextGlobal;
3703
4304
  const { baseSlug, trackerPart } = planFileSlugParts(config2, story);
3704
- targetPath = path13.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.partial.md`);
4305
+ targetPath = path17.join(featurePlanDir, `${formatSequence(nn)}-story-${baseSlug}${trackerPart}.partial.md`);
3705
4306
  }
3706
4307
  const body = `${input5.metadataHeader.trimEnd()}
3707
4308
 
3708
4309
  ${input5.planBodyMarkdown.trimStart()}
3709
4310
  `;
3710
- fs20.writeFileSync(targetPath, body, "utf8");
3711
- upsertOverviewRow(featurePlanDir, story.feature, nn, path13.basename(targetPath), story, true);
4311
+ fs24.writeFileSync(targetPath, body, "utf8");
4312
+ upsertOverviewRow(featurePlanDir, story.feature, nn, path17.basename(targetPath), story, true);
3712
4313
  return { planFile: targetPath, sequenceNumber: nn, overwrote };
3713
4314
  }
3714
4315
  function findExistingPlan(featureDir, storyId) {
3715
- if (!fs20.existsSync(featureDir)) return void 0;
4316
+ if (!fs24.existsSync(featureDir)) return void 0;
3716
4317
  const pattern = /^(\d{2,})-story-.+\.md$/;
3717
- for (const entry of fs20.readdirSync(featureDir)) {
4318
+ for (const entry of fs24.readdirSync(featureDir)) {
3718
4319
  if (entry.endsWith(".partial.md")) continue;
3719
4320
  const m = entry.match(pattern);
3720
4321
  if (!m) continue;
3721
- if (entry.includes(storyId)) return { absPath: path13.join(featureDir, entry), nn: parseInt(m[1], 10) };
4322
+ if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
3722
4323
  }
3723
4324
  return void 0;
3724
4325
  }
3725
4326
  function findExistingPartialPlan(featureDir, storyId) {
3726
- if (!fs20.existsSync(featureDir)) return void 0;
4327
+ if (!fs24.existsSync(featureDir)) return void 0;
3727
4328
  const pattern = /^(\d{2,})-story-.+\.partial\.md$/;
3728
- for (const entry of fs20.readdirSync(featureDir)) {
4329
+ for (const entry of fs24.readdirSync(featureDir)) {
3729
4330
  const m = entry.match(pattern);
3730
4331
  if (!m) continue;
3731
- if (entry.includes(storyId)) return { absPath: path13.join(featureDir, entry), nn: parseInt(m[1], 10) };
4332
+ if (entry.includes(storyId)) return { absPath: path17.join(featureDir, entry), nn: parseInt(m[1], 10) };
3732
4333
  }
3733
4334
  return void 0;
3734
4335
  }
3735
4336
  function upsertOverviewRow(featureDir, feature, nn, filename, story, isPartial) {
3736
- const overviewPath = path13.join(featureDir, "00-overview.md");
4337
+ const overviewPath = path17.join(featureDir, "00-overview.md");
3737
4338
  const titleCell = isPartial ? `${story.titleHint ?? story.id} (partial)` : story.titleHint ?? story.id;
3738
4339
  const row = `| ${formatSequence(nn)} | \`${filename}\` | ${titleCell} | ${story.id} | \u2014 |`;
3739
- if (!fs20.existsSync(overviewPath)) {
4340
+ if (!fs24.existsSync(overviewPath)) {
3740
4341
  const content = `# ${feature} \u2014 plan overview
3741
4342
 
3742
4343
  Entry point for the **${feature}** feature. Stories execute in order by their \`NN\` prefix.
@@ -3747,10 +4348,10 @@ Entry point for the **${feature}** feature. Stories execute in order by their \`
3747
4348
  |----|------|-------|------------|------------|
3748
4349
  ${row}
3749
4350
  `;
3750
- fs20.writeFileSync(overviewPath, content, "utf8");
4351
+ fs24.writeFileSync(overviewPath, content, "utf8");
3751
4352
  return;
3752
4353
  }
3753
- const current = fs20.readFileSync(overviewPath, "utf8");
4354
+ const current = fs24.readFileSync(overviewPath, "utf8");
3754
4355
  const lines = current.split("\n");
3755
4356
  const existingIdx = lines.findIndex((l) => l.includes(`\`${filename}\``) || l.includes(`| ${formatSequence(nn)} |`));
3756
4357
  if (existingIdx >= 0) {
@@ -3759,7 +4360,7 @@ ${row}
3759
4360
  const tableEnd = findTableEnd(lines);
3760
4361
  lines.splice(tableEnd, 0, row);
3761
4362
  }
3762
- fs20.writeFileSync(overviewPath, lines.join("\n"), "utf8");
4363
+ fs24.writeFileSync(overviewPath, lines.join("\n"), "utf8");
3763
4364
  }
3764
4365
  function findTableEnd(lines) {
3765
4366
  let inTable = false;
@@ -3792,17 +4393,28 @@ squad-kit-plan-status: partial
3792
4393
  return line1;
3793
4394
  }
3794
4395
 
4396
+ // src/core/copy-plan-prompt.ts
4397
+ function buildCopyPlanPromptMarkdown(config2, intakeContent) {
4398
+ const metaPromptTemplate = readBundledPrompt("generate-plan.md");
4399
+ return render(metaPromptTemplate, {
4400
+ projectRoots: (config2.project.projectRoots ?? ["."]).join(", "),
4401
+ primaryLanguage: config2.project.primaryLanguage ?? "",
4402
+ trackerType: config2.tracker.type,
4403
+ intakeContent
4404
+ });
4405
+ }
4406
+
3795
4407
  // src/core/last-run.ts
3796
- import fs21 from "fs/promises";
3797
- import path14 from "path";
4408
+ import fs25 from "fs/promises";
4409
+ import path18 from "path";
3798
4410
  async function writeLastRun(paths, rec) {
3799
- const file = path14.join(paths.squadDir, ".last-run.json");
3800
- await fs21.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
4411
+ const file = path18.join(paths.squadDir, ".last-run.json");
4412
+ await fs25.writeFile(file, JSON.stringify({ ...rec, version: 1 }, null, 2) + "\n", "utf8");
3801
4413
  }
3802
4414
  async function readLastRun(paths) {
3803
- const file = path14.join(paths.squadDir, ".last-run.json");
4415
+ const file = path18.join(paths.squadDir, ".last-run.json");
3804
4416
  try {
3805
- const raw = await fs21.readFile(file, "utf8");
4417
+ const raw = await fs25.readFile(file, "utf8");
3806
4418
  const parsed = JSON.parse(raw);
3807
4419
  if (parsed.version !== 1) return void 0;
3808
4420
  return parsed;
@@ -3862,8 +4474,8 @@ async function runNewPlan(intakePath, opts) {
3862
4474
  }
3863
4475
  }
3864
4476
  function resolveFromPath(intakePath, stories, root) {
3865
- const resolved = path15.resolve(intakePath.trim());
3866
- if (!fs22.existsSync(resolved)) {
4477
+ const resolved = path19.resolve(intakePath.trim());
4478
+ if (!fs26.existsSync(resolved)) {
3867
4479
  throw new Error(
3868
4480
  `Intake not found: ${resolved}. Run \`squad list\` to see intakes, or \`squad new-story\` to create one first.`
3869
4481
  );
@@ -3875,7 +4487,7 @@ function resolveFromPath(intakePath, stories, root) {
3875
4487
  );
3876
4488
  }
3877
4489
  function relativeStoriesDir(root) {
3878
- return path15.relative(root, path15.join(root, ".squad", "stories"));
4490
+ return path19.relative(root, path19.join(root, ".squad", "stories"));
3879
4491
  }
3880
4492
  async function pickStory(stories, opts) {
3881
4493
  const candidates = opts.all ? stories : stories.filter((s) => !s.planFile);
@@ -3898,7 +4510,7 @@ async function pickStory(stories, opts) {
3898
4510
  "No intake path provided and not running interactively. Run `squad new-plan` with a path to intake.md, or `squad new-plan` in a TTY to pick, or add `--yes` with an explicit path in CI."
3899
4511
  );
3900
4512
  }
3901
- const pick = await select5({
4513
+ const pick = await select6({
3902
4514
  message: "Pick a story to plan:",
3903
4515
  pageSize: Math.min(10, candidates.length),
3904
4516
  choices: candidates.map((s) => ({
@@ -3906,7 +4518,7 @@ async function pickStory(stories, opts) {
3906
4518
  value: s.intakePath
3907
4519
  }))
3908
4520
  });
3909
- return candidates.find((s) => path15.resolve(s.intakePath) === path15.resolve(pick));
4521
+ return candidates.find((s) => path19.resolve(s.intakePath) === path19.resolve(pick));
3910
4522
  }
3911
4523
  function formatLabel(s) {
3912
4524
  const head = `${s.feature} / ${s.id}`;
@@ -3984,9 +4596,36 @@ async function emitViaApi(story, paths, config2, opts) {
3984
4596
  const sessionSpinner = { current: null };
3985
4597
  const startedAt = Date.now();
3986
4598
  const cacheEnabled = planner.cache?.enabled ?? true;
4599
+ const runId = newRunId();
4600
+ const bus = new PlannerEventBus();
4601
+ const unsubscribe = bus.subscribe((e) => {
4602
+ switch (e.kind) {
4603
+ case "tool_call": {
4604
+ const p = e.toolCall.input.path ?? "<unknown>";
4605
+ sessionSpinner.current?.succeed(
4606
+ `read ${p} (${(e.bytesLoaded / 1024).toFixed(1)} KB \xB7 ${(e.totalBytes / 1024).toFixed(1)} KB / ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB)`
4607
+ );
4608
+ sessionSpinner.current = spinner("reading next file\u2026");
4609
+ break;
4610
+ }
4611
+ case "assistant_text":
4612
+ sessionSpinner.current?.succeed("planner thinking complete (this chunk)");
4613
+ sessionSpinner.current = spinner("thinking\u2026");
4614
+ break;
4615
+ case "rate_limit":
4616
+ sessionSpinner.current?.stop();
4617
+ warning(`${planner.provider} rate limit hit \u2014 retrying in ${e.waitSec}s`);
4618
+ sessionSpinner.current = spinner("waiting for rate limit to reset\u2026");
4619
+ break;
4620
+ case "usage":
4621
+ break;
4622
+ default:
4623
+ break;
4624
+ }
4625
+ });
3987
4626
  const decideOnLimit = interactive ? async (ctx) => {
3988
4627
  printPlannerLimitExplanation(ctx);
3989
- const ans = await select5({
4628
+ const ans = await select6({
3990
4629
  message: "Continue this planning session? (Continuing sends more API requests and is billed.)",
3991
4630
  choices: [
3992
4631
  { name: "Continue \u2014 extend limits for this run", value: "continue" },
@@ -3996,35 +4635,26 @@ async function emitViaApi(story, paths, config2, opts) {
3996
4635
  if (ans === "cancel") printPlannerLimitNextSteps();
3997
4636
  return ans;
3998
4637
  } : void 0;
3999
- const result = await runPlanner({
4000
- root: paths.root,
4001
- provider,
4002
- model,
4003
- apiKey,
4004
- systemPrompt,
4005
- userPrompt,
4006
- budget,
4007
- maxOutputTokens: planner.maxOutputTokens,
4008
- cacheEnabled,
4009
- decideOnLimit,
4010
- onToolCall: (tc, bytes, total) => {
4011
- const p = tc.input.path ?? "<unknown>";
4012
- sessionSpinner.current?.succeed(
4013
- `read ${p} (${(bytes / 1024).toFixed(1)} KB \xB7 ${(total / 1024).toFixed(1)} KB / ${(planner.budget.maxContextBytes / 1024).toFixed(0)} KB)`
4014
- );
4015
- sessionSpinner.current = spinner(`reading next file\u2026`);
4016
- },
4017
- onAssistantText: () => {
4018
- sessionSpinner.current?.succeed("planner thinking complete (this chunk)");
4019
- sessionSpinner.current = spinner("thinking\u2026");
4020
- },
4021
- onRateLimit: (waitSec) => {
4022
- sessionSpinner.current?.stop();
4023
- warning(`${planner.provider} rate limit hit \u2014 retrying in ${waitSec}s`);
4024
- sessionSpinner.current = spinner("waiting for rate limit to reset\u2026");
4025
- }
4026
- });
4027
- sessionSpinner.current?.stop();
4638
+ let result;
4639
+ try {
4640
+ result = await runPlanner({
4641
+ root: paths.root,
4642
+ provider,
4643
+ model,
4644
+ apiKey,
4645
+ systemPrompt,
4646
+ userPrompt,
4647
+ budget,
4648
+ maxOutputTokens: planner.maxOutputTokens,
4649
+ cacheEnabled,
4650
+ decideOnLimit,
4651
+ events: bus,
4652
+ runId
4653
+ });
4654
+ } finally {
4655
+ unsubscribe();
4656
+ sessionSpinner.current?.stop();
4657
+ }
4028
4658
  if (!result.planText.trim()) {
4029
4659
  throw new Error(
4030
4660
  "Planner returned no plan text. Run `squad doctor` to check provider and models, or `squad new-plan --copy` to avoid the API."
@@ -4052,6 +4682,7 @@ async function emitViaApi(story, paths, config2, opts) {
4052
4682
  metadataHeader: header,
4053
4683
  partial: !success2
4054
4684
  });
4685
+ const relPlan = path19.relative(paths.root, planFile);
4055
4686
  try {
4056
4687
  await writeLastRun(paths, {
4057
4688
  stats: result.stats,
@@ -4059,9 +4690,23 @@ async function emitViaApi(story, paths, config2, opts) {
4059
4690
  provider: planner.provider,
4060
4691
  model
4061
4692
  });
4693
+ await appendRun(paths, {
4694
+ runId,
4695
+ provider: planner.provider,
4696
+ model,
4697
+ feature: story.feature,
4698
+ storyId: story.id,
4699
+ startedAt: new Date(startedAt).toISOString(),
4700
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
4701
+ success: success2,
4702
+ partial: !success2,
4703
+ planFile: relPlan,
4704
+ stats: result.stats,
4705
+ cacheEnabled,
4706
+ durationMs: elapsedMs
4707
+ });
4062
4708
  } catch {
4063
4709
  }
4064
- const relPlan = path15.relative(paths.root, planFile);
4065
4710
  blank();
4066
4711
  summaryBox(success2 ? " plan generated " : " plan saved (incomplete) ", [
4067
4712
  { key: "file", value: relPlan },
@@ -4091,20 +4736,14 @@ async function emitViaApi(story, paths, config2, opts) {
4091
4736
  info("Note: file-read budget was reached; the model finalised without further reads.");
4092
4737
  }
4093
4738
  blank();
4094
- info(`next \u2192 open a new agent chat and attach only ${path15.basename(planFile)}`);
4739
+ info(`next \u2192 open a new agent chat and attach only ${path19.basename(planFile)}`);
4095
4740
  }
4096
4741
  async function emitCopyPrompt(story, paths, config2, clipboard) {
4097
4742
  const intakeContent = readFile(story.intakePath);
4098
- const metaPromptTemplate = readBundledPrompt("generate-plan.md");
4099
- const composed = render(metaPromptTemplate, {
4100
- projectRoots: (config2.project.projectRoots ?? ["."]).join(", "),
4101
- primaryLanguage: config2.project.primaryLanguage ?? "",
4102
- trackerType: config2.tracker.type,
4103
- intakeContent
4104
- });
4105
- const promptFile = path15.join(paths.squadDir, ".last-copy-prompt.md");
4106
- fs22.writeFileSync(promptFile, composed, "utf8");
4107
- const relPromptFile = path15.relative(paths.root, promptFile);
4743
+ const composed = buildCopyPlanPromptMarkdown(config2, intakeContent);
4744
+ const promptFile = path19.join(paths.squadDir, ".last-copy-prompt.md");
4745
+ fs26.writeFileSync(promptFile, composed, "utf8");
4746
+ const relPromptFile = path19.relative(paths.root, promptFile);
4108
4747
  const bytes = Buffer.byteLength(composed, "utf8");
4109
4748
  const estTokens = Math.round(bytes / 4);
4110
4749
  const clipResult = clipboard ? await copyToClipboard(composed) : { ok: false, reason: "clipboard disabled (--no-clipboard)" };
@@ -4162,7 +4801,7 @@ function printModelBanner(config2) {
4162
4801
  }
4163
4802
 
4164
4803
  // src/commands/status.ts
4165
- import fs23 from "fs";
4804
+ import fs27 from "fs";
4166
4805
  var kw = 28;
4167
4806
  function probeTrackerCredential(config2, overlay) {
4168
4807
  const { client, error: error2 } = clientFor(config2, overlay);
@@ -4188,7 +4827,7 @@ async function runStatus() {
4188
4827
  const scan = scanPlans(paths.plansDir);
4189
4828
  const storyCount = listStories(paths).length;
4190
4829
  const planCount = scan.usedNumbers.length;
4191
- const secrets = fs23.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
4830
+ const secrets = fs27.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
4192
4831
  const overlay = overlayTrackerEnv(secrets);
4193
4832
  banner();
4194
4833
  step("squad-kit status");
@@ -4242,13 +4881,13 @@ function buildTrackerLine(config2, overlay) {
4242
4881
  }
4243
4882
 
4244
4883
  // src/commands/list.ts
4245
- import path16 from "path";
4884
+ import path20 from "path";
4246
4885
  async function runList(opts) {
4247
4886
  const root = requireSquadRoot();
4248
4887
  const paths = buildPaths(root);
4249
4888
  const records = listStories(paths, { feature: opts.feature });
4250
4889
  const rows = records.map((r) => {
4251
- const planAbs = r.planFile ? path16.join(paths.plansDir, r.feature, r.planFile) : void 0;
4890
+ const planAbs = r.planFile ? path20.join(paths.plansDir, r.feature, r.planFile) : void 0;
4252
4891
  const meta = planAbs ? readPlanMetadata(planAbs) : void 0;
4253
4892
  return {
4254
4893
  feature: r.feature,
@@ -4287,9 +4926,9 @@ function stripAnsi(s) {
4287
4926
  }
4288
4927
 
4289
4928
  // src/commands/tracker-link.ts
4290
- import fs24 from "fs";
4291
- import path17 from "path";
4292
- import { input as input4, select as select6 } from "@inquirer/prompts";
4929
+ import fs28 from "fs";
4930
+ import path21 from "path";
4931
+ import { input as input4, select as select7 } from "@inquirer/prompts";
4293
4932
  async function runTrackerLink(storyPath, trackerId, opts = {}) {
4294
4933
  const root = requireSquadRoot();
4295
4934
  const paths = buildPaths(root);
@@ -4306,7 +4945,7 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
4306
4945
  if (stories.length === 0) {
4307
4946
  throw new Error("No stories found under .squad/stories/. Create one with `squad new-story` first.");
4308
4947
  }
4309
- resolvedStoryPath = await select6({
4948
+ resolvedStoryPath = await select7({
4310
4949
  message: "Story to link:",
4311
4950
  choices: stories.map((s) => ({
4312
4951
  name: `${s.feature}/${s.id}`,
@@ -4335,17 +4974,17 @@ async function runTrackerLink(storyPath, trackerId, opts = {}) {
4335
4974
  `Invalid tracker id "${resolvedTrackerId}" for tracker type "${config2.tracker.type}". Run \`squad config show\` to review the expected format, or \`squad config set tracker\` if the type is wrong.`
4336
4975
  );
4337
4976
  }
4338
- const resolved = path17.resolve(resolvedStoryPath);
4339
- const intakeFile = resolved.endsWith("intake.md") ? resolved : path17.join(resolved, "intake.md");
4340
- if (!fs24.existsSync(intakeFile)) {
4977
+ const resolved = path21.resolve(resolvedStoryPath);
4978
+ const intakeFile = resolved.endsWith("intake.md") ? resolved : path21.join(resolved, "intake.md");
4979
+ if (!fs28.existsSync(intakeFile)) {
4341
4980
  throw new Error(
4342
4981
  `intake.md not found at ${intakeFile}. Run \`squad list\` to see valid paths, or \`squad new-story\` to create a story first.`
4343
4982
  );
4344
4983
  }
4345
- const content = fs24.readFileSync(intakeFile, "utf8");
4984
+ const content = fs28.readFileSync(intakeFile, "utf8");
4346
4985
  const updated = upsertTrackerId(content, resolvedTrackerId);
4347
- fs24.writeFileSync(intakeFile, updated, "utf8");
4348
- success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path17.relative(root, intakeFile)}`);
4986
+ fs28.writeFileSync(intakeFile, updated, "utf8");
4987
+ success(`Linked tracker id ${resolvedTrackerId} \u2192 ${path21.relative(root, intakeFile)}`);
4349
4988
  blank();
4350
4989
  step("Next:");
4351
4990
  info("1) Review the intake \u2014 the Source block at the top now carries the tracker id.");
@@ -4362,9 +5001,9 @@ function upsertTrackerId(content, id) {
4362
5001
  `;
4363
5002
  }
4364
5003
 
4365
- // src/commands/doctor.ts
4366
- import fs25 from "fs";
4367
- import path18 from "path";
5004
+ // src/commands/doctor-engine.ts
5005
+ import fs29 from "fs";
5006
+ import path22 from "path";
4368
5007
  async function gatherContext(paths) {
4369
5008
  const ctx = { hasLegacyPromptsDir: false };
4370
5009
  try {
@@ -4373,29 +5012,29 @@ async function gatherContext(paths) {
4373
5012
  ctx.configError = err;
4374
5013
  }
4375
5014
  try {
4376
- ctx.secrets = fs25.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
5015
+ ctx.secrets = fs29.existsSync(paths.secretsFile) ? loadSecrets(paths.secretsFile) : {};
4377
5016
  } catch (err) {
4378
5017
  ctx.secretsError = err;
4379
5018
  }
4380
- ctx.hasLegacyPromptsDir = fs25.existsSync(paths.promptsDir);
5019
+ ctx.hasLegacyPromptsDir = fs29.existsSync(paths.promptsDir);
4381
5020
  return ctx;
4382
5021
  }
4383
5022
  function gitignoreHasManagedBlock(repoRoot) {
4384
- const gitignore = path18.join(repoRoot, ".gitignore");
4385
- return fs25.existsSync(gitignore) && fs25.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
5023
+ const gitignore = path22.join(repoRoot, ".gitignore");
5024
+ return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(".squad/secrets.yaml");
4386
5025
  }
4387
5026
  function gitignoreHasTrashPattern(repoRoot) {
4388
- const gitignore = path18.join(repoRoot, ".gitignore");
4389
- return fs25.existsSync(gitignore) && fs25.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
5027
+ const gitignore = path22.join(repoRoot, ".gitignore");
5028
+ return fs29.existsSync(gitignore) && fs29.readFileSync(gitignore, "utf8").includes(SQUAD_TRASH_PATTERN);
4390
5029
  }
4391
5030
  async function checkDirStructure(paths, _ctx, fix) {
4392
- const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs25.existsSync(p));
5031
+ const need = [paths.squadDir, paths.storiesDir, paths.plansDir].filter((p) => !fs29.existsSync(p));
4393
5032
  if (need.length === 0) {
4394
5033
  return { id: "dirs", name: ".squad/ directory structure", status: "ok" };
4395
5034
  }
4396
5035
  if (fix) {
4397
5036
  for (const p of need) {
4398
- fs25.mkdirSync(p, { recursive: true });
5037
+ fs29.mkdirSync(p, { recursive: true });
4399
5038
  }
4400
5039
  return { id: "dirs", name: ".squad/ directory structure", status: "ok", detail: "repaired" };
4401
5040
  }
@@ -4403,7 +5042,7 @@ async function checkDirStructure(paths, _ctx, fix) {
4403
5042
  id: "dirs",
4404
5043
  name: ".squad/ directory structure",
4405
5044
  status: "warn",
4406
- detail: `missing: ${need.map((p) => path18.relative(paths.root, p)).join(", ")}`,
5045
+ detail: `missing: ${need.map((p) => path22.relative(paths.root, p)).join(", ")}`,
4407
5046
  fixable: true,
4408
5047
  fixHint: "squad doctor --fix"
4409
5048
  };
@@ -4458,15 +5097,15 @@ async function checkSecretsPermissions(paths, _ctx, fix) {
4458
5097
  if (process.platform === "win32") {
4459
5098
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "skip", detail: "Windows" };
4460
5099
  }
4461
- if (!fs25.existsSync(paths.secretsFile)) {
5100
+ if (!fs29.existsSync(paths.secretsFile)) {
4462
5101
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "not present" };
4463
5102
  }
4464
- const mode = fs25.statSync(paths.secretsFile).mode & 511;
5103
+ const mode = fs29.statSync(paths.secretsFile).mode & 511;
4465
5104
  if (mode === 384) {
4466
5105
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok" };
4467
5106
  }
4468
5107
  if (fix) {
4469
- fs25.chmodSync(paths.secretsFile, 384);
5108
+ fs29.chmodSync(paths.secretsFile, 384);
4470
5109
  return { id: "secrets-perms", name: ".squad/secrets.yaml permissions", status: "ok", detail: "repaired" };
4471
5110
  }
4472
5111
  return {
@@ -4487,7 +5126,7 @@ async function checkSecretsParseable(_paths, ctx) {
4487
5126
  detail: ctx.secretsError.message
4488
5127
  };
4489
5128
  }
4490
- if (!fs25.existsSync(_paths.secretsFile)) {
5129
+ if (!fs29.existsSync(_paths.secretsFile)) {
4491
5130
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok", detail: "not present" };
4492
5131
  }
4493
5132
  return { id: "secrets-yaml", name: ".squad/secrets.yaml parseable", status: "ok" };
@@ -4869,6 +5508,8 @@ function summarise(checks) {
4869
5508
  skip: checks.filter((c) => c.status === "skip").length
4870
5509
  };
4871
5510
  }
5511
+
5512
+ // src/commands/doctor.ts
4872
5513
  function colorForStatus(s) {
4873
5514
  switch (s) {
4874
5515
  case "ok":
@@ -4961,23 +5602,23 @@ async function runDoctor(opts) {
4961
5602
  }
4962
5603
 
4963
5604
  // src/commands/migrate.ts
4964
- import fs26 from "fs";
4965
- import path19 from "path";
5605
+ import fs30 from "fs";
5606
+ import path23 from "path";
4966
5607
  import { confirm as confirm7 } from "@inquirer/prompts";
4967
5608
  var LEGACY_PROMPTS_MIGRATION = {
4968
5609
  id: "legacy-prompts",
4969
5610
  name: "Remove legacy .squad/prompts/",
4970
- isApplied: (paths) => !fs26.existsSync(paths.promptsDir),
4971
- describe: (paths) => `delete ${path19.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
5611
+ isApplied: (paths) => !fs30.existsSync(paths.promptsDir),
5612
+ describe: (paths) => `delete ${path23.relative(paths.root, paths.promptsDir)} (prompts are now bundled in squad-kit)`,
4972
5613
  apply: (paths) => {
4973
5614
  const target = paths.promptsDir;
4974
- if (!target.includes(path19.sep + ".squad" + path19.sep + "prompts")) {
5615
+ if (!target.includes(path23.sep + ".squad" + path23.sep + "prompts")) {
4975
5616
  throw new Error(
4976
5617
  `Refusing to delete suspicious path: ${target}. This is a safety check \u2014 run \`squad doctor\` if .squad/ paths look wrong.`
4977
5618
  );
4978
5619
  }
4979
- if (fs26.existsSync(target)) {
4980
- fs26.rmSync(target, { recursive: true, force: true });
5620
+ if (fs30.existsSync(target)) {
5621
+ fs30.rmSync(target, { recursive: true, force: true });
4981
5622
  }
4982
5623
  }
4983
5624
  };
@@ -4985,8 +5626,8 @@ var GITIGNORE_MIGRATION = {
4985
5626
  id: "gitignore",
4986
5627
  name: "Ensure .gitignore managed block",
4987
5628
  isApplied: (paths) => {
4988
- const f = path19.join(paths.root, ".gitignore");
4989
- return fs26.existsSync(f) && fs26.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
5629
+ const f = path23.join(paths.root, ".gitignore");
5630
+ return fs30.existsSync(f) && fs30.readFileSync(f, "utf8").includes(".squad/secrets.yaml");
4990
5631
  },
4991
5632
  describe: () => "add squad-kit managed block to .gitignore",
4992
5633
  apply: (paths) => {
@@ -4998,23 +5639,23 @@ var SECRETS_PERMS_MIGRATION = {
4998
5639
  name: "Tighten .squad/secrets.yaml permissions",
4999
5640
  isApplied: (paths) => {
5000
5641
  if (process.platform === "win32") return true;
5001
- if (!fs26.existsSync(paths.secretsFile)) return true;
5002
- const st = fs26.statSync(paths.secretsFile);
5642
+ if (!fs30.existsSync(paths.secretsFile)) return true;
5643
+ const st = fs30.statSync(paths.secretsFile);
5003
5644
  return (st.mode & 511) === 384;
5004
5645
  },
5005
- describe: (paths) => `chmod 0600 ${path19.relative(paths.root, paths.secretsFile)}`,
5646
+ describe: (paths) => `chmod 0600 ${path23.relative(paths.root, paths.secretsFile)}`,
5006
5647
  apply: (paths) => {
5007
5648
  if (process.platform === "win32") return;
5008
- if (fs26.existsSync(paths.secretsFile)) fs26.chmodSync(paths.secretsFile, 384);
5649
+ if (fs30.existsSync(paths.secretsFile)) fs30.chmodSync(paths.secretsFile, 384);
5009
5650
  }
5010
5651
  };
5011
5652
  var PLANNER_BUDGET_DEFAULTS_MIGRATION = {
5012
5653
  id: "planner-budget-defaults",
5013
5654
  name: "Backfill planner.budget defaults in config.yaml",
5014
5655
  isApplied: (paths) => {
5015
- if (!fs26.existsSync(paths.configFile)) return true;
5656
+ if (!fs30.existsSync(paths.configFile)) return true;
5016
5657
  try {
5017
- const raw = fs26.readFileSync(paths.configFile, "utf8");
5658
+ const raw = fs30.readFileSync(paths.configFile, "utf8");
5018
5659
  if (!/^\s*planner:/m.test(raw)) return true;
5019
5660
  if (/^\s*budget:/m.test(raw)) return true;
5020
5661
  const cfg = loadConfig(paths.configFile);
@@ -5033,9 +5674,9 @@ var CONFIG_NORMALIZE_MIGRATION = {
5033
5674
  id: "config-normalize",
5034
5675
  name: "Normalise .squad/config.yaml formatting",
5035
5676
  isApplied: (paths) => {
5036
- if (!fs26.existsSync(paths.configFile)) return true;
5677
+ if (!fs30.existsSync(paths.configFile)) return true;
5037
5678
  try {
5038
- const raw = fs26.readFileSync(paths.configFile, "utf8");
5679
+ const raw = fs30.readFileSync(paths.configFile, "utf8");
5039
5680
  const cfg = loadConfig(paths.configFile);
5040
5681
  return raw.trim() === serializeConfig(cfg).trim();
5041
5682
  } catch {
@@ -5044,7 +5685,7 @@ var CONFIG_NORMALIZE_MIGRATION = {
5044
5685
  },
5045
5686
  describe: () => "rewrite config.yaml with canonical YAML formatting (drops comments)",
5046
5687
  apply: (paths) => {
5047
- if (!fs26.existsSync(paths.configFile)) return;
5688
+ if (!fs30.existsSync(paths.configFile)) return;
5048
5689
  const cfg = loadConfig(paths.configFile);
5049
5690
  saveConfig(paths.configFile, cfg);
5050
5691
  }
@@ -5125,8 +5766,8 @@ import process3 from "process";
5125
5766
  import { confirm as confirm8 } from "@inquirer/prompts";
5126
5767
 
5127
5768
  // src/core/package-info.ts
5128
- import fs27 from "fs";
5129
- import path20 from "path";
5769
+ import fs31 from "fs";
5770
+ import path24 from "path";
5130
5771
  function changelogBlobUrl(repositoryUrl, version) {
5131
5772
  let base = repositoryUrl;
5132
5773
  if (!base) base = "https://github.com/AzmSquad/squad-kit";
@@ -5137,9 +5778,9 @@ function changelogBlobUrl(repositoryUrl, version) {
5137
5778
  }
5138
5779
  function readInstalledPackage() {
5139
5780
  const root = packageRoot();
5140
- const pkgFile = path20.join(root, "package.json");
5141
- const pkg = JSON.parse(fs27.readFileSync(pkgFile, "utf8"));
5142
- const isDevInstall = fs27.existsSync(path20.join(root, "src"));
5781
+ const pkgFile = path24.join(root, "package.json");
5782
+ const pkg = JSON.parse(fs31.readFileSync(pkgFile, "utf8"));
5783
+ const isDevInstall = fs31.existsSync(path24.join(root, "src"));
5143
5784
  return {
5144
5785
  name: pkg.name,
5145
5786
  version: pkg.version,
@@ -5179,7 +5820,7 @@ function isNewer(a, b) {
5179
5820
  }
5180
5821
 
5181
5822
  // src/core/package-manager.ts
5182
- import path21 from "path";
5823
+ import path25 from "path";
5183
5824
  import process2 from "process";
5184
5825
  function detectPackageManager(installRoot) {
5185
5826
  const ua = process2.env.npm_config_user_agent;
@@ -5190,11 +5831,11 @@ function detectPackageManager(installRoot) {
5190
5831
  if (ua.startsWith("npm/")) return { pm: "npm", reason: "npm_config_user_agent" };
5191
5832
  }
5192
5833
  const root = installRoot.toLowerCase();
5193
- if (root.includes(`${path21.sep}pnpm${path21.sep}`) || root.includes(`${path21.sep}.pnpm${path21.sep}`))
5834
+ if (root.includes(`${path25.sep}pnpm${path25.sep}`) || root.includes(`${path25.sep}.pnpm${path25.sep}`))
5194
5835
  return { pm: "pnpm", reason: `install path contains pnpm` };
5195
- if (root.includes(`${path21.sep}.yarn${path21.sep}`) || root.includes(`${path21.sep}yarn${path21.sep}`))
5836
+ if (root.includes(`${path25.sep}.yarn${path25.sep}`) || root.includes(`${path25.sep}yarn${path25.sep}`))
5196
5837
  return { pm: "yarn", reason: `install path contains yarn` };
5197
- if (root.includes(`${path21.sep}.bun${path21.sep}`) || root.includes(`${path21.sep}bun${path21.sep}`))
5838
+ if (root.includes(`${path25.sep}.bun${path25.sep}`) || root.includes(`${path25.sep}bun${path25.sep}`))
5198
5839
  return { pm: "bun", reason: `install path contains bun` };
5199
5840
  return { pm: "npm", reason: "default fallback" };
5200
5841
  }
@@ -5259,244 +5900,1069 @@ async function runUpgrade(opts) {
5259
5900
  info(`Run to upgrade: ${cmd} ${args.join(" ")}`);
5260
5901
  return;
5261
5902
  }
5262
- if (!opts.yes) {
5263
- const interactive = Boolean(process3.stdin.isTTY) && process3.env.CI !== "true";
5264
- if (!interactive) {
5265
- failure(
5266
- `Refusing to run a global install without --yes. Run \`squad upgrade --yes\` in non-interactive mode, or run manually: ${cmd} ${args.join(" ")}`
5267
- );
5268
- process3.exit(1);
5903
+ if (!opts.yes) {
5904
+ const interactive = Boolean(process3.stdin.isTTY) && process3.env.CI !== "true";
5905
+ if (!interactive) {
5906
+ failure(
5907
+ `Refusing to run a global install without --yes. Run \`squad upgrade --yes\` in non-interactive mode, or run manually: ${cmd} ${args.join(" ")}`
5908
+ );
5909
+ process3.exit(1);
5910
+ }
5911
+ const go = await confirm8({ message: `Run "${cmd} ${args.join(" ")}"?`, default: true });
5912
+ if (!go) {
5913
+ info("Cancelled. No changes applied.");
5914
+ return;
5915
+ }
5916
+ }
5917
+ blank();
5918
+ info(`Running: ${cmd} ${args.join(" ")}`);
5919
+ const exitCode = await spawnInherit(cmd, args);
5920
+ if (exitCode !== 0) {
5921
+ failure(
5922
+ `${cmd} exited with code ${exitCode}. Upgrade may be incomplete \u2014 run \`squad doctor\` and re-run \`squad upgrade\` if needed.`
5923
+ );
5924
+ process3.exit(exitCode);
5925
+ }
5926
+ blank();
5927
+ success(`Upgraded to ${latest}.`);
5928
+ info("Run `squad migrate` to update your .squad/ structure if needed.");
5929
+ }
5930
+ function spawnInherit(cmd, args) {
5931
+ return new Promise((resolve2, reject) => {
5932
+ const child = spawn2(cmd, args, { stdio: "inherit", shell: process3.platform === "win32" });
5933
+ child.on("exit", (code) => resolve2(code ?? 0));
5934
+ child.on("error", (err) => reject(err));
5935
+ });
5936
+ }
5937
+
5938
+ // src/commands/console.ts
5939
+ import { randomBytes as randomBytes2 } from "crypto";
5940
+ import { resolve } from "path";
5941
+ import open from "open";
5942
+
5943
+ // src/console/server.ts
5944
+ import { createServer } from "net";
5945
+ import { serve } from "@hono/node-server";
5946
+ import { Hono } from "hono";
5947
+ import { cors } from "hono/cors";
5948
+ import { secureHeaders } from "hono/secure-headers";
5949
+
5950
+ // src/console/api/stories.ts
5951
+ import fs32 from "fs";
5952
+ import { z } from "zod";
5953
+
5954
+ // src/utils/path-safety.ts
5955
+ import path26 from "path";
5956
+ function isInside(abs, parent) {
5957
+ const a = path26.resolve(abs) + path26.sep;
5958
+ const p = path26.resolve(parent) + path26.sep;
5959
+ return a.startsWith(p);
5960
+ }
5961
+
5962
+ // src/console/api/stories.ts
5963
+ var CreateBody = z.object({
5964
+ feature: z.string().min(1).max(80),
5965
+ title: z.string().max(200).optional(),
5966
+ trackerId: z.string().max(80).optional()
5967
+ });
5968
+ var PatchBody = z.object({
5969
+ intakeContent: z.string().min(1).max(1e6)
5970
+ });
5971
+ function safeFeatureParam(feature) {
5972
+ return feature.length > 0 && !feature.includes("/") && !feature.includes("..") && slugify(feature) === feature;
5973
+ }
5974
+ function mountStoriesApi(app, opts) {
5975
+ app.get("/api/stories", (c) => {
5976
+ const feature = c.req.query("feature") ?? void 0;
5977
+ const stories = listStories(opts.paths, { feature });
5978
+ return c.json(
5979
+ stories.map((s) => ({
5980
+ feature: s.feature,
5981
+ id: s.id,
5982
+ intakePath: s.intakePath,
5983
+ storyDir: s.storyDir,
5984
+ planFile: s.planFile ?? null,
5985
+ titleHint: s.titleHint ?? null
5986
+ }))
5987
+ );
5988
+ });
5989
+ app.get("/api/stories/:feature/:id", (c) => {
5990
+ const { feature, id } = c.req.param();
5991
+ if (!safeFeatureParam(feature)) return c.json({ error: "forbidden" }, 403);
5992
+ const stories = listStories(opts.paths, { feature });
5993
+ const hit = stories.find((s) => s.id === id);
5994
+ if (!hit) return c.json({ error: "not_found" }, 404);
5995
+ if (!isInside(hit.intakePath, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
5996
+ let intakeContent = "";
5997
+ try {
5998
+ intakeContent = fs32.readFileSync(hit.intakePath, "utf8");
5999
+ } catch {
6000
+ }
6001
+ return c.json({
6002
+ feature: hit.feature,
6003
+ id: hit.id,
6004
+ intakePath: hit.intakePath,
6005
+ storyDir: hit.storyDir,
6006
+ planFile: hit.planFile ?? null,
6007
+ titleHint: hit.titleHint ?? null,
6008
+ intakeContent
6009
+ });
6010
+ });
6011
+ app.post("/api/stories", async (c) => {
6012
+ const body = CreateBody.safeParse(await c.req.json());
6013
+ if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
6014
+ const cfg = loadConfig(opts.paths.configFile);
6015
+ let created;
6016
+ try {
6017
+ created = createStoryRecord({
6018
+ paths: opts.paths,
6019
+ config: cfg,
6020
+ feature: body.data.feature,
6021
+ title: body.data.title,
6022
+ trackerId: body.data.trackerId
6023
+ });
6024
+ } catch (e) {
6025
+ const msg = e instanceof Error ? e.message : String(e);
6026
+ if (/already exists|Intake already exists/.test(msg)) {
6027
+ return c.json({ error: "conflict", message: msg }, 409);
6028
+ }
6029
+ return c.json({ error: "invalid_request", message: msg }, 400);
6030
+ }
6031
+ return c.json(created, 201);
6032
+ });
6033
+ app.patch("/api/stories/:feature/:id", async (c) => {
6034
+ const body = PatchBody.safeParse(await c.req.json());
6035
+ if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
6036
+ const { feature, id } = c.req.param();
6037
+ if (!safeFeatureParam(feature)) return c.json({ error: "forbidden" }, 403);
6038
+ const stories = listStories(opts.paths, { feature });
6039
+ const hit = stories.find((s) => s.id === id);
6040
+ if (!hit) return c.json({ error: "not_found" }, 404);
6041
+ if (!isInside(hit.intakePath, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
6042
+ fs32.writeFileSync(hit.intakePath, body.data.intakeContent, "utf8");
6043
+ return c.json({ ok: true });
6044
+ });
6045
+ app.delete("/api/stories/:feature/:id", (c) => {
6046
+ const trash = c.req.query("trash") === "1";
6047
+ const { feature, id } = c.req.param();
6048
+ if (!safeFeatureParam(feature)) return c.json({ error: "forbidden" }, 403);
6049
+ const stories = listStories(opts.paths, { feature });
6050
+ const hit = stories.find((s) => s.id === id);
6051
+ if (!hit) return c.json({ error: "not_found" }, 404);
6052
+ if (!isInside(hit.storyDir, opts.paths.storiesDir)) return c.json({ error: "forbidden" }, 403);
6053
+ const result = deleteStoryRecord({ paths: opts.paths, story: hit, trash });
6054
+ return c.json(result);
6055
+ });
6056
+ }
6057
+
6058
+ // src/console/api/plans.ts
6059
+ import fs33 from "fs";
6060
+ import path27 from "path";
6061
+ import { z as z2 } from "zod";
6062
+ import { diffLines } from "diff";
6063
+ var DiffQuery = z2.object({
6064
+ feature: z2.string().min(1).max(80),
6065
+ a: z2.string().min(1).max(200),
6066
+ b: z2.string().min(1).max(200)
6067
+ });
6068
+ function safeFeatureParam2(feature) {
6069
+ return feature.length > 0 && !feature.includes("/") && !feature.includes("..") && slugify(feature) === feature;
6070
+ }
6071
+ function mountPlansApi(app, opts) {
6072
+ app.get("/api/plans", (c) => {
6073
+ const out = [];
6074
+ for (const feature of listPlanFeatureNames(opts.paths)) {
6075
+ const featureDir = path27.join(opts.paths.plansDir, feature);
6076
+ if (!fs33.existsSync(featureDir)) continue;
6077
+ for (const entry of fs33.readdirSync(featureDir)) {
6078
+ if (!entry.endsWith(".md") || entry === "00-index.md") continue;
6079
+ const abs = path27.join(featureDir, entry);
6080
+ out.push({ feature, planFile: entry, metadata: readPlanMetadata(abs) });
6081
+ }
6082
+ }
6083
+ return c.json(out);
6084
+ });
6085
+ app.get("/api/plans/:feature/:planFile", (c) => {
6086
+ const { feature, planFile } = c.req.param();
6087
+ if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
6088
+ const abs = path27.resolve(path27.join(opts.paths.plansDir, feature, planFile));
6089
+ if (!isInside(abs, opts.paths.plansDir)) return c.json({ error: "forbidden" }, 403);
6090
+ if (!fs33.existsSync(abs)) return c.json({ error: "not_found" }, 404);
6091
+ const content = fs33.readFileSync(abs, "utf8");
6092
+ return c.json({
6093
+ feature,
6094
+ planFile,
6095
+ content,
6096
+ absPath: abs,
6097
+ metadata: readPlanMetadata(abs)
6098
+ });
6099
+ });
6100
+ app.get("/api/plans/diff", (c) => {
6101
+ const parse = DiffQuery.safeParse({
6102
+ feature: c.req.query("feature"),
6103
+ a: c.req.query("a"),
6104
+ b: c.req.query("b")
6105
+ });
6106
+ if (!parse.success) return c.json({ error: "invalid_query", issues: parse.error.issues }, 400);
6107
+ const { feature, a, b } = parse.data;
6108
+ if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
6109
+ const featureDir = path27.join(opts.paths.plansDir, feature);
6110
+ const aAbs = path27.join(featureDir, a);
6111
+ const bAbs = path27.join(featureDir, b);
6112
+ if (!isInside(aAbs, opts.paths.plansDir) || !isInside(bAbs, opts.paths.plansDir)) {
6113
+ return c.json({ error: "forbidden" }, 403);
6114
+ }
6115
+ if (!fs33.existsSync(aAbs) || !fs33.existsSync(bAbs)) return c.json({ error: "not_found" }, 404);
6116
+ const aText = fs33.readFileSync(aAbs, "utf8");
6117
+ const bText = fs33.readFileSync(bAbs, "utf8");
6118
+ const changes = diffLines(aText, bText);
6119
+ return c.json({ feature, a, b, changes });
6120
+ });
6121
+ app.delete("/api/plans/:feature/:planFile", (c) => {
6122
+ const trash = c.req.query("trash") === "1";
6123
+ const { feature, planFile } = c.req.param();
6124
+ if (!safeFeatureParam2(feature)) return c.json({ error: "forbidden" }, 403);
6125
+ const abs = path27.join(opts.paths.plansDir, feature, planFile);
6126
+ if (!isInside(abs, opts.paths.plansDir)) return c.json({ error: "forbidden" }, 403);
6127
+ if (!fs33.existsSync(abs)) return c.json({ error: "not_found" }, 404);
6128
+ if (trash) {
6129
+ const result = trashOrDelete([abs], opts.paths.trashDir, true);
6130
+ removeOverviewRowForPlanFile(path27.join(opts.paths.plansDir, feature), planFile);
6131
+ return c.json({ ok: true, trashed: result.trashed });
6132
+ }
6133
+ fs33.rmSync(abs);
6134
+ removeOverviewRowForPlanFile(path27.join(opts.paths.plansDir, feature), planFile);
6135
+ return c.json({ ok: true });
6136
+ });
6137
+ }
6138
+
6139
+ // src/console/api/config.ts
6140
+ import { z as z3 } from "zod";
6141
+ var ConfigBody = z3.object({}).passthrough();
6142
+ function mountConfigApi(app, opts) {
6143
+ app.get("/api/config", (c) => {
6144
+ const config2 = loadConfig(opts.paths.configFile);
6145
+ return c.json(config2);
6146
+ });
6147
+ app.put("/api/config", async (c) => {
6148
+ const body = ConfigBody.safeParse(await c.req.json());
6149
+ if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
6150
+ try {
6151
+ saveConfig(opts.paths.configFile, body.data);
6152
+ return c.json({ ok: true });
6153
+ } catch (err) {
6154
+ return c.json({ error: "invalid_config", detail: err.message }, 400);
6155
+ }
6156
+ });
6157
+ }
6158
+
6159
+ // src/console/api/meta.ts
6160
+ function mountMetaApi(app, opts) {
6161
+ app.get("/api/meta", async (c) => {
6162
+ const config2 = loadConfig(opts.paths.configFile);
6163
+ const lastRun = await readLastRun(opts.paths);
6164
+ return c.json({
6165
+ version: "0.6.0",
6166
+ root: opts.paths.root,
6167
+ project: config2.project,
6168
+ planner: config2.planner ? { provider: config2.planner.provider, enabled: config2.planner.enabled } : null,
6169
+ tracker: { type: config2.tracker.type },
6170
+ lastRun
6171
+ });
6172
+ });
6173
+ app.get("/api/last-run", async (c) => {
6174
+ const lastRun = await readLastRun(opts.paths);
6175
+ return c.json(lastRun ?? null);
6176
+ });
6177
+ }
6178
+
6179
+ // src/console/api/runs.ts
6180
+ import { streamSSE } from "hono/streaming";
6181
+ import { z as z4 } from "zod";
6182
+ import path28 from "path";
6183
+ import fs34 from "fs";
6184
+ var active = /* @__PURE__ */ new Map();
6185
+ var EVENT_BUF_CAP = 512;
6186
+ var StartBody = z4.object({
6187
+ feature: z4.string().min(1),
6188
+ storyId: z4.string().min(1)
6189
+ });
6190
+ function emptyStats(durationMs) {
6191
+ return {
6192
+ turns: 0,
6193
+ inputTokens: 0,
6194
+ outputTokens: 0,
6195
+ cacheCreationTokens: 0,
6196
+ cacheReadTokens: 0,
6197
+ cacheHitRatio: 0,
6198
+ durationMs
6199
+ };
6200
+ }
6201
+ function mountRunsApi(app, opts) {
6202
+ app.get("/api/runs", async (c) => c.json(await listRuns(opts.paths)));
6203
+ app.get("/api/runs/active", (c) => {
6204
+ const rows = [...active.values()].map((r) => ({
6205
+ runId: r.runId,
6206
+ feature: r.feature,
6207
+ storyId: r.storyId,
6208
+ startedAt: r.startedAt
6209
+ }));
6210
+ return c.json(rows);
6211
+ });
6212
+ app.post("/api/runs", async (c) => {
6213
+ const body = StartBody.safeParse(await c.req.json());
6214
+ if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
6215
+ const cfg = loadConfig(opts.paths.configFile);
6216
+ if (!cfg.planner?.enabled) {
6217
+ return c.json({ error: "planner_disabled", detail: "Run `squad config set planner` first." }, 400);
6218
+ }
6219
+ const apiKey = readProviderKeyForPaths(opts.paths, cfg.planner.provider);
6220
+ if (!apiKey) {
6221
+ return c.json(
6222
+ {
6223
+ error: "missing_credentials",
6224
+ detail: `Set ${providerEnvVar(cfg.planner.provider)} or save a key via the Secrets tab.`
6225
+ },
6226
+ 400
6227
+ );
6228
+ }
6229
+ const stories = listStories(opts.paths, { feature: body.data.feature });
6230
+ const story = stories.find((s) => s.id === body.data.storyId);
6231
+ if (!story) return c.json({ error: "not_found" }, 404);
6232
+ const runId = newRunId();
6233
+ const eventBuffer = [];
6234
+ const bus = new PlannerEventBus();
6235
+ bus.subscribe((e) => {
6236
+ eventBuffer.push(e);
6237
+ if (eventBuffer.length > EVENT_BUF_CAP) eventBuffer.splice(0, eventBuffer.length - EVENT_BUF_CAP);
6238
+ });
6239
+ const controller = new AbortController();
6240
+ const startedAt = Date.now();
6241
+ const done = (async () => {
6242
+ const cfgFresh = loadConfig(opts.paths.configFile);
6243
+ const planner = cfgFresh.planner;
6244
+ const provider = providerFor(planner.provider);
6245
+ const model = modelFor(planner.provider, "plan", planner.modelOverride);
6246
+ const budget = new Budget(planner.budget);
6247
+ const cacheEnabled = planner.cache?.enabled ?? true;
6248
+ const repoMap = buildRepoMap(opts.paths.root);
6249
+ const systemPrompt = composeSystemPrompt({
6250
+ projectRoots: cfgFresh.project.projectRoots ?? ["."],
6251
+ primaryLanguage: cfgFresh.project.primaryLanguage ?? "",
6252
+ trackerType: cfgFresh.tracker.type,
6253
+ repoMap
6254
+ });
6255
+ const userPrompt = composeUserPrompt({
6256
+ intakeContent: fs34.readFileSync(story.intakePath, "utf8")
6257
+ });
6258
+ let result;
6259
+ try {
6260
+ result = await runPlanner({
6261
+ root: opts.paths.root,
6262
+ provider,
6263
+ model,
6264
+ apiKey,
6265
+ systemPrompt,
6266
+ userPrompt,
6267
+ budget,
6268
+ maxOutputTokens: planner.maxOutputTokens,
6269
+ cacheEnabled,
6270
+ events: bus,
6271
+ runId,
6272
+ abort: controller.signal
6273
+ });
6274
+ } catch (err) {
6275
+ const durationMs = Date.now() - startedAt;
6276
+ bus.emit({ kind: "error", runId, message: err.message });
6277
+ bus.emit({
6278
+ kind: "done",
6279
+ runId,
6280
+ success: false,
6281
+ planFile: null,
6282
+ partial: true,
6283
+ stats: emptyStats(durationMs),
6284
+ durationMs
6285
+ });
6286
+ return;
6287
+ }
6288
+ const runSuccess = result.finishedNormally && !result.timedOut && !result.userCancelled;
6289
+ let planFile = null;
6290
+ if (result.planText.trim()) {
6291
+ const snap = budget.snapshot();
6292
+ const header = buildMetadataHeader({
6293
+ provider: planner.provider,
6294
+ model,
6295
+ reads: snap.reads,
6296
+ bytes: snap.bytes,
6297
+ inputTokens: snap.usage.inputTokens,
6298
+ outputTokens: snap.usage.outputTokens,
6299
+ durationMs: Date.now() - startedAt,
6300
+ costUsd: snap.usage.costUsd,
6301
+ planStatus: runSuccess ? void 0 : "partial"
6302
+ });
6303
+ const out = writePlanFile({
6304
+ paths: opts.paths,
6305
+ config: cfgFresh,
6306
+ story,
6307
+ planBodyMarkdown: result.planText,
6308
+ metadataHeader: header,
6309
+ partial: !runSuccess
6310
+ });
6311
+ planFile = path28.relative(opts.paths.root, out.planFile);
6312
+ }
6313
+ try {
6314
+ await writeLastRun(opts.paths, {
6315
+ stats: result.stats,
6316
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
6317
+ provider: planner.provider,
6318
+ model
6319
+ });
6320
+ await appendRun(opts.paths, {
6321
+ runId,
6322
+ provider: planner.provider,
6323
+ model,
6324
+ feature: story.feature,
6325
+ storyId: story.id,
6326
+ startedAt: new Date(startedAt).toISOString(),
6327
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
6328
+ success: runSuccess,
6329
+ partial: !runSuccess,
6330
+ planFile,
6331
+ stats: result.stats,
6332
+ cacheEnabled,
6333
+ durationMs: Date.now() - startedAt
6334
+ });
6335
+ } catch {
6336
+ }
6337
+ bus.emit({
6338
+ kind: "done",
6339
+ runId,
6340
+ success: runSuccess,
6341
+ planFile,
6342
+ partial: !runSuccess,
6343
+ stats: result.stats,
6344
+ durationMs: Date.now() - startedAt
6345
+ });
6346
+ })().finally(() => {
6347
+ active.delete(runId);
6348
+ });
6349
+ active.set(runId, { runId, bus, controller, feature: story.feature, storyId: story.id, startedAt, done, eventBuffer });
6350
+ return c.json({ runId, eventStream: `/api/runs/${runId}/stream` }, 202);
6351
+ });
6352
+ app.get("/api/runs/:runId/stream", (c) => {
6353
+ const runId = c.req.param("runId");
6354
+ const run = active.get(runId);
6355
+ return streamSSE(c, async (stream) => {
6356
+ if (!run) {
6357
+ await stream.writeSSE({ event: "error", data: JSON.stringify({ message: "unknown run" }) });
6358
+ await stream.writeSSE({ event: "closed", data: "{}" });
6359
+ return;
6360
+ }
6361
+ let idx = 0;
6362
+ let resolveWaiter = null;
6363
+ const notify = () => resolveWaiter?.();
6364
+ const unsubscribe = run.bus.subscribe(() => notify());
6365
+ const ka = setInterval(() => stream.writeSSE({ event: "ping", data: "{}" }).catch(() => {
6366
+ }), 15e3);
6367
+ let closed = false;
6368
+ stream.onAbort(() => {
6369
+ closed = true;
6370
+ unsubscribe();
6371
+ clearInterval(ka);
6372
+ });
6373
+ try {
6374
+ while (!closed) {
6375
+ while (idx < run.eventBuffer.length) {
6376
+ const e = run.eventBuffer[idx++];
6377
+ await stream.writeSSE({ event: e.kind, data: JSON.stringify(e) });
6378
+ if (e.kind === "done") {
6379
+ await stream.writeSSE({ event: "closed", data: JSON.stringify({ ok: true }) });
6380
+ clearInterval(ka);
6381
+ unsubscribe();
6382
+ return;
6383
+ }
6384
+ }
6385
+ await new Promise((resolve2) => {
6386
+ resolveWaiter = resolve2;
6387
+ });
6388
+ resolveWaiter = null;
6389
+ }
6390
+ } finally {
6391
+ clearInterval(ka);
6392
+ unsubscribe();
6393
+ }
6394
+ });
6395
+ });
6396
+ app.delete("/api/runs/:runId", (c) => {
6397
+ const runId = c.req.param("runId");
6398
+ const run = active.get(runId);
6399
+ if (!run) return c.json({ error: "not_found" }, 404);
6400
+ run.controller.abort();
6401
+ return c.json({ ok: true });
6402
+ });
6403
+ }
6404
+
6405
+ // src/console/api/secrets.ts
6406
+ import { z as z5 } from "zod";
6407
+
6408
+ // src/core/mask-token.ts
6409
+ function maskToken(t) {
6410
+ if (!t) return null;
6411
+ if (t.length <= 8) return "\u2022\u2022\u2022\u2022" + t.slice(-2);
6412
+ return t.slice(0, 4) + "\u2026" + t.slice(-4);
6413
+ }
6414
+
6415
+ // src/console/api/secrets.ts
6416
+ var PROVIDERS = ["anthropic", "openai", "google"];
6417
+ function mountSecretsApi(app, opts) {
6418
+ app.get("/api/secrets", (c) => {
6419
+ const s = loadSecrets(opts.paths.secretsFile);
6420
+ return c.json({
6421
+ planner: {
6422
+ anthropic: maskToken(s.planner?.anthropic),
6423
+ openai: maskToken(s.planner?.openai),
6424
+ google: maskToken(s.planner?.google)
6425
+ },
6426
+ tracker: {
6427
+ jira: {
6428
+ host: s.tracker?.jira?.host ?? null,
6429
+ email: s.tracker?.jira?.email ?? null,
6430
+ token: maskToken(s.tracker?.jira?.token)
6431
+ },
6432
+ azure: {
6433
+ organization: s.tracker?.azure?.organization ?? null,
6434
+ project: s.tracker?.azure?.project ?? null,
6435
+ pat: maskToken(s.tracker?.azure?.pat)
6436
+ }
6437
+ }
6438
+ });
6439
+ });
6440
+ const PutBody = z5.object({
6441
+ planner: z5.object({
6442
+ anthropic: z5.string().optional(),
6443
+ openai: z5.string().optional(),
6444
+ google: z5.string().optional()
6445
+ }).optional(),
6446
+ tracker: z5.object({
6447
+ jira: z5.object({
6448
+ host: z5.string().optional(),
6449
+ email: z5.string().optional(),
6450
+ token: z5.string().optional()
6451
+ }).optional(),
6452
+ azure: z5.object({
6453
+ organization: z5.string().optional(),
6454
+ project: z5.string().optional(),
6455
+ pat: z5.string().optional()
6456
+ }).optional()
6457
+ }).optional()
6458
+ });
6459
+ app.put("/api/secrets", async (c) => {
6460
+ const parse = PutBody.safeParse(await c.req.json());
6461
+ if (!parse.success) return c.json({ error: "invalid_body", issues: parse.error.issues }, 400);
6462
+ const current = loadSecrets(opts.paths.secretsFile);
6463
+ const merged = mergeSecrets(current, parse.data);
6464
+ saveSecrets(opts.paths.secretsFile, merged);
6465
+ return c.json({ ok: true });
6466
+ });
6467
+ for (const p of PROVIDERS) {
6468
+ app.post(`/api/secrets/test/${p}`, async (c) => {
6469
+ const s = loadSecrets(opts.paths.secretsFile);
6470
+ const key = s.planner?.[p];
6471
+ if (!key) return c.json({ ok: false, detail: `${p} key not saved` }, 400);
6472
+ const result = await fetchProviderModelIds(p, key);
6473
+ if (result.ok) return c.json({ ok: true, modelCount: result.ids.size });
6474
+ return c.json({ ok: false, status: result.status, detail: result.body.slice(0, 200) }, 200);
6475
+ });
6476
+ }
6477
+ app.post("/api/secrets/test/jira", async (c) => {
6478
+ const cfg = loadConfig(opts.paths.configFile);
6479
+ const s = loadSecrets(opts.paths.secretsFile);
6480
+ return c.json(await probeJiraConnectivity(s, cfg));
6481
+ });
6482
+ app.post("/api/secrets/test/azure", async (c) => {
6483
+ const cfg = loadConfig(opts.paths.configFile);
6484
+ const s = loadSecrets(opts.paths.secretsFile);
6485
+ return c.json(await probeAzureConnectivity(s, cfg));
6486
+ });
6487
+ }
6488
+
6489
+ // src/console/api/tracker.ts
6490
+ import { z as z6 } from "zod";
6491
+ var SearchQuery = z6.object({ q: z6.string().max(500) });
6492
+ var ImportBody = z6.object({
6493
+ issueId: z6.string().min(1),
6494
+ feature: z6.string().min(1).max(80),
6495
+ withAttachments: z6.boolean().optional()
6496
+ });
6497
+ function mountTrackerApi(app, opts) {
6498
+ app.get("/api/tracker/search", async (c) => {
6499
+ const q = SearchQuery.safeParse({ q: c.req.query("q") });
6500
+ if (!q.success) return c.json({ error: "invalid_query" }, 400);
6501
+ const cfg = loadConfig(opts.paths.configFile);
6502
+ const secrets = overlayTrackerEnv(loadSecrets(opts.paths.secretsFile));
6503
+ const { client, error: error2 } = clientFor(cfg, secrets);
6504
+ if (!client) {
6505
+ return c.json({ error: error2?.kind ?? "unsupported", detail: error2?.detail ?? "" }, 400);
5269
6506
  }
5270
- const go = await confirm8({ message: `Run "${cmd} ${args.join(" ")}"?`, default: true });
5271
- if (!go) {
5272
- info("Cancelled. No changes applied.");
5273
- return;
6507
+ try {
6508
+ const results = await client.searchIssues(q.data.q);
6509
+ return c.json({ ok: true, results });
6510
+ } catch (err) {
6511
+ if (err instanceof TrackerError) {
6512
+ return c.json({ ok: false, kind: err.kind, status: err.statusCode, detail: err.message }, 200);
6513
+ }
6514
+ return c.json({ ok: false, detail: err.message }, 200);
5274
6515
  }
5275
- }
5276
- blank();
5277
- info(`Running: ${cmd} ${args.join(" ")}`);
5278
- const exitCode = await spawnInherit(cmd, args);
5279
- if (exitCode !== 0) {
5280
- failure(
5281
- `${cmd} exited with code ${exitCode}. Upgrade may be incomplete \u2014 run \`squad doctor\` and re-run \`squad upgrade\` if needed.`
5282
- );
5283
- process3.exit(exitCode);
5284
- }
5285
- blank();
5286
- success(`Upgraded to ${latest}.`);
5287
- info("Run `squad migrate` to update your .squad/ structure if needed.");
6516
+ });
6517
+ app.post("/api/tracker/import", async (c) => {
6518
+ const body = ImportBody.safeParse(await c.req.json());
6519
+ if (!body.success) return c.json({ error: "invalid_body", issues: body.error.issues }, 400);
6520
+ const cfg = loadConfig(opts.paths.configFile);
6521
+ const secrets = overlayTrackerEnv(loadSecrets(opts.paths.secretsFile));
6522
+ const { client, error: error2 } = clientFor(cfg, secrets);
6523
+ if (!client) {
6524
+ return c.json({ error: error2?.kind ?? "unsupported", detail: error2?.detail ?? "" }, 400);
6525
+ }
6526
+ try {
6527
+ const issue = await client.fetchIssue(body.data.issueId);
6528
+ const created = await createStoryRecordFromIssue({
6529
+ paths: opts.paths,
6530
+ feature: body.data.feature,
6531
+ issue,
6532
+ withAttachments: body.data.withAttachments ?? true,
6533
+ client,
6534
+ config: cfg
6535
+ });
6536
+ return c.json(created, 201);
6537
+ } catch (err) {
6538
+ if (err instanceof TrackerError) return c.json({ error: err.kind, detail: err.message }, 502);
6539
+ return c.json({ error: "fetch_failed", detail: err.message }, 500);
6540
+ }
6541
+ });
5288
6542
  }
5289
- function spawnInherit(cmd, args) {
5290
- return new Promise((resolve, reject) => {
5291
- const child = spawn2(cmd, args, { stdio: "inherit", shell: process3.platform === "win32" });
5292
- child.on("exit", (code) => resolve(code ?? 0));
5293
- child.on("error", (err) => reject(err));
6543
+
6544
+ // src/commands/doctor-api.ts
6545
+ async function gatherContextForApi(paths) {
6546
+ return gatherContext(paths);
6547
+ }
6548
+ async function runAllChecksForApi(paths, ctx, fix) {
6549
+ return runAllChecks(paths, ctx, fix);
6550
+ }
6551
+
6552
+ // src/console/api/doctor.ts
6553
+ function mountDoctorApi(app, opts) {
6554
+ app.get("/api/doctor", async (c) => {
6555
+ const ctx = await gatherContextForApi(opts.paths);
6556
+ const checks = await runAllChecksForApi(opts.paths, ctx, false);
6557
+ return c.json({ root: opts.paths.root, checks, summary: summarise(checks) });
6558
+ });
6559
+ app.post("/api/doctor/fix", async (c) => {
6560
+ const ctx = await gatherContextForApi(opts.paths);
6561
+ const checks = await runAllChecksForApi(opts.paths, ctx, true);
6562
+ return c.json({ root: opts.paths.root, checks, summary: summarise(checks) });
5294
6563
  });
5295
6564
  }
5296
6565
 
5297
- // src/commands/rm/shared.ts
5298
- import fs28 from "fs";
5299
- import path22 from "path";
5300
- import { select as select7 } from "@inquirer/prompts";
5301
- var STORY_PLAN = /^(\d{2,})-story-.+\.md$/;
5302
- function canPromptForRm(opts) {
5303
- if (opts.yes) return false;
5304
- return isInteractive() && Boolean(process.stdin.isTTY);
6566
+ // src/console/api/copy-plan-prompt.ts
6567
+ function mountCopyPlanPromptApi(app, opts) {
6568
+ app.get("/api/copy-plan-prompt", (c) => {
6569
+ const feature = (c.req.query("feature") ?? "").trim();
6570
+ const storyId = (c.req.query("storyId") ?? "").trim();
6571
+ if (!feature || !storyId) {
6572
+ return c.json({ error: "bad_request", detail: "Query params `feature` and `storyId` are required." }, 400);
6573
+ }
6574
+ const stories = listStories(opts.paths, { feature });
6575
+ const hit = stories.find((s) => s.feature === feature && s.id === storyId);
6576
+ if (!hit) {
6577
+ return c.json({ error: "not_found", detail: `No story intake for feature "${feature}" and id "${storyId}".` }, 404);
6578
+ }
6579
+ const config2 = loadConfig(opts.paths.configFile);
6580
+ const intakeContent = readFile(hit.intakePath);
6581
+ const prompt = buildCopyPlanPromptMarkdown(config2, intakeContent);
6582
+ const bytes = Buffer.byteLength(prompt, "utf8");
6583
+ return c.json({
6584
+ prompt,
6585
+ feature: hit.feature,
6586
+ storyId: hit.id,
6587
+ bytes,
6588
+ estTokensApprox: Math.round(bytes / 4)
6589
+ });
6590
+ });
5305
6591
  }
5306
- function removeOverviewRow(featurePlanDir, storyId) {
5307
- const overview = path22.join(featurePlanDir, "00-overview.md");
5308
- if (!fs28.existsSync(overview)) return;
5309
- const src = fs28.readFileSync(overview, "utf8");
5310
- const lines = src.split("\n");
5311
- const filtered = lines.filter((line2) => {
5312
- if (!line2.startsWith("|")) return true;
5313
- if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) return true;
5314
- if (/^\|\s*-+/.test(line2)) return true;
5315
- return !line2.includes(storyId);
6592
+
6593
+ // src/console/api/dashboard.ts
6594
+ function mountDashboardApi(app, opts) {
6595
+ app.get("/api/dashboard", async (c) => {
6596
+ const cfg = loadConfig(opts.paths.configFile);
6597
+ const lastRun = await readLastRun(opts.paths) ?? null;
6598
+ const runs = await listRuns(opts.paths);
6599
+ const stories = listStories(opts.paths);
6600
+ return c.json({
6601
+ version: "0.6.0",
6602
+ root: opts.paths.root,
6603
+ project: cfg.project,
6604
+ planner: cfg.planner ? { provider: cfg.planner.provider, enabled: cfg.planner.enabled } : null,
6605
+ tracker: { type: cfg.tracker.type },
6606
+ lastRun,
6607
+ runs: runs.map((r) => ({
6608
+ runId: r.runId,
6609
+ startedAt: r.startedAt,
6610
+ durationMs: r.durationMs,
6611
+ success: r.success,
6612
+ partial: r.partial,
6613
+ inputTokens: r.stats.inputTokens,
6614
+ outputTokens: r.stats.outputTokens,
6615
+ cacheHitRatio: r.stats.cacheHitRatio
6616
+ })),
6617
+ storyCounts: countStories(stories),
6618
+ stories: stories.map((s) => ({
6619
+ feature: s.feature,
6620
+ id: s.id,
6621
+ intakePath: s.intakePath,
6622
+ storyDir: s.storyDir,
6623
+ planFile: s.planFile ?? null,
6624
+ titleHint: s.titleHint ?? null
6625
+ }))
6626
+ });
5316
6627
  });
5317
- fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
5318
6628
  }
5319
- function removeOverviewRowForPlanFile(featurePlanDir, planBasename) {
5320
- const overview = path22.join(featurePlanDir, "00-overview.md");
5321
- if (!fs28.existsSync(overview)) return;
5322
- const src = fs28.readFileSync(overview, "utf8");
5323
- const token = `\`${planBasename}\``;
5324
- const lines = src.split("\n");
5325
- const filtered = lines.filter((line2) => {
5326
- if (!line2.startsWith("|")) return true;
5327
- if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) return true;
5328
- if (/^\|\s*-+/.test(line2)) return true;
5329
- return !line2.includes(token);
6629
+ function countStories(stories) {
6630
+ const total = stories.length;
6631
+ const planned = stories.filter((s) => !!s.planFile).length;
6632
+ return { total, planned, unplanned: total - planned };
6633
+ }
6634
+
6635
+ // src/console/api/projects.ts
6636
+ import fs35 from "fs/promises";
6637
+ import path29 from "path";
6638
+ import os from "os";
6639
+ var MAX = 10;
6640
+ function recentFilePath() {
6641
+ return path29.join(os.homedir(), ".squad", "recent-projects.json");
6642
+ }
6643
+ async function loadRecent() {
6644
+ const RECENT_FILE = recentFilePath();
6645
+ try {
6646
+ const raw = await fs35.readFile(RECENT_FILE, "utf8");
6647
+ const parsed = JSON.parse(raw);
6648
+ return Array.isArray(parsed) ? parsed.slice(0, MAX) : [];
6649
+ } catch {
6650
+ return [];
6651
+ }
6652
+ }
6653
+ async function saveRecent(list) {
6654
+ const RECENT_FILE = recentFilePath();
6655
+ await fs35.mkdir(path29.dirname(RECENT_FILE), { recursive: true });
6656
+ await fs35.writeFile(RECENT_FILE, JSON.stringify(list.slice(0, MAX), null, 2) + "\n", "utf8");
6657
+ }
6658
+ function mountProjectsApi(app, opts) {
6659
+ app.get("/api/projects/recent", async (c) => c.json(await loadRecent()));
6660
+ app.post("/api/projects/touch", async (c) => {
6661
+ const list = await loadRecent();
6662
+ const filtered = list.filter((p) => p.root !== opts.paths.root);
6663
+ filtered.unshift({ root: opts.paths.root, lastOpenedAt: (/* @__PURE__ */ new Date()).toISOString() });
6664
+ await saveRecent(filtered);
6665
+ return c.json({ ok: true });
5330
6666
  });
5331
- fs28.writeFileSync(overview, filtered.join("\n"), "utf8");
5332
6667
  }
5333
- function trashOrDelete(absPaths, trashRoot, useTrash) {
5334
- if (!useTrash) {
5335
- for (const p of absPaths) fs28.rmSync(p, { recursive: true, force: true });
5336
- return { removed: absPaths };
5337
- }
5338
- const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5339
- const bucket = path22.join(trashRoot, ts);
5340
- fs28.mkdirSync(bucket, { recursive: true });
5341
- for (const src of absPaths) {
5342
- const rel = path22.basename(src);
5343
- let dst = path22.join(bucket, rel);
5344
- let n = 0;
5345
- while (fs28.existsSync(dst)) {
5346
- n += 1;
5347
- dst = path22.join(bucket, `${n}_${rel}`);
5348
- }
5349
- fs28.renameSync(src, dst);
5350
- }
5351
- return { removed: absPaths, trashed: bucket };
6668
+
6669
+ // src/console/api/index.ts
6670
+ function mountApi(app, opts) {
6671
+ mountMetaApi(app, opts);
6672
+ mountDashboardApi(app, opts);
6673
+ mountProjectsApi(app, opts);
6674
+ mountStoriesApi(app, opts);
6675
+ mountPlansApi(app, opts);
6676
+ mountConfigApi(app, opts);
6677
+ mountRunsApi(app, opts);
6678
+ mountSecretsApi(app, opts);
6679
+ mountTrackerApi(app, opts);
6680
+ mountDoctorApi(app, opts);
6681
+ mountCopyPlanPromptApi(app, opts);
5352
6682
  }
5353
- function renderPreview(kind, targets, opts) {
5354
- divider(`squad rm ${kind}`);
5355
- step(opts.dryRun ? "Dry run \u2014 nothing will be changed." : "About to remove:");
5356
- for (const t of targets) {
5357
- kv(t.kind, t.label, 6);
5358
- }
5359
- if (opts.trash) {
5360
- info(`Items will move to ${path22.relative(process.cwd(), opts.trashRoot)}/<timestamp>/`);
5361
- } else if (!opts.dryRun) {
5362
- warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
6683
+
6684
+ // src/console/static-assets.ts
6685
+ import fs36 from "fs";
6686
+ import path30 from "path";
6687
+ import { fileURLToPath as fileURLToPath2 } from "url";
6688
+ import { Readable } from "stream";
6689
+ function pathnameForStatic(c) {
6690
+ const rawUrl = c.env.incoming?.url;
6691
+ if (typeof rawUrl === "string" && rawUrl.startsWith("/")) {
6692
+ const pathOnly = rawUrl.split("?", 1)[0] ?? rawUrl;
6693
+ try {
6694
+ return decodeURIComponent(pathOnly);
6695
+ } catch {
6696
+ return pathOnly;
6697
+ }
5363
6698
  }
6699
+ try {
6700
+ return decodeURIComponent(new URL(c.req.url).pathname);
6701
+ } catch {
6702
+ return new URL(c.req.url).pathname;
6703
+ }
6704
+ }
6705
+ function mountStaticAssets(app, _opts) {
6706
+ const distDir = resolveConsoleUiDist();
6707
+ app.get("/logo.svg", (c) => serveFile(c, distDir, "logo.svg", "image/svg+xml"));
6708
+ app.get("/favicon.ico", (c) => serveFile(c, distDir, "favicon.ico", "image/x-icon"));
6709
+ app.get("*", (c) => {
6710
+ const pathname = pathnameForStatic(c);
6711
+ if (pathname.startsWith("/assets/")) {
6712
+ return assetResponse(c, distDir, pathname);
6713
+ }
6714
+ if (c.req.path.startsWith("/api/") || c.req.path.startsWith("/healthz")) return c.notFound();
6715
+ if (!distDir) return c.text("UI not built. Run `pnpm -C console-ui build`.", 404);
6716
+ const indexHtml = path30.join(distDir, "index.html");
6717
+ if (!fs36.existsSync(indexHtml)) return c.text("UI not built", 404);
6718
+ return c.html(fs36.readFileSync(indexHtml, "utf8"), 200, { "cache-control": "no-store" });
6719
+ });
5364
6720
  }
5365
- function listAllPlanListEntries(paths, featureFilter) {
5366
- if (!fs28.existsSync(paths.plansDir)) return [];
5367
- const out = [];
5368
- for (const ent of fs28.readdirSync(paths.plansDir, { withFileTypes: true })) {
5369
- if (!ent.isDirectory()) continue;
5370
- if (featureFilter && ent.name !== featureFilter) continue;
5371
- const featureDir = path22.join(paths.plansDir, ent.name);
5372
- for (const name of fs28.readdirSync(featureDir)) {
5373
- if (!STORY_PLAN.test(name)) continue;
5374
- out.push({ feature: ent.name, file: name, abs: path22.join(featureDir, name) });
6721
+ function assetResponse(c, distDir, pathname) {
6722
+ if (!distDir) return c.text("UI not built", 404);
6723
+ const rel = pathname.replace(/^\//, "");
6724
+ const abs = path30.normalize(path30.join(distDir, rel));
6725
+ if (!isInside2(abs, distDir)) return c.text("forbidden", 403);
6726
+ if (!fs36.existsSync(abs) || !fs36.statSync(abs).isFile()) return c.text("not found", 404);
6727
+ const body = Readable.toWeb(fs36.createReadStream(abs));
6728
+ return new Response(body, {
6729
+ headers: {
6730
+ "content-type": contentTypeFor(abs),
6731
+ "cache-control": "public, max-age=31536000, immutable"
5375
6732
  }
6733
+ });
6734
+ }
6735
+ function serveFile(c, distDir, name, ct) {
6736
+ if (!distDir) return c.text("UI not built", 404);
6737
+ const abs = path30.join(distDir, name);
6738
+ if (!fs36.existsSync(abs)) return c.text("not found", 404);
6739
+ return new Response(fs36.readFileSync(abs), {
6740
+ headers: { "content-type": ct, "cache-control": "public, max-age=86400" }
6741
+ });
6742
+ }
6743
+ function contentTypeFor(p) {
6744
+ if (p.endsWith(".js")) return "application/javascript; charset=utf-8";
6745
+ if (p.endsWith(".css")) return "text/css; charset=utf-8";
6746
+ if (p.endsWith(".html")) return "text/html; charset=utf-8";
6747
+ if (p.endsWith(".svg")) return "image/svg+xml";
6748
+ if (p.endsWith(".png")) return "image/png";
6749
+ if (p.endsWith(".woff2")) return "font/woff2";
6750
+ if (p.endsWith(".json")) return "application/json";
6751
+ return "application/octet-stream";
6752
+ }
6753
+ function isInside2(abs, parent) {
6754
+ const a = path30.resolve(abs) + path30.sep;
6755
+ const p = path30.resolve(parent) + path30.sep;
6756
+ return a.startsWith(p);
6757
+ }
6758
+ function resolveConsoleUiDist() {
6759
+ const fromEnv = process.env.SQUAD_CONSOLE_UI_DIST;
6760
+ if (fromEnv && fs36.existsSync(path30.join(fromEnv, "index.html"))) return fromEnv;
6761
+ const candidates = [
6762
+ fileURLToPath2(new URL("./console-ui/", import.meta.url)),
6763
+ // dist/cli.js → dist/console-ui/
6764
+ fileURLToPath2(new URL("../../dist/console-ui/", import.meta.url)),
6765
+ // src/console/*.ts → project/dist/console-ui/
6766
+ fileURLToPath2(new URL("../console-ui/dist/", import.meta.url)),
6767
+ // dist/cli.js → project/console-ui/dist
6768
+ fileURLToPath2(new URL("../../console-ui/dist/", import.meta.url))
6769
+ // src/console/*.ts → project/console-ui/dist
6770
+ ];
6771
+ for (const c of candidates) {
6772
+ if (fs36.existsSync(path30.join(c, "index.html"))) return c;
5376
6773
  }
5377
- return out.sort((a, b) => (a.feature + a.file).localeCompare(b.feature + b.file));
6774
+ return null;
5378
6775
  }
5379
- async function pickStoryInteractive(paths, featureFilter) {
5380
- const stories = listAllStories(paths).filter((s) => !featureFilter || s.feature === featureFilter);
5381
- if (stories.length === 0) {
5382
- throw new Error("No stories to pick. Run `squad new-story` first, or `squad list` to verify paths.");
6776
+
6777
+ // src/console/auth.ts
6778
+ function authMiddleware(expected) {
6779
+ return async (c, next) => {
6780
+ const presented = readPresentedToken(c.req.raw);
6781
+ if (!presented || !timingSafeEqualHex(presented, expected)) {
6782
+ return c.json({ error: "unauthorized", detail: "missing or invalid session token" }, 401);
6783
+ }
6784
+ await next();
6785
+ };
6786
+ }
6787
+ function readPresentedToken(req) {
6788
+ const auth = req.headers.get("authorization");
6789
+ if (auth && auth.toLowerCase().startsWith("bearer ")) {
6790
+ return auth.slice("bearer ".length).trim();
5383
6791
  }
5384
- if (stories.length === 1) return stories[0];
5385
- const choice = await select7({
5386
- message: "Story to remove:",
5387
- choices: stories.map((s) => ({
5388
- name: `${s.feature}/${s.id} \xB7 ${s.titleHint ?? s.id}`,
5389
- value: s
5390
- }))
5391
- });
5392
- return choice;
6792
+ const url = new URL(req.url);
6793
+ return url.searchParams.get("t");
5393
6794
  }
5394
- async function pickPlanInteractive(paths, featureFilter) {
5395
- const plans = listAllPlanListEntries(paths, featureFilter);
5396
- if (plans.length === 0) {
5397
- throw new Error("No plan files to pick. Run `squad new-plan` first, or `squad list` to see plans.");
6795
+ function timingSafeEqualHex(a, b) {
6796
+ if (a.length !== b.length) return false;
6797
+ let mismatch = 0;
6798
+ for (let i = 0; i < a.length; i += 1) {
6799
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
5398
6800
  }
5399
- if (plans.length === 1) return plans[0];
5400
- return select7({
5401
- message: "Plan file to remove:",
5402
- choices: plans.map((p) => ({
5403
- name: `${p.feature}/${p.file}`,
5404
- value: p
5405
- }))
6801
+ return mismatch === 0;
6802
+ }
6803
+
6804
+ // src/console/server.ts
6805
+ async function startConsoleServer(opts) {
6806
+ const bindPort = opts.requestedPort === 0 ? await reserveEphemeralPort() : await findFreePortFromBase(opts.requestedPort);
6807
+ const app = new Hono();
6808
+ app.get(
6809
+ "/healthz",
6810
+ (c) => c.json(
6811
+ { ok: true, version: "0.6.0", port: bindPort },
6812
+ 200,
6813
+ {
6814
+ "cache-control": "no-store"
6815
+ }
6816
+ )
6817
+ );
6818
+ app.use(
6819
+ "*",
6820
+ secureHeaders({
6821
+ contentSecurityPolicy: {
6822
+ defaultSrc: ["'self'"],
6823
+ styleSrc: ["'self'", "'unsafe-inline'"],
6824
+ imgSrc: ["'self'", "data:"],
6825
+ connectSrc: ["'self'"],
6826
+ scriptSrc: ["'self'"],
6827
+ frameAncestors: ["'none'"]
6828
+ },
6829
+ referrerPolicy: "no-referrer"
6830
+ })
6831
+ );
6832
+ app.use("*", cors({ origin: "http://127.0.0.1:" + bindPort }));
6833
+ app.use("/api/*", authMiddleware(opts.token));
6834
+ mountApi(app, { paths: opts.paths });
6835
+ mountStaticAssets(app, { paths: opts.paths });
6836
+ const nodeServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: bindPort });
6837
+ let resolveDone = () => {
6838
+ };
6839
+ const done = new Promise((resolve2) => {
6840
+ resolveDone = resolve2;
5406
6841
  });
6842
+ const actualPort = (() => {
6843
+ const addr = nodeServer.address();
6844
+ if (addr && typeof addr === "object") return addr.port;
6845
+ return bindPort;
6846
+ })();
6847
+ const shutdown = (onClose) => {
6848
+ nodeServer.close((err) => {
6849
+ onClose(err);
6850
+ });
6851
+ };
6852
+ const stop = () => {
6853
+ shutdown((err) => {
6854
+ if (err) warning(`server close: ${err.message}`);
6855
+ resolveDone();
6856
+ });
6857
+ };
6858
+ process.once("SIGINT", stop);
6859
+ process.once("SIGTERM", stop);
6860
+ return {
6861
+ port: actualPort,
6862
+ done,
6863
+ close: () => new Promise((resolve2, reject) => {
6864
+ shutdown((err) => {
6865
+ if (err) {
6866
+ reject(err);
6867
+ return;
6868
+ }
6869
+ resolve2();
6870
+ resolveDone();
6871
+ });
6872
+ })
6873
+ };
5407
6874
  }
5408
- async function pickFeatureInteractive(paths) {
5409
- const feats = listFeatures(paths);
5410
- if (feats.length === 0) {
5411
- throw new Error("No features to pick. Run `squad new-story` first, or `squad list` to see features.");
5412
- }
5413
- if (feats.length === 1) return feats[0];
5414
- return select7({
5415
- message: "Feature to remove:",
5416
- choices: feats.map((f) => ({ name: f, value: f }))
6875
+ function reserveEphemeralPort() {
6876
+ return new Promise((resolve2, reject) => {
6877
+ const s = createServer();
6878
+ s.once("error", reject);
6879
+ s.listen(0, "127.0.0.1", () => {
6880
+ const addr = s.address();
6881
+ s.close((closeErr) => {
6882
+ if (closeErr) {
6883
+ reject(closeErr);
6884
+ return;
6885
+ }
6886
+ if (addr && typeof addr === "object") {
6887
+ resolve2(addr.port);
6888
+ } else {
6889
+ reject(new Error("Could not determine ephemeral port"));
6890
+ }
6891
+ });
6892
+ });
5417
6893
  });
5418
6894
  }
5419
- function resolveStoryFromArg(arg, stories, root) {
5420
- const t = arg.trim();
5421
- for (const s of stories) {
5422
- if (s.id === t) return s;
5423
- if (`${s.feature}/${s.id}` === t) return s;
6895
+ async function findFreePortFromBase(start, attempts = 20) {
6896
+ for (let i = 0; i < attempts; i += 1) {
6897
+ const candidate = start + i;
6898
+ if (await isPortFree(candidate)) return candidate;
5424
6899
  }
5425
- const abs = path22.isAbsolute(t) ? t : path22.resolve(root, t);
5426
- const withIntake = t.endsWith("intake.md") ? abs : path22.join(abs, "intake.md");
5427
- return findStoryByIntake(stories, withIntake) ?? findStoryByIntake(stories, abs);
5428
- }
5429
- function isSequenceToken(s) {
5430
- return /^\d{1,4}$/.test(s) && s.length > 0;
6900
+ throw new Error(
6901
+ `Could not find a free port in [${start}, ${start + attempts - 1}]. Pass \`--port <n>\` to pick another base.`
6902
+ );
5431
6903
  }
5432
- function tryParsePlanFilePath(absPath, paths, featureFilter) {
5433
- if (!fs28.existsSync(absPath) || !STORY_PLAN.test(path22.basename(absPath))) return void 0;
5434
- const planRoot = fs28.realpathSync(path22.resolve(paths.plansDir));
5435
- const fileReal = fs28.realpathSync(path22.resolve(absPath));
5436
- const rel = path22.relative(planRoot, fileReal);
5437
- if (rel.startsWith("..") || path22.isAbsolute(rel)) return void 0;
5438
- const segs = rel.split(path22.sep);
5439
- if (segs.length < 2) return void 0;
5440
- const feature = segs[0];
5441
- const file = segs.slice(1).join(path22.sep);
5442
- if (featureFilter && feature !== featureFilter) return void 0;
5443
- return { feature, file, abs: path22.resolve(absPath) };
6904
+ function isPortFree(port) {
6905
+ return new Promise((resolve2) => {
6906
+ const probe = createServer();
6907
+ probe.once("error", () => resolve2(false));
6908
+ probe.once("listening", () => probe.close(() => resolve2(true)));
6909
+ probe.listen(port, "127.0.0.1");
6910
+ });
5444
6911
  }
5445
- function resolvePlanFromArg(arg, paths, featureFilter) {
5446
- const t = arg.trim();
5447
- if (!t) return void 0;
5448
- const asPath = path22.isAbsolute(t) ? t : path22.resolve(process.cwd(), t);
5449
- const a = tryParsePlanFilePath(asPath, paths, featureFilter);
5450
- if (a) return a;
5451
- if (!path22.isAbsolute(t)) {
5452
- const under = tryParsePlanFilePath(path22.join(paths.plansDir, t), paths, featureFilter);
5453
- if (under) return under;
6912
+
6913
+ // src/commands/console.ts
6914
+ var DEFAULT_PORT = 4571;
6915
+ async function runConsole(opts) {
6916
+ const root = requireSquadRoot();
6917
+ const paths = buildPaths(root);
6918
+ loadConfig(paths.configFile);
6919
+ const portOpt = opts.port;
6920
+ const requestedPort = portOpt !== void 0 && !Number.isNaN(portOpt) && portOpt >= 0 ? portOpt : DEFAULT_PORT;
6921
+ const token = opts.token ?? randomBytes2(32).toString("hex");
6922
+ const server = await startConsoleServer({ paths, requestedPort, token });
6923
+ const url = `http://127.0.0.1:${server.port}/?t=${token}`;
6924
+ banner();
6925
+ divider("squad console");
6926
+ summaryBox(" squad console ", [
6927
+ { key: "project", value: resolve(paths.root) },
6928
+ { key: "url", value: url },
6929
+ { key: "token", value: `${token.slice(0, 8)}\u2026` },
6930
+ { key: "port", value: String(server.port) }
6931
+ ]);
6932
+ if (server.port !== requestedPort) {
6933
+ warning(`port ${requestedPort} was busy \u2014 bound to ${server.port} instead`);
5454
6934
  }
5455
- if (isSequenceToken(t)) {
5456
- const nn = formatSequence(parseInt(t, 10));
5457
- const prefix = `${nn}-`;
5458
- const cands = [];
5459
- for (const p of listAllPlanListEntries(paths, featureFilter)) {
5460
- if (p.file.startsWith(prefix)) cands.push(p);
5461
- }
5462
- if (cands.length === 1) return cands[0];
5463
- if (cands.length > 1) {
5464
- throw new Error(
5465
- `Multiple plans match sequence "${t}". Run \`squad rm plan\` in a TTY, or add \`--feature <slug>\` to disambiguate.`
5466
- );
6935
+ blank();
6936
+ step("Next:");
6937
+ info("1) Pick a story in the Stories tab.");
6938
+ info('2) Click "Generate plan" to start a planning run live.');
6939
+ info("3) Press Ctrl-C in this terminal to stop the server when you're done.");
6940
+ if (opts.open !== false) {
6941
+ try {
6942
+ await open(url);
6943
+ } catch {
6944
+ warning("Could not open a browser automatically. Copy the URL above into your browser.");
5467
6945
  }
6946
+ } else {
6947
+ info("Browser auto-open disabled (--no-open). Copy the URL above into your browser.");
5468
6948
  }
5469
- return void 0;
5470
- }
5471
- function countOverviewDataRows(overviewContent) {
5472
- let n = 0;
5473
- for (const line2 of overviewContent.split("\n")) {
5474
- if (!line2.startsWith("|")) continue;
5475
- if (/^\|\s*-+/.test(line2)) continue;
5476
- if (line2.includes("NN") && line2.includes("File") && line2.includes("Title")) continue;
5477
- n++;
5478
- }
5479
- return n;
5480
- }
5481
- function featureStoriesDirEmpty(paths, feature) {
5482
- const d = path22.join(paths.storiesDir, feature);
5483
- if (!fs28.existsSync(d)) return true;
5484
- return fs28.readdirSync(d).length === 0;
6949
+ await server.done;
6950
+ info("console stopped");
5485
6951
  }
5486
6952
 
5487
6953
  // src/commands/rm/story.ts
5488
- import fs30 from "fs";
5489
- import path24 from "path";
6954
+ import fs38 from "fs";
6955
+ import path32 from "path";
5490
6956
  import { confirm as confirm10 } from "@inquirer/prompts";
5491
6957
 
5492
6958
  // src/commands/rm/feature.ts
5493
- import fs29 from "fs";
5494
- import path23 from "path";
6959
+ import fs37 from "fs";
6960
+ import path31 from "path";
5495
6961
  import { confirm as confirm9 } from "@inquirer/prompts";
5496
6962
  function countStoryDirs(storiesDir, feature) {
5497
- const d = path23.join(storiesDir, feature);
5498
- if (!fs29.existsSync(d)) return 0;
5499
- return fs29.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
6963
+ const d = path31.join(storiesDir, feature);
6964
+ if (!fs37.existsSync(d)) return 0;
6965
+ return fs37.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).length;
5500
6966
  }
5501
6967
  async function runRmFeature(featureSlug, opts = {}) {
5502
6968
  const root = requireSquadRoot();
@@ -5530,17 +6996,17 @@ async function runRmFeature(featureSlug, opts = {}) {
5530
6996
  feature = await pickFeatureInteractive(paths);
5531
6997
  }
5532
6998
  }
5533
- const storyDir = path23.join(paths.storiesDir, feature);
5534
- const planDir = path23.join(paths.plansDir, feature);
6999
+ const storyDir = path31.join(paths.storiesDir, feature);
7000
+ const planDir = path31.join(paths.plansDir, feature);
5535
7001
  const nStories = countStoryDirs(paths.storiesDir, feature);
5536
7002
  const nPlans = listAllPlanListEntries(paths, feature).length;
5537
- const hasOverview = fs29.existsSync(path23.join(planDir, "00-overview.md")) ? 1 : 0;
7003
+ const hasOverview = fs37.existsSync(path31.join(planDir, "00-overview.md")) ? 1 : 0;
5538
7004
  const targets = [];
5539
- if (fs29.existsSync(storyDir)) {
5540
- targets.push({ label: path23.relative(root, storyDir), absPath: storyDir, kind: "dir" });
7005
+ if (fs37.existsSync(storyDir)) {
7006
+ targets.push({ label: path31.relative(root, storyDir), absPath: storyDir, kind: "dir" });
5541
7007
  }
5542
- if (fs29.existsSync(planDir)) {
5543
- targets.push({ label: path23.relative(root, planDir), absPath: planDir, kind: "dir" });
7008
+ if (fs37.existsSync(planDir)) {
7009
+ targets.push({ label: path31.relative(root, planDir), absPath: planDir, kind: "dir" });
5544
7010
  }
5545
7011
  divider("squad rm feature");
5546
7012
  info(`Feature: ${feature}`);
@@ -5555,7 +7021,7 @@ async function runRmFeature(featureSlug, opts = {}) {
5555
7021
  kv(t.kind, t.label, 6);
5556
7022
  }
5557
7023
  if (useTrash) {
5558
- info(`Items will move to ${path23.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
7024
+ info(`Items will move to ${path31.relative(process.cwd(), paths.trashDir)}/<timestamp>/`);
5559
7025
  } else if (!dryRun) {
5560
7026
  warning("This is a permanent delete. Pass --trash to move to .squad/.trash/ instead.");
5561
7027
  }
@@ -5588,7 +7054,7 @@ async function runRmFeature(featureSlug, opts = {}) {
5588
7054
  useTrash
5589
7055
  );
5590
7056
  if (result.trashed) {
5591
- success(`Feature removed. Items moved to ${path23.relative(root, result.trashed)}.`);
7057
+ success(`Feature removed. Items moved to ${path31.relative(root, result.trashed)}.`);
5592
7058
  } else {
5593
7059
  success("Feature removed (stories and plans directories).");
5594
7060
  }
@@ -5629,11 +7095,11 @@ async function runRmStory(storyPathOrId, opts = {}) {
5629
7095
  }
5630
7096
  }
5631
7097
  const targets = [
5632
- { label: path24.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
7098
+ { label: path32.relative(root, story.storyDir), absPath: story.storyDir, kind: "dir" }
5633
7099
  ];
5634
7100
  if (story.planFile) {
5635
- const planAbs = path24.join(paths.plansDir, story.feature, story.planFile);
5636
- targets.push({ label: path24.relative(root, planAbs), absPath: planAbs, kind: "file" });
7101
+ const planAbs = path32.join(paths.plansDir, story.feature, story.planFile);
7102
+ targets.push({ label: path32.relative(root, planAbs), absPath: planAbs, kind: "file" });
5637
7103
  }
5638
7104
  renderPreview("story", targets, {
5639
7105
  dryRun,
@@ -5656,18 +7122,16 @@ async function runRmStory(storyPathOrId, opts = {}) {
5656
7122
  return;
5657
7123
  }
5658
7124
  }
5659
- const absPaths = targets.map((t) => t.absPath);
5660
- const result = trashOrDelete(absPaths, paths.trashDir, useTrash);
5661
- removeOverviewRow(path24.join(paths.plansDir, story.feature), story.id);
7125
+ const result = deleteStoryRecord({ paths, story, trash: useTrash });
5662
7126
  if (result.trashed) {
5663
- success(`Removed (moved to ${path24.relative(root, result.trashed)}).`);
7127
+ success(`Removed (moved to ${path32.relative(root, result.trashed)}).`);
5664
7128
  } else {
5665
7129
  success("Removed story folder, plan file (if any), and overview row.");
5666
7130
  }
5667
- const featurePlanDir = path24.join(paths.plansDir, story.feature);
5668
- const overviewPath = path24.join(featurePlanDir, "00-overview.md");
5669
- if (fs30.existsSync(overviewPath)) {
5670
- const body = fs30.readFileSync(overviewPath, "utf8");
7131
+ const featurePlanDir = path32.join(paths.plansDir, story.feature);
7132
+ const overviewPath = path32.join(featurePlanDir, "00-overview.md");
7133
+ if (fs38.existsSync(overviewPath)) {
7134
+ const body = fs38.readFileSync(overviewPath, "utf8");
5671
7135
  const onlyShell = countOverviewDataRows(body) === 0;
5672
7136
  if (onlyShell && featureStoriesDirEmpty(paths, story.feature)) {
5673
7137
  if (canPrompt) {
@@ -5687,7 +7151,7 @@ async function runRmStory(storyPathOrId, opts = {}) {
5687
7151
  }
5688
7152
 
5689
7153
  // src/commands/rm/plan.ts
5690
- import path25 from "path";
7154
+ import path33 from "path";
5691
7155
  import { confirm as confirm11 } from "@inquirer/prompts";
5692
7156
  async function runRmPlan(planPathOrSequence, opts = {}) {
5693
7157
  const root = requireSquadRoot();
@@ -5723,7 +7187,7 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
5723
7187
  }
5724
7188
  const storyRows = listStories(paths, { feature: plan.feature });
5725
7189
  const linked = storyRows.find((s) => s.planFile === plan.file);
5726
- const targets = [{ label: path25.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
7190
+ const targets = [{ label: path33.relative(root, plan.abs), absPath: plan.abs, kind: "file" }];
5727
7191
  renderPreview("plan", targets, {
5728
7192
  dryRun,
5729
7193
  trash: useTrash,
@@ -5746,14 +7210,14 @@ async function runRmPlan(planPathOrSequence, opts = {}) {
5746
7210
  }
5747
7211
  }
5748
7212
  trashOrDelete([plan.abs], paths.trashDir, useTrash);
5749
- removeOverviewRowForPlanFile(path25.join(paths.plansDir, plan.feature), plan.file);
5750
- const intakeHint = linked ? path25.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
7213
+ removeOverviewRowForPlanFile(path33.join(paths.plansDir, plan.feature), plan.file);
7214
+ const intakeHint = linked ? path33.relative(root, linked.intakePath) : `under .squad/stories/${plan.feature}/`;
5751
7215
  success(`Plan removed. Run \`squad new-plan ${intakeHint}\` to regenerate.`);
5752
7216
  }
5753
7217
 
5754
7218
  // src/cli.ts
5755
7219
  var program = new Command();
5756
- program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.4.0");
7220
+ program.name("squad").description("Plan once, execute cheap. A 3-step SDD workflow CLI.").version("0.6.0");
5757
7221
  program.command("init").description("Bootstrap .squad/ in the current directory").option("--agents <list>", "Comma-separated agent list: claude-code,cursor,copilot,gemini").option("--tracker <type>", "Tracker type: none|github|linear|jira|azure").option(
5758
7222
  "--tracker-workspace <hostOrOrg>",
5759
7223
  "Jira host or Azure org (non-interactive default; also written to config for jira/azure)"
@@ -5786,6 +7250,11 @@ program.command("status").description("Show squad-kit workspace status").action(
5786
7250
  program.command("doctor").description("Run a health check on the local .squad/ workspace and its external integrations").option("--fix", "Apply non-destructive repairs (gitignore, permissions, missing dirs)", false).option("--json", "Emit results as JSON to stdout (for scripting)", false).action(wrap(runDoctor));
5787
7251
  program.command("migrate").description("Apply one-shot structural migrations to bring .squad/ to the latest version").option("--dry-run", "Show what would change without touching the filesystem", false).option("-y, --yes", "Skip the confirmation prompt", false).action(wrap(runMigrate));
5788
7252
  program.command("upgrade").description("Check for and install the latest squad-kit release").option("--check", "Only check for updates; do not install", false).option("-y, --yes", "Skip the confirmation prompt", false).action(wrap(runUpgrade));
7253
+ program.command("console").description("Open the squad-kit web console (local, dark-modern UI for stories, plans, runs, config)").option(
7254
+ "--port <number>",
7255
+ "Port to bind on 127.0.0.1 (default 4571; falls back to next free port)",
7256
+ (v) => parseInt(v, 10)
7257
+ ).option("--no-open", "Do not auto-open the browser; print the URL instead").option("--token <hex>", "Reuse a specific session token (advanced; default: random per session)").action(wrap(runConsole));
5789
7258
  program.command("list").description("List stories and their plan state").option("--feature <slug>", "Filter by feature slug").action(wrap(runList));
5790
7259
  var rm = program.command("rm").description("Delete stories, plans, or entire features safely");
5791
7260
  rm.command("story [story-path-or-id]").description("Delete a story: intake folder + matching plan file + overview row").option("--feature <slug>", "Scope the picker or lookup to one feature").option("--dry-run", "Show what would be deleted without touching the filesystem", false).option("--trash", "Move into .squad/.trash/<ts>/ instead of permanent delete", false).option("-y, --yes", "Skip the confirmation prompt", false).action(wrapArgs(runRmStory));