squad-kit 0.3.0 → 0.6.0

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