@phren/cli 0.0.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 (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/mcp/dist/capabilities/cli.js +61 -0
  4. package/mcp/dist/capabilities/index.js +15 -0
  5. package/mcp/dist/capabilities/mcp.js +61 -0
  6. package/mcp/dist/capabilities/types.js +57 -0
  7. package/mcp/dist/capabilities/vscode.js +61 -0
  8. package/mcp/dist/capabilities/web-ui.js +61 -0
  9. package/mcp/dist/cli-actions.js +302 -0
  10. package/mcp/dist/cli-config.js +580 -0
  11. package/mcp/dist/cli-extract.js +305 -0
  12. package/mcp/dist/cli-govern.js +371 -0
  13. package/mcp/dist/cli-graph.js +169 -0
  14. package/mcp/dist/cli-hooks-citations.js +44 -0
  15. package/mcp/dist/cli-hooks-context.js +56 -0
  16. package/mcp/dist/cli-hooks-globs.js +83 -0
  17. package/mcp/dist/cli-hooks-output.js +130 -0
  18. package/mcp/dist/cli-hooks-retrieval.js +2 -0
  19. package/mcp/dist/cli-hooks-session.js +1402 -0
  20. package/mcp/dist/cli-hooks.js +350 -0
  21. package/mcp/dist/cli-namespaces.js +989 -0
  22. package/mcp/dist/cli-ops.js +253 -0
  23. package/mcp/dist/cli-search.js +407 -0
  24. package/mcp/dist/cli.js +108 -0
  25. package/mcp/dist/content-archive.js +278 -0
  26. package/mcp/dist/content-citation.js +391 -0
  27. package/mcp/dist/content-dedup.js +622 -0
  28. package/mcp/dist/content-learning.js +472 -0
  29. package/mcp/dist/content-metadata.js +186 -0
  30. package/mcp/dist/content-validate.js +462 -0
  31. package/mcp/dist/core-finding.js +54 -0
  32. package/mcp/dist/core-project.js +36 -0
  33. package/mcp/dist/core-search.js +50 -0
  34. package/mcp/dist/data-access.js +400 -0
  35. package/mcp/dist/data-tasks.js +821 -0
  36. package/mcp/dist/embedding.js +344 -0
  37. package/mcp/dist/entrypoint.js +387 -0
  38. package/mcp/dist/finding-context.js +172 -0
  39. package/mcp/dist/finding-impact.js +181 -0
  40. package/mcp/dist/finding-journal.js +122 -0
  41. package/mcp/dist/finding-lifecycle.js +259 -0
  42. package/mcp/dist/governance-audit.js +22 -0
  43. package/mcp/dist/governance-locks.js +96 -0
  44. package/mcp/dist/governance-policy.js +648 -0
  45. package/mcp/dist/governance-scores.js +355 -0
  46. package/mcp/dist/hooks.js +449 -0
  47. package/mcp/dist/impact-scoring.js +22 -0
  48. package/mcp/dist/index-query.js +168 -0
  49. package/mcp/dist/index.js +205 -0
  50. package/mcp/dist/init-config.js +336 -0
  51. package/mcp/dist/init-preferences.js +62 -0
  52. package/mcp/dist/init-setup.js +1305 -0
  53. package/mcp/dist/init-shared.js +29 -0
  54. package/mcp/dist/init.js +1730 -0
  55. package/mcp/dist/link-checksums.js +62 -0
  56. package/mcp/dist/link-context.js +257 -0
  57. package/mcp/dist/link-doctor.js +591 -0
  58. package/mcp/dist/link-skills.js +212 -0
  59. package/mcp/dist/link.js +596 -0
  60. package/mcp/dist/logger.js +15 -0
  61. package/mcp/dist/machine-identity.js +38 -0
  62. package/mcp/dist/mcp-config.js +254 -0
  63. package/mcp/dist/mcp-data.js +315 -0
  64. package/mcp/dist/mcp-extract-facts.js +78 -0
  65. package/mcp/dist/mcp-extract.js +133 -0
  66. package/mcp/dist/mcp-finding.js +557 -0
  67. package/mcp/dist/mcp-graph.js +339 -0
  68. package/mcp/dist/mcp-hooks.js +256 -0
  69. package/mcp/dist/mcp-memory.js +58 -0
  70. package/mcp/dist/mcp-ops.js +328 -0
  71. package/mcp/dist/mcp-search.js +628 -0
  72. package/mcp/dist/mcp-session.js +651 -0
  73. package/mcp/dist/mcp-skills.js +189 -0
  74. package/mcp/dist/mcp-tasks.js +551 -0
  75. package/mcp/dist/mcp-types.js +7 -0
  76. package/mcp/dist/memory-ui-assets.js +6 -0
  77. package/mcp/dist/memory-ui-data.js +513 -0
  78. package/mcp/dist/memory-ui-graph.js +1910 -0
  79. package/mcp/dist/memory-ui-page.js +353 -0
  80. package/mcp/dist/memory-ui-scripts.js +1387 -0
  81. package/mcp/dist/memory-ui-server.js +1218 -0
  82. package/mcp/dist/memory-ui-styles.js +555 -0
  83. package/mcp/dist/memory-ui.js +9 -0
  84. package/mcp/dist/package-metadata.js +13 -0
  85. package/mcp/dist/phren-art.js +52 -0
  86. package/mcp/dist/phren-core.js +108 -0
  87. package/mcp/dist/phren-dotenv.js +67 -0
  88. package/mcp/dist/phren-paths.js +476 -0
  89. package/mcp/dist/proactivity.js +172 -0
  90. package/mcp/dist/profile-store.js +228 -0
  91. package/mcp/dist/project-config.js +85 -0
  92. package/mcp/dist/project-locator.js +25 -0
  93. package/mcp/dist/project-topics.js +1134 -0
  94. package/mcp/dist/provider-adapters.js +176 -0
  95. package/mcp/dist/runtime-profile.js +18 -0
  96. package/mcp/dist/session-checkpoints.js +131 -0
  97. package/mcp/dist/session-utils.js +68 -0
  98. package/mcp/dist/shared-content.js +8 -0
  99. package/mcp/dist/shared-embedding-cache.js +143 -0
  100. package/mcp/dist/shared-fragment-graph.js +456 -0
  101. package/mcp/dist/shared-governance.js +4 -0
  102. package/mcp/dist/shared-index.js +1334 -0
  103. package/mcp/dist/shared-ollama.js +192 -0
  104. package/mcp/dist/shared-paths.js +1 -0
  105. package/mcp/dist/shared-retrieval.js +796 -0
  106. package/mcp/dist/shared-search-fallback.js +375 -0
  107. package/mcp/dist/shared-sqljs.js +42 -0
  108. package/mcp/dist/shared-stemmer.js +171 -0
  109. package/mcp/dist/shared-vector-index.js +199 -0
  110. package/mcp/dist/shared.js +114 -0
  111. package/mcp/dist/shell-entry.js +209 -0
  112. package/mcp/dist/shell-input.js +943 -0
  113. package/mcp/dist/shell-palette.js +119 -0
  114. package/mcp/dist/shell-render.js +252 -0
  115. package/mcp/dist/shell-state-store.js +81 -0
  116. package/mcp/dist/shell-types.js +13 -0
  117. package/mcp/dist/shell-view-list.js +14 -0
  118. package/mcp/dist/shell-view.js +707 -0
  119. package/mcp/dist/shell.js +352 -0
  120. package/mcp/dist/skill-files.js +117 -0
  121. package/mcp/dist/skill-registry.js +279 -0
  122. package/mcp/dist/skill-state.js +28 -0
  123. package/mcp/dist/startup-embedding.js +57 -0
  124. package/mcp/dist/status.js +323 -0
  125. package/mcp/dist/synonyms.json +670 -0
  126. package/mcp/dist/task-hygiene.js +251 -0
  127. package/mcp/dist/task-lifecycle.js +347 -0
  128. package/mcp/dist/tasks-github.js +76 -0
  129. package/mcp/dist/telemetry.js +165 -0
  130. package/mcp/dist/test-global-setup.js +37 -0
  131. package/mcp/dist/tool-registry.js +104 -0
  132. package/mcp/dist/update.js +97 -0
  133. package/mcp/dist/utils.js +543 -0
  134. package/package.json +67 -0
  135. package/skills/README.md +7 -0
  136. package/skills/consolidate/SKILL.md +152 -0
  137. package/skills/discover/SKILL.md +175 -0
  138. package/skills/init/SKILL.md +216 -0
  139. package/skills/profiles/SKILL.md +121 -0
  140. package/skills/sync/SKILL.md +261 -0
  141. package/starter/README.md +74 -0
  142. package/starter/global/CLAUDE.md +89 -0
  143. package/starter/global/skills/humanize.md +30 -0
  144. package/starter/global/skills/pipeline.md +35 -0
  145. package/starter/global/skills/release.md +35 -0
  146. package/starter/machines.yaml +8 -0
  147. package/starter/my-api/.claude/skills/README.md +7 -0
  148. package/starter/my-api/CLAUDE.md +33 -0
  149. package/starter/my-api/FINDINGS.md +9 -0
  150. package/starter/my-api/summary.md +7 -0
  151. package/starter/my-api/tasks.md +7 -0
  152. package/starter/my-first-project/.claude/skills/README.md +7 -0
  153. package/starter/my-first-project/CLAUDE.md +49 -0
  154. package/starter/my-first-project/FINDINGS.md +24 -0
  155. package/starter/my-first-project/summary.md +11 -0
  156. package/starter/my-first-project/tasks.md +25 -0
  157. package/starter/my-frontend/.claude/skills/README.md +7 -0
  158. package/starter/my-frontend/CLAUDE.md +33 -0
  159. package/starter/my-frontend/FINDINGS.md +9 -0
  160. package/starter/my-frontend/summary.md +7 -0
  161. package/starter/my-frontend/tasks.md +7 -0
  162. package/starter/profiles/default.yaml +4 -0
  163. package/starter/profiles/personal.yaml +4 -0
  164. package/starter/profiles/work.yaml +4 -0
  165. package/starter/templates/README.md +7 -0
  166. package/starter/templates/frontend/CLAUDE.md +23 -0
  167. package/starter/templates/frontend/FINDINGS.md +7 -0
  168. package/starter/templates/frontend/reference/README.md +4 -0
  169. package/starter/templates/frontend/summary.md +7 -0
  170. package/starter/templates/frontend/tasks.md +11 -0
  171. package/starter/templates/library/CLAUDE.md +22 -0
  172. package/starter/templates/library/FINDINGS.md +7 -0
  173. package/starter/templates/library/reference/README.md +4 -0
  174. package/starter/templates/library/summary.md +7 -0
  175. package/starter/templates/library/tasks.md +11 -0
  176. package/starter/templates/monorepo/CLAUDE.md +21 -0
  177. package/starter/templates/monorepo/FINDINGS.md +7 -0
  178. package/starter/templates/monorepo/reference/README.md +4 -0
  179. package/starter/templates/monorepo/summary.md +7 -0
  180. package/starter/templates/monorepo/tasks.md +11 -0
  181. package/starter/templates/python-project/CLAUDE.md +21 -0
  182. package/starter/templates/python-project/FINDINGS.md +7 -0
  183. package/starter/templates/python-project/reference/README.md +4 -0
  184. package/starter/templates/python-project/summary.md +7 -0
  185. package/starter/templates/python-project/tasks.md +10 -0
@@ -0,0 +1,821 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { randomBytes, randomUUID } from "crypto";
4
+ import { phrenErr, PhrenError, phrenOk, forwardErr, getProjectDirs, } from "./shared.js";
5
+ import { withFileLock as withFileLockRaw } from "./shared-governance.js";
6
+ import { validateTaskFormat } from "./shared-content.js";
7
+ import { isValidProjectName, safeProjectPath, errorMessage } from "./utils.js";
8
+ function withSafeLock(filePath, fn) {
9
+ try {
10
+ return withFileLockRaw(filePath, fn);
11
+ }
12
+ catch (err) {
13
+ const msg = errorMessage(err);
14
+ if (msg.includes("could not acquire lock")) {
15
+ return phrenErr(`Could not acquire write lock for "${path.basename(filePath)}". Another write may be in progress; please retry.`, PhrenError.LOCK_TIMEOUT);
16
+ }
17
+ throw err;
18
+ }
19
+ }
20
+ const ACTIVE_HEADINGS = new Set(["active", "in progress", "in-progress", "current", "wip"]);
21
+ const QUEUE_HEADINGS = new Set(["queue", "queued", "task", "todo", "upcoming", "next"]);
22
+ const DONE_HEADINGS = new Set(["done", "completed", "finished", "archived"]);
23
+ export const TASKS_FILENAME = "tasks.md";
24
+ export const TASK_FILE_ALIASES = [TASKS_FILENAME];
25
+ const TASK_SECTIONS = ["Active", "Queue", "Done"];
26
+ function normalizePriority(text) {
27
+ const m = text.replace(/\s*\[pinned\]/gi, "").match(/\[(high|medium|low)\]\s*$/i);
28
+ if (!m)
29
+ return undefined;
30
+ return m[1].toLowerCase();
31
+ }
32
+ function detectPinned(text) {
33
+ return /\[pinned\]/i.test(text);
34
+ }
35
+ function stripPinnedTag(text) {
36
+ return text.replace(/\s*\[pinned\]/gi, "").trim();
37
+ }
38
+ function stripBulletPrefix(line) {
39
+ const checked = /^-\s*\[[xX]\]\s+/.test(line);
40
+ const body = line
41
+ .replace(/^-\s*\[[ xX]\]\s+/, "")
42
+ .replace(/^-\s+/, "")
43
+ .trim();
44
+ return { checked, body };
45
+ }
46
+ function parseGitHubIssueReference(raw) {
47
+ const trimmed = raw.trim();
48
+ if (!trimmed)
49
+ return {};
50
+ const urlMatch = trimmed.match(/https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/(\d+)(?:[?#][^\s]*)?/);
51
+ const issueMatch = trimmed.match(/#?(\d+)/);
52
+ const githubIssue = urlMatch
53
+ ? Number.parseInt(urlMatch[1], 10)
54
+ : issueMatch
55
+ ? Number.parseInt(issueMatch[1], 10)
56
+ : undefined;
57
+ const githubUrl = urlMatch ? urlMatch[0] : undefined;
58
+ return {
59
+ githubIssue: Number.isFinite(githubIssue) ? githubIssue : undefined,
60
+ githubUrl,
61
+ };
62
+ }
63
+ function isValidGitHubIssueUrl(raw) {
64
+ return Boolean(parseGitHubIssueReference(raw).githubUrl);
65
+ }
66
+ function formatGitHubIssueReference(item) {
67
+ if (!item.githubIssue && !item.githubUrl)
68
+ return undefined;
69
+ if (item.githubIssue && item.githubUrl)
70
+ return `#${item.githubIssue} ${item.githubUrl}`;
71
+ if (item.githubIssue)
72
+ return `#${item.githubIssue}`;
73
+ return item.githubUrl;
74
+ }
75
+ function parseContinuation(lines, idx) {
76
+ let context;
77
+ let githubIssue;
78
+ let githubUrl;
79
+ let linesToSkip = 0;
80
+ for (let cursor = idx + 1; cursor < lines.length; cursor++) {
81
+ const raw = lines[cursor];
82
+ if (!raw.startsWith(" "))
83
+ break;
84
+ const trimmed = raw.trim();
85
+ if (!trimmed) {
86
+ linesToSkip++;
87
+ continue;
88
+ }
89
+ if (trimmed.startsWith("Context:")) {
90
+ context = trimmed.slice("Context:".length).trim();
91
+ linesToSkip++;
92
+ continue;
93
+ }
94
+ if (trimmed.startsWith("GitHub:")) {
95
+ const parsed = parseGitHubIssueReference(trimmed.slice("GitHub:".length));
96
+ githubIssue = parsed.githubIssue;
97
+ githubUrl = parsed.githubUrl;
98
+ linesToSkip++;
99
+ continue;
100
+ }
101
+ break;
102
+ }
103
+ return { context, githubIssue, githubUrl, linesToSkip };
104
+ }
105
+ function ensureProject(phrenPath, project) {
106
+ if (!isValidProjectName(project))
107
+ return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
108
+ const dir = safeProjectPath(phrenPath, project);
109
+ if (!dir)
110
+ return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
111
+ if (!fs.existsSync(dir)) {
112
+ return phrenErr(`No project "${project}" found. Add it with 'cd ~/your-project && phren add'.`, PhrenError.PROJECT_NOT_FOUND);
113
+ }
114
+ return phrenOk(dir);
115
+ }
116
+ /** Pattern that matches the task metadata comment embedded in task item lines.
117
+ * Format: <!-- bid:HASH [rank:N] [lastActivity:ISO] -->
118
+ */
119
+ const METADATA_PATTERN = /\s*<!--\s*bid:([a-z0-9]{8})(?:\s+rank:(\d+))?(?:\s+lastActivity:([^\s>]+))?(?:\s+created:([^\s>]+))?(?:\s+session:([^\s>]+))?(?:\s+scope:([^\s>]+))?(?:\s+findings:((?:[a-z0-9]{8}(?::[a-z0-9]{8})?|fid:[a-z0-9]{8})(?:,[a-z0-9a-z:]{3,})*))?(?:\s+parentFinding:([^\s>]+))?(\s+speculative)?\s*-->/;
120
+ /** Generate a new 8-character random stable ID. */
121
+ function newBid() {
122
+ return randomBytes(4).toString("hex");
123
+ }
124
+ /** Strip the metadata comment from a raw line, returning the clean text and any extracted fields. */
125
+ function stripBid(text) {
126
+ const m = text.match(METADATA_PATTERN);
127
+ if (!m)
128
+ return { clean: text };
129
+ const rankNum = m[2] ? Number.parseInt(m[2], 10) : undefined;
130
+ const childFindings = m[7] ? m[7].split(",").filter(Boolean) : undefined;
131
+ return {
132
+ clean: text.replace(METADATA_PATTERN, "").trimEnd(),
133
+ bid: m[1],
134
+ rank: Number.isFinite(rankNum) ? rankNum : undefined,
135
+ lastActivity: m[3] || undefined,
136
+ createdAt: m[4] || undefined,
137
+ sessionId: m[5] || undefined,
138
+ scope: m[6] || undefined,
139
+ childFindings: childFindings && childFindings.length > 0 ? childFindings : undefined,
140
+ parentFinding: m[8] || undefined,
141
+ speculative: m[9] ? true : undefined,
142
+ };
143
+ }
144
+ /**
145
+ * Auto-assign numeric ranks to items without a rank.
146
+ * high-priority items get lowest numbers, then medium, then low, then unranked.
147
+ */
148
+ function assignMissingRanks(items) {
149
+ const unranked = items.filter((item) => item.rank === undefined);
150
+ if (!unranked.length)
151
+ return;
152
+ const maxExisting = items.reduce((max, item) => (item.rank !== undefined && item.rank > max ? item.rank : max), 0);
153
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
154
+ unranked.sort((a, b) => (priorityOrder[a.priority ?? ""] ?? 3) - (priorityOrder[b.priority ?? ""] ?? 3));
155
+ let next = maxExisting + 1;
156
+ for (const item of unranked) {
157
+ item.rank = next++;
158
+ }
159
+ }
160
+ /**
161
+ * Apply gravity to tasks: items with stale lastActivity drift toward higher rank numbers.
162
+ * Only affects display order — does not mutate the file.
163
+ */
164
+ export function applyGravity(items) {
165
+ const now = Date.now();
166
+ const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000;
167
+ const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
168
+ return items.map((item) => {
169
+ if (!item.lastActivity || item.rank === undefined)
170
+ return item;
171
+ const age = now - new Date(item.lastActivity).getTime();
172
+ if (age <= TWO_WEEKS_MS)
173
+ return item;
174
+ const weeksStale = Math.floor((age - TWO_WEEKS_MS) / ONE_WEEK_MS);
175
+ return { ...item, rank: item.rank + Math.min(weeksStale, 10) };
176
+ });
177
+ }
178
+ export function canonicalTaskFilePath(phrenPath, project) {
179
+ const resolved = safeProjectPath(phrenPath, project);
180
+ if (!resolved)
181
+ return null;
182
+ return path.join(resolved, TASKS_FILENAME);
183
+ }
184
+ export function isTaskFileName(filename) {
185
+ return filename.toLowerCase() === TASKS_FILENAME;
186
+ }
187
+ export function resolveTaskFilePath(phrenPath, project) {
188
+ return canonicalTaskFilePath(phrenPath, project);
189
+ }
190
+ function normalizeTaskItemLine(item) {
191
+ let text = stripPinnedTag(item.line.replace(/\s*\[(high|medium|low)\]\s*$/gi, "")).trim();
192
+ if (item.priority)
193
+ text = `${text} [${item.priority}]`;
194
+ if (item.pinned)
195
+ text = `${text} [pinned]`;
196
+ const prefix = item.checked || item.section === "Done" ? "- [x] " : "- [ ] ";
197
+ const bid = item.stableId ?? newBid();
198
+ const rankPart = item.rank !== undefined ? ` rank:${item.rank}` : "";
199
+ const activityPart = item.lastActivity ? ` lastActivity:${item.lastActivity}` : "";
200
+ const createdPart = item.createdAt ? ` created:${item.createdAt}` : "";
201
+ const sessionPart = item.sessionId ? ` session:${item.sessionId}` : "";
202
+ const scopePart = item.scope ? ` scope:${item.scope}` : "";
203
+ const findingsPart = item.childFindings && item.childFindings.length > 0 ? ` findings:${item.childFindings.join(",")}` : "";
204
+ const parentFindingPart = item.parentFinding ? ` parentFinding:${item.parentFinding}` : "";
205
+ const speculativePart = item.speculative ? " speculative" : "";
206
+ return `${prefix}${text} <!-- bid:${bid}${rankPart}${activityPart}${createdPart}${sessionPart}${scopePart}${findingsPart}${parentFindingPart}${speculativePart} -->`;
207
+ }
208
+ function parseTaskContent(project, taskPath, content) {
209
+ const lines = content.split("\n");
210
+ const title = lines[0]?.trim() || `# ${project} tasks`;
211
+ const items = {
212
+ Active: [],
213
+ Queue: [],
214
+ Done: [],
215
+ };
216
+ let section = "Queue";
217
+ const sectionCounters = { Active: 0, Queue: 0, Done: 0 };
218
+ for (let i = 0; i < lines.length; i++) {
219
+ const line = lines[i];
220
+ const heading = line.trim().match(/^##\s+(.+?)[\s]*$/);
221
+ if (heading) {
222
+ const token = heading[1].replace(/\s+/g, " ").trim().toLowerCase();
223
+ if (ACTIVE_HEADINGS.has(token)) {
224
+ section = "Active";
225
+ }
226
+ else if (QUEUE_HEADINGS.has(token)) {
227
+ section = "Queue";
228
+ }
229
+ else if (DONE_HEADINGS.has(token)) {
230
+ section = "Done";
231
+ }
232
+ continue;
233
+ }
234
+ if (!line.startsWith("- "))
235
+ continue;
236
+ const parsed = stripBulletPrefix(line);
237
+ // Extract and strip the metadata comment before further parsing.
238
+ const { clean: cleanBody, bid, rank, lastActivity, createdAt, sessionId, scope, childFindings, parentFinding, speculative } = stripBid(parsed.body);
239
+ const pinned = detectPinned(cleanBody);
240
+ const priority = normalizePriority(cleanBody);
241
+ const continuation = parseContinuation(lines, i);
242
+ const sectionPrefix = section === "Active" ? "A" : section === "Queue" ? "Q" : "D";
243
+ sectionCounters[section]++;
244
+ items[section].push({
245
+ id: `${sectionPrefix}${sectionCounters[section]}`,
246
+ stableId: bid,
247
+ section,
248
+ line: cleanBody,
249
+ checked: parsed.checked || section === "Done",
250
+ priority,
251
+ rank,
252
+ lastActivity,
253
+ createdAt,
254
+ sessionId,
255
+ scope,
256
+ childFindings,
257
+ parentFinding,
258
+ speculative,
259
+ context: continuation.context,
260
+ pinned: pinned || undefined,
261
+ githubIssue: continuation.githubIssue,
262
+ githubUrl: continuation.githubUrl,
263
+ });
264
+ i += continuation.linesToSkip;
265
+ }
266
+ // Assign ranks to items that don't have one yet (migration from priority-only files)
267
+ for (const section of TASK_SECTIONS) {
268
+ assignMissingRanks(items[section]);
269
+ }
270
+ return {
271
+ project,
272
+ title,
273
+ path: taskPath,
274
+ items,
275
+ issues: validateTaskFormat(content),
276
+ };
277
+ }
278
+ function renderTask(doc) {
279
+ const out = [doc.title, ""];
280
+ for (const section of TASK_SECTIONS) {
281
+ out.push(`## ${section}`, "");
282
+ for (const item of doc.items[section]) {
283
+ out.push(normalizeTaskItemLine(item));
284
+ if (item.context)
285
+ out.push(` Context: ${item.context}`);
286
+ const githubRef = formatGitHubIssueReference(item);
287
+ if (githubRef)
288
+ out.push(` GitHub: ${githubRef}`);
289
+ }
290
+ out.push("");
291
+ }
292
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
293
+ }
294
+ function findItemByMatch(doc, match) {
295
+ const needle = match.trim().toLowerCase();
296
+ if (!needle)
297
+ return { error: `${PhrenError.EMPTY_INPUT}: Please provide the item text or ID to match against.`, errorCode: PhrenError.EMPTY_INPUT };
298
+ // 1a) Stable ID match (bid:XXXX or just the 8-char hex).
299
+ const bidNeedle = needle.replace(/^bid:/, "");
300
+ if (/^[a-f0-9]{8}$/.test(bidNeedle)) {
301
+ for (const section of TASK_SECTIONS) {
302
+ const idx = doc.items[section].findIndex((item) => item.stableId === bidNeedle);
303
+ if (idx !== -1)
304
+ return { match: { section, index: idx } };
305
+ }
306
+ }
307
+ // 1b) Positional ID match (A1, Q2, D3).
308
+ for (const section of TASK_SECTIONS) {
309
+ const idx = doc.items[section].findIndex((item) => item.id.toLowerCase() === needle);
310
+ if (idx !== -1)
311
+ return { match: { section, index: idx } };
312
+ }
313
+ // 2) Exact line match.
314
+ const exact = [];
315
+ for (const section of TASK_SECTIONS) {
316
+ doc.items[section].forEach((item, index) => {
317
+ if (item.line.trim().toLowerCase() === needle)
318
+ exact.push({ section, index });
319
+ });
320
+ }
321
+ if (exact.length === 1)
322
+ return { match: exact[0] };
323
+ if (exact.length > 1) {
324
+ return { error: `${PhrenError.AMBIGUOUS_MATCH}: "${match}" is ambiguous (${exact.length} exact matches). Use item ID.`, errorCode: PhrenError.AMBIGUOUS_MATCH };
325
+ }
326
+ // 3) Substring fallback, but only when unique.
327
+ const partial = [];
328
+ for (const section of TASK_SECTIONS) {
329
+ doc.items[section].forEach((item, index) => {
330
+ if (item.line.toLowerCase().includes(needle))
331
+ partial.push({ section, index });
332
+ });
333
+ }
334
+ if (partial.length === 1)
335
+ return { match: partial[0] };
336
+ if (partial.length > 1) {
337
+ return { error: `${PhrenError.AMBIGUOUS_MATCH}: "${match}" is ambiguous (${partial.length} partial matches). Use item ID.`, errorCode: PhrenError.AMBIGUOUS_MATCH };
338
+ }
339
+ return { error: `${PhrenError.NOT_FOUND}: Item not found — no task matching "${match}".`, errorCode: PhrenError.NOT_FOUND };
340
+ }
341
+ function taskItemNotFound(project, match) {
342
+ return phrenErr(`Item not found: no task matching "${match}" in project "${project}". Check the item text or use its ID (shown in the tasks view).`, PhrenError.NOT_FOUND);
343
+ }
344
+ function writeTaskDoc(doc) {
345
+ const tmpPath = `${doc.path}.tmp-${randomUUID()}`;
346
+ fs.writeFileSync(tmpPath, renderTask(doc));
347
+ fs.renameSync(tmpPath, doc.path);
348
+ }
349
+ function taskArchivePath(phrenPath, project) {
350
+ return path.join(phrenPath, ".governance", "task-archive", `${project}.md`);
351
+ }
352
+ export function readTasks(phrenPath, project) {
353
+ const ensured = ensureProject(phrenPath, project);
354
+ if (!ensured.ok)
355
+ return forwardErr(ensured);
356
+ const taskPath = canonicalTaskFilePath(phrenPath, project);
357
+ if (!taskPath)
358
+ return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
359
+ if (!fs.existsSync(taskPath)) {
360
+ return phrenOk({
361
+ project,
362
+ title: `# ${project} tasks`,
363
+ path: taskPath,
364
+ issues: [],
365
+ items: { Active: [], Queue: [], Done: [] },
366
+ });
367
+ }
368
+ const content = fs.readFileSync(taskPath, "utf8");
369
+ return phrenOk(parseTaskContent(project, taskPath, content));
370
+ }
371
+ export function readTasksAcrossProjects(phrenPath, profile) {
372
+ const projects = getProjectDirs(phrenPath, profile).map((dir) => path.basename(dir)).sort();
373
+ const result = [];
374
+ for (const project of projects) {
375
+ const file = canonicalTaskFilePath(phrenPath, project);
376
+ if (!file || !fs.existsSync(file))
377
+ continue;
378
+ const parsed = readTasks(phrenPath, project);
379
+ if (!parsed.ok)
380
+ continue;
381
+ result.push(parsed.data);
382
+ }
383
+ return result;
384
+ }
385
+ export function resolveTaskItem(phrenPath, project, match) {
386
+ const parsed = readTasks(phrenPath, project);
387
+ if (!parsed.ok)
388
+ return forwardErr(parsed);
389
+ const found = findItemByMatch(parsed.data, match);
390
+ if (found.error)
391
+ return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
392
+ if (!found.match)
393
+ return taskItemNotFound(project, match);
394
+ return phrenOk(parsed.data.items[found.match.section][found.match.index]);
395
+ }
396
+ export function addTask(phrenPath, project, item, opts) {
397
+ const bPath = canonicalTaskFilePath(phrenPath, project);
398
+ if (!bPath)
399
+ return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
400
+ // Validate project exists before acquiring the lock — withFileLock creates the parent
401
+ // directory via mkdirSync, which would silently create an unintended project directory.
402
+ const preCheck = ensureProject(phrenPath, project);
403
+ if (!preCheck.ok)
404
+ return forwardErr(preCheck);
405
+ return withSafeLock(bPath, () => {
406
+ const parsed = readTasks(phrenPath, project);
407
+ if (!parsed.ok)
408
+ return forwardErr(parsed);
409
+ const line = item.replace(/^-\s*/, "").trim();
410
+ const newItem = {
411
+ id: `Q${parsed.data.items.Queue.length + 1}`,
412
+ stableId: newBid(),
413
+ section: "Queue",
414
+ line,
415
+ checked: false,
416
+ priority: normalizePriority(line),
417
+ createdAt: opts?.createdAt,
418
+ sessionId: opts?.sessionId,
419
+ scope: opts?.scope,
420
+ parentFinding: opts?.parentFinding,
421
+ speculative: opts?.speculative || undefined,
422
+ };
423
+ parsed.data.items.Queue.push(newItem);
424
+ writeTaskDoc(parsed.data);
425
+ return phrenOk(newItem);
426
+ });
427
+ }
428
+ export function addTasks(phrenPath, project, items) {
429
+ const bPath = canonicalTaskFilePath(phrenPath, project);
430
+ if (!bPath)
431
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
432
+ const preCheck = ensureProject(phrenPath, project);
433
+ if (!preCheck.ok)
434
+ return forwardErr(preCheck);
435
+ return withSafeLock(bPath, () => {
436
+ const parsed = readTasks(phrenPath, project);
437
+ if (!parsed.ok)
438
+ return forwardErr(parsed);
439
+ const added = [];
440
+ const errors = [];
441
+ for (const item of items) {
442
+ const line = item.replace(/^-\s*/, "").trim();
443
+ if (!line) {
444
+ errors.push(item);
445
+ continue;
446
+ }
447
+ parsed.data.items.Queue.push({
448
+ id: `Q${parsed.data.items.Queue.length + 1}`,
449
+ section: "Queue",
450
+ line,
451
+ checked: false,
452
+ priority: normalizePriority(line),
453
+ });
454
+ added.push(line);
455
+ }
456
+ writeTaskDoc(parsed.data);
457
+ return phrenOk({ added, errors });
458
+ });
459
+ }
460
+ export function completeTasks(phrenPath, project, matches) {
461
+ const bPath = canonicalTaskFilePath(phrenPath, project);
462
+ if (!bPath)
463
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
464
+ return withSafeLock(bPath, () => {
465
+ const parsed = readTasks(phrenPath, project);
466
+ if (!parsed.ok)
467
+ return forwardErr(parsed);
468
+ const completed = [];
469
+ const errors = [];
470
+ for (const match of matches) {
471
+ const found = findItemByMatch(parsed.data, match);
472
+ if (found.error || !found.match) {
473
+ errors.push(match);
474
+ continue;
475
+ }
476
+ const [item] = parsed.data.items[found.match.section].splice(found.match.index, 1);
477
+ item.section = "Done";
478
+ item.checked = true;
479
+ parsed.data.items.Done.unshift(item);
480
+ completed.push(item.line);
481
+ }
482
+ writeTaskDoc(parsed.data);
483
+ return phrenOk({ completed, errors });
484
+ });
485
+ }
486
+ export function completeTask(phrenPath, project, match) {
487
+ const bPath = canonicalTaskFilePath(phrenPath, project);
488
+ if (!bPath)
489
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
490
+ return withSafeLock(bPath, () => {
491
+ const parsed = readTasks(phrenPath, project);
492
+ if (!parsed.ok)
493
+ return forwardErr(parsed);
494
+ const found = findItemByMatch(parsed.data, match);
495
+ if (found.error)
496
+ return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
497
+ if (!found.match)
498
+ return taskItemNotFound(project, match);
499
+ const [item] = parsed.data.items[found.match.section].splice(found.match.index, 1);
500
+ item.section = "Done";
501
+ item.checked = true;
502
+ parsed.data.items.Done.unshift(item);
503
+ writeTaskDoc(parsed.data);
504
+ return phrenOk(`Marked done in ${project}: ${item.line}`);
505
+ });
506
+ }
507
+ export function removeTask(phrenPath, project, match) {
508
+ const bPath = canonicalTaskFilePath(phrenPath, project);
509
+ if (!bPath)
510
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
511
+ return withSafeLock(bPath, () => {
512
+ const parsed = readTasks(phrenPath, project);
513
+ if (!parsed.ok)
514
+ return forwardErr(parsed);
515
+ const found = findItemByMatch(parsed.data, match);
516
+ if (found.error)
517
+ return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
518
+ if (!found.match)
519
+ return taskItemNotFound(project, match);
520
+ const [item] = parsed.data.items[found.match.section].splice(found.match.index, 1);
521
+ writeTaskDoc(parsed.data);
522
+ return phrenOk(`Removed task from ${project}: ${item.line}`);
523
+ });
524
+ }
525
+ export function updateTask(phrenPath, project, match, updates) {
526
+ const bPath = canonicalTaskFilePath(phrenPath, project);
527
+ if (!bPath)
528
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
529
+ return withSafeLock(bPath, () => {
530
+ const parsed = readTasks(phrenPath, project);
531
+ if (!parsed.ok)
532
+ return forwardErr(parsed);
533
+ const found = findItemByMatch(parsed.data, match);
534
+ if (found.error)
535
+ return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
536
+ if (!found.match)
537
+ return taskItemNotFound(project, match);
538
+ const item = parsed.data.items[found.match.section][found.match.index];
539
+ const changes = [];
540
+ if (updates.priority) {
541
+ const priority = updates.priority.toLowerCase();
542
+ if (["high", "medium", "low"].includes(priority)) {
543
+ item.priority = priority;
544
+ item.line = item.line.replace(/\s*\[(high|medium|low)\]\s*$/gi, "").trim();
545
+ item.line = `${item.line} [${item.priority}]`;
546
+ changes.push(`priority -> ${priority}`);
547
+ }
548
+ }
549
+ if (updates.context) {
550
+ if (updates.replace_context || !item.context)
551
+ item.context = updates.context;
552
+ else
553
+ item.context = `${item.context}; ${updates.context}`;
554
+ changes.push("context updated");
555
+ }
556
+ if (updates.unlink_github) {
557
+ item.githubIssue = undefined;
558
+ item.githubUrl = undefined;
559
+ changes.push("github link removed");
560
+ }
561
+ else if (updates.github_issue !== undefined || updates.github_url !== undefined) {
562
+ if (updates.github_url && !isValidGitHubIssueUrl(updates.github_url)) {
563
+ return phrenErr("github_url must be a valid GitHub issue URL.", PhrenError.VALIDATION_ERROR);
564
+ }
565
+ const githubIssueRaw = typeof updates.github_issue === "string"
566
+ ? updates.github_issue.trim()
567
+ : updates.github_issue !== undefined
568
+ ? String(updates.github_issue)
569
+ : "";
570
+ const parsedIssue = parseGitHubIssueReference([
571
+ githubIssueRaw,
572
+ updates.github_url?.trim() || "",
573
+ ].filter(Boolean).join(" "));
574
+ if (!parsedIssue.githubIssue && !parsedIssue.githubUrl) {
575
+ return phrenErr("GitHub link update requires a valid issue number and/or GitHub issue URL.", PhrenError.VALIDATION_ERROR);
576
+ }
577
+ item.githubIssue = parsedIssue.githubIssue;
578
+ item.githubUrl = parsedIssue.githubUrl;
579
+ changes.push(item.githubIssue ? `github -> #${item.githubIssue}` : "github link updated");
580
+ }
581
+ if (updates.section) {
582
+ const target = updates.section[0].toUpperCase() + updates.section.slice(1).toLowerCase();
583
+ if (["Active", "Queue", "Done"].includes(target)) {
584
+ parsed.data.items[found.match.section].splice(found.match.index, 1);
585
+ const section = target;
586
+ item.section = section;
587
+ item.checked = section === "Done";
588
+ parsed.data.items[section].unshift(item);
589
+ changes.push(`moved to ${section}`);
590
+ }
591
+ }
592
+ writeTaskDoc(parsed.data);
593
+ return phrenOk(`Updated item in ${project}: ${changes.join(", ") || "no changes"}`);
594
+ });
595
+ }
596
+ export function pinTask(phrenPath, project, match) {
597
+ const bPath = canonicalTaskFilePath(phrenPath, project);
598
+ if (!bPath)
599
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
600
+ return withSafeLock(bPath, () => {
601
+ const parsed = readTasks(phrenPath, project);
602
+ if (!parsed.ok)
603
+ return forwardErr(parsed);
604
+ const found = findItemByMatch(parsed.data, match);
605
+ if (found.error)
606
+ return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
607
+ if (!found.match)
608
+ return taskItemNotFound(project, match);
609
+ const section = found.match.section;
610
+ const item = parsed.data.items[section][found.match.index];
611
+ if (item.pinned)
612
+ return phrenOk(`Already pinned in ${project}: ${item.line}`);
613
+ item.pinned = true;
614
+ item.line = stripPinnedTag(item.line);
615
+ parsed.data.items[section].splice(found.match.index, 1);
616
+ parsed.data.items[section].unshift(item);
617
+ writeTaskDoc(parsed.data);
618
+ return phrenOk(`Pinned in ${project}: ${item.line}`);
619
+ });
620
+ }
621
+ export function unpinTask(phrenPath, project, match) {
622
+ const bPath = canonicalTaskFilePath(phrenPath, project);
623
+ if (!bPath)
624
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
625
+ return withSafeLock(bPath, () => {
626
+ const parsed = readTasks(phrenPath, project);
627
+ if (!parsed.ok)
628
+ return forwardErr(parsed);
629
+ const found = findItemByMatch(parsed.data, match);
630
+ if (found.error)
631
+ return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
632
+ if (!found.match)
633
+ return taskItemNotFound(project, match);
634
+ const item = parsed.data.items[found.match.section][found.match.index];
635
+ if (!item.pinned)
636
+ return phrenOk(`Not pinned in ${project}: ${item.line}`);
637
+ item.pinned = undefined;
638
+ item.line = stripPinnedTag(item.line);
639
+ writeTaskDoc(parsed.data);
640
+ return phrenOk(`Unpinned in ${project}: ${item.line}`);
641
+ });
642
+ }
643
+ export function reorderTask(phrenPath, project, match, targetRank) {
644
+ const bPath = canonicalTaskFilePath(phrenPath, project);
645
+ if (!bPath)
646
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
647
+ return withSafeLock(bPath, () => {
648
+ const parsed = readTasks(phrenPath, project);
649
+ if (!parsed.ok)
650
+ return forwardErr(parsed);
651
+ const found = findItemByMatch(parsed.data, match);
652
+ if (found.error)
653
+ return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
654
+ if (!found.match)
655
+ return taskItemNotFound(project, match);
656
+ const section = found.match.section;
657
+ const items = parsed.data.items[section];
658
+ const item = items[found.match.index];
659
+ const oldRank = item.rank ?? found.match.index + 1;
660
+ const clampedTarget = Math.max(1, Math.min(targetRank, items.length));
661
+ for (const other of items) {
662
+ if (other === item || other.rank === undefined)
663
+ continue;
664
+ if (clampedTarget <= oldRank) {
665
+ if (other.rank >= clampedTarget && other.rank < oldRank)
666
+ other.rank++;
667
+ }
668
+ else {
669
+ if (other.rank > oldRank && other.rank <= clampedTarget)
670
+ other.rank--;
671
+ }
672
+ }
673
+ item.rank = clampedTarget;
674
+ // Re-sort by rank so file order reflects new priority order
675
+ items.sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999));
676
+ writeTaskDoc(parsed.data);
677
+ return phrenOk(`Reordered in ${project}: "${item.line}" moved to rank ${clampedTarget}`);
678
+ });
679
+ }
680
+ export function appendChildFinding(phrenPath, project, match, findingId) {
681
+ const bPath = canonicalTaskFilePath(phrenPath, project);
682
+ if (!bPath)
683
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
684
+ return withSafeLock(bPath, () => {
685
+ const parsed = readTasks(phrenPath, project);
686
+ if (!parsed.ok)
687
+ return forwardErr(parsed);
688
+ const found = findItemByMatch(parsed.data, match);
689
+ if (found.error)
690
+ return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
691
+ if (!found.match)
692
+ return taskItemNotFound(project, match);
693
+ const item = parsed.data.items[found.match.section][found.match.index];
694
+ item.childFindings = [...(item.childFindings ?? []), findingId];
695
+ item.lastActivity = new Date().toISOString();
696
+ writeTaskDoc(parsed.data);
697
+ return phrenOk(`Linked finding ${findingId} to task in ${project}: ${item.line}`);
698
+ });
699
+ }
700
+ export function promoteTask(phrenPath, project, match, moveToActive) {
701
+ const bPath = canonicalTaskFilePath(phrenPath, project);
702
+ if (!bPath)
703
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
704
+ return withSafeLock(bPath, () => {
705
+ const parsed = readTasks(phrenPath, project);
706
+ if (!parsed.ok)
707
+ return forwardErr(parsed);
708
+ const found = findItemByMatch(parsed.data, match);
709
+ if (found.error)
710
+ return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
711
+ if (!found.match)
712
+ return taskItemNotFound(project, match);
713
+ const item = parsed.data.items[found.match.section][found.match.index];
714
+ item.speculative = undefined;
715
+ if (moveToActive && item.section !== "Active") {
716
+ parsed.data.items[found.match.section].splice(found.match.index, 1);
717
+ item.section = "Active";
718
+ item.checked = false;
719
+ parsed.data.items.Active.unshift(item);
720
+ }
721
+ writeTaskDoc(parsed.data);
722
+ return phrenOk(item);
723
+ });
724
+ }
725
+ export function workNextTask(phrenPath, project) {
726
+ const bPath = canonicalTaskFilePath(phrenPath, project);
727
+ if (!bPath)
728
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
729
+ return withSafeLock(bPath, () => {
730
+ const parsed = readTasks(phrenPath, project);
731
+ if (!parsed.ok)
732
+ return forwardErr(parsed);
733
+ if (!parsed.data.items.Queue.length) {
734
+ return phrenErr(`No queued tasks in "${project}". Add items with :add or the add_task tool.`, PhrenError.NOT_FOUND);
735
+ }
736
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
737
+ parsed.data.items.Queue.sort((a, b) => {
738
+ const pa = priorityOrder[a.priority ?? ""] ?? 3;
739
+ const pb = priorityOrder[b.priority ?? ""] ?? 3;
740
+ return pa - pb;
741
+ });
742
+ const item = parsed.data.items.Queue.shift();
743
+ item.section = "Active";
744
+ item.checked = false;
745
+ parsed.data.items.Active.push(item);
746
+ writeTaskDoc(parsed.data);
747
+ return phrenOk(`Moved next queue item to Active in ${project}: ${item.line}`);
748
+ });
749
+ }
750
+ export function tidyDoneTasks(phrenPath, project, keep = 30, dryRun) {
751
+ const bPath = canonicalTaskFilePath(phrenPath, project);
752
+ if (!bPath)
753
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
754
+ return withSafeLock(bPath, () => {
755
+ const parsed = readTasks(phrenPath, project);
756
+ if (!parsed.ok)
757
+ return forwardErr(parsed);
758
+ const safeKeep = Number.isFinite(keep) ? Math.max(0, Math.floor(keep)) : 30;
759
+ if (parsed.data.items.Done.length <= safeKeep) {
760
+ return phrenOk(`No tidy needed for ${project}. Done=${parsed.data.items.Done.length}, keep=${safeKeep}.`);
761
+ }
762
+ const archived = parsed.data.items.Done.slice(safeKeep);
763
+ if (dryRun) {
764
+ return phrenOk(`[dry-run] Would archive ${archived.length} done item(s) for ${project}, keeping ${safeKeep}.`);
765
+ }
766
+ parsed.data.items.Done = parsed.data.items.Done.slice(0, safeKeep);
767
+ const archiveFile = taskArchivePath(phrenPath, project);
768
+ fs.mkdirSync(path.dirname(archiveFile), { recursive: true });
769
+ const stamp = new Date().toISOString();
770
+ const lines = archived.map((item) => `- [x] ${item.line}${item.context ? `\n Context: ${item.context}` : ""}`);
771
+ const block = `## ${stamp}\n\n${lines.join("\n")}\n\n`;
772
+ const prior = fs.existsSync(archiveFile) ? fs.readFileSync(archiveFile, "utf8") : `# ${project} tasks archive\n\n`;
773
+ fs.writeFileSync(archiveFile, prior + block);
774
+ writeTaskDoc(parsed.data);
775
+ return phrenOk(`Tidied ${project}: archived ${archived.length} done item(s), kept ${safeKeep}.`);
776
+ });
777
+ }
778
+ export function taskMarkdown(doc) {
779
+ return renderTask(doc);
780
+ }
781
+ export function linkTaskIssue(phrenPath, project, match, link) {
782
+ const bPath = canonicalTaskFilePath(phrenPath, project);
783
+ if (!bPath)
784
+ return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
785
+ return withSafeLock(bPath, () => {
786
+ const parsed = readTasks(phrenPath, project);
787
+ if (!parsed.ok)
788
+ return forwardErr(parsed);
789
+ const found = findItemByMatch(parsed.data, match);
790
+ if (found.error)
791
+ return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
792
+ if (!found.match)
793
+ return taskItemNotFound(project, match);
794
+ const item = parsed.data.items[found.match.section][found.match.index];
795
+ if (link.unlink) {
796
+ item.githubIssue = undefined;
797
+ item.githubUrl = undefined;
798
+ }
799
+ else {
800
+ if (link.github_url && !isValidGitHubIssueUrl(link.github_url)) {
801
+ return phrenErr("github_url must be a valid GitHub issue URL.", PhrenError.VALIDATION_ERROR);
802
+ }
803
+ const githubIssueRaw = typeof link.github_issue === "string"
804
+ ? link.github_issue.trim()
805
+ : link.github_issue !== undefined
806
+ ? String(link.github_issue)
807
+ : "";
808
+ const parsedLink = parseGitHubIssueReference([
809
+ githubIssueRaw,
810
+ link.github_url?.trim() || "",
811
+ ].filter(Boolean).join(" "));
812
+ if (!parsedLink.githubIssue && !parsedLink.githubUrl) {
813
+ return phrenErr("GitHub link update requires a valid issue number and/or GitHub issue URL.", PhrenError.VALIDATION_ERROR);
814
+ }
815
+ item.githubIssue = parsedLink.githubIssue;
816
+ item.githubUrl = parsedLink.githubUrl;
817
+ }
818
+ writeTaskDoc(parsed.data);
819
+ return phrenOk(item);
820
+ });
821
+ }