deuk-agent-flow 4.0.36 → 4.2.7

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.
@@ -0,0 +1,60 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { AGENT_ROOT_DIR, computeTicketPath, parseFrontMatter } from "./cli-utils.mjs";
4
+
5
+ export const TICKET_REPORTS_SUBDIR = "plan";
6
+
7
+ export function resolveTicketReportDir(cwd) {
8
+ return join(cwd, AGENT_ROOT_DIR, "docs", TICKET_REPORTS_SUBDIR);
9
+ }
10
+
11
+ export function resolveTicketPath(cwd, relPath) {
12
+ return join(cwd, String(relPath || ""));
13
+ }
14
+
15
+ export function resolveTicketEntryPath(cwd, entry) {
16
+ return resolveTicketPath(cwd, entry?.path || "");
17
+ }
18
+
19
+ export function resolveTicketEntryOrComputedPath(cwd, entry) {
20
+ return resolveTicketPath(cwd, entry?.path || computeTicketPath(entry));
21
+ }
22
+
23
+ export function resolveTicketKnowledgeDir(cwd) {
24
+ return join(cwd, AGENT_ROOT_DIR, "knowledge");
25
+ }
26
+
27
+ export function readTicketDocument(cwd, entry, options = {}) {
28
+ const shouldCompute = options.computePath || !entry?.path;
29
+ const absPath = shouldCompute
30
+ ? resolveTicketEntryOrComputedPath(cwd, entry)
31
+ : resolveTicketEntryPath(cwd, entry);
32
+
33
+ const parse = options.parse ?? true;
34
+ const requireExists = options.requireExists ?? true;
35
+ const action = options.action || "ticket";
36
+
37
+ const exists = existsSync(absPath);
38
+ if (!exists && requireExists) {
39
+ const target = entry?.path || "unknown";
40
+ throw new Error(`${action}: Ticket file not found: ${target}`);
41
+ }
42
+
43
+ if (!exists) {
44
+ return {
45
+ absPath,
46
+ exists: false,
47
+ body: "",
48
+ meta: {},
49
+ content: ""
50
+ };
51
+ }
52
+
53
+ const body = readFileSync(absPath, "utf8");
54
+ if (!parse) {
55
+ return { absPath, exists: true, body, meta: {}, content: "" };
56
+ }
57
+
58
+ const parsed = parseFrontMatter(body);
59
+ return { absPath, exists: true, body, ...parsed };
60
+ }
@@ -1,12 +1,18 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, copyFileSync, readdirSync, rmSync, statSync } from "fs";
2
2
  import { hostname } from "os";
3
3
  import { basename, join, dirname, relative, resolve } from "path";
4
- import {
4
+ import {
5
5
  toSlug, toSnakeCaseKey, requireNonEmptySlug, toRepoRelativePath, toFileUri, inferRefTitleAndTopic, resolveReferencedTicketPath, toPosixPath, stringifyFrontMatter,
6
6
  resolveDocsLanguage, inferDocsLanguageFromText, normalizeDocsLanguage, isMcpActive, withReadline, parseFrontMatter,
7
7
  AGENT_ROOT_DIR, TICKET_SUBDIR, TICKET_DIR_NAME, TICKET_INDEX_FILENAME, WORKFLOW_MODE_EXECUTE,
8
8
  detectConsumerTicketDir, resolveConsumerTicketRoot, loadInitConfig, computeTicketPath, normalizeWorkflowMode
9
9
  } from "./cli-utils.mjs";
10
+ import {
11
+ readTicketDocument,
12
+ resolveTicketEntryOrComputedPath,
13
+ resolveTicketKnowledgeDir,
14
+ resolveTicketReportDir
15
+ } from "./cli-ticket-command-shared.mjs";
10
16
  import { readTicketIndexJson, writeTicketIndexJson, syncActiveTicketId, generateTicketId, syncToPipeline } from "./cli-ticket-index.mjs";
11
17
  import { appendTicketEntry, rebuildTicketIndexFromTopicFilesIfNeeded, updateTicketEntryStatus } from "./cli-ticket-parser.mjs";
12
18
  import { appendInternalWorkflowEvent, buildTelemetrySummary, getTelemetryCompactSummary } from "./cli-telemetry-commands.mjs";
@@ -250,25 +256,30 @@ function hasSubstantiveSectionContent(text, scaffolds = []) {
250
256
  return !scaffolds.some(phrase => normalized.includes(phrase));
251
257
  }
252
258
 
253
- function getMarkerBody(text, marker, nextMarkers) {
254
- const lines = String(text || "").split("\n");
255
- const start = lines.findIndex(line => line.includes(marker));
256
- if (start < 0) return null;
259
+ function hasApcMarker(text, marker) {
260
+ const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
261
+ const markerPattern = new RegExp(`^\\s*(?:#{1,6}\\s+)?${escapedMarker}(?:\\s|$)`);
262
+ return String(text || "").split("\n").some(line => markerPattern.test(line));
263
+ }
257
264
 
258
- const body = [];
259
- for (const line of lines.slice(start + 1)) {
260
- if (nextMarkers.some(nextMarker => line.includes(nextMarker))) break;
261
- body.push(line);
262
- }
263
- return body.join("\n").trim();
265
+ function getApcContentWithoutMarkers(text) {
266
+ return String(text || "")
267
+ .split("\n")
268
+ .map(line => {
269
+ for (const { marker } of REQUIRED_APC_MARKERS) {
270
+ const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
271
+ const markerPattern = new RegExp(`^\\s*(?:#{1,6}\\s+)?${escapedMarker}\\s*`);
272
+ if (markerPattern.test(line)) return line.replace(markerPattern, "");
273
+ }
274
+ return line;
275
+ })
276
+ .join("\n")
277
+ .trim();
264
278
  }
265
279
 
266
280
  function getMissingApcFields(text) {
267
281
  return REQUIRED_APC_MARKERS
268
- .filter(({ marker }, index) => {
269
- const nextMarkers = REQUIRED_APC_MARKERS.slice(index + 1).map(item => item.marker);
270
- return !hasSubstantiveSectionContent(getMarkerBody(text, marker, nextMarkers), []);
271
- })
282
+ .filter(({ marker }) => !hasApcMarker(text, marker))
272
283
  .map(field => field.name);
273
284
  }
274
285
 
@@ -288,6 +299,9 @@ function getPhase1PlanBodyReasons(body) {
288
299
  for (const field of getMissingApcFields(apcSection)) {
289
300
  reasons.push(`apc_${field}_missing`);
290
301
  }
302
+ if (!hasSubstantiveSectionContent(getApcContentWithoutMarkers(apcSection), [])) {
303
+ reasons.push("apc_content_incomplete");
304
+ }
291
305
  }
292
306
 
293
307
  for (const sectionName of REQUIRED_PHASE1_DATA_SECTIONS) {
@@ -312,7 +326,7 @@ function buildPlanBodyRequiredMessage(reasons = []) {
312
326
  "Use the AGENTS.md self-serve recipe: do not ask the user, call help, or search for templates.",
313
327
  "Run with stdin: `deuk-agent-flow ticket create --topic <topic> --summary \"<summary>\" --plan-body-file - --non-interactive`.",
314
328
  "Use these exact H2 headings: `## Agent Permission Contract (APC)`, `## Compact Plan`, `## Problem Analysis`, `## Source Observations`, `## Cause Hypotheses`, `## Improvement Direction`, `## Audit Evidence`.",
315
- "Under `## Agent Permission Contract (APC)`, include `[BOUNDARY]`, `[CONTRACT]`, and `[PATCH PLAN]`.",
329
+ "Under `## Agent Permission Contract (APC)`, use `### [BOUNDARY]`, `### [CONTRACT]`, and `### [PATCH PLAN]`; write each body on following lines.",
316
330
  "If a scratch plan-body file is unavoidable, keep it outside the workspace, delete it after create, and never present it as a ticket artifact.",
317
331
  "Do not rely on template defaults or auto-generated filler text for Phase 1 ticket content."
318
332
  ].join("\n");
@@ -757,8 +771,13 @@ function rollbackTicketLifecycleArtifacts(cwd, previousIndex, previousBody, absP
757
771
  }
758
772
 
759
773
  function getPhase1IncompleteReasonsFromBody(body) {
760
- parseFrontMatter(body);
761
- return [];
774
+ let parsed = { meta: {}, content: "" };
775
+ try {
776
+ parsed = parseFrontMatter(body);
777
+ } catch (err) {
778
+ return ["frontmatter_parse_failed"];
779
+ }
780
+ return getPhase1PlanBodyReasons(normalizePhase1PlanBodyHeadings(parsed.content || ""));
762
781
  }
763
782
 
764
783
  function getPhase1IncompleteReasons(cwd, absPath) {
@@ -981,18 +1000,53 @@ function buildOpenTicketLimitError(indexJson) {
981
1000
  return lines.join("\n");
982
1001
  }
983
1002
 
1003
+ function topicKeysForEntry(entry = {}) {
1004
+ const keys = [entry.topic, entry.id];
1005
+ try {
1006
+ if (entry.title) keys.push(toSlug(entry.title));
1007
+ } catch {
1008
+ // Non-ASCII or otherwise unsluggable titles are not duplicate keys.
1009
+ }
1010
+ return keys
1011
+ .map(value => String(value || "").toLowerCase())
1012
+ .filter(Boolean);
1013
+ }
1014
+
1015
+ function findReusableCompletedTicket(indexJson, topic, opts = {}) {
1016
+ const key = String(topic || "").toLowerCase();
1017
+ if (!key) return null;
1018
+ return filterTicketEntries(indexJson.entries, opts)
1019
+ .filter(entry => !OPEN_TICKET_STATUSES.has(String(entry.status || "open").toLowerCase()))
1020
+ .sort((a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || "")))
1021
+ .find(entry => topicKeysForEntry(entry).includes(key)) || null;
1022
+ }
1023
+
1024
+ function buildReusableTicketCreateError(entry, topic) {
1025
+ const id = entry?.id || entry?.topic || topic;
1026
+ const status = entry?.status || "closed";
1027
+ return [
1028
+ `[DUPLICATE TICKET BLOCKED] A ${status} ticket already matches "${topic}".`,
1029
+ `Existing ticket: ${id}`,
1030
+ "Do not guess .deuk-agent/tickets/sub paths for closed or archived tickets.",
1031
+ `Use: deuk-agent-flow ticket status --topic ${id} --status-detail --non-interactive`,
1032
+ `Or: deuk-agent-flow ticket use --topic ${id} --non-interactive`,
1033
+ "Create a new ticket only when the scope is genuinely different; use a distinct topic for that scope."
1034
+ ].join("\n");
1035
+ }
1036
+
984
1037
  function resolveArchiveReport(cwd, fileName, report) {
985
1038
  if (report) return resolve(cwd, report);
986
1039
 
987
- const reportDir = join(cwd, AGENT_ROOT_DIR, "docs", "plan");
1040
+ const reportDir = resolveTicketReportDir(cwd);
988
1041
  const potentialReport = fileName.replace(/\.md$/i, "-report.md");
989
1042
  const potentialPath = join(reportDir, potentialReport);
990
1043
  return existsSync(potentialPath) ? potentialPath : null;
991
1044
  }
992
1045
 
993
1046
  function archiveTicketEntry({ cwd, ticketDir, indexJson, found, opts = {}, report }) {
994
- const absPath = join(cwd, found.path);
995
- const fileName = found.path.split(/[/\\]/).pop();
1047
+ const absPath = resolveTicketEntryOrComputedPath(cwd, found);
1048
+ const fileName = (found.path || computeTicketPath(found)).split(/[/\\]/).pop();
1049
+ const resolvedRelPath = found.path || computeTicketPath(found);
996
1050
  if (!existsSync(absPath)) {
997
1051
  const archivedAbsPath = findExistingArchivedTicketPath(ticketDir, found, fileName);
998
1052
  if (archivedAbsPath) {
@@ -1027,7 +1081,7 @@ function archiveTicketEntry({ cwd, ticketDir, indexJson, found, opts = {}, repor
1027
1081
  }
1028
1082
  return { id: found.id, path: archivedRelativePath, normalized: true };
1029
1083
  }
1030
- throw new Error("ticket archive: file not found " + found.path);
1084
+ throw new Error("ticket archive: file not found " + resolvedRelPath);
1031
1085
  }
1032
1086
 
1033
1087
  const originalBody = readFileSync(absPath, "utf8");
@@ -1039,11 +1093,11 @@ function archiveTicketEntry({ cwd, ticketDir, indexJson, found, opts = {}, repor
1039
1093
  const reportSrc = resolveArchiveReport(cwd, fileName, report);
1040
1094
  let reportDest = null;
1041
1095
 
1042
- if (reportSrc) {
1043
- if (!existsSync(reportSrc)) {
1044
- throw new Error("ticket archive: report file not found " + report);
1045
- }
1046
- const reportDir = join(cwd, AGENT_ROOT_DIR, "docs", "plan");
1096
+ if (reportSrc) {
1097
+ if (!existsSync(reportSrc)) {
1098
+ throw new Error("ticket archive: report file not found " + report);
1099
+ }
1100
+ const reportDir = resolveTicketReportDir(cwd);
1047
1101
  if (!opts.dryRun) mkdirSync(reportDir, { recursive: true });
1048
1102
 
1049
1103
  const reportBaseName = fileName.replace(/\.md$/i, "-report.md");
@@ -1213,20 +1267,25 @@ export async function runTicketCreate(opts) {
1213
1267
  }
1214
1268
 
1215
1269
  const indexJson = readTicketIndexJson(opts.cwd);
1270
+ const reusableTicket = findReusableCompletedTicket(indexJson, finalTopic, opts);
1271
+ if (reusableTicket) {
1272
+ throw new Error(buildReusableTicketCreateError(reusableTicket, finalTopic));
1273
+ }
1216
1274
 
1217
1275
  // Smart close: check previous active ticket's completion state before deciding
1218
1276
  const activeId = indexJson.activeTicketId;
1219
1277
  if (activeId) {
1220
1278
  const activeEntry = indexJson.entries.find(e => e.id === activeId && (e.status === "open" || e.status === "active"));
1221
1279
  if (activeEntry) {
1222
- const absPath = join(opts.cwd, activeEntry.path);
1280
+ const activeDoc = readTicketDocument(opts.cwd, activeEntry, { action: "ticket create", requireExists: false });
1281
+ const absPath = activeDoc.absPath;
1223
1282
  let shouldClose = false;
1224
1283
  let reason = "";
1225
1284
 
1226
- if (existsSync(absPath)) {
1285
+ if (activeDoc.exists) {
1227
1286
  try {
1228
- const body = readFileSync(absPath, "utf8");
1229
- const { meta, content } = parseFrontMatter(body);
1287
+ const body = activeDoc.body;
1288
+ const { meta, content } = activeDoc;
1230
1289
 
1231
1290
  // Count checklist items
1232
1291
  const checked = (content.match(/- \[x\]/gi) || []).length;
@@ -1283,7 +1342,7 @@ export async function runTicketCreate(opts) {
1283
1342
 
1284
1343
 
1285
1344
 
1286
- const ticketId = generateTicketId(finalTopic, indexJson.entries);
1345
+ const ticketId = generateTicketId(finalTopic, indexJson);
1287
1346
  const finalFileName = `${ticketId}.md`;
1288
1347
 
1289
1348
  const abs = join(ticketDir, group, finalFileName);
@@ -1480,11 +1539,10 @@ export async function runTicketStatus(opts) {
1480
1539
  const found = pickTicketEntry(opts, index);
1481
1540
  if (!found) throw new Error("ticket status: no matching ticket found");
1482
1541
 
1483
- const absPath = join(opts.cwd, found.path);
1484
- const fileMissing = !existsSync(absPath);
1485
- const body = fileMissing ? "" : readFileSync(absPath, "utf8");
1486
- const parsed = fileMissing ? { meta: {}, content: "" } : parseFrontMatter(body);
1487
- if (!fileMissing) assertTicketLifecycleProvenance(found, parsed.meta);
1542
+ const ticketDoc = readTicketDocument(opts.cwd, found, { action: "ticket status", requireExists: false });
1543
+ const absPath = ticketDoc.absPath;
1544
+ const parsed = { meta: ticketDoc.meta, content: ticketDoc.content };
1545
+ if (ticketDoc.exists) assertTicketLifecycleProvenance(found, parsed.meta);
1488
1546
  const phase = Number(parsed.meta.phase || 1);
1489
1547
  const incompleteReasons = getPhase1IncompleteReasons(opts.cwd, absPath);
1490
1548
  const derivedStatus = incompleteReasons.length > 0 && phase === 1
@@ -1494,7 +1552,7 @@ export async function runTicketStatus(opts) {
1494
1552
  const out = {
1495
1553
  id: found.id,
1496
1554
  title: found.title,
1497
- path: found.path,
1555
+ path: toRepoRelativePath(opts.cwd, ticketDoc.absPath),
1498
1556
  phase,
1499
1557
  status: derivedStatus,
1500
1558
  summary: parsed.meta.summary || null,
@@ -1538,16 +1596,15 @@ export async function runTicketGuard(opts) {
1538
1596
  throw new Error("ticket guard: no matching durable ticket found; do not call set_workflow_context.");
1539
1597
  }
1540
1598
 
1541
- const absPath = join(opts.cwd, found.path);
1542
- if (!existsSync(absPath)) {
1599
+ const ticketDoc = readTicketDocument(opts.cwd, found, { action: "ticket guard", requireExists: false });
1600
+ if (!ticketDoc.exists) {
1543
1601
  throw new Error(`ticket guard: durable ticket file missing for ${found.id || found.topic}; do not call set_workflow_context.`);
1544
1602
  }
1603
+ const { meta: parsedMeta } = ticketDoc;
1604
+ const absPath = ticketDoc.absPath;
1605
+ assertTicketLifecycleProvenance(found, parsedMeta);
1545
1606
 
1546
- const body = readFileSync(absPath, "utf8");
1547
- const parsed = parseFrontMatter(body);
1548
- assertTicketLifecycleProvenance(found, parsed.meta);
1549
-
1550
- const phase = Number(parsed.meta.phase || 1);
1607
+ const phase = Number(parsedMeta.phase || 1);
1551
1608
  const reasons = getPhase1IncompleteReasons(opts.cwd, absPath);
1552
1609
  if (phase === 1 && reasons.length > 0) {
1553
1610
  throw new Error(`[VALIDATION FAILED] ticket guard rejected incomplete Phase 1 for ${found.id || found.topic}: ${reasons.join(", ")}. Do not call set_workflow_context until the durable ticket is complete.`);
@@ -1566,8 +1623,8 @@ export async function runTicketGuard(opts) {
1566
1623
  id: found.id,
1567
1624
  topic: found.topic,
1568
1625
  phase,
1569
- status: parsed.meta.status || found.status || "open",
1570
- path: found.path
1626
+ status: parsedMeta.status || found.status || "open",
1627
+ path: toRepoRelativePath(opts.cwd, absPath)
1571
1628
  };
1572
1629
 
1573
1630
  if (opts.json) {
@@ -1585,10 +1642,10 @@ export async function runTicketHandoff(opts) {
1585
1642
  const current = pickTicketEntry(opts, index);
1586
1643
  if (!current) throw new Error("ticket handoff: no matching ticket found");
1587
1644
 
1588
- const currentAbs = join(opts.cwd, current.path);
1589
- const currentMissing = !existsSync(currentAbs);
1590
- const currentBody = currentMissing ? "" : readFileSync(currentAbs, "utf8");
1591
- const currentParsed = currentMissing ? { meta: {}, content: "" } : parseFrontMatter(currentBody);
1645
+ const currentDoc = readTicketDocument(opts.cwd, current, { action: "ticket handoff", requireExists: false });
1646
+ const currentAbs = currentDoc.absPath;
1647
+ const currentMissing = !currentDoc.exists;
1648
+ const currentParsed = currentMissing ? { meta: {}, content: "" } : { meta: currentDoc.meta, content: currentDoc.content };
1592
1649
  const currentPhase = Number(currentParsed.meta.phase || 1);
1593
1650
  const currentReasons = currentMissing ? ["ticket_file_missing"] : getPhase1IncompleteReasons(opts.cwd, currentAbs);
1594
1651
  const currentStatus = currentReasons.length > 0 && currentPhase === 1
@@ -1686,9 +1743,7 @@ export async function runTicketEvidenceCheck(opts) {
1686
1743
  throw new Error("ticket evidence: no matching ticket found.");
1687
1744
  }
1688
1745
 
1689
- const absPath = join(opts.cwd, target.path);
1690
- if (!existsSync(absPath)) throw new Error("Ticket file not found: " + target.path);
1691
- const { meta, content } = parseFrontMatter(readFileSync(absPath, "utf8"));
1746
+ const { absPath, meta, content } = readTicketDocument(opts.cwd, target, { action: "ticket evidence", requireExists: true });
1692
1747
  const result = getClaimEvidenceResult(target, meta, content, opts.claim);
1693
1748
  const implementationGuard = getImplementationClaimGuardResult(opts.cwd, { claim: opts.claim, content, changedFiles: opts.changedFiles });
1694
1749
 
@@ -1717,9 +1772,7 @@ export async function runTicketEvidenceReport(opts) {
1717
1772
  throw new Error("ticket report: no matching ticket found.");
1718
1773
  }
1719
1774
 
1720
- const absPath = join(opts.cwd, target.path);
1721
- if (!existsSync(absPath)) throw new Error("Ticket file not found: " + target.path);
1722
- const { meta, content } = parseFrontMatter(readFileSync(absPath, "utf8"));
1775
+ const { absPath, meta, content } = readTicketDocument(opts.cwd, target, { action: "ticket report", requireExists: true });
1723
1776
  const result = getClaimEvidenceResult(target, meta, content, opts.claim);
1724
1777
  const implementationGuard = getImplementationClaimGuardResult(opts.cwd, { claim: opts.claim, content, changedFiles: opts.changedFiles });
1725
1778
 
@@ -1772,10 +1825,8 @@ export async function runTicketClose(opts) {
1772
1825
  throw new Error("No matching ticket found to update status");
1773
1826
  }
1774
1827
 
1775
- const abs = join(opts.cwd, targetEntry.path);
1776
- if (!existsSync(abs)) throw new Error("Ticket file not found: " + targetEntry.path);
1777
- const previousBody = readFileSync(abs, "utf8");
1778
- const parsedForClose = parseFrontMatter(previousBody);
1828
+ const { absPath: abs, meta: closeMeta, body: previousBody, content: closeContent } = readTicketDocument(opts.cwd, targetEntry, { action: "ticket close", requireExists: true });
1829
+ const parsedForClose = { meta: closeMeta, content: closeContent };
1779
1830
  const closePlanningReasons = getCloseLifecycleReasons(parsedForClose.meta, parsedForClose.content);
1780
1831
  const implementationGuard = getImplementationClaimGuardResult(opts.cwd, { content: parsedForClose.content, changedFiles: opts.changedFiles });
1781
1832
  if (!implementationGuard.ok) {
@@ -1808,7 +1859,7 @@ export async function runTicketClose(opts) {
1808
1859
  syncActiveTicketId(opts.cwd);
1809
1860
  }
1810
1861
 
1811
- const finalPath = archiveResult?.path || entry.path;
1862
+ const finalPath = archiveResult?.path || computeTicketPath(entry);
1812
1863
  appendTelemetryEvent(opts.cwd, {
1813
1864
  event: "ticket_closed",
1814
1865
  action: "ticket-close",
@@ -1868,25 +1919,23 @@ export async function runTicketUse(opts) {
1868
1919
  throw new Error(buildUseNoMatchError(targetTopic, candidates));
1869
1920
  }
1870
1921
 
1871
- const foundAbsPath = join(opts.cwd, found.path);
1872
- if (!existsSync(foundAbsPath)) throw new Error("Ticket file not found: " + found.path);
1873
- const foundParsed = parseFrontMatter(readFileSync(foundAbsPath, "utf8"));
1874
- assertTicketLifecycleProvenance(found, foundParsed.meta);
1922
+ const foundDoc = readTicketDocument(opts.cwd, found, { action: "ticket use", requireExists: true });
1923
+ assertTicketLifecycleProvenance(found, foundDoc.meta);
1875
1924
 
1876
1925
  // Explicitly set activeTicketId to the selected ticket
1877
1926
  if (index.activeTicketId !== found.id) {
1878
1927
  writeTicketIndexJson(opts.cwd, { ...index, activeTicketId: found.id });
1879
1928
  }
1880
1929
 
1881
- const absPath = toPosixPath(join(opts.cwd, found.path));
1930
+ const absPath = toPosixPath(foundDoc.absPath);
1882
1931
  if (isCompactTicketOutput(opts) || opts.pathOnly) {
1883
1932
  printTicketSelectionLine(found.id, absPath, opts);
1884
1933
  } else {
1885
- const posixPath = toPosixPath(found.path);
1934
+ const posixPath = toRepoRelativePath(opts.cwd, foundDoc.absPath);
1886
1935
  console.log(`Active ticket: ${found.id}`);
1887
1936
  console.log(`Path: [${posixPath}](file://${absPath})`);
1888
1937
  printTicketStartLine(found.id, absPath);
1889
- if (opts.printContent) console.log("\n" + readFileSync(join(opts.cwd, found.path), "utf8"));
1938
+ if (opts.printContent) console.log("\n" + readFileSync(foundDoc.absPath, "utf8"));
1890
1939
  printUsageReminder(opts.cwd, opts);
1891
1940
  }
1892
1941
  }
@@ -1920,7 +1969,7 @@ function distillKnowledge(absPath, ticketId, cwd, sourceBody = null) {
1920
1969
  "Design Decisions",
1921
1970
  "Analysis & Constraints"
1922
1971
  ]);
1923
- const knowledgeDir = join(cwd, AGENT_ROOT_DIR, "knowledge");
1972
+ const knowledgeDir = resolveTicketKnowledgeDir(cwd);
1924
1973
  if (!existsSync(knowledgeDir)) mkdirSync(knowledgeDir, { recursive: true });
1925
1974
 
1926
1975
  const dest = join(knowledgeDir, `${ticketId}.json`);
@@ -2034,12 +2083,12 @@ export async function runTicketArchive(opts) {
2034
2083
  const found = pickTicketEntry(opts, indexJson);
2035
2084
  if (!found) throw new Error("ticket archive: no matching entry");
2036
2085
 
2037
- const fileName = found.path.split(/[/\\]/).pop();
2086
+ const fileName = (found.path || computeTicketPath(found)).split(/[/\\]/).pop();
2038
2087
 
2039
2088
  // Auto-search for report if not provided
2040
2089
  let report = opts.report;
2041
2090
  if (!opts.report) {
2042
- const reportDir = join(opts.cwd, AGENT_ROOT_DIR, "docs", "plan");
2091
+ const reportDir = resolveTicketReportDir(opts.cwd);
2043
2092
  const potentialReport = fileName.replace(/\.md$/i, "-report.md");
2044
2093
  const potentialPath = join(reportDir, potentialReport);
2045
2094
  if (existsSync(potentialPath)) {
@@ -2089,8 +2138,9 @@ export async function runTicketDiscard(opts) {
2089
2138
  const found = pickTicketEntry(opts, indexJson);
2090
2139
  if (!found) throw new Error("ticket discard: no matching entry");
2091
2140
 
2092
- const absPath = join(opts.cwd, found.path);
2093
- if (!existsSync(absPath)) throw new Error("Ticket file not found: " + found.path);
2141
+ const absPath = resolveTicketEntryOrComputedPath(opts.cwd, found);
2142
+ const relativePath = toRepoRelativePath(opts.cwd, absPath);
2143
+ if (!existsSync(absPath)) throw new Error("Ticket file not found: " + relativePath);
2094
2144
 
2095
2145
  const body = readFileSync(absPath, "utf8");
2096
2146
  const { meta } = parseFrontMatter(body);
@@ -2101,8 +2151,8 @@ export async function runTicketDiscard(opts) {
2101
2151
  }
2102
2152
 
2103
2153
  if (opts.dryRun) {
2104
- console.log(`ticket discard: would delete ${found.id || found.topic} (${found.path})`);
2105
- return { id: found.id, path: found.path, discarded: false };
2154
+ console.log(`ticket discard: would delete ${found.id || found.topic} (${relativePath})`);
2155
+ return { id: found.id, path: relativePath, discarded: false };
2106
2156
  }
2107
2157
 
2108
2158
  rmSync(absPath, { force: true });
@@ -2117,19 +2167,19 @@ export async function runTicketDiscard(opts) {
2117
2167
  event: "ticket_discarded",
2118
2168
  action: "ticket-discard",
2119
2169
  ticket: found.id || found.topic,
2120
- file: found.path,
2170
+ file: relativePath,
2121
2171
  phase,
2122
2172
  status: "discarded"
2123
2173
  });
2124
2174
  console.log(`ticket: discarded -> ${found.id || found.topic}`);
2125
- return { id: found.id, path: found.path, discarded: true };
2175
+ return { id: found.id, path: relativePath, discarded: true };
2126
2176
  }
2127
2177
 
2128
2178
  export async function runTicketReports(opts) {
2129
2179
  applyTicketRootContext(opts);
2130
2180
  const ticketDir = detectConsumerTicketDir(opts.cwd);
2131
2181
  if (!ticketDir) throw new Error("No ticket system found.");
2132
- const reportDir = join(opts.cwd, AGENT_ROOT_DIR, "docs", "plan");
2182
+ const reportDir = resolveTicketReportDir(opts.cwd);
2133
2183
  console.log("\n📄 Agent Reports:");
2134
2184
  if (!existsSync(reportDir)) {
2135
2185
  console.log(" No reports found.");
@@ -2164,16 +2214,15 @@ export async function runTicketReportAttach(opts) {
2164
2214
  const found = pickTicketEntry(opts, index);
2165
2215
  if (!found) throw new Error("ticket report attach: no matching ticket found");
2166
2216
 
2167
- const absTicketPath = join(opts.cwd, found.path);
2168
- if (!existsSync(absTicketPath)) throw new Error("Ticket file not found: " + found.path);
2217
+ const { absPath: absTicketPath } = readTicketDocument(opts.cwd, found, { action: "ticket report attach", requireExists: true });
2169
2218
 
2170
2219
  const reportSrc = resolve(opts.cwd, opts.report);
2171
2220
  if (!existsSync(reportSrc)) throw new Error("Report file not found: " + opts.report);
2172
2221
 
2173
- const reportDir = join(opts.cwd, AGENT_ROOT_DIR, "docs", "plan");
2222
+ const reportDir = resolveTicketReportDir(opts.cwd);
2174
2223
  if (!opts.dryRun) mkdirSync(reportDir, { recursive: true });
2175
2224
 
2176
- const ticketFileName = found.path.split(/[/\\]/).pop();
2225
+ const ticketFileName = (found.path || computeTicketPath(found)).split(/[/\\]/).pop();
2177
2226
  const reportBaseName = ticketFileName.replace(/\.md$/i, "-report.md");
2178
2227
  const reportDest = join(reportDir, reportBaseName);
2179
2228
 
@@ -2214,15 +2263,17 @@ export async function runTicketMove(opts) {
2214
2263
 
2215
2264
  if (!entry) throw new Error("No matching ticket found to move.");
2216
2265
 
2217
- const abs = join(opts.cwd, entry.path);
2218
- if (!existsSync(abs)) throw new Error("Ticket file not found: " + entry.path);
2266
+ const { absPath: abs, meta: parsedMeta, content } = readTicketDocument(opts.cwd, entry, { action: "ticket move", requireExists: true });
2219
2267
 
2220
2268
  const previousIndex = readTicketIndexJson(opts.cwd);
2221
2269
  const body = readFileSync(abs, "utf8");
2222
- const { meta, content } = parseFrontMatter(body);
2223
-
2224
- const currentPhase = meta.phase || 1;
2225
- let nextPhase = opts.next ? currentPhase + 1 : (opts.phase || currentPhase + 1);
2270
+ const currentPhase = Number(parsedMeta.phase || 1);
2271
+ const requestedPhase = Number(opts.phase);
2272
+ let nextPhase = opts.next
2273
+ ? currentPhase + 1
2274
+ : Number.isFinite(requestedPhase)
2275
+ ? requestedPhase
2276
+ : currentPhase + 1;
2226
2277
 
2227
2278
  if (currentPhase === 1 && nextPhase >= 2) {
2228
2279
  const reasons = getPhase1IncompleteReasons(opts.cwd, abs);
@@ -2238,22 +2289,22 @@ export async function runTicketMove(opts) {
2238
2289
  }
2239
2290
  }
2240
2291
 
2241
- meta.phase = nextPhase;
2292
+ parsedMeta.phase = nextPhase;
2242
2293
  if (nextPhase >= 4) {
2243
- meta.status = "closed";
2244
- } else if (nextPhase >= 2 && (!meta.status || meta.status === "open")) {
2245
- meta.status = "active";
2294
+ parsedMeta.status = "closed";
2295
+ } else if (nextPhase >= 2 && (!parsedMeta.status || parsedMeta.status === "open")) {
2296
+ parsedMeta.status = "active";
2246
2297
  }
2247
2298
 
2248
- const newBody = stringifyFrontMatter(meta, content);
2299
+ const newBody = stringifyFrontMatter(parsedMeta, content);
2249
2300
 
2250
2301
  try {
2251
2302
  writeFileSync(abs, newBody, "utf8");
2252
2303
 
2253
2304
  // Re-sync index to reflect the status change if any
2254
2305
  opts.topic = entry.topic;
2255
- if (meta.status !== entry.status) {
2256
- opts.status = meta.status;
2306
+ if (parsedMeta.status !== entry.status) {
2307
+ opts.status = parsedMeta.status;
2257
2308
  updateTicketEntryStatus(opts.cwd, opts);
2258
2309
  } else {
2259
2310
  rebuildTicketIndexFromTopicFilesIfNeeded(opts.cwd, { ...opts, force: true });
@@ -2269,11 +2320,11 @@ export async function runTicketMove(opts) {
2269
2320
  event: "ticket_phase_moved",
2270
2321
  action: "ticket-move",
2271
2322
  ticket: entry.id || entry.topic,
2272
- file: entry.path,
2323
+ file: computeTicketPath(entry),
2273
2324
  phase: nextPhase,
2274
- status: meta.status
2325
+ status: parsedMeta.status
2275
2326
  });
2276
- console.log(`ticket: moved -> ${entry.topic} is now in Phase ${nextPhase} (${meta.status})`);
2327
+ console.log(`ticket: moved -> ${entry.topic} is now in Phase ${nextPhase} (${parsedMeta.status})`);
2277
2328
  printUsageReminder(opts.cwd, opts);
2278
2329
  } catch (err) {
2279
2330
  rollbackTicketLifecycleArtifacts(opts.cwd, previousIndex, body, abs, opts);
@@ -2298,9 +2349,9 @@ export async function runTicketNext(opts) {
2298
2349
  .filter(entry => String(entry.status || "").toLowerCase() === "closed")
2299
2350
  .sort((a, b) => String(b.updatedAt || b.createdAt || "").localeCompare(String(a.updatedAt || a.createdAt || "")))[0];
2300
2351
  if (latestClosed) {
2301
- const latestClosedPath = join(opts.cwd, latestClosed.path || computeTicketPath(latestClosed));
2352
+ const latestClosedPath = resolveTicketEntryOrComputedPath(opts.cwd, latestClosed);
2302
2353
  if (existsSync(latestClosedPath)) {
2303
- const { content } = parseFrontMatter(readFileSync(latestClosedPath, "utf8"));
2354
+ const { content } = readTicketDocument(opts.cwd, latestClosed, { action: "ticket next", computePath: true });
2304
2355
  if (followUpDecisionMeansNoFollowUp(content)) {
2305
2356
  const absPath = toPosixPath(latestClosedPath);
2306
2357
  if (opts.pathOnly) {
@@ -2308,7 +2359,7 @@ export async function runTicketNext(opts) {
2308
2359
  } else if (isCompactTicketOutput(opts)) {
2309
2360
  console.log(`no-follow-up:${latestClosed.id}`);
2310
2361
  } else {
2311
- const posixPath = toPosixPath(latestClosed.path || computeTicketPath(latestClosed));
2362
+ const posixPath = toPosixPath(toRepoRelativePath(opts.cwd, latestClosedPath));
2312
2363
  console.log(`No follow-up required after ${latestClosed.id}`);
2313
2364
  console.log(`Path: [${posixPath}](file://${absPath})`);
2314
2365
  }
@@ -2323,23 +2374,23 @@ export async function runTicketNext(opts) {
2323
2374
  writeTicketIndexJson(opts.cwd, { ...index, activeTicketId: found.id });
2324
2375
  }
2325
2376
 
2326
- const absPath = toPosixPath(join(opts.cwd, found.path));
2377
+ const foundAbs = readTicketDocument(opts.cwd, found, { action: "ticket next", requireExists: true });
2378
+ const absPath = toPosixPath(foundAbs.absPath);
2327
2379
  if (isCompactTicketOutput(opts) || opts.pathOnly) {
2328
2380
  printTicketSelectionLine(found.id, absPath, opts);
2329
2381
  } else {
2330
- const posixPath = toPosixPath(found.path);
2382
+ const posixPath = toPosixPath(toRepoRelativePath(opts.cwd, foundAbs.absPath));
2331
2383
  console.log(`Next ticket: ${found.id}`);
2332
2384
  console.log(`Path: [${posixPath}](file://${absPath})`);
2333
- if (opts.printContent) console.log("\n" + readFileSync(join(opts.cwd, found.path), "utf8"));
2385
+ if (opts.printContent) console.log("\n" + readFileSync(foundAbs.absPath, "utf8"));
2334
2386
  }
2335
2387
  }
2336
2388
 
2337
2389
  function isTicketNextRunnableCandidate(cwd, entry) {
2338
- const entryPath = entry.path || computeTicketPath(entry);
2339
- const absPath = join(cwd, entryPath);
2340
- if (!existsSync(absPath)) return true;
2390
+ const ticketDoc = readTicketDocument(cwd, entry, { action: "ticket next", computePath: true, requireExists: false });
2391
+ if (!ticketDoc.exists) return true;
2341
2392
 
2342
- const { meta } = parseFrontMatter(readFileSync(absPath, "utf8"));
2393
+ const { meta } = ticketDoc;
2343
2394
  const lifecycleSource = String(meta.lifecycleSource || meta.ticketLifecycleSource || "").trim();
2344
2395
  return lifecycleSource === "ticket-create";
2345
2396
  }
@@ -2378,11 +2429,7 @@ export async function runTicketHotfix(opts) {
2378
2429
 
2379
2430
  if (!entry) throw new Error("No matching ticket found for hotfix.");
2380
2431
 
2381
- const abs = join(opts.cwd, entry.path);
2382
- if (!existsSync(abs)) throw new Error("Ticket file not found: " + entry.path);
2383
-
2384
- const body = readFileSync(abs, "utf8");
2385
- const { meta, content } = parseFrontMatter(body);
2432
+ const { absPath: abs, meta, content } = readTicketDocument(opts.cwd, entry, { action: "ticket hotfix", requireExists: true });
2386
2433
 
2387
2434
  // Force phase 2 and active status, bypassing APC checks
2388
2435
  meta.phase = 2;