squad-kit 0.4.0 → 0.6.1

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