@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.
- package/LICENSE +21 -0
- package/README.md +590 -0
- package/mcp/dist/capabilities/cli.js +61 -0
- package/mcp/dist/capabilities/index.js +15 -0
- package/mcp/dist/capabilities/mcp.js +61 -0
- package/mcp/dist/capabilities/types.js +57 -0
- package/mcp/dist/capabilities/vscode.js +61 -0
- package/mcp/dist/capabilities/web-ui.js +61 -0
- package/mcp/dist/cli-actions.js +302 -0
- package/mcp/dist/cli-config.js +580 -0
- package/mcp/dist/cli-extract.js +305 -0
- package/mcp/dist/cli-govern.js +371 -0
- package/mcp/dist/cli-graph.js +169 -0
- package/mcp/dist/cli-hooks-citations.js +44 -0
- package/mcp/dist/cli-hooks-context.js +56 -0
- package/mcp/dist/cli-hooks-globs.js +83 -0
- package/mcp/dist/cli-hooks-output.js +130 -0
- package/mcp/dist/cli-hooks-retrieval.js +2 -0
- package/mcp/dist/cli-hooks-session.js +1402 -0
- package/mcp/dist/cli-hooks.js +350 -0
- package/mcp/dist/cli-namespaces.js +989 -0
- package/mcp/dist/cli-ops.js +253 -0
- package/mcp/dist/cli-search.js +407 -0
- package/mcp/dist/cli.js +108 -0
- package/mcp/dist/content-archive.js +278 -0
- package/mcp/dist/content-citation.js +391 -0
- package/mcp/dist/content-dedup.js +622 -0
- package/mcp/dist/content-learning.js +472 -0
- package/mcp/dist/content-metadata.js +186 -0
- package/mcp/dist/content-validate.js +462 -0
- package/mcp/dist/core-finding.js +54 -0
- package/mcp/dist/core-project.js +36 -0
- package/mcp/dist/core-search.js +50 -0
- package/mcp/dist/data-access.js +400 -0
- package/mcp/dist/data-tasks.js +821 -0
- package/mcp/dist/embedding.js +344 -0
- package/mcp/dist/entrypoint.js +387 -0
- package/mcp/dist/finding-context.js +172 -0
- package/mcp/dist/finding-impact.js +181 -0
- package/mcp/dist/finding-journal.js +122 -0
- package/mcp/dist/finding-lifecycle.js +259 -0
- package/mcp/dist/governance-audit.js +22 -0
- package/mcp/dist/governance-locks.js +96 -0
- package/mcp/dist/governance-policy.js +648 -0
- package/mcp/dist/governance-scores.js +355 -0
- package/mcp/dist/hooks.js +449 -0
- package/mcp/dist/impact-scoring.js +22 -0
- package/mcp/dist/index-query.js +168 -0
- package/mcp/dist/index.js +205 -0
- package/mcp/dist/init-config.js +336 -0
- package/mcp/dist/init-preferences.js +62 -0
- package/mcp/dist/init-setup.js +1305 -0
- package/mcp/dist/init-shared.js +29 -0
- package/mcp/dist/init.js +1730 -0
- package/mcp/dist/link-checksums.js +62 -0
- package/mcp/dist/link-context.js +257 -0
- package/mcp/dist/link-doctor.js +591 -0
- package/mcp/dist/link-skills.js +212 -0
- package/mcp/dist/link.js +596 -0
- package/mcp/dist/logger.js +15 -0
- package/mcp/dist/machine-identity.js +38 -0
- package/mcp/dist/mcp-config.js +254 -0
- package/mcp/dist/mcp-data.js +315 -0
- package/mcp/dist/mcp-extract-facts.js +78 -0
- package/mcp/dist/mcp-extract.js +133 -0
- package/mcp/dist/mcp-finding.js +557 -0
- package/mcp/dist/mcp-graph.js +339 -0
- package/mcp/dist/mcp-hooks.js +256 -0
- package/mcp/dist/mcp-memory.js +58 -0
- package/mcp/dist/mcp-ops.js +328 -0
- package/mcp/dist/mcp-search.js +628 -0
- package/mcp/dist/mcp-session.js +651 -0
- package/mcp/dist/mcp-skills.js +189 -0
- package/mcp/dist/mcp-tasks.js +551 -0
- package/mcp/dist/mcp-types.js +7 -0
- package/mcp/dist/memory-ui-assets.js +6 -0
- package/mcp/dist/memory-ui-data.js +513 -0
- package/mcp/dist/memory-ui-graph.js +1910 -0
- package/mcp/dist/memory-ui-page.js +353 -0
- package/mcp/dist/memory-ui-scripts.js +1387 -0
- package/mcp/dist/memory-ui-server.js +1218 -0
- package/mcp/dist/memory-ui-styles.js +555 -0
- package/mcp/dist/memory-ui.js +9 -0
- package/mcp/dist/package-metadata.js +13 -0
- package/mcp/dist/phren-art.js +52 -0
- package/mcp/dist/phren-core.js +108 -0
- package/mcp/dist/phren-dotenv.js +67 -0
- package/mcp/dist/phren-paths.js +476 -0
- package/mcp/dist/proactivity.js +172 -0
- package/mcp/dist/profile-store.js +228 -0
- package/mcp/dist/project-config.js +85 -0
- package/mcp/dist/project-locator.js +25 -0
- package/mcp/dist/project-topics.js +1134 -0
- package/mcp/dist/provider-adapters.js +176 -0
- package/mcp/dist/runtime-profile.js +18 -0
- package/mcp/dist/session-checkpoints.js +131 -0
- package/mcp/dist/session-utils.js +68 -0
- package/mcp/dist/shared-content.js +8 -0
- package/mcp/dist/shared-embedding-cache.js +143 -0
- package/mcp/dist/shared-fragment-graph.js +456 -0
- package/mcp/dist/shared-governance.js +4 -0
- package/mcp/dist/shared-index.js +1334 -0
- package/mcp/dist/shared-ollama.js +192 -0
- package/mcp/dist/shared-paths.js +1 -0
- package/mcp/dist/shared-retrieval.js +796 -0
- package/mcp/dist/shared-search-fallback.js +375 -0
- package/mcp/dist/shared-sqljs.js +42 -0
- package/mcp/dist/shared-stemmer.js +171 -0
- package/mcp/dist/shared-vector-index.js +199 -0
- package/mcp/dist/shared.js +114 -0
- package/mcp/dist/shell-entry.js +209 -0
- package/mcp/dist/shell-input.js +943 -0
- package/mcp/dist/shell-palette.js +119 -0
- package/mcp/dist/shell-render.js +252 -0
- package/mcp/dist/shell-state-store.js +81 -0
- package/mcp/dist/shell-types.js +13 -0
- package/mcp/dist/shell-view-list.js +14 -0
- package/mcp/dist/shell-view.js +707 -0
- package/mcp/dist/shell.js +352 -0
- package/mcp/dist/skill-files.js +117 -0
- package/mcp/dist/skill-registry.js +279 -0
- package/mcp/dist/skill-state.js +28 -0
- package/mcp/dist/startup-embedding.js +57 -0
- package/mcp/dist/status.js +323 -0
- package/mcp/dist/synonyms.json +670 -0
- package/mcp/dist/task-hygiene.js +251 -0
- package/mcp/dist/task-lifecycle.js +347 -0
- package/mcp/dist/tasks-github.js +76 -0
- package/mcp/dist/telemetry.js +165 -0
- package/mcp/dist/test-global-setup.js +37 -0
- package/mcp/dist/tool-registry.js +104 -0
- package/mcp/dist/update.js +97 -0
- package/mcp/dist/utils.js +543 -0
- package/package.json +67 -0
- package/skills/README.md +7 -0
- package/skills/consolidate/SKILL.md +152 -0
- package/skills/discover/SKILL.md +175 -0
- package/skills/init/SKILL.md +216 -0
- package/skills/profiles/SKILL.md +121 -0
- package/skills/sync/SKILL.md +261 -0
- package/starter/README.md +74 -0
- package/starter/global/CLAUDE.md +89 -0
- package/starter/global/skills/humanize.md +30 -0
- package/starter/global/skills/pipeline.md +35 -0
- package/starter/global/skills/release.md +35 -0
- package/starter/machines.yaml +8 -0
- package/starter/my-api/.claude/skills/README.md +7 -0
- package/starter/my-api/CLAUDE.md +33 -0
- package/starter/my-api/FINDINGS.md +9 -0
- package/starter/my-api/summary.md +7 -0
- package/starter/my-api/tasks.md +7 -0
- package/starter/my-first-project/.claude/skills/README.md +7 -0
- package/starter/my-first-project/CLAUDE.md +49 -0
- package/starter/my-first-project/FINDINGS.md +24 -0
- package/starter/my-first-project/summary.md +11 -0
- package/starter/my-first-project/tasks.md +25 -0
- package/starter/my-frontend/.claude/skills/README.md +7 -0
- package/starter/my-frontend/CLAUDE.md +33 -0
- package/starter/my-frontend/FINDINGS.md +9 -0
- package/starter/my-frontend/summary.md +7 -0
- package/starter/my-frontend/tasks.md +7 -0
- package/starter/profiles/default.yaml +4 -0
- package/starter/profiles/personal.yaml +4 -0
- package/starter/profiles/work.yaml +4 -0
- package/starter/templates/README.md +7 -0
- package/starter/templates/frontend/CLAUDE.md +23 -0
- package/starter/templates/frontend/FINDINGS.md +7 -0
- package/starter/templates/frontend/reference/README.md +4 -0
- package/starter/templates/frontend/summary.md +7 -0
- package/starter/templates/frontend/tasks.md +11 -0
- package/starter/templates/library/CLAUDE.md +22 -0
- package/starter/templates/library/FINDINGS.md +7 -0
- package/starter/templates/library/reference/README.md +4 -0
- package/starter/templates/library/summary.md +7 -0
- package/starter/templates/library/tasks.md +11 -0
- package/starter/templates/monorepo/CLAUDE.md +21 -0
- package/starter/templates/monorepo/FINDINGS.md +7 -0
- package/starter/templates/monorepo/reference/README.md +4 -0
- package/starter/templates/monorepo/summary.md +7 -0
- package/starter/templates/monorepo/tasks.md +11 -0
- package/starter/templates/python-project/CLAUDE.md +21 -0
- package/starter/templates/python-project/FINDINGS.md +7 -0
- package/starter/templates/python-project/reference/README.md +4 -0
- package/starter/templates/python-project/summary.md +7 -0
- package/starter/templates/python-project/tasks.md +10 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { isTaskFileName, readTasks } from "./data-tasks.js";
|
|
4
|
+
import { STOP_WORDS, extractKeywords, errorMessage } from "./utils.js";
|
|
5
|
+
const TEXT_EXTENSIONS = new Set([
|
|
6
|
+
".cjs",
|
|
7
|
+
".css",
|
|
8
|
+
".go",
|
|
9
|
+
".html",
|
|
10
|
+
".java",
|
|
11
|
+
".js",
|
|
12
|
+
".json",
|
|
13
|
+
".jsx",
|
|
14
|
+
".kt",
|
|
15
|
+
".md",
|
|
16
|
+
".mjs",
|
|
17
|
+
".py",
|
|
18
|
+
".rb",
|
|
19
|
+
".rs",
|
|
20
|
+
".scss",
|
|
21
|
+
".sh",
|
|
22
|
+
".sql",
|
|
23
|
+
".swift",
|
|
24
|
+
".toml",
|
|
25
|
+
".ts",
|
|
26
|
+
".tsx",
|
|
27
|
+
".txt",
|
|
28
|
+
".yaml",
|
|
29
|
+
".yml",
|
|
30
|
+
]);
|
|
31
|
+
const SKIP_DIRS = new Set([
|
|
32
|
+
".git",
|
|
33
|
+
".next",
|
|
34
|
+
".turbo",
|
|
35
|
+
"build",
|
|
36
|
+
"coverage",
|
|
37
|
+
"dist",
|
|
38
|
+
"node_modules",
|
|
39
|
+
"target",
|
|
40
|
+
]);
|
|
41
|
+
const GENERIC_TASK_TERMS = new Set([
|
|
42
|
+
"add",
|
|
43
|
+
"audit",
|
|
44
|
+
"task",
|
|
45
|
+
"check",
|
|
46
|
+
"checks",
|
|
47
|
+
"coverage",
|
|
48
|
+
"doctor",
|
|
49
|
+
"docs",
|
|
50
|
+
"environment",
|
|
51
|
+
"environments",
|
|
52
|
+
"finish",
|
|
53
|
+
"fix",
|
|
54
|
+
"harness",
|
|
55
|
+
"improve",
|
|
56
|
+
"install",
|
|
57
|
+
"installs",
|
|
58
|
+
"integration",
|
|
59
|
+
"item",
|
|
60
|
+
"items",
|
|
61
|
+
"maintain",
|
|
62
|
+
"message",
|
|
63
|
+
"messages",
|
|
64
|
+
"next",
|
|
65
|
+
"pass",
|
|
66
|
+
"project",
|
|
67
|
+
"projects",
|
|
68
|
+
"queue",
|
|
69
|
+
"queued",
|
|
70
|
+
"refactor",
|
|
71
|
+
"repo",
|
|
72
|
+
"search",
|
|
73
|
+
"setup",
|
|
74
|
+
"stability",
|
|
75
|
+
"support",
|
|
76
|
+
"test",
|
|
77
|
+
"tests",
|
|
78
|
+
"workflow",
|
|
79
|
+
]);
|
|
80
|
+
const MAX_TEXT_BYTES = 16 * 1024;
|
|
81
|
+
const MAX_FILES_PER_ROOT = 400;
|
|
82
|
+
function uniqueValues(values) {
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const value of values) {
|
|
86
|
+
const trimmed = value.trim();
|
|
87
|
+
if (!trimmed || seen.has(trimmed))
|
|
88
|
+
continue;
|
|
89
|
+
seen.add(trimmed);
|
|
90
|
+
out.push(trimmed);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function uniqueTerms(values) {
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const value of values) {
|
|
98
|
+
const trimmed = value.trim().toLowerCase();
|
|
99
|
+
if (!trimmed || seen.has(trimmed))
|
|
100
|
+
continue;
|
|
101
|
+
seen.add(trimmed);
|
|
102
|
+
out.push(trimmed);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
function isLikelyTextFile(filePath) {
|
|
107
|
+
return TEXT_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
108
|
+
}
|
|
109
|
+
function collectCorpus(root) {
|
|
110
|
+
if (!root || !fs.existsSync(root))
|
|
111
|
+
return [];
|
|
112
|
+
const texts = [];
|
|
113
|
+
const stack = [root];
|
|
114
|
+
let filesSeen = 0;
|
|
115
|
+
while (stack.length > 0 && filesSeen < MAX_FILES_PER_ROOT) {
|
|
116
|
+
const current = stack.pop();
|
|
117
|
+
let entries;
|
|
118
|
+
try {
|
|
119
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
const fullPath = path.join(current, entry.name);
|
|
126
|
+
if (entry.isDirectory()) {
|
|
127
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
128
|
+
stack.push(fullPath);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!entry.isFile())
|
|
132
|
+
continue;
|
|
133
|
+
if (isTaskFileName(entry.name))
|
|
134
|
+
continue;
|
|
135
|
+
filesSeen += 1;
|
|
136
|
+
const rel = path.relative(root, fullPath).replace(/\\/g, "/").toLowerCase();
|
|
137
|
+
texts.push(rel);
|
|
138
|
+
if (!isLikelyTextFile(fullPath))
|
|
139
|
+
continue;
|
|
140
|
+
try {
|
|
141
|
+
texts.push(fs.readFileSync(fullPath, "utf8").slice(0, MAX_TEXT_BYTES).toLowerCase());
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
145
|
+
process.stderr.write(`[phren] task hygiene read ${fullPath}: ${errorMessage(err)}\n`);
|
|
146
|
+
}
|
|
147
|
+
if (filesSeen >= MAX_FILES_PER_ROOT)
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return texts;
|
|
152
|
+
}
|
|
153
|
+
function corpusHas(corpus, term) {
|
|
154
|
+
const needle = term.trim().toLowerCase();
|
|
155
|
+
if (!needle)
|
|
156
|
+
return false;
|
|
157
|
+
return corpus.some((entry) => entry.includes(needle));
|
|
158
|
+
}
|
|
159
|
+
function extractAnchors(line) {
|
|
160
|
+
const anchors = [];
|
|
161
|
+
const backticks = [...line.matchAll(/`([^`]+)`/g)].map((match) => match[1]);
|
|
162
|
+
for (const raw of backticks) {
|
|
163
|
+
anchors.push(raw);
|
|
164
|
+
const base = path.basename(raw);
|
|
165
|
+
if (base && base !== raw)
|
|
166
|
+
anchors.push(base);
|
|
167
|
+
}
|
|
168
|
+
for (const match of line.matchAll(/([A-Za-z0-9_.-]+\.(?:cjs|css|go|html|java|js|json|jsx|kt|md|mjs|py|rb|rs|scss|sh|sql|swift|toml|ts|tsx|txt|yaml|yml))/g)) {
|
|
169
|
+
anchors.push(match[1]);
|
|
170
|
+
}
|
|
171
|
+
for (const match of line.matchAll(/\b(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+\b/g)) {
|
|
172
|
+
anchors.push(match[0]);
|
|
173
|
+
anchors.push(path.basename(match[0]));
|
|
174
|
+
}
|
|
175
|
+
for (const match of line.matchAll(/\b[A-Z][A-Za-z0-9]+(?:[A-Z][A-Za-z0-9]+)+\b/g)) {
|
|
176
|
+
anchors.push(match[0]);
|
|
177
|
+
}
|
|
178
|
+
return uniqueTerms(anchors.filter((term) => term.length >= 3));
|
|
179
|
+
}
|
|
180
|
+
function extractDistinctiveKeywords(line) {
|
|
181
|
+
const clean = line
|
|
182
|
+
.replace(/<!--.*?-->/g, " ")
|
|
183
|
+
.replace(/`[^`]+`/g, " ")
|
|
184
|
+
.replace(/\[(?:high|medium|low|pinned)\]/gi, " ");
|
|
185
|
+
const rawTerms = extractKeywords(clean).split(/\s+/).filter(Boolean);
|
|
186
|
+
return uniqueTerms(rawTerms.filter((term) => {
|
|
187
|
+
if (term.length < 4)
|
|
188
|
+
return false;
|
|
189
|
+
if (STOP_WORDS.has(term))
|
|
190
|
+
return false;
|
|
191
|
+
if (GENERIC_TASK_TERMS.has(term))
|
|
192
|
+
return false;
|
|
193
|
+
return true;
|
|
194
|
+
})).slice(0, 6);
|
|
195
|
+
}
|
|
196
|
+
function formatDetail(issues, roots) {
|
|
197
|
+
if (roots.length === 0)
|
|
198
|
+
return "skipped: no project repo/docs roots available for task hygiene scan";
|
|
199
|
+
if (issues.length === 0)
|
|
200
|
+
return `ok across ${roots.length} root${roots.length === 1 ? "" : "s"}`;
|
|
201
|
+
const preview = issues
|
|
202
|
+
.slice(0, 3)
|
|
203
|
+
.map((issue) => `${issue.id} ${issue.reason === "anchors-missing" ? "missing anchors" : "missing keywords"} (${issue.evidence.join(", ")})`)
|
|
204
|
+
.join("; ");
|
|
205
|
+
return `${issues.length} suspect task(s): ${preview}`;
|
|
206
|
+
}
|
|
207
|
+
export function inspectTaskHygiene(phrenPath, project, repoPath) {
|
|
208
|
+
const parsed = readTasks(phrenPath, project);
|
|
209
|
+
if (!parsed.ok) {
|
|
210
|
+
return { ok: true, detail: "skipped: tasks unavailable", issues: [] };
|
|
211
|
+
}
|
|
212
|
+
const roots = uniqueValues([
|
|
213
|
+
path.join(phrenPath, project),
|
|
214
|
+
repoPath || "",
|
|
215
|
+
].filter((candidate) => candidate && fs.existsSync(candidate)));
|
|
216
|
+
const corpus = roots.flatMap((root) => collectCorpus(root));
|
|
217
|
+
const issues = [];
|
|
218
|
+
const items = [...parsed.data.items.Active, ...parsed.data.items.Queue];
|
|
219
|
+
for (const item of items) {
|
|
220
|
+
const anchors = extractAnchors(item.line);
|
|
221
|
+
if (anchors.length > 0) {
|
|
222
|
+
const matched = anchors.filter((term) => corpusHas(corpus, term));
|
|
223
|
+
if (matched.length === 0) {
|
|
224
|
+
issues.push({
|
|
225
|
+
id: item.id,
|
|
226
|
+
line: item.line,
|
|
227
|
+
reason: "anchors-missing",
|
|
228
|
+
evidence: anchors.slice(0, 3),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const keywords = extractDistinctiveKeywords(item.line);
|
|
234
|
+
if (keywords.length < 2)
|
|
235
|
+
continue;
|
|
236
|
+
const matched = keywords.filter((term) => corpusHas(corpus, term));
|
|
237
|
+
if (matched.length === 0) {
|
|
238
|
+
issues.push({
|
|
239
|
+
id: item.id,
|
|
240
|
+
line: item.line,
|
|
241
|
+
reason: "keywords-missing",
|
|
242
|
+
evidence: keywords.slice(0, 3),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
ok: issues.length === 0,
|
|
248
|
+
detail: formatDetail(issues, roots),
|
|
249
|
+
issues,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import { addTask, completeTask, readTasks, resolveTaskItem, updateTask, } from "./data-access.js";
|
|
3
|
+
import { parseGithubIssueUrl, resolveProjectGithubRepo } from "./tasks-github.js";
|
|
4
|
+
import { getProactivityLevelForTask, shouldAutoCaptureTaskForLevel, hasExecutionIntent, hasDiscoveryIntent, hasSuppressTaskIntent, hasCodeChangeContext } from "./proactivity.js";
|
|
5
|
+
import { getWorkflowPolicy } from "./shared-governance.js";
|
|
6
|
+
import { debugLog, sessionMarker } from "./shared.js";
|
|
7
|
+
import { errorMessage } from "./utils.js";
|
|
8
|
+
import { incrementSessionTasksCompleted } from "./mcp-session.js";
|
|
9
|
+
const ACTION_PREFIX_RE = /^(?:please\s+|can you\s+|could you\s+|would you\s+|i want you to\s+|i want to\s+|let(?:'|’)s\s+|lets\s+|help me\s+)/i;
|
|
10
|
+
const EXPLICIT_TASK_PREFIX_RE = /^(?:add(?:\s+(?:this|that|it))?\s+(?:to\s+(?:the\s+)?)?(?:task|todo(?:\s+list)?|task(?:\s+list)?)|add\s+(?:a\s+)?task|put(?:\s+(?:this|that|it))?\s+(?:in|on)\s+(?:the\s+)?(?:task|todo(?:\s+list)?|task(?:\s+list)?))\s*(?::|-|,)?\s*/i;
|
|
11
|
+
const NON_ACTIONABLE_RE = /\b(brainstorm|idea|ideas|maybe|what if|should we|could we|would it make sense|question|explain|why is|how does)\b/i;
|
|
12
|
+
const ACTIONABLE_RE = /\b(add|build|change|complete|continue|create|delete|fix|implement|improve|investigate|make|move|refactor|remove|rename|repair|ship|start|update|wire)\b/i;
|
|
13
|
+
const CONTINUE_RE = /\b(continue|keep going|finish|resume|pick up|work on that|that task)\b/i;
|
|
14
|
+
const GITHUB_URL_RE = /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/\d+(?:[?#][^\s]*)?/g;
|
|
15
|
+
const GITHUB_ISSUE_RE = /(^|[^\w/])#(\d+)\b/g;
|
|
16
|
+
const TASK_STOP_WORDS = new Set([
|
|
17
|
+
"about",
|
|
18
|
+
"after",
|
|
19
|
+
"again",
|
|
20
|
+
"also",
|
|
21
|
+
"auto",
|
|
22
|
+
"automatic",
|
|
23
|
+
"because",
|
|
24
|
+
"before",
|
|
25
|
+
"code",
|
|
26
|
+
"phren",
|
|
27
|
+
"current",
|
|
28
|
+
"during",
|
|
29
|
+
"feature",
|
|
30
|
+
"from",
|
|
31
|
+
"have",
|
|
32
|
+
"into",
|
|
33
|
+
"just",
|
|
34
|
+
"like",
|
|
35
|
+
"make",
|
|
36
|
+
"more",
|
|
37
|
+
"need",
|
|
38
|
+
"really",
|
|
39
|
+
"should",
|
|
40
|
+
"some",
|
|
41
|
+
"something",
|
|
42
|
+
"stuff",
|
|
43
|
+
"task",
|
|
44
|
+
"tasks",
|
|
45
|
+
"that",
|
|
46
|
+
"them",
|
|
47
|
+
"then",
|
|
48
|
+
"this",
|
|
49
|
+
"thing",
|
|
50
|
+
"want",
|
|
51
|
+
"with",
|
|
52
|
+
"work",
|
|
53
|
+
]);
|
|
54
|
+
function taskSessionPath(phrenPath, sessionId) {
|
|
55
|
+
return sessionMarker(phrenPath, `task-${sessionId}.json`);
|
|
56
|
+
}
|
|
57
|
+
function readTaskSessionState(phrenPath, sessionId) {
|
|
58
|
+
const file = taskSessionPath(phrenPath, sessionId);
|
|
59
|
+
if (!fs.existsSync(file))
|
|
60
|
+
return null;
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
debugLog(`task lifecycle read session ${sessionId}: ${errorMessage(err)}`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function writeTaskSessionState(phrenPath, state) {
|
|
70
|
+
const file = taskSessionPath(phrenPath, state.sessionId);
|
|
71
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
|
|
72
|
+
}
|
|
73
|
+
function clearTaskSessionState(phrenPath, sessionId) {
|
|
74
|
+
const file = taskSessionPath(phrenPath, sessionId);
|
|
75
|
+
try {
|
|
76
|
+
if (fs.existsSync(file))
|
|
77
|
+
fs.unlinkSync(file);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
debugLog(`task lifecycle clear session ${sessionId}: ${errorMessage(err)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function getTaskMode(phrenPath) {
|
|
84
|
+
return getWorkflowPolicy(phrenPath).taskMode;
|
|
85
|
+
}
|
|
86
|
+
function isActionablePrompt(prompt, intent) {
|
|
87
|
+
const normalized = prompt.trim();
|
|
88
|
+
if (!normalized)
|
|
89
|
+
return false;
|
|
90
|
+
if (NON_ACTIONABLE_RE.test(normalized))
|
|
91
|
+
return false;
|
|
92
|
+
if (intent === "general")
|
|
93
|
+
return ACTIONABLE_RE.test(normalized);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
function normalizeTaskSummary(prompt) {
|
|
97
|
+
const withoutGithub = prompt
|
|
98
|
+
.replace(GITHUB_URL_RE, " ")
|
|
99
|
+
.replace(GITHUB_ISSUE_RE, "$1 ")
|
|
100
|
+
.replace(/\s+/g, " ")
|
|
101
|
+
.trim();
|
|
102
|
+
const stripped = withoutGithub.replace(ACTION_PREFIX_RE, "").trim();
|
|
103
|
+
const withoutTaskPrefix = stripped.replace(EXPLICIT_TASK_PREFIX_RE, "").trim();
|
|
104
|
+
const taskSource = withoutTaskPrefix || stripped;
|
|
105
|
+
const firstClause = taskSource.split(/[\n.!?]/)[0]?.trim() || taskSource;
|
|
106
|
+
const cleaned = firstClause
|
|
107
|
+
.replace(/^to\s+/i, "")
|
|
108
|
+
.replace(/\s+/g, " ")
|
|
109
|
+
.replace(/^["'`]+|["'`]+$/g, "")
|
|
110
|
+
.trim();
|
|
111
|
+
const capped = cleaned.length > 110 ? `${cleaned.slice(0, 109).trimEnd()}…` : cleaned;
|
|
112
|
+
if (!capped)
|
|
113
|
+
return "Follow up on current work";
|
|
114
|
+
return capped.charAt(0).toUpperCase() + capped.slice(1);
|
|
115
|
+
}
|
|
116
|
+
function tokenizeTaskText(value) {
|
|
117
|
+
return value
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
.replace(/<!--.*?-->/g, " ")
|
|
120
|
+
.replace(/[`"'.,!?()[\]{}:/\\]/g, " ")
|
|
121
|
+
.split(/\s+/)
|
|
122
|
+
.filter((token) => token.length >= 4 && !TASK_STOP_WORDS.has(token));
|
|
123
|
+
}
|
|
124
|
+
function overlapScore(prompt, item) {
|
|
125
|
+
const promptTokens = new Set(tokenizeTaskText(prompt));
|
|
126
|
+
if (promptTokens.size === 0)
|
|
127
|
+
return 0;
|
|
128
|
+
const itemTokens = tokenizeTaskText(item.line);
|
|
129
|
+
let score = 0;
|
|
130
|
+
for (const token of itemTokens) {
|
|
131
|
+
if (promptTokens.has(token))
|
|
132
|
+
score += 1;
|
|
133
|
+
}
|
|
134
|
+
if (prompt.toLowerCase().includes(item.line.toLowerCase()))
|
|
135
|
+
score += 3;
|
|
136
|
+
return score;
|
|
137
|
+
}
|
|
138
|
+
function matchExistingActiveTask(prompt, activeItems) {
|
|
139
|
+
if (activeItems.length === 0)
|
|
140
|
+
return null;
|
|
141
|
+
if (activeItems.length === 1 && CONTINUE_RE.test(prompt))
|
|
142
|
+
return activeItems[0];
|
|
143
|
+
const ranked = activeItems
|
|
144
|
+
.map((item) => ({ item, score: overlapScore(prompt, item) }))
|
|
145
|
+
.filter((entry) => entry.score > 0)
|
|
146
|
+
.sort((a, b) => b.score - a.score);
|
|
147
|
+
if (ranked.length === 0)
|
|
148
|
+
return null;
|
|
149
|
+
if (ranked[0].score >= 2 && (ranked.length === 1 || ranked[0].score > ranked[1].score)) {
|
|
150
|
+
return ranked[0].item;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
function resolveTrackedSessionTask(phrenPath, state) {
|
|
155
|
+
const match = state.stableId ? `bid:${state.stableId}` : state.item;
|
|
156
|
+
const resolved = resolveTaskItem(phrenPath, state.project, match);
|
|
157
|
+
return resolved.ok ? resolved.data : null;
|
|
158
|
+
}
|
|
159
|
+
function extractGithubMetadata(phrenPath, project, prompt) {
|
|
160
|
+
const repo = resolveProjectGithubRepo(phrenPath, project);
|
|
161
|
+
for (const match of prompt.matchAll(GITHUB_URL_RE)) {
|
|
162
|
+
const parsed = parseGithubIssueUrl(match[0]);
|
|
163
|
+
if (!parsed)
|
|
164
|
+
continue;
|
|
165
|
+
if (repo && parsed.repo && parsed.repo !== repo)
|
|
166
|
+
continue;
|
|
167
|
+
return {
|
|
168
|
+
github_issue: parsed.issueNumber,
|
|
169
|
+
github_url: parsed.url,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (!repo)
|
|
173
|
+
return {};
|
|
174
|
+
const issueMatch = GITHUB_ISSUE_RE.exec(prompt);
|
|
175
|
+
GITHUB_ISSUE_RE.lastIndex = 0;
|
|
176
|
+
if (!issueMatch)
|
|
177
|
+
return {};
|
|
178
|
+
return { github_issue: Number.parseInt(issueMatch[2], 10) };
|
|
179
|
+
}
|
|
180
|
+
function buildSuggestionNotice(project, line, issueMeta) {
|
|
181
|
+
const githubLine = issueMeta.github_url
|
|
182
|
+
? `Suggested link: ${issueMeta.github_url}`
|
|
183
|
+
: issueMeta.github_issue
|
|
184
|
+
? `Suggested GitHub link: #${issueMeta.github_issue}`
|
|
185
|
+
: "";
|
|
186
|
+
return [
|
|
187
|
+
"<phren-notice>",
|
|
188
|
+
`Task suggestion for ${project}:`,
|
|
189
|
+
`- ${line}`,
|
|
190
|
+
...(githubLine ? [githubLine] : []),
|
|
191
|
+
"<phren-notice>",
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
function persistTaskAttachment(phrenPath, sessionId, project, item, summary, mode) {
|
|
195
|
+
writeTaskSessionState(phrenPath, {
|
|
196
|
+
sessionId,
|
|
197
|
+
project,
|
|
198
|
+
stableId: item.stableId,
|
|
199
|
+
item: item.line,
|
|
200
|
+
summary,
|
|
201
|
+
mode,
|
|
202
|
+
createdAt: new Date().toISOString(),
|
|
203
|
+
updatedAt: new Date().toISOString(),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
export function handleTaskPromptLifecycle(args) {
|
|
207
|
+
const mode = getTaskMode(args.phrenPath);
|
|
208
|
+
if (mode === "off" || mode === "manual" || !args.project || !args.sessionId) {
|
|
209
|
+
return { mode, noticeLines: [] };
|
|
210
|
+
}
|
|
211
|
+
// Suppression takes absolute priority — user explicitly said not to create a task.
|
|
212
|
+
if (hasSuppressTaskIntent(args.prompt)) {
|
|
213
|
+
debugLog(`task lifecycle suppressed ${args.project}: suppress-task intent detected`);
|
|
214
|
+
return { mode, noticeLines: [] };
|
|
215
|
+
}
|
|
216
|
+
if (!isActionablePrompt(args.prompt, args.intent)) {
|
|
217
|
+
return { mode, noticeLines: [] };
|
|
218
|
+
}
|
|
219
|
+
const taskLevel = args.taskLevel ?? getProactivityLevelForTask(args.phrenPath);
|
|
220
|
+
if (mode === "auto" && !shouldAutoCaptureTaskForLevel(taskLevel, args.prompt)) {
|
|
221
|
+
debugLog(`task lifecycle skipped ${args.project}: task proactivity=${taskLevel}`);
|
|
222
|
+
return { mode, noticeLines: [] };
|
|
223
|
+
}
|
|
224
|
+
const parsed = readTasks(args.phrenPath, args.project);
|
|
225
|
+
if (!parsed.ok)
|
|
226
|
+
return { mode, noticeLines: [] };
|
|
227
|
+
const summary = normalizeTaskSummary(args.prompt);
|
|
228
|
+
const issueMeta = extractGithubMetadata(args.phrenPath, args.project, args.prompt);
|
|
229
|
+
const trackedState = readTaskSessionState(args.phrenPath, args.sessionId);
|
|
230
|
+
const trackedItem = trackedState && trackedState.project === args.project
|
|
231
|
+
? resolveTrackedSessionTask(args.phrenPath, trackedState)
|
|
232
|
+
: null;
|
|
233
|
+
const activeItems = parsed.data.items.Active;
|
|
234
|
+
const reusable = trackedItem && trackedItem.section === "Active"
|
|
235
|
+
? trackedItem
|
|
236
|
+
: matchExistingActiveTask(args.prompt, activeItems);
|
|
237
|
+
if (mode === "suggest") {
|
|
238
|
+
const line = reusable?.line || summary;
|
|
239
|
+
return {
|
|
240
|
+
mode,
|
|
241
|
+
noticeLines: buildSuggestionNotice(args.project, line, issueMeta),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// Intent-aware auto mode: if the user is in discovery mode (brainstorming,
|
|
245
|
+
// exploring ideas) and NOT in execution mode (approving, committing to work,
|
|
246
|
+
// or performing code changes), create a speculative task and surface a suggestion.
|
|
247
|
+
if (mode === "auto" && !hasExecutionIntent(args.prompt) && !hasCodeChangeContext(args.prompt) && hasDiscoveryIntent(args.prompt)) {
|
|
248
|
+
const line = reusable?.line || summary;
|
|
249
|
+
debugLog(`task lifecycle auto→speculative ${args.project}: discovery intent detected`);
|
|
250
|
+
if (!reusable) {
|
|
251
|
+
addTask(args.phrenPath, args.project, summary, {
|
|
252
|
+
createdAt: new Date().toISOString(),
|
|
253
|
+
sessionId: args.sessionId,
|
|
254
|
+
speculative: true,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
mode: "auto",
|
|
259
|
+
noticeLines: buildSuggestionNotice(args.project, line, issueMeta),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const targetMatch = reusable?.stableId ? `bid:${reusable.stableId}` : reusable?.id;
|
|
263
|
+
if (!reusable) {
|
|
264
|
+
const add = addTask(args.phrenPath, args.project, summary, {
|
|
265
|
+
createdAt: new Date().toISOString(),
|
|
266
|
+
sessionId: args.sessionId,
|
|
267
|
+
});
|
|
268
|
+
if (!add.ok) {
|
|
269
|
+
debugLog(`task lifecycle add ${args.project}: ${add.error}`);
|
|
270
|
+
return { mode, noticeLines: [] };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const update = updateTask(args.phrenPath, args.project, targetMatch || summary, {
|
|
274
|
+
section: "active",
|
|
275
|
+
context: summary,
|
|
276
|
+
replace_context: true,
|
|
277
|
+
...issueMeta,
|
|
278
|
+
});
|
|
279
|
+
if (!update.ok) {
|
|
280
|
+
debugLog(`task lifecycle update ${args.project}: ${update.error}`);
|
|
281
|
+
return { mode, noticeLines: [] };
|
|
282
|
+
}
|
|
283
|
+
const resolved = resolveTaskItem(args.phrenPath, args.project, targetMatch || summary);
|
|
284
|
+
if (!resolved.ok) {
|
|
285
|
+
debugLog(`task lifecycle resolve ${args.project}: ${resolved.error}`);
|
|
286
|
+
return { mode, noticeLines: [] };
|
|
287
|
+
}
|
|
288
|
+
persistTaskAttachment(args.phrenPath, args.sessionId, args.project, resolved.data, summary, "auto");
|
|
289
|
+
return {
|
|
290
|
+
mode,
|
|
291
|
+
noticeLines: [
|
|
292
|
+
"<phren-notice>",
|
|
293
|
+
`Active task (${args.project}): ${resolved.data.line}`,
|
|
294
|
+
"<phren-notice>",
|
|
295
|
+
],
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
export function finalizeTaskSession(args) {
|
|
299
|
+
if (!args.sessionId || getTaskMode(args.phrenPath) !== "auto")
|
|
300
|
+
return;
|
|
301
|
+
const state = readTaskSessionState(args.phrenPath, args.sessionId);
|
|
302
|
+
if (!state || state.mode !== "auto")
|
|
303
|
+
return;
|
|
304
|
+
const match = state.stableId ? `bid:${state.stableId}` : state.item;
|
|
305
|
+
if (args.status === "saved-local" || args.status === "saved-pushed") {
|
|
306
|
+
const completed = completeTask(args.phrenPath, state.project, match);
|
|
307
|
+
if (!completed.ok) {
|
|
308
|
+
debugLog(`task lifecycle complete ${state.project}: ${completed.error}`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
incrementSessionTasksCompleted(args.phrenPath, 1, state.sessionId, state.project);
|
|
312
|
+
clearTaskSessionState(args.phrenPath, args.sessionId);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (args.status === "error") {
|
|
316
|
+
const blocked = updateTask(args.phrenPath, state.project, match, {
|
|
317
|
+
section: "active",
|
|
318
|
+
context: `Blocked: ${args.detail}`,
|
|
319
|
+
replace_context: true,
|
|
320
|
+
});
|
|
321
|
+
if (!blocked.ok) {
|
|
322
|
+
debugLog(`task lifecycle block ${state.project}: ${blocked.error}`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
writeTaskSessionState(args.phrenPath, {
|
|
326
|
+
...state,
|
|
327
|
+
summary: `Blocked: ${args.detail}`,
|
|
328
|
+
updatedAt: new Date().toISOString(),
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
export function clearTaskSession(phrenPath, sessionId) {
|
|
334
|
+
if (!sessionId)
|
|
335
|
+
return;
|
|
336
|
+
clearTaskSessionState(phrenPath, sessionId);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Return the active TaskItem tracked for a session+project, if any.
|
|
340
|
+
* Used by mcp-finding.ts to link findings to active tasks.
|
|
341
|
+
*/
|
|
342
|
+
export function getActiveTaskForSession(phrenPath, sessionId, project) {
|
|
343
|
+
const state = readTaskSessionState(phrenPath, sessionId);
|
|
344
|
+
if (!state || state.project !== project)
|
|
345
|
+
return null;
|
|
346
|
+
return resolveTrackedSessionTask(phrenPath, state);
|
|
347
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { execFileSync } from "child_process";
|
|
4
|
+
import { EXEC_TIMEOUT_MS, phrenErr, phrenOk, PhrenError } from "./shared.js";
|
|
5
|
+
import { errorMessage, resolveExecCommand } from "./utils.js";
|
|
6
|
+
const GITHUB_REPO_URL = /https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?:\/|\b|$)/;
|
|
7
|
+
const GITHUB_ISSUE_URL = /https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)(?:[?#][^\s]*)?$/;
|
|
8
|
+
export function parseGithubIssueUrl(url) {
|
|
9
|
+
const trimmed = url.trim();
|
|
10
|
+
const match = trimmed.match(GITHUB_ISSUE_URL);
|
|
11
|
+
if (!match)
|
|
12
|
+
return null;
|
|
13
|
+
return {
|
|
14
|
+
repo: match[1],
|
|
15
|
+
issueNumber: Number.parseInt(match[2], 10),
|
|
16
|
+
url: match[0],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function extractGithubRepoFromText(content) {
|
|
20
|
+
const match = content.match(GITHUB_REPO_URL);
|
|
21
|
+
return match?.[1];
|
|
22
|
+
}
|
|
23
|
+
export function resolveProjectGithubRepo(phrenPath, project) {
|
|
24
|
+
for (const file of ["CLAUDE.md", "summary.md"]) {
|
|
25
|
+
const fullPath = path.join(phrenPath, project, file);
|
|
26
|
+
if (!fs.existsSync(fullPath))
|
|
27
|
+
continue;
|
|
28
|
+
const repo = extractGithubRepoFromText(fs.readFileSync(fullPath, "utf8"));
|
|
29
|
+
if (repo)
|
|
30
|
+
return repo;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
export function buildTaskIssueBody(project, item) {
|
|
35
|
+
const lines = [
|
|
36
|
+
`Imported from phren task for project \`${project}\`.`,
|
|
37
|
+
"",
|
|
38
|
+
`Task item: ${item.line}`,
|
|
39
|
+
];
|
|
40
|
+
if (item.context) {
|
|
41
|
+
lines.push("", `Context: ${item.context}`);
|
|
42
|
+
}
|
|
43
|
+
if (item.stableId) {
|
|
44
|
+
lines.push("", `Task ID: \`bid:${item.stableId}\``);
|
|
45
|
+
}
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
|
48
|
+
export function createGithubIssueForTask(args) {
|
|
49
|
+
try {
|
|
50
|
+
const ghExec = resolveExecCommand("gh");
|
|
51
|
+
const stdout = execFileSync(ghExec.command, [
|
|
52
|
+
"issue",
|
|
53
|
+
"create",
|
|
54
|
+
"--repo",
|
|
55
|
+
args.repo,
|
|
56
|
+
"--title",
|
|
57
|
+
args.title,
|
|
58
|
+
"--body",
|
|
59
|
+
args.body,
|
|
60
|
+
], {
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
63
|
+
shell: ghExec.shell,
|
|
64
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
65
|
+
}).trim();
|
|
66
|
+
const parsed = parseGithubIssueUrl(stdout);
|
|
67
|
+
return phrenOk({
|
|
68
|
+
repo: args.repo,
|
|
69
|
+
issueNumber: parsed?.issueNumber,
|
|
70
|
+
url: parsed?.url || stdout,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
return phrenErr(`Could not create GitHub issue. Ensure GitHub CLI is installed and authenticated: ${errorMessage(err)}`, PhrenError.NETWORK_ERROR);
|
|
75
|
+
}
|
|
76
|
+
}
|