plan-assistant 1.4.5 → 1.6.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +31 -12
  2. package/build/client/_app/immutable/assets/0.Bc8GgJCN.css +1 -0
  3. package/build/client/_app/immutable/chunks/{ABPQj_I6.js → 5KpFG7Y3.js} +1 -1
  4. package/build/client/_app/immutable/chunks/AMggw8F_.js +1 -0
  5. package/build/client/_app/immutable/chunks/{DCP7pAeu.js → B0Br67Jm.js} +22 -22
  6. package/build/client/_app/immutable/chunks/{D01yuNpr.js → BGuzEOEG.js} +1 -1
  7. package/build/client/_app/immutable/chunks/{x3JJhWj3.js → BPC30ZkC.js} +1 -1
  8. package/build/client/_app/immutable/chunks/{Dn9lSg-V.js → BT-rYp04.js} +1 -1
  9. package/build/client/_app/immutable/chunks/{B25Hd2fz.js → BXachUYg.js} +1 -1
  10. package/build/client/_app/immutable/chunks/{CKmKshyc.js → BdxNwExl.js} +1 -1
  11. package/build/client/_app/immutable/chunks/{BcKBKgcZ.js → Bdy6d4YN.js} +1 -1
  12. package/build/client/_app/immutable/chunks/{ClpyzqjJ.js → BkkBWYh4.js} +1 -1
  13. package/build/client/_app/immutable/chunks/{yS11QLaS.js → Bmsplg2Y.js} +1 -1
  14. package/build/client/_app/immutable/chunks/{hHxGQnZj.js → Bn0XLNse.js} +1 -1
  15. package/build/client/_app/immutable/chunks/{J7DoTV-B.js → BuxFeYZa.js} +1 -1
  16. package/build/client/_app/immutable/chunks/{DJ4AwCJ9.js → C2OLrLCJ.js} +1 -1
  17. package/build/client/_app/immutable/chunks/C88p_n1l.js +3 -0
  18. package/build/client/_app/immutable/chunks/{DVaxwt1f.js → CFomSPzW.js} +1 -1
  19. package/build/client/_app/immutable/chunks/{BRUI-1NR.js → CFx4VLDw.js} +1 -1
  20. package/build/client/_app/immutable/chunks/{DBY3mAk0.js → CRUUUIwS.js} +1 -1
  21. package/build/client/_app/immutable/chunks/{CfICpXHz.js → CRuCklg1.js} +1 -1
  22. package/build/client/_app/immutable/chunks/{DmuA5Qzn.js → CUHvMbx0.js} +1 -1
  23. package/build/client/_app/immutable/chunks/{CadzRJ6P.js → C_5efo6-.js} +1 -1
  24. package/build/client/_app/immutable/chunks/{BierXyQN.js → Cb177sm-.js} +1 -1
  25. package/build/client/_app/immutable/chunks/{CWe1fJjs.js → Cb9ekAV0.js} +1 -1
  26. package/build/client/_app/immutable/chunks/{DnZ1MoPi.js → CdiHcRRA.js} +1 -1
  27. package/build/client/_app/immutable/chunks/{BAG_vqoT.js → CgIOtWnS.js} +1 -1
  28. package/build/client/_app/immutable/chunks/{CbLSu4oh.js → CghPw8Xx.js} +1 -1
  29. package/build/client/_app/immutable/chunks/{Uy7RUP_Z.js → Cmym6IfH.js} +1 -1
  30. package/build/client/_app/immutable/chunks/{C7FtzXJF.js → Cvpo3167.js} +1 -1
  31. package/build/client/_app/immutable/chunks/{4dPdNqKL.js → Cy0JyQfD.js} +1 -1
  32. package/build/client/_app/immutable/chunks/{DXnvq78W.js → D-0mahX7.js} +1 -1
  33. package/build/client/_app/immutable/chunks/{gqH_bfvs.js → D8823oiN.js} +1 -1
  34. package/build/client/_app/immutable/chunks/{BCxCv_4N.js → DIjXbgfi.js} +1 -1
  35. package/build/client/_app/immutable/chunks/{CF3N_j1A.js → DUIJXtzj.js} +1 -1
  36. package/build/client/_app/immutable/chunks/{DbxsK-Gw.js → DXlvMCt6.js} +1 -1
  37. package/build/client/_app/immutable/chunks/{DA_DyJGG.js → Dffa6mVP.js} +1 -1
  38. package/build/client/_app/immutable/chunks/{D8aFfI6M.js → DgzenGSu.js} +1 -1
  39. package/build/client/_app/immutable/chunks/{1hoFVlwF.js → DqaGnr0n.js} +4 -4
  40. package/build/client/_app/immutable/chunks/{CE48ZXRi.js → Drf3oeac.js} +1 -1
  41. package/build/client/_app/immutable/chunks/{CmCoqHco.js → EBvoHjF1.js} +1 -1
  42. package/build/client/_app/immutable/chunks/{Cumsgxi9.js → Fqo6xqtt.js} +1 -1
  43. package/build/client/_app/immutable/chunks/QDQklEwM.js +1 -0
  44. package/build/client/_app/immutable/chunks/{zMHAx0Qk.js → Q_gMk3PN.js} +1 -1
  45. package/build/client/_app/immutable/chunks/{Wv7WdG7L.js → XHkT9caI.js} +1 -1
  46. package/build/client/_app/immutable/chunks/{B3CyqxdZ.js → Zxe_1j0j.js} +1 -1
  47. package/build/client/_app/immutable/chunks/{Bysr0gLv.js → czbfzgw3.js} +1 -1
  48. package/build/client/_app/immutable/chunks/{BdcZtdph.js → mlMCI16G.js} +1 -1
  49. package/build/client/_app/immutable/chunks/{BEobpKJ6.js → qFFXeAvY.js} +1 -1
  50. package/build/client/_app/immutable/entry/{app.BdutA_ce.js → app.C_pWXh8R.js} +2 -2
  51. package/build/client/_app/immutable/entry/start.DfbNX0kb.js +1 -0
  52. package/build/client/_app/immutable/nodes/{1.BBwvzKm5.js → 1.DN_p76XO.js} +1 -1
  53. package/build/client/_app/immutable/nodes/3.B0eShLlr.js +1 -0
  54. package/build/client/_app/version.json +1 -1
  55. package/build/server/chunks/{0-DpiwjOzQ.js → 0-rXmaDIAK.js} +3 -3
  56. package/build/server/chunks/{0-DpiwjOzQ.js.map → 0-rXmaDIAK.js.map} +1 -1
  57. package/build/server/chunks/{1-DLxoXeuD.js → 1-CEFNOpUG.js} +2 -2
  58. package/build/server/chunks/{1-DLxoXeuD.js.map → 1-CEFNOpUG.js.map} +1 -1
  59. package/build/server/chunks/{2-Cy9PX2zK.js → 2-qGWtN2-9.js} +3 -2
  60. package/build/server/chunks/{2-Cy9PX2zK.js.map → 2-qGWtN2-9.js.map} +1 -1
  61. package/build/server/chunks/{3-DIjT-1nt.js → 3-OUU46X3b.js} +6 -7
  62. package/build/server/chunks/3-OUU46X3b.js.map +1 -0
  63. package/build/server/chunks/{_page.svelte-XhzZOhTs.js → _page.svelte-DcqVlJwT.js} +13 -14
  64. package/build/server/chunks/_page.svelte-DcqVlJwT.js.map +1 -0
  65. package/build/server/chunks/_server.ts-BOWeAYnM.js +13 -0
  66. package/build/server/chunks/_server.ts-BOWeAYnM.js.map +1 -0
  67. package/build/server/chunks/{_server.ts-CEtvrNT1.js → _server.ts-BbMhMik1.js} +4 -3
  68. package/build/server/chunks/_server.ts-BbMhMik1.js.map +1 -0
  69. package/build/server/chunks/{_server.ts-b4ynvdd4.js → _server.ts-BhmTJZsP.js} +3 -4
  70. package/build/server/chunks/_server.ts-BhmTJZsP.js.map +1 -0
  71. package/build/server/chunks/{_server.ts-DCa-_qe7.js → _server.ts-BjSxqsHx.js} +5 -7
  72. package/build/server/chunks/_server.ts-BjSxqsHx.js.map +1 -0
  73. package/build/server/chunks/{_server.ts-sRxHDQa9.js → _server.ts-C7d7vtwn.js} +3 -2
  74. package/build/server/chunks/_server.ts-C7d7vtwn.js.map +1 -0
  75. package/build/server/chunks/{_server.ts-aWI5JVTi.js → _server.ts-D9nBKsq1.js} +2 -2
  76. package/build/server/chunks/{_server.ts-aWI5JVTi.js.map → _server.ts-D9nBKsq1.js.map} +1 -1
  77. package/build/server/chunks/_server.ts-DTGwAmWz.js +14 -0
  78. package/build/server/chunks/_server.ts-DTGwAmWz.js.map +1 -0
  79. package/build/server/chunks/{_server.ts-DVH0ejp0.js → _server.ts-DiMlmks0.js} +2 -2
  80. package/build/server/chunks/{_server.ts-DVH0ejp0.js.map → _server.ts-DiMlmks0.js.map} +1 -1
  81. package/build/server/chunks/{hooks.server-sBuk2932.js → hooks.server-DigZpFgW.js} +4 -3
  82. package/build/server/chunks/{hooks.server-sBuk2932.js.map → hooks.server-DigZpFgW.js.map} +1 -1
  83. package/build/server/chunks/{idle-timer-CouFy_WF.js → idle-timer-BshN5Svi.js} +3 -2
  84. package/build/server/chunks/idle-timer-BshN5Svi.js.map +1 -0
  85. package/build/server/chunks/{session-manager-Vdr0BxAo.js → session-manager-scAVAR7S.js} +39 -41
  86. package/build/server/chunks/session-manager-scAVAR7S.js.map +1 -0
  87. package/build/server/index.js +2 -2
  88. package/build/server/index.js.map +1 -1
  89. package/build/server/manifest.js +13 -20
  90. package/build/server/manifest.js.map +1 -1
  91. package/dist/cli/commands/clean.js +33 -29
  92. package/dist/cli/commands/export.js +5 -8
  93. package/dist/cli/commands/feedback.js +5 -8
  94. package/dist/cli/commands/init.js +2 -1
  95. package/dist/cli/commands/review.js +18 -121
  96. package/dist/cli/commands/status.js +20 -23
  97. package/dist/cli/commands/stop.js +7 -15
  98. package/dist/cli/errors.js +23 -0
  99. package/dist/cli/export-html.js +1 -1
  100. package/dist/cli/index.js +21 -5
  101. package/dist/cli/markdown-parser.js +117 -132
  102. package/dist/cli/markdown-to-plan.js +1 -50
  103. package/dist/cli/review-session.js +130 -0
  104. package/dist/cli/server-client.js +20 -33
  105. package/dist/cli/session-reader.js +20 -47
  106. package/dist/cli/session-resolver.js +10 -0
  107. package/dist/lib/server/session-manager.js +115 -0
  108. package/dist/lib/utils/status.js +53 -0
  109. package/package.json +1 -1
  110. package/build/client/_app/immutable/assets/0.CltY7ilp.css +0 -1
  111. package/build/client/_app/immutable/chunks/CULHGyhn.js +0 -1
  112. package/build/client/_app/immutable/chunks/DPsQ0F5p.js +0 -3
  113. package/build/client/_app/immutable/chunks/UfQGGAux.js +0 -1
  114. package/build/client/_app/immutable/entry/start.CTfow7ge.js +0 -1
  115. package/build/client/_app/immutable/nodes/3.DHzG8WJV.js +0 -1
  116. package/build/server/chunks/3-DIjT-1nt.js.map +0 -1
  117. package/build/server/chunks/_page.svelte-XhzZOhTs.js.map +0 -1
  118. package/build/server/chunks/_server.ts-BkcqX0Gw.js +0 -14
  119. package/build/server/chunks/_server.ts-BkcqX0Gw.js.map +0 -1
  120. package/build/server/chunks/_server.ts-C9Rz5pqb.js +0 -15
  121. package/build/server/chunks/_server.ts-C9Rz5pqb.js.map +0 -1
  122. package/build/server/chunks/_server.ts-CEtvrNT1.js.map +0 -1
  123. package/build/server/chunks/_server.ts-DCa-_qe7.js.map +0 -1
  124. package/build/server/chunks/_server.ts-b4ynvdd4.js.map +0 -1
  125. package/build/server/chunks/_server.ts-r59HGb-h.js +0 -22
  126. package/build/server/chunks/_server.ts-r59HGb-h.js.map +0 -1
  127. package/build/server/chunks/_server.ts-sRxHDQa9.js.map +0 -1
  128. package/build/server/chunks/idle-timer-CouFy_WF.js.map +0 -1
  129. package/build/server/chunks/session-manager-Vdr0BxAo.js.map +0 -1
  130. /package/build/client/_app/immutable/nodes/{0.D7ZwKtS7.js → 0.CuhA9F69.js} +0 -0
@@ -1,16 +1,12 @@
1
1
  import { dirname } from "node:path";
2
- import { resolveSession } from "../session-resolver.js";
3
- import { stopServer, checkHealth, DEFAULT_BASE_PORT, MAX_PORT, } from "../server-client.js";
4
- import { outputJson, outputError } from "../output.js";
2
+ import { requireSession } from "../session-resolver.js";
3
+ import { stopServer, checkHealth, fetchWithTimeout, clearLock, DEFAULT_BASE_PORT, MAX_PORT, } from "../server-client.js";
4
+ import { outputJson } from "../output.js";
5
5
  export async function stop(args) {
6
6
  const target = args.positional[0];
7
7
  if (target) {
8
8
  // Stop server for a specific session
9
- const session = resolveSession(target);
10
- if (!session) {
11
- outputError(`Session not found: ${target}`, "NOT_FOUND");
12
- process.exit(1);
13
- }
9
+ const session = requireSession(target);
14
10
  const parentDir = dirname(session.sessionDir);
15
11
  const stopped = await stopServer(parentDir);
16
12
  if (stopped) {
@@ -30,13 +26,8 @@ export async function stop(args) {
30
26
  const health = await checkHealth(port);
31
27
  if (health) {
32
28
  try {
33
- const controller = new AbortController();
34
- const timeout = setTimeout(() => controller.abort(), 2000);
35
- await fetch(`http://localhost:${port}/api/shutdown`, {
36
- method: "POST",
37
- signal: controller.signal,
38
- });
39
- clearTimeout(timeout);
29
+ await fetchWithTimeout(`http://localhost:${port}/api/shutdown`, { method: "POST" }, 2000);
30
+ clearLock(health.sessionDir);
40
31
  results.push({ port, sessionDir: health.sessionDir });
41
32
  stopped++;
42
33
  }
@@ -44,6 +35,7 @@ export async function stop(args) {
44
35
  // Try SIGTERM
45
36
  try {
46
37
  process.kill(health.pid, "SIGTERM");
38
+ clearLock(health.sessionDir);
47
39
  results.push({ port, sessionDir: health.sessionDir });
48
40
  stopped++;
49
41
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Thrown by CLI commands to signal an error exit.
3
+ * Caught by main() which writes message to stderr and calls process.exit(exitCode).
4
+ */
5
+ export class CliError extends Error {
6
+ exitCode;
7
+ constructor(message, exitCode = 1) {
8
+ super(message);
9
+ this.exitCode = exitCode;
10
+ this.name = "CliError";
11
+ }
12
+ }
13
+ /**
14
+ * Thrown by CLI commands to signal intentional non-error exit with a specific code.
15
+ * For example, `status` exits with code 3 for "needs-work" — not an error, just a signal.
16
+ * Caught by main() which calls process.exit(exitCode) without printing an error.
17
+ */
18
+ export class CliExitCode {
19
+ exitCode;
20
+ constructor(exitCode) {
21
+ this.exitCode = exitCode;
22
+ }
23
+ }
@@ -1,4 +1,4 @@
1
- function escapeHtml(text) {
1
+ export function escapeHtml(text) {
2
2
  return text
3
3
  .replace(/&/g, "&")
4
4
  .replace(/</g, "&lt;")
package/dist/cli/index.js CHANGED
@@ -130,8 +130,8 @@ Optional top-level sections (all H2):
130
130
  ## Testing Strategy
131
131
  ## References`);
132
132
  }
133
- export async function main(args) {
134
- const parsed = parseArgs(args);
133
+ import { CliError, CliExitCode } from "./errors.js";
134
+ async function dispatch(parsed) {
135
135
  switch (parsed.command) {
136
136
  case "review": {
137
137
  const { review } = await import("./commands/review.js");
@@ -176,8 +176,24 @@ export async function main(args) {
176
176
  }
177
177
  return;
178
178
  default:
179
- console.error(`Unknown command: ${parsed.command}`);
180
- usage();
181
- process.exit(1);
179
+ throw new CliError(`Unknown command: ${parsed.command}`, 1);
180
+ }
181
+ }
182
+ export async function main(args) {
183
+ const parsed = parseArgs(args);
184
+ try {
185
+ await dispatch(parsed);
186
+ }
187
+ catch (err) {
188
+ if (err instanceof CliExitCode) {
189
+ process.exit(err.exitCode);
190
+ }
191
+ if (err instanceof CliError) {
192
+ console.error(err.message);
193
+ process.exit(err.exitCode);
194
+ }
195
+ // Unexpected error
196
+ console.error(err instanceof Error ? err.message : String(err));
197
+ process.exit(1);
182
198
  }
183
199
  }
@@ -260,7 +260,7 @@ export function tryMatchPhaseHeading(heading, autoNumber) {
260
260
  return null;
261
261
  }
262
262
  /** Known top-level section patterns that should NOT be treated as phases */
263
- const KNOWN_SECTION_PATTERNS = [
263
+ export const KNOWN_SECTION_PATTERNS = [
264
264
  /^Overview$/i,
265
265
  /^Current\s+State/i,
266
266
  /What\s+We.*NOT\s+Doing/i,
@@ -274,147 +274,132 @@ const KNOWN_SECTION_PATTERNS = [
274
274
  ];
275
275
  const CHANGES_HEADING_PATTERN = /^(?:Changes\s+Required|Changes|File\s+Changes|Modifications):?$/i;
276
276
  const SUCCESS_CRITERIA_HEADING_PATTERN = /^(?:Success\s+Criteria|Criteria|Verification):?$/i;
277
+ /**
278
+ * Detect whether a section is a phase heading.
279
+ * Handles canonical (## Phase N:), unnumbered H2s with phase-like content,
280
+ * and H3 phase headings (with warning).
281
+ * Returns null if the section is not a phase.
282
+ */
283
+ function detectPhaseHeading(section, allSections, sectionIdx, autoNumber, ctx) {
284
+ if (section.level !== 2 && section.level !== 3)
285
+ return null;
286
+ const phaseMatch = tryMatchPhaseHeading(section.heading, autoNumber);
287
+ // Check for unnumbered H2s that look like phases
288
+ if (!phaseMatch && section.level === 2) {
289
+ if (KNOWN_SECTION_PATTERNS.some((p) => p.test(section.heading)))
290
+ return null;
291
+ const subSections = collectSectionsUntilLevel(allSections, sectionIdx, 2);
292
+ const hasChanges = subSections.some((s) => CHANGES_HEADING_PATTERN.test(s.heading));
293
+ const hasCriteria = subSections.some((s) => SUCCESS_CRITERIA_HEADING_PATTERN.test(s.heading) ||
294
+ /Automated\s+Verification/i.test(s.heading) ||
295
+ /Manual\s+Verification/i.test(s.heading));
296
+ const hasNumberedChanges = subSections.some((s) => (s.level === 4 || s.level === 3) && /^\d+\.\s/.test(s.heading));
297
+ if (hasChanges || hasCriteria || hasNumberedChanges) {
298
+ ctx.warn(`Unnumbered heading "${section.heading}" treated as Phase ${autoNumber}`);
299
+ return { number: autoNumber, name: section.heading, variant: "unnumbered" };
300
+ }
301
+ return null;
302
+ }
303
+ return phaseMatch;
304
+ }
305
+ // ── Phase content extraction ─────────────────────────────────────
306
+ function extractPhaseChanges(subSections, allSections, phaseNumber, ctx) {
307
+ const changesSection = subSections.find((s) => CHANGES_HEADING_PATTERN.test(s.heading));
308
+ if (!changesSection)
309
+ return [];
310
+ if (!/^Changes\s+Required/i.test(changesSection.heading)) {
311
+ ctx.warn(`Phase ${phaseNumber}: "${changesSection.heading}" used instead of "Changes Required" — accepted`);
312
+ }
313
+ const changesIdx = allSections.indexOf(changesSection);
314
+ if (changesIdx < 0)
315
+ return [];
316
+ // Strategy 1: Heading-based changes (#### N. Name)
317
+ const changeSubs = collectSectionsUntilLevel(allSections, changesIdx, changesSection.level);
318
+ const changes = parseChangesFromHeadings(changeSubs, ctx);
319
+ // Strategy 2: List-based changes if no heading-based changes found
320
+ if (changes.length === 0) {
321
+ return parseChangesFromList(changesSection.tokens, ctx);
322
+ }
323
+ return changes;
324
+ }
325
+ function extractPhaseCriteria(subSections, allSections) {
326
+ const criteriaSection = subSections.find((s) => SUCCESS_CRITERIA_HEADING_PATTERN.test(s.heading));
327
+ const criteriaIdx = criteriaSection
328
+ ? allSections.indexOf(criteriaSection)
329
+ : -1;
330
+ const criteriaSubs = criteriaIdx >= 0 && criteriaSection
331
+ ? collectSectionsUntilLevel(allSections, criteriaIdx, criteriaSection.level)
332
+ : [];
333
+ const automatedSection = criteriaSubs.find((s) => /Automated\s+Verification/i.test(s.heading)) ??
334
+ subSections.find((s) => /Automated\s+Verification/i.test(s.heading));
335
+ const manualSection = criteriaSubs.find((s) => /Manual\s+Verification/i.test(s.heading)) ??
336
+ subSections.find((s) => /Manual\s+Verification/i.test(s.heading));
337
+ const automated = automatedSection
338
+ ? parseCriteria(automatedSection.tokens, "automated")
339
+ : [];
340
+ const manual = manualSection
341
+ ? parseCriteria(manualSection.tokens, "manual")
342
+ : [];
343
+ // If criteria section exists but no automated/manual sub-sections,
344
+ // treat all list items as manual criteria
345
+ if (criteriaSection &&
346
+ automated.length === 0 &&
347
+ manual.length === 0 &&
348
+ criteriaSubs.length === 0) {
349
+ manual.push(...parseCriteria(criteriaSection.tokens, "manual"));
350
+ }
351
+ return { automated, manual };
352
+ }
353
+ function extractPhaseContentAndSubItems(phaseSection, subSections, phaseId, phaseNumber) {
354
+ const recognizedPattern = /^(Overview|Changes\s+Required|Changes|File\s+Changes|Modifications|Automated\s+Verification|Manual\s+Verification|Success\s+Criteria|Criteria|Verification):?$/i;
355
+ const subItemPattern = /^(\d+)([a-z])\.\s+(.+)/i;
356
+ const contentParts = [];
357
+ const subItems = [];
358
+ const directText = tokensToMarkdown(phaseSection.tokens);
359
+ if (directText)
360
+ contentParts.push(directText);
361
+ for (const sub of subSections) {
362
+ if (recognizedPattern.test(sub.heading))
363
+ continue;
364
+ const siMatch = sub.heading.match(subItemPattern);
365
+ if (siMatch && parseInt(siMatch[1], 10) === phaseNumber) {
366
+ const letter = siMatch[2].toLowerCase();
367
+ subItems.push({
368
+ id: `${phaseId}-${letter}`,
369
+ letter,
370
+ name: siMatch[3].trim(),
371
+ content: tokensToMarkdown(sub.tokens),
372
+ });
373
+ }
374
+ else {
375
+ const subBody = tokensToMarkdown(sub.tokens);
376
+ contentParts.push(`### ${sub.heading}\n\n${subBody}`);
377
+ }
378
+ }
379
+ const content = contentParts.join("\n\n").trim() || undefined;
380
+ return { content, subItems };
381
+ }
382
+ // ── Main phase parser ────────────────────────────────────────────
277
383
  export function parsePhases(allSections, ctx) {
278
384
  const phases = [];
279
385
  let autoNumber = 1;
280
386
  for (let i = 0; i < allSections.length; i++) {
281
387
  const section = allSections[i];
282
- // Try matching at level 2 (canonical) or level 3 (with warning)
283
- if (section.level !== 2 && section.level !== 3)
388
+ const detected = detectPhaseHeading(section, allSections, i, autoNumber, ctx);
389
+ if (!detected)
284
390
  continue;
285
- const phaseMatch = tryMatchPhaseHeading(section.heading, autoNumber);
286
- // Also check for unnumbered H2s that look like phases
287
- let isUnnumberedPhase = false;
288
- if (!phaseMatch && section.level === 2) {
289
- // Skip known section patterns
290
- if (KNOWN_SECTION_PATTERNS.some((p) => p.test(section.heading)))
291
- continue;
292
- // Check if this H2 has subsections that look like phase content
293
- // (has Changes Required, or Success Criteria, or numbered sub-headings)
294
- const subSections = collectSectionsUntilLevel(allSections, i, 2);
295
- const hasChanges = subSections.some((s) => CHANGES_HEADING_PATTERN.test(s.heading));
296
- const hasCriteria = subSections.some((s) => SUCCESS_CRITERIA_HEADING_PATTERN.test(s.heading) ||
297
- /Automated\s+Verification/i.test(s.heading) ||
298
- /Manual\s+Verification/i.test(s.heading));
299
- const hasNumberedChanges = subSections.some((s) => (s.level === 4 || s.level === 3) && /^\d+\.\s/.test(s.heading));
300
- if (hasChanges || hasCriteria || hasNumberedChanges) {
301
- isUnnumberedPhase = true;
302
- }
303
- else {
304
- continue;
305
- }
391
+ if (detected.variant !== "canonical" && detected.variant !== "unnumbered") {
392
+ ctx.warn(`Phase "${section.heading}" uses non-canonical format (${detected.variant}) accepted`);
306
393
  }
307
- if (!phaseMatch && !isUnnumberedPhase) {
308
- // H3 with phase-like heading
309
- if (section.level === 3) {
310
- const h3Match = tryMatchPhaseHeading(section.heading, autoNumber);
311
- if (h3Match) {
312
- ctx.warn(`Phase "${section.heading}" uses h3 instead of h2 — accepted but non-canonical`);
313
- // Fall through with h3Match
314
- }
315
- else {
316
- continue;
317
- }
318
- }
319
- else {
320
- continue;
321
- }
322
- }
323
- const match = phaseMatch ?? {
324
- number: autoNumber,
325
- name: section.heading,
326
- variant: "unnumbered",
327
- };
328
- if (match.variant !== "canonical" && match.variant !== "unnumbered") {
329
- ctx.warn(`Phase "${section.heading}" uses non-canonical format (${match.variant}) — accepted`);
330
- }
331
- if (match.variant === "unnumbered") {
332
- ctx.warn(`Unnumbered heading "${section.heading}" treated as Phase ${match.number}`);
333
- }
334
- const number = match.number;
335
- const name = match.name;
394
+ const { number, name } = detected;
336
395
  const id = `phase-${number}`;
337
396
  autoNumber = number + 1;
338
397
  const subSections = collectSectionsUntilLevel(allSections, i, section.level);
339
398
  const overviewSection = subSections.find((s) => /^Overview$/i.test(s.heading));
340
- const overview = overviewSection
341
- ? tokensToMarkdown(overviewSection.tokens)
342
- : "";
343
- // Flexible changes section heading
344
- const changesSection = subSections.find((s) => CHANGES_HEADING_PATTERN.test(s.heading));
345
- if (changesSection &&
346
- !/^Changes\s+Required/i.test(changesSection.heading)) {
347
- ctx.warn(`Phase ${number}: "${changesSection.heading}" used instead of "Changes Required" — accepted`);
348
- }
349
- const changesIdx = changesSection
350
- ? allSections.indexOf(changesSection)
351
- : -1;
352
- let changes = [];
353
- if (changesIdx >= 0 && changesSection) {
354
- // Strategy 1: Heading-based changes (#### N. Name)
355
- const changeSubs = collectSectionsUntilLevel(allSections, changesIdx, changesSection.level);
356
- changes = parseChangesFromHeadings(changeSubs, ctx);
357
- // Strategy 2: List-based changes if no heading-based changes found
358
- if (changes.length === 0) {
359
- changes = parseChangesFromList(changesSection.tokens, ctx);
360
- }
361
- }
362
- // Flexible success criteria heading
363
- const criteriaSection = subSections.find((s) => SUCCESS_CRITERIA_HEADING_PATTERN.test(s.heading));
364
- const criteriaIdx = criteriaSection
365
- ? allSections.indexOf(criteriaSection)
366
- : -1;
367
- const criteriaSubs = criteriaIdx >= 0 && criteriaSection
368
- ? collectSectionsUntilLevel(allSections, criteriaIdx, criteriaSection.level)
369
- : [];
370
- const automatedSection = criteriaSubs.find((s) => /Automated\s+Verification/i.test(s.heading)) ??
371
- subSections.find((s) => /Automated\s+Verification/i.test(s.heading));
372
- const manualSection = criteriaSubs.find((s) => /Manual\s+Verification/i.test(s.heading)) ??
373
- subSections.find((s) => /Manual\s+Verification/i.test(s.heading));
374
- const automated = automatedSection
375
- ? parseCriteria(automatedSection.tokens, "automated")
376
- : [];
377
- const manual = manualSection
378
- ? parseCriteria(manualSection.tokens, "manual")
379
- : [];
380
- // If criteria section exists but no automated/manual sub-sections,
381
- // treat all list items as manual criteria
382
- if (criteriaSection &&
383
- automated.length === 0 &&
384
- manual.length === 0 &&
385
- criteriaSubs.length === 0) {
386
- manual.push(...parseCriteria(criteriaSection.tokens, "manual"));
387
- }
388
- // Build content from direct tokens + unrecognized subsections
389
- const recognizedPattern = /^(Overview|Changes\s+Required|Changes|File\s+Changes|Modifications|Automated\s+Verification|Manual\s+Verification|Success\s+Criteria|Criteria|Verification):?$/i;
390
- const subItemPattern = /^(\d+)([a-z])\.\s+(.+)/i;
391
- const contentParts = [];
392
- const subItems = [];
393
- // Direct tokens between phase heading and first sub-heading
394
- const phaseSection = allSections[i];
395
- const directText = tokensToMarkdown(phaseSection.tokens);
396
- if (directText)
397
- contentParts.push(directText);
398
- // Unrecognized subsections — check for sub-items first
399
- for (const sub of subSections) {
400
- if (recognizedPattern.test(sub.heading))
401
- continue;
402
- const siMatch = sub.heading.match(subItemPattern);
403
- if (siMatch && parseInt(siMatch[1], 10) === number) {
404
- const letter = siMatch[2].toLowerCase();
405
- subItems.push({
406
- id: `${id}-${letter}`,
407
- letter,
408
- name: siMatch[3].trim(),
409
- content: tokensToMarkdown(sub.tokens),
410
- });
411
- }
412
- else {
413
- const subBody = tokensToMarkdown(sub.tokens);
414
- contentParts.push(`### ${sub.heading}\n\n${subBody}`);
415
- }
416
- }
417
- const content = contentParts.join("\n\n").trim() || undefined;
399
+ const overview = overviewSection ? tokensToMarkdown(overviewSection.tokens) : "";
400
+ const changes = extractPhaseChanges(subSections, allSections, number, ctx);
401
+ const successCriteria = extractPhaseCriteria(subSections, allSections);
402
+ const { content, subItems } = extractPhaseContentAndSubItems(allSections[i], subSections, id, number);
418
403
  phases.push({
419
404
  id,
420
405
  number,
@@ -423,7 +408,7 @@ export function parsePhases(allSections, ctx) {
423
408
  content,
424
409
  subItems,
425
410
  changes,
426
- successCriteria: { automated, manual },
411
+ successCriteria,
427
412
  });
428
413
  }
429
414
  return phases;
@@ -2,7 +2,7 @@ import { Lexer } from "marked";
2
2
  import { createHash } from "node:crypto";
3
3
  import { basename } from "node:path";
4
4
  import { generatePhaseFlowDiagram } from "./mermaid-gen.js";
5
- import { createParseContext, splitIntoSections, tokensToMarkdown, findSection, collectSectionsUntilLevel, parseKeyDiscoveries, parseScopeExclusions, parsePhases, parseTestingStrategy, parseReferences, tryMatchPhaseHeading, } from "./markdown-parser.js";
5
+ import { createParseContext, splitIntoSections, tokensToMarkdown, findSection, collectSectionsUntilLevel, parseKeyDiscoveries, parseScopeExclusions, parsePhases, parseTestingStrategy, parseReferences, } from "./markdown-parser.js";
6
6
  export function sessionIdFromPath(absolutePath) {
7
7
  return createHash("sha256").update(absolutePath).digest("hex").slice(0, 8);
8
8
  }
@@ -77,54 +77,6 @@ export function parseMarkdownToPlan(markdown, markdownPath, projectDir, version
77
77
  // References
78
78
  const refsSection = findSection(sections, /^References$/i, 2);
79
79
  const references = refsSection ? parseReferences(refsSection.tokens) : [];
80
- // Collect additional (unrecognized) H2 sections
81
- const knownH2Indices = new Set();
82
- const knownPatterns = [
83
- /^Overview$/i,
84
- /^Current\s+State/i,
85
- /What\s+We.*NOT\s+Doing/i,
86
- /Implementation\s+Approach/i,
87
- /Testing\s+Strategy/i,
88
- /^References$/i,
89
- ];
90
- for (let i = 0; i < sections.length; i++) {
91
- if (sections[i].level === 1) {
92
- knownH2Indices.add(i);
93
- continue;
94
- }
95
- if (sections[i].level !== 2)
96
- continue;
97
- // Check if it's a known section
98
- if (knownPatterns.some((p) => p.test(sections[i].heading))) {
99
- knownH2Indices.add(i);
100
- continue;
101
- }
102
- // Check if it was parsed as a phase
103
- if (phases.some((p) => p.name === sections[i].heading ||
104
- tryMatchPhaseHeading(sections[i].heading, 0))) {
105
- knownH2Indices.add(i);
106
- continue;
107
- }
108
- }
109
- const additionalSections = [];
110
- for (let i = 0; i < sections.length; i++) {
111
- if (sections[i].level !== 2 || knownH2Indices.has(i))
112
- continue;
113
- // Double-check it's not a parsed phase
114
- const isPhase = phases.some((p) => p.name === sections[i].heading ||
115
- sections[i].heading.match(new RegExp(`Phase\\s+${p.number}`, "i")));
116
- if (isPhase)
117
- continue;
118
- const subs = collectSectionsUntilLevel(sections, i, 2);
119
- let content = tokensToMarkdown(sections[i].tokens);
120
- for (const sub of subs) {
121
- content += `\n\n${"#".repeat(sub.level)} ${sub.heading}\n\n${tokensToMarkdown(sub.tokens)}`;
122
- }
123
- additionalSections.push({
124
- heading: sections[i].heading,
125
- content: content.trim(),
126
- });
127
- }
128
80
  const now = new Date().toISOString();
129
81
  const plan = {
130
82
  schemaVersion: 1,
@@ -147,7 +99,6 @@ export function parseMarkdownToPlan(markdown, markdownPath, projectDir, version
147
99
  diagrams,
148
100
  testingStrategy,
149
101
  references,
150
- ...(additionalSections.length > 0 ? { additionalSections } : {}),
151
102
  };
152
103
  return { plan, warnings: ctx.warnings };
153
104
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Session setup for the review command.
3
+ * Extracted from review.ts to reduce god-function complexity.
4
+ */
5
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, } from "node:fs";
6
+ import { resolve, dirname, join } from "node:path";
7
+ import { watch } from "chokidar";
8
+ import { parseMarkdownToPlan, sessionIdFromPath, } from "./markdown-to-plan.js";
9
+ import { CliError } from "./errors.js";
10
+ /**
11
+ * Parse a markdown file, create/update the session directory, and write session files.
12
+ * Returns all the context needed by the rest of the review command.
13
+ */
14
+ export function prepareSession(markdownFile) {
15
+ const absolutePath = resolve(markdownFile);
16
+ if (!existsSync(absolutePath)) {
17
+ throw new CliError(`File not found: ${absolutePath}`);
18
+ }
19
+ const sessionDir = join(dirname(absolutePath), ".plan-sessions");
20
+ const sessionId = sessionIdFromPath(absolutePath);
21
+ const sessionPath = join(sessionDir, sessionId);
22
+ const projectDir = process.cwd();
23
+ // Determine version
24
+ let version = 1;
25
+ const existingPlanPath = join(sessionPath, "plan.json");
26
+ if (existsSync(existingPlanPath)) {
27
+ try {
28
+ const existing = JSON.parse(readFileSync(existingPlanPath, "utf-8"));
29
+ version = (existing.meta?.version ?? 0) + 1;
30
+ }
31
+ catch {
32
+ // ignore, start at 1
33
+ }
34
+ }
35
+ // Parse markdown
36
+ const markdown = readFileSync(absolutePath, "utf-8");
37
+ const { plan, warnings } = parseMarkdownToPlan(markdown, absolutePath, projectDir, version);
38
+ // Display parser warnings
39
+ for (const warning of warnings) {
40
+ console.error(`Warning: ${warning}`);
41
+ }
42
+ if (plan.phases.length === 0) {
43
+ console.error(`
44
+ ⚠ No phases found — plan will appear empty in the browser.
45
+
46
+ Use the correct format for phases:
47
+
48
+ ## Phase 1: Phase Name
49
+
50
+ ### Changes Required:
51
+
52
+ #### 1. Component Name
53
+ **File**: \`path/to/file.ext\`
54
+ Description of what to change.
55
+
56
+ ### Success Criteria:
57
+ - [ ] \`npm test\`
58
+
59
+ Accepted phase keywords: "Phase N:", "Step N:", "Task N:" (H2 headings)
60
+ Run \`npx plan-assistant init --output <file>\` to generate a correctly-formatted template.
61
+ `);
62
+ }
63
+ // Write session files
64
+ mkdirSync(sessionPath, { recursive: true });
65
+ mkdirSync(join(sessionPath, "versions"), { recursive: true });
66
+ // Clear stale feedback from previous review cycle
67
+ const oldFeedbackPath = join(sessionPath, "feedback.json");
68
+ if (existsSync(oldFeedbackPath)) {
69
+ try {
70
+ unlinkSync(oldFeedbackPath);
71
+ }
72
+ catch {
73
+ // ignore
74
+ }
75
+ }
76
+ const meta = {
77
+ id: sessionId,
78
+ planTitle: plan.meta.title,
79
+ markdownPath: absolutePath,
80
+ projectDir,
81
+ status: "active",
82
+ planVersion: version,
83
+ createdAt: plan.meta.createdAt,
84
+ updatedAt: plan.meta.updatedAt,
85
+ };
86
+ writeFileSync(join(sessionPath, "meta.json"), JSON.stringify(meta, null, 2));
87
+ writeFileSync(join(sessionPath, "plan.json"), JSON.stringify(plan, null, 2));
88
+ writeFileSync(join(sessionPath, "versions", `v${version}.json`), JSON.stringify(plan, null, 2));
89
+ return {
90
+ sessionId,
91
+ sessionPath,
92
+ sessionDir,
93
+ plan,
94
+ version,
95
+ absolutePath,
96
+ projectDir,
97
+ existingPlanPath,
98
+ };
99
+ }
100
+ /**
101
+ * Create a chokidar watcher on the markdown file that re-parses and
102
+ * updates session files on change.
103
+ */
104
+ export function watchMarkdownFile(absolutePath, sessionPath, projectDir, existingPlanPath, ensureServer) {
105
+ const mdWatcher = watch(absolutePath, {
106
+ awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
107
+ });
108
+ mdWatcher.on("change", async () => {
109
+ try {
110
+ await ensureServer();
111
+ const updated = readFileSync(absolutePath, "utf-8");
112
+ const existingPlan = JSON.parse(readFileSync(existingPlanPath, "utf-8"));
113
+ const newVersion = (existingPlan.meta?.version ?? 0) + 1;
114
+ const { plan: newPlan, warnings: newWarnings } = parseMarkdownToPlan(updated, absolutePath, projectDir, newVersion);
115
+ for (const warning of newWarnings) {
116
+ console.error(`Warning: ${warning}`);
117
+ }
118
+ if (newPlan.phases.length === 0) {
119
+ console.error(`⚠ No phases found after reload — check format. Run \`npx plan-assistant init\` for a template.`);
120
+ }
121
+ writeFileSync(join(sessionPath, "plan.json"), JSON.stringify(newPlan, null, 2));
122
+ writeFileSync(join(sessionPath, "versions", `v${newVersion}.json`), JSON.stringify(newPlan, null, 2));
123
+ console.error(`[${new Date().toLocaleTimeString()}] Plan updated (v${newVersion})`);
124
+ }
125
+ catch (err) {
126
+ console.error(`Error re-parsing markdown: ${err}`);
127
+ }
128
+ });
129
+ return mdWatcher;
130
+ }