@dotdotgod/pi 0.1.21
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 +94 -0
- package/README.md +120 -0
- package/extensions/context-metrics/utils.ts +66 -0
- package/extensions/load-project/README.md +44 -0
- package/extensions/load-project/index.ts +76 -0
- package/extensions/load-project/utils.ts +338 -0
- package/extensions/plan-mode/README.md +65 -0
- package/extensions/plan-mode/index.ts +830 -0
- package/extensions/plan-mode/utils.ts +747 -0
- package/package.json +65 -0
- package/skills/project-initializer/SKILL.md +66 -0
- package/skills/project-initializer/agents/openai.yaml +4 -0
- package/skills/project-initializer/references/agent-docs.md +25 -0
- package/skills/project-initializer/scripts/init_project.sh +344 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions for plan mode.
|
|
3
|
+
* Extracted for testability.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Destructive commands blocked in plan mode
|
|
7
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
8
|
+
/\brm\b/i,
|
|
9
|
+
/\brmdir\b/i,
|
|
10
|
+
/\bmv\b/i,
|
|
11
|
+
/\bcp\b/i,
|
|
12
|
+
/\bmkdir\b/i,
|
|
13
|
+
/\btouch\b/i,
|
|
14
|
+
/\bchmod\b/i,
|
|
15
|
+
/\bchown\b/i,
|
|
16
|
+
/\bchgrp\b/i,
|
|
17
|
+
/\bln\b/i,
|
|
18
|
+
/\btee\b/i,
|
|
19
|
+
/\btruncate\b/i,
|
|
20
|
+
/\bdd\b/i,
|
|
21
|
+
/\bshred\b/i,
|
|
22
|
+
/(^|[^<])>(?!>)/,
|
|
23
|
+
/>>/,
|
|
24
|
+
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
25
|
+
/\byarn\s+(add|remove|install|publish|upgrade|dlx|create)/i,
|
|
26
|
+
/\bpnpm\s+(add|remove|install|publish|dlx|create)/i,
|
|
27
|
+
/\bpip\s+(install|uninstall)/i,
|
|
28
|
+
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
29
|
+
/\bbrew\s+(install|uninstall|upgrade)/i,
|
|
30
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
31
|
+
/\bsudo\b/i,
|
|
32
|
+
/\bsu\b/i,
|
|
33
|
+
/\bkill\b/i,
|
|
34
|
+
/\bpkill\b/i,
|
|
35
|
+
/\bkillall\b/i,
|
|
36
|
+
/\breboot\b/i,
|
|
37
|
+
/\bshutdown\b/i,
|
|
38
|
+
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
|
39
|
+
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
|
40
|
+
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Safe read-only commands allowed in plan mode
|
|
44
|
+
const SAFE_PATTERNS = [
|
|
45
|
+
/^\s*cat\b/,
|
|
46
|
+
/^\s*head\b/,
|
|
47
|
+
/^\s*tail\b/,
|
|
48
|
+
/^\s*less\b/,
|
|
49
|
+
/^\s*more\b/,
|
|
50
|
+
/^\s*grep\b/,
|
|
51
|
+
/^\s*find\b/,
|
|
52
|
+
/^\s*ls\b/,
|
|
53
|
+
/^\s*pwd\b/,
|
|
54
|
+
/^\s*echo\b/,
|
|
55
|
+
/^\s*printf\b/,
|
|
56
|
+
/^\s*wc\b/,
|
|
57
|
+
/^\s*sort\b/,
|
|
58
|
+
/^\s*uniq\b/,
|
|
59
|
+
/^\s*diff\b/,
|
|
60
|
+
/^\s*file\b/,
|
|
61
|
+
/^\s*stat\b/,
|
|
62
|
+
/^\s*du\b/,
|
|
63
|
+
/^\s*df\b/,
|
|
64
|
+
/^\s*tree\b/,
|
|
65
|
+
/^\s*which\b/,
|
|
66
|
+
/^\s*whereis\b/,
|
|
67
|
+
/^\s*type\b/,
|
|
68
|
+
/^\s*env\b/,
|
|
69
|
+
/^\s*printenv\b/,
|
|
70
|
+
/^\s*uname\b/,
|
|
71
|
+
/^\s*whoami\b/,
|
|
72
|
+
/^\s*id\b/,
|
|
73
|
+
/^\s*date\b/,
|
|
74
|
+
/^\s*cal\b/,
|
|
75
|
+
/^\s*uptime\b/,
|
|
76
|
+
/^\s*ps\b/,
|
|
77
|
+
/^\s*top\b/,
|
|
78
|
+
/^\s*htop\b/,
|
|
79
|
+
/^\s*free\b/,
|
|
80
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
|
81
|
+
/^\s*git\s+ls-/i,
|
|
82
|
+
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
|
83
|
+
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
84
|
+
/^\s*node\s+--version/i,
|
|
85
|
+
/^\s*python\s+--version/i,
|
|
86
|
+
/^\s*curl\s+(-I|--head|--silent|-s|--location|-L|https?:\/\/)/i,
|
|
87
|
+
/^\s*wget\s+-O\s*-/i,
|
|
88
|
+
/^\s*jq\b/,
|
|
89
|
+
/^\s*sed\s+-n/i,
|
|
90
|
+
/^\s*awk\b/,
|
|
91
|
+
/^\s*rg\b/,
|
|
92
|
+
/^\s*fd\b/,
|
|
93
|
+
/^\s*bat\b/,
|
|
94
|
+
/^\s*eza\b/,
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
export function tokenizeShellCommand(command: string): string[] | undefined {
|
|
98
|
+
const tokens: string[] = [];
|
|
99
|
+
let current = "";
|
|
100
|
+
let quote: "'" | '"' | undefined;
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
103
|
+
const char = command[i];
|
|
104
|
+
if (!char) continue;
|
|
105
|
+
|
|
106
|
+
if (quote) {
|
|
107
|
+
if (char === quote) {
|
|
108
|
+
quote = undefined;
|
|
109
|
+
} else {
|
|
110
|
+
current += char;
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (char === "'" || char === '"') {
|
|
116
|
+
quote = char;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (/\s/.test(char)) {
|
|
121
|
+
if (current.length > 0) {
|
|
122
|
+
tokens.push(current);
|
|
123
|
+
current = "";
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
current += char;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (quote) return undefined;
|
|
132
|
+
if (current.length > 0) tokens.push(current);
|
|
133
|
+
return tokens;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isPlanArchivePath(path: string): boolean {
|
|
137
|
+
const normalized = path.replace(/^@/, "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/");
|
|
138
|
+
if (normalized.includes("\0") || normalized.includes("*")) return false;
|
|
139
|
+
if (normalized.startsWith("/") || normalized.startsWith("../") || normalized.includes("/../")) return false;
|
|
140
|
+
return (
|
|
141
|
+
normalized.startsWith("docs/plan/") ||
|
|
142
|
+
normalized.startsWith("docs/archive/") ||
|
|
143
|
+
normalized === "docs/archive/plan"
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function isProtectedPlanArchiveRoot(path: string): boolean {
|
|
148
|
+
const normalized = path.replace(/^@/, "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/");
|
|
149
|
+
return normalized === "docs/plan" || normalized === "docs/archive" || normalized === "docs/archive/plan";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function isSafePlanArchiveCommand(command: string): boolean {
|
|
153
|
+
if (/[;&|<>`$\n\r]/.test(command)) return false;
|
|
154
|
+
|
|
155
|
+
const tokens = tokenizeShellCommand(command.trim());
|
|
156
|
+
if (!tokens || tokens.length === 0) return false;
|
|
157
|
+
|
|
158
|
+
const [program, ...args] = tokens;
|
|
159
|
+
if (program === "mkdir") {
|
|
160
|
+
const paths = args.filter((arg) => arg !== "-p");
|
|
161
|
+
const options = args.filter((arg) => arg.startsWith("-"));
|
|
162
|
+
return paths.length > 0 && options.every((option) => option === "-p") && paths.every(isPlanArchivePath);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (program === "mv") {
|
|
166
|
+
const paths = args.filter((arg) => !arg.startsWith("-"));
|
|
167
|
+
const options = args.filter((arg) => arg.startsWith("-"));
|
|
168
|
+
return paths.length >= 2 && options.every((option) => option === "-f" || option === "-n") && paths.every(isPlanArchivePath);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (program === "rm" || program === "rmdir") {
|
|
172
|
+
const paths = args.filter((arg) => !arg.startsWith("-"));
|
|
173
|
+
const options = args.filter((arg) => arg.startsWith("-"));
|
|
174
|
+
const optionsAllowed =
|
|
175
|
+
program === "rmdir"
|
|
176
|
+
? options.length === 0
|
|
177
|
+
: options.every((option) => /^-[rRf]+$/.test(option));
|
|
178
|
+
return (
|
|
179
|
+
paths.length > 0 &&
|
|
180
|
+
optionsAllowed &&
|
|
181
|
+
paths.every(isPlanArchivePath) &&
|
|
182
|
+
paths.every((path) => !isProtectedPlanArchiveRoot(path))
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function isSafeCommand(command: string): boolean {
|
|
190
|
+
if (isSafePlanArchiveCommand(command)) return true;
|
|
191
|
+
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
|
|
192
|
+
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
|
|
193
|
+
return !isDestructive && isSafe;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function getDotdotgodCliArgs(command: string): string[] | undefined {
|
|
197
|
+
if (/[;&|<>`$\n\r]/.test(command)) return undefined;
|
|
198
|
+
|
|
199
|
+
const tokens = tokenizeShellCommand(command.trim());
|
|
200
|
+
if (!tokens || tokens.length === 0) return undefined;
|
|
201
|
+
|
|
202
|
+
const [program, script, ...rest] = tokens;
|
|
203
|
+
if (program === "dotdotgod") return tokens.slice(1);
|
|
204
|
+
if (program !== "node" || !script || script.startsWith("-")) return undefined;
|
|
205
|
+
|
|
206
|
+
const normalizedScript = script.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/");
|
|
207
|
+
return normalizedScript === "packages/cli/bin/dotdotgod.mjs" ? rest : undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function isDotdotgodCliCommand(command: string): boolean {
|
|
211
|
+
return getDotdotgodCliArgs(command) !== undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function isAutoAllowedDotdotgodPlanModeCommand(command: string): boolean {
|
|
215
|
+
const args = getDotdotgodCliArgs(command);
|
|
216
|
+
if (!args || args.length === 0) return false;
|
|
217
|
+
const commandName = args[0];
|
|
218
|
+
const subcommand = args[1];
|
|
219
|
+
if (["status", "load-snapshot", "resolve", "expand", "index"].includes(commandName ?? "")) return true;
|
|
220
|
+
if (commandName === "config") return subcommand !== "init";
|
|
221
|
+
if (commandName === "graph") return subcommand === "impact" || subcommand === "communities";
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface PlanModeBashApproval {
|
|
226
|
+
hasUI: boolean;
|
|
227
|
+
confirm: (title: string, message: string) => boolean | Promise<boolean>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface PlanModeBashDecision {
|
|
231
|
+
allow: boolean;
|
|
232
|
+
reason?: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function shouldAllowPlanModeBashCommand(command: string, approval?: PlanModeBashApproval): Promise<PlanModeBashDecision> {
|
|
236
|
+
if (isSafeCommand(command)) return { allow: true };
|
|
237
|
+
|
|
238
|
+
if (isDotdotgodCliCommand(command)) {
|
|
239
|
+
if (isAutoAllowedDotdotgodPlanModeCommand(command)) return { allow: true };
|
|
240
|
+
|
|
241
|
+
if (!approval?.hasUI) {
|
|
242
|
+
return {
|
|
243
|
+
allow: false,
|
|
244
|
+
reason: `Plan mode: dotdotgod CLI commands require interactive user approval.\nCommand: ${command}`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const approved = await approval.confirm(
|
|
249
|
+
"Allow dotdotgod CLI in Plan Mode?",
|
|
250
|
+
`The agent wants to run:\n${command}\n\nThis can read or update dotdotgod cache files depending on the subcommand. Allow this one command?`,
|
|
251
|
+
);
|
|
252
|
+
return approved
|
|
253
|
+
? { allow: true }
|
|
254
|
+
: {
|
|
255
|
+
allow: false,
|
|
256
|
+
reason: `Plan mode: dotdotgod CLI command blocked by user.\nCommand: ${command}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
allow: false,
|
|
262
|
+
reason: `Plan mode: command is not allowlisted. Mutating, install, or deletion commands are only allowed during execution mode.\nCommand: ${command}`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function getCurrentPlanReadmePath(path: string): string | undefined {
|
|
267
|
+
const normalized = path.replace(/^@/, "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/");
|
|
268
|
+
const match = normalized.match(/^docs\/plan\/([a-z0-9]+(?:-[a-z0-9]+)*)\/(README\.md|[A-Z0-9]+(?:_[A-Z0-9]+)*\.md)$/);
|
|
269
|
+
if (!match?.[1]) return undefined;
|
|
270
|
+
return `docs/plan/${match[1]}/README.md`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function detectPlanExecutionIntent(text: string): boolean {
|
|
274
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
275
|
+
if (!normalized) return false;
|
|
276
|
+
|
|
277
|
+
const refinementOnly = /(refine|revise|edit|modify|adjust|improve|update)\b.*\b(plan|proposal)|\b(plan|proposal)\b.*\b(refine|revise|edit|modify|adjust|improve|update)\b|수정하자|수정해줘|다듬|보완|개선|계획하자|계획을 세우|계획 만들어|플랜.*(수정|다듬|보완|개선)/i;
|
|
278
|
+
const explicitEnglishExecution = /\b(execute|start|run|begin|implement)\b.*\b(plan|docs\/plan\/[a-z0-9-]+\/README\.md|[a-z0-9]+(?:-[a-z0-9]+)+)\b|\b(proceed with|carry out)\b.*\b(plan|docs\/plan\/[a-z0-9-]+\/README\.md|[a-z0-9]+(?:-[a-z0-9]+)+)\b/i;
|
|
279
|
+
const explicitKoreanExecution = /(진행|시작|실행)(해줘|해주세요|하자|합시다|해보자|해|해라|시켜줘)/i;
|
|
280
|
+
const executeNow = /^(execute|start|run|begin|implement|proceed)\b/i;
|
|
281
|
+
|
|
282
|
+
const hasExecution = explicitEnglishExecution.test(normalized) || explicitKoreanExecution.test(normalized) || executeNow.test(normalized);
|
|
283
|
+
if (!hasExecution) return false;
|
|
284
|
+
if (refinementOnly.test(normalized) && !explicitEnglishExecution.test(normalized) && !explicitKoreanExecution.test(normalized)) return false;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function extractPlanSlugMentions(text: string): string[] {
|
|
289
|
+
const slugs: string[] = [];
|
|
290
|
+
const seen = new Set<string>();
|
|
291
|
+
const add = (slug: string | undefined) => {
|
|
292
|
+
if (!slug || seen.has(slug)) return;
|
|
293
|
+
seen.add(slug);
|
|
294
|
+
slugs.push(slug);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
for (const match of text.matchAll(/docs\/plan\/([a-z0-9]+(?:-[a-z0-9]+)*)\/(?:README\.md|[A-Z0-9]+(?:_[A-Z0-9]+)*\.md)/g)) {
|
|
298
|
+
add(match[1]);
|
|
299
|
+
}
|
|
300
|
+
for (const match of text.matchAll(/(?:^|[\s`"'(:])([a-z0-9]+(?:-[a-z0-9]+)+)(?=$|[\s`"'),.;:])/g)) {
|
|
301
|
+
add(match[1]);
|
|
302
|
+
}
|
|
303
|
+
return slugs;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function resolveMentionedPlanPath(
|
|
307
|
+
cwd: string,
|
|
308
|
+
text: string | undefined,
|
|
309
|
+
currentPlanPath: string | undefined,
|
|
310
|
+
touchedPaths: readonly string[],
|
|
311
|
+
pathExists: (cwd: string, path: string) => boolean,
|
|
312
|
+
): string | undefined {
|
|
313
|
+
const candidates = [
|
|
314
|
+
...extractPathMentions(text ?? "").map((path) => getCurrentPlanReadmePath(path)).filter((path): path is string => Boolean(path)),
|
|
315
|
+
...extractPlanSlugMentions(text ?? "").map((slug) => `docs/plan/${slug}/README.md`),
|
|
316
|
+
...(currentPlanPath ? [currentPlanPath] : []),
|
|
317
|
+
...touchedPaths.map((path) => getCurrentPlanReadmePath(path)).filter((path): path is string => Boolean(path)),
|
|
318
|
+
];
|
|
319
|
+
const seen = new Set<string>();
|
|
320
|
+
return candidates.find((path) => {
|
|
321
|
+
if (seen.has(path)) return false;
|
|
322
|
+
seen.add(path);
|
|
323
|
+
return pathExists(cwd, path);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function extractPathMentions(text: string): string[] {
|
|
328
|
+
const paths: string[] = [];
|
|
329
|
+
const seen = new Set<string>();
|
|
330
|
+
const re = /(?:^|[\s`"'(:])(@?\.?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+)(?=$|[\s`"'),.;:])/g;
|
|
331
|
+
let match;
|
|
332
|
+
while ((match = re.exec(text)) !== null) {
|
|
333
|
+
const raw = match[1];
|
|
334
|
+
if (!raw) continue;
|
|
335
|
+
const normalized = raw.replace(/^@/, "").replace(/^\.\//, "").replace(/\/+/g, "/");
|
|
336
|
+
if (normalized.includes("..") || normalized.endsWith("/")) continue;
|
|
337
|
+
if (!/[.][A-Za-z0-9]+$/.test(normalized)) continue;
|
|
338
|
+
if (!seen.has(normalized)) {
|
|
339
|
+
seen.add(normalized);
|
|
340
|
+
paths.push(normalized);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return paths;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isLikelyImpactTarget(path: string): boolean {
|
|
347
|
+
const normalized = path.replace(/^@/, "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/");
|
|
348
|
+
if (!normalized || normalized.startsWith(".") || normalized.includes("..")) return false;
|
|
349
|
+
if (normalized.startsWith("docs/plan/") || normalized.startsWith("docs/archive/")) return false;
|
|
350
|
+
if (normalized.startsWith(".dotdotgod/") || normalized.startsWith("node_modules/") || normalized.startsWith("dist/") || normalized.startsWith("build/") || normalized.startsWith("coverage/")) return false;
|
|
351
|
+
return /[.][A-Za-z0-9]+$/.test(normalized);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function selectPlanImpactPaths(
|
|
355
|
+
cwd: string,
|
|
356
|
+
latestRequest: string | undefined,
|
|
357
|
+
currentPlanPath: string | undefined,
|
|
358
|
+
currentPlanContent: string | undefined,
|
|
359
|
+
touchedPaths: readonly string[],
|
|
360
|
+
pathExists: (cwd: string, path: string) => boolean,
|
|
361
|
+
limit = 3,
|
|
362
|
+
): string[] {
|
|
363
|
+
const candidates = [
|
|
364
|
+
...extractPathMentions(latestRequest ?? ""),
|
|
365
|
+
...extractPathMentions(currentPlanContent ?? ""),
|
|
366
|
+
...(currentPlanPath ? [currentPlanPath] : []),
|
|
367
|
+
...touchedPaths,
|
|
368
|
+
];
|
|
369
|
+
const selected: string[] = [];
|
|
370
|
+
const seen = new Set<string>();
|
|
371
|
+
for (const path of candidates) {
|
|
372
|
+
const normalized = path.replace(/^@/, "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/");
|
|
373
|
+
if (seen.has(normalized) || !isLikelyImpactTarget(normalized) || !pathExists(cwd, normalized)) continue;
|
|
374
|
+
seen.add(normalized);
|
|
375
|
+
selected.push(normalized);
|
|
376
|
+
if (selected.length >= limit) break;
|
|
377
|
+
}
|
|
378
|
+
return selected;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function hasExplicitBracketReferences(text: string | undefined): boolean {
|
|
382
|
+
return /\[\[[^\]\n]+\]\]/.test(text ?? "");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function hasLikelyFuzzyReferences(text: string | undefined): boolean {
|
|
386
|
+
const value = text ?? "";
|
|
387
|
+
if (hasExplicitBracketReferences(value)) return true;
|
|
388
|
+
if (/(?:^|\s)(?:[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?:#[A-Za-z0-9 _-]+)?|[A-Z0-9]{3,})(?=$|\s|[.,:;!?])/.test(value)) return true;
|
|
389
|
+
if (/(?:^|\s)(?:\.?\/?(?:docs|packages|src|test|spec|arch|plan|archive)\/)?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+(?:\.md)?(?:#[A-Za-z0-9 _-]+)?(?=$|\s|[.,:;!?])/.test(value)) return true;
|
|
390
|
+
if (/[`"'][^`"'\n]{4,80}[`"']/.test(value)) return true;
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function formatCandidatePath(candidate: Record<string, unknown>): string | undefined {
|
|
395
|
+
const path = typeof candidate.path === "string" ? candidate.path : undefined;
|
|
396
|
+
if (!path) return undefined;
|
|
397
|
+
const title = typeof candidate.title === "string" && candidate.title ? `#${candidate.title}` : "";
|
|
398
|
+
const score = typeof candidate.score === "number" ? ` score=${candidate.score}` : "";
|
|
399
|
+
return `${path}${title}${score}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function formatReferenceExpansionSummary(data: unknown, candidateLimit = 3, impactLimit = 3): string {
|
|
403
|
+
const payload = data && typeof data === "object" ? (data as Record<string, unknown>) : undefined;
|
|
404
|
+
const refs = Array.isArray(payload?.refs) ? payload.refs : [];
|
|
405
|
+
if (refs.length === 0) return "";
|
|
406
|
+
|
|
407
|
+
const lines = ["Reference expansion:"];
|
|
408
|
+
for (const refValue of refs) {
|
|
409
|
+
const ref = refValue && typeof refValue === "object" ? (refValue as Record<string, unknown>) : undefined;
|
|
410
|
+
if (!ref) continue;
|
|
411
|
+
const query = String(ref.query ?? ref.input ?? "unknown");
|
|
412
|
+
const source = typeof ref.source === "string" ? ` ${ref.source}` : "";
|
|
413
|
+
const confidence = typeof ref.confidence === "string" ? ` confidence=${ref.confidence}` : "";
|
|
414
|
+
const ambiguous = ref.ambiguous === true ? " ambiguous" : "";
|
|
415
|
+
const omitted = typeof ref.omitted === "number" && ref.omitted > 0 ? `; omitted=${ref.omitted}` : "";
|
|
416
|
+
lines.push(`- ${query}:${source}${confidence}${ambiguous}${omitted}`);
|
|
417
|
+
|
|
418
|
+
const candidates = Array.isArray(ref.candidates) ? ref.candidates : [];
|
|
419
|
+
for (const candidateValue of candidates.slice(0, candidateLimit)) {
|
|
420
|
+
const candidate = candidateValue && typeof candidateValue === "object" ? (candidateValue as Record<string, unknown>) : undefined;
|
|
421
|
+
if (!candidate) continue;
|
|
422
|
+
const formatted = formatCandidatePath(candidate);
|
|
423
|
+
if (formatted) lines.push(` - ${formatted}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const impact = ref.impact && typeof ref.impact === "object" ? (ref.impact as Record<string, unknown>) : undefined;
|
|
427
|
+
const related = Array.isArray(impact?.related) ? impact.related : [];
|
|
428
|
+
const impactPaths = related
|
|
429
|
+
.slice(0, impactLimit)
|
|
430
|
+
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : undefined))
|
|
431
|
+
.map((item) => (typeof item?.path === "string" ? item.path : undefined))
|
|
432
|
+
.filter((path): path is string => Boolean(path));
|
|
433
|
+
if (impactPaths.length > 0) lines.push(` impact=${impactPaths.join(", ")}`);
|
|
434
|
+
}
|
|
435
|
+
return lines.length > 1 ? lines.join("\n") : "";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function selectPlanImpactPath(
|
|
439
|
+
cwd: string,
|
|
440
|
+
latestRequest: string | undefined,
|
|
441
|
+
currentPlanPath: string | undefined,
|
|
442
|
+
touchedPaths: readonly string[],
|
|
443
|
+
pathExists: (cwd: string, path: string) => boolean,
|
|
444
|
+
): string | undefined {
|
|
445
|
+
return selectPlanImpactPaths(cwd, latestRequest, currentPlanPath, undefined, touchedPaths, pathExists, 1)[0];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function impactRecord(value: unknown): Record<string, unknown> | undefined {
|
|
449
|
+
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function impactPathOf(item: Record<string, unknown>): string {
|
|
453
|
+
return String(item.path ?? item.id ?? item.name ?? item.command ?? "unknown");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function formatCompactImpactSummary(changedPath: string, payload: unknown, topLimit = 5): string {
|
|
457
|
+
const data = impactRecord(payload);
|
|
458
|
+
const impact = impactRecord(data?.impact);
|
|
459
|
+
const groups = impactRecord(impact?.groups);
|
|
460
|
+
if (!data || !impact || !groups) return `- Impact: skipped or unavailable for ${changedPath}.`;
|
|
461
|
+
|
|
462
|
+
const groupCount = (name: string): number => {
|
|
463
|
+
const items = impactRecord(groups[name])?.items;
|
|
464
|
+
return Array.isArray(items) ? items.length : 0;
|
|
465
|
+
};
|
|
466
|
+
const related = Array.isArray(impact.related) ? impact.related : [];
|
|
467
|
+
const topItems = related
|
|
468
|
+
.filter((item): item is Record<string, unknown> => Boolean(impactRecord(item)) && impactPathOf(item as Record<string, unknown>) !== changedPath && impactPathOf(item as Record<string, unknown>) !== `file:${changedPath}`)
|
|
469
|
+
.slice(0, topLimit)
|
|
470
|
+
.map((item) => {
|
|
471
|
+
const score = typeof item.impactScore === "number" ? ` score=${Math.round(item.impactScore * 10) / 10}` : "";
|
|
472
|
+
const reasons = Array.isArray(item.reasons) ? item.reasons.slice(0, 3).map(String).join("+") : "";
|
|
473
|
+
return `${impactPathOf(item)}${score}${reasons ? ` reasons=${reasons}` : ""}`;
|
|
474
|
+
});
|
|
475
|
+
const top = topItems.length > 0 ? `; top=${topItems.join(" | ")}` : "";
|
|
476
|
+
return `- Impact: changed=${changedPath}; docs=${groupCount("docs")}; tests=${groupCount("tests")}; files=${groupCount("files")}; commands=${groupCount("commands")}; events=${groupCount("events")}${top}`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export const DEFAULT_PLAN_MODE_TOOLS = [
|
|
480
|
+
"read",
|
|
481
|
+
"bash",
|
|
482
|
+
"edit",
|
|
483
|
+
"write",
|
|
484
|
+
"grep",
|
|
485
|
+
"find",
|
|
486
|
+
"ls",
|
|
487
|
+
"questionnaire",
|
|
488
|
+
"web_search",
|
|
489
|
+
"code_search",
|
|
490
|
+
"fetch_content",
|
|
491
|
+
"get_search_content",
|
|
492
|
+
];
|
|
493
|
+
|
|
494
|
+
export const PLAN_COMPACTION_PERCENT_THRESHOLD = 60;
|
|
495
|
+
export const PLAN_COMPACTION_TOKEN_FALLBACK = 100_000;
|
|
496
|
+
export const PLAN_COMPACTION_CONTEXT_RESERVE = 32_000;
|
|
497
|
+
|
|
498
|
+
export const PLAN_MODE_COMPACTION_INSTRUCTIONS =
|
|
499
|
+
"Preserve only planning-critical context for dotdotgod Plan Mode. Prioritize the latest user request, active plan task slug/path/status, current target files, concrete user decisions and constraints, implementation decisions, verification commands/results, unresolved risks/questions, next steps, and completed [DONE:n] markers if present. Demote or omit old completed plans unless directly relevant, repeated project-load summaries, package publish history unless task-related, generic Plan Mode boilerplate recoverable from runtime prompts, repeated tool output, stale alternatives, generic chatter, and unrelated archive detail. Summarize in a compact structure that lets the next assistant continue the current plan or execution without asking the user to repeat context.";
|
|
500
|
+
|
|
501
|
+
export function parsePlanModeExtraTools(value: unknown): string[] {
|
|
502
|
+
if (typeof value !== "string") return [];
|
|
503
|
+
const seen = new Set<string>();
|
|
504
|
+
return value
|
|
505
|
+
.split(",")
|
|
506
|
+
.map((tool) => tool.trim())
|
|
507
|
+
.filter((tool) => /^[A-Za-z0-9_:-]+$/.test(tool))
|
|
508
|
+
.filter((tool) => {
|
|
509
|
+
if (seen.has(tool)) return false;
|
|
510
|
+
seen.add(tool);
|
|
511
|
+
return true;
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function resolvePlanModeTools(extraTools: unknown, availableTools?: readonly string[]): string[] {
|
|
516
|
+
const available = availableTools ? new Set(availableTools) : undefined;
|
|
517
|
+
const seen = new Set<string>();
|
|
518
|
+
const requested = [...DEFAULT_PLAN_MODE_TOOLS, ...parsePlanModeExtraTools(extraTools)];
|
|
519
|
+
return requested.filter((tool) => {
|
|
520
|
+
if (seen.has(tool)) return false;
|
|
521
|
+
if (available && !available.has(tool)) return false;
|
|
522
|
+
seen.add(tool);
|
|
523
|
+
return true;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function buildPlanModeFullContextPrompt(allowedTools = DEFAULT_PLAN_MODE_TOOLS): string {
|
|
528
|
+
return `[PLAN MODE ACTIVE]
|
|
529
|
+
You are in Plan Mode. This is a read-only exploration and design phase before code changes.
|
|
530
|
+
|
|
531
|
+
Restrictions:
|
|
532
|
+
- Allowed tools: ${allowedTools.join(", ")}
|
|
533
|
+
- edit/write are allowed only for markdown plan/archive files under docs/plan/ or docs/archive/.
|
|
534
|
+
- Under docs/, directories must use kebab-case and markdown file names must use UPPER_SNAKE_CASE.md, including README.md.
|
|
535
|
+
- Forbidden: source/code/config file mutation outside docs/plan/ and docs/archive/.
|
|
536
|
+
- Bash is restricted to read-only allowlisted commands.
|
|
537
|
+
|
|
538
|
+
Project context:
|
|
539
|
+
- Use already-loaded project memory and load-snapshot summaries first when available.
|
|
540
|
+
- Read AGENTS.md and docs/README.md when they are missing, stale, or needed for the current task.
|
|
541
|
+
- Treat project docs as the source of truth for stack, commands, conventions, and architecture.
|
|
542
|
+
- Check docs/arch when code conventions, module boundaries, infrastructure/runtime dependencies, or integration constraints may affect the plan.
|
|
543
|
+
|
|
544
|
+
Workflow:
|
|
545
|
+
- Explore relevant files thoroughly before planning; ask clarifying questions when requirements are ambiguous, using questionnaire if available.
|
|
546
|
+
- If planning compaction has just occurred, rely on the preserved planning summary plus current project docs before writing or refining the plan.
|
|
547
|
+
- Use web_search, code_search, and fetch_content when library or web evidence is needed.
|
|
548
|
+
- Manage active work under docs/plan/<task-slug>/README.md, with optional UPPER_SNAKE_CASE support files in the same task directory.
|
|
549
|
+
- When one docs domain grows into multiple files, group it under docs/<area>/<domain>/README.md plus supporting UPPER_SNAKE_CASE files.
|
|
550
|
+
- Include scope, status, target files, impact-informed related files, risks, verification, and a final archive step to docs/archive/plan/<task-slug>/.
|
|
551
|
+
- When dotdotgod CLI impact summaries are available, use the related specs, tests, docs, commands, scores, and reasons to strengthen target files, verification, and risks before asking for execution.
|
|
552
|
+
- Do not change product/source files in plan mode. Only maintain docs/plan or docs/archive markdown files and produce an executable plan.
|
|
553
|
+
|
|
554
|
+
Always write the task README with scope, target files, impact-informed related files/checks, implementation steps, verification, risks when useful, and archive housekeeping.
|
|
555
|
+
|
|
556
|
+
In the final response, use a Plan: section only for concrete executable steps. Avoid generic template labels such as "Target files and rationale", "Implementation steps", or "Verification method" as numbered plan items.
|
|
557
|
+
|
|
558
|
+
Do not change source/code/config files in Plan Mode. You may create or update only the allowed docs/plan or docs/archive markdown files needed to produce the durable plan.`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export const PLAN_MODE_COMPACT_CONTEXT_PROMPT = `[PLAN MODE ACTIVE]
|
|
562
|
+
Compact reminder: stay in read-only planning until execution mode. Do not mutate source/code/config files. edit/write are allowed only for UPPER_SNAKE_CASE markdown under docs/plan/ or docs/archive/; bash remains read-only allowlisted. Use AGENTS.md and docs indexes as source of truth when needed. Maintain the active task under docs/plan/<task-slug>/README.md and use a Plan: section only for concrete executable steps when ready.`;
|
|
563
|
+
|
|
564
|
+
export function buildPlanModeContextPrompt(compact = false, allowedTools = DEFAULT_PLAN_MODE_TOOLS): string {
|
|
565
|
+
return compact ? PLAN_MODE_COMPACT_CONTEXT_PROMPT : buildPlanModeFullContextPrompt(allowedTools);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export interface PlanCompactionFocus {
|
|
569
|
+
task?: string;
|
|
570
|
+
activePlanPaths?: string[];
|
|
571
|
+
touchedMemoryPaths?: string[];
|
|
572
|
+
todoSummary?: string;
|
|
573
|
+
pendingLoadAfterCompaction?: boolean;
|
|
574
|
+
constraints?: string[];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export interface PlanContextUsage {
|
|
578
|
+
tokens?: number | null;
|
|
579
|
+
contextWindow?: number | null;
|
|
580
|
+
percent?: number | null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export interface PlanningContextShapeTriggerState {
|
|
584
|
+
planModeEnabled: boolean;
|
|
585
|
+
executionMode: boolean;
|
|
586
|
+
planningContextShapePending: boolean;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function shouldShapePlanningContextOnAgentStart(state: PlanningContextShapeTriggerState): boolean {
|
|
590
|
+
return state.planModeEnabled && !state.executionMode && state.planningContextShapePending;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export interface PlanChoiceTriggerState {
|
|
594
|
+
planModeEnabled: boolean;
|
|
595
|
+
executionMode: boolean;
|
|
596
|
+
hasUI: boolean;
|
|
597
|
+
pendingPlanChoicePath?: string | undefined;
|
|
598
|
+
activePlanTouched?: boolean | undefined;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function shouldPromptForPlanChoice(state: PlanChoiceTriggerState): boolean {
|
|
602
|
+
return state.planModeEnabled && !state.executionMode && state.hasUI && Boolean(state.pendingPlanChoicePath || state.activePlanTouched);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function formatFocusList(label: string, values: string[] | undefined): string | undefined {
|
|
606
|
+
const cleaned = [...new Set(values?.map((value) => value.trim()).filter(Boolean) ?? [])];
|
|
607
|
+
if (cleaned.length === 0) return undefined;
|
|
608
|
+
return `- ${label}: ${cleaned.slice(0, 8).join(", ")}${cleaned.length > 8 ? `, +${cleaned.length - 8} more` : ""}`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function formatPlanCompactionFocus(focus?: PlanCompactionFocus): string | undefined {
|
|
612
|
+
if (!focus) return undefined;
|
|
613
|
+
const lines = [
|
|
614
|
+
focus.task?.trim() ? `- Task: ${focus.task.trim()}` : undefined,
|
|
615
|
+
formatFocusList("Active plan", focus.activePlanPaths),
|
|
616
|
+
formatFocusList("Touched plan/archive memory", focus.touchedMemoryPaths),
|
|
617
|
+
focus.todoSummary?.trim() ? `- Todo state: ${focus.todoSummary.trim()}` : undefined,
|
|
618
|
+
focus.pendingLoadAfterCompaction ? "- Pending: load curated project memory after compaction" : undefined,
|
|
619
|
+
formatFocusList("Preserve constraints", focus.constraints),
|
|
620
|
+
].filter((line): line is string => Boolean(line));
|
|
621
|
+
if (lines.length === 0) return undefined;
|
|
622
|
+
return `Current work focus:\n${lines.join("\n")}`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function buildPlanCompactionInstructions(reason?: string, focus?: PlanCompactionFocus): string {
|
|
626
|
+
const sections = [];
|
|
627
|
+
const normalizedReason = reason?.trim();
|
|
628
|
+
if (normalizedReason) sections.push(`Reason: ${normalizedReason}`);
|
|
629
|
+
const formattedFocus = formatPlanCompactionFocus(focus);
|
|
630
|
+
if (formattedFocus) sections.push(formattedFocus);
|
|
631
|
+
sections.push(PLAN_MODE_COMPACTION_INSTRUCTIONS);
|
|
632
|
+
return sections.join("\n\n");
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function getPlanCompactionReason(usage: PlanContextUsage | null | undefined): string | undefined {
|
|
636
|
+
if (!usage) return undefined;
|
|
637
|
+
|
|
638
|
+
const percent = usage.percent ?? null;
|
|
639
|
+
if (typeof percent === "number") {
|
|
640
|
+
const normalizedPercent = percent <= 1 ? percent * 100 : percent;
|
|
641
|
+
if (normalizedPercent >= PLAN_COMPACTION_PERCENT_THRESHOLD) {
|
|
642
|
+
return `Plan Mode context exceeded ${PLAN_COMPACTION_PERCENT_THRESHOLD}% of the context window.`;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const tokens = usage.tokens ?? null;
|
|
647
|
+
if (typeof tokens !== "number") return undefined;
|
|
648
|
+
|
|
649
|
+
const contextWindow = usage.contextWindow ?? null;
|
|
650
|
+
if (typeof contextWindow === "number" && tokens >= contextWindow - PLAN_COMPACTION_CONTEXT_RESERVE) {
|
|
651
|
+
return `Plan Mode context is within ${PLAN_COMPACTION_CONTEXT_RESERVE.toLocaleString()} tokens of the context window.`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (tokens >= PLAN_COMPACTION_TOKEN_FALLBACK) {
|
|
655
|
+
return `Plan Mode context exceeded ${PLAN_COMPACTION_TOKEN_FALLBACK.toLocaleString()} tokens.`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export interface TodoItem {
|
|
662
|
+
step: number;
|
|
663
|
+
text: string;
|
|
664
|
+
completed: boolean;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function cleanStepText(text: string): string {
|
|
668
|
+
let cleaned = text
|
|
669
|
+
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
|
|
670
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
671
|
+
.replace(
|
|
672
|
+
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install|Analyze|Review|Test)\s+(the\s+)?/i,
|
|
673
|
+
"",
|
|
674
|
+
)
|
|
675
|
+
.replace(/\s+/g, " ")
|
|
676
|
+
.trim();
|
|
677
|
+
|
|
678
|
+
if (cleaned.length > 0) {
|
|
679
|
+
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
|
680
|
+
}
|
|
681
|
+
if (cleaned.length > 50) {
|
|
682
|
+
cleaned = `${cleaned.slice(0, 47)}...`;
|
|
683
|
+
}
|
|
684
|
+
return cleaned;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function isTemplatePlanStep(text: string): boolean {
|
|
688
|
+
const normalized = text
|
|
689
|
+
.toLowerCase()
|
|
690
|
+
.replace(/[`:*_()[\]{}]/g, "")
|
|
691
|
+
.replace(/\s+/g, " ")
|
|
692
|
+
.trim();
|
|
693
|
+
|
|
694
|
+
return (
|
|
695
|
+
normalized === "target files and rationale" ||
|
|
696
|
+
normalized === "implementation steps" ||
|
|
697
|
+
normalized === "verification method" ||
|
|
698
|
+
normalized === "risks and edge cases" ||
|
|
699
|
+
normalized === "archive step" ||
|
|
700
|
+
normalized === "completion" ||
|
|
701
|
+
normalized.includes("실행/유지/수정 선택") ||
|
|
702
|
+
normalized.includes("선택 프롬프트")
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function extractTodoItems(message: string): TodoItem[] {
|
|
707
|
+
const items: TodoItem[] = [];
|
|
708
|
+
const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
|
|
709
|
+
if (!headerMatch) return items;
|
|
710
|
+
|
|
711
|
+
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
|
|
712
|
+
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
|
|
713
|
+
|
|
714
|
+
for (const match of planSection.matchAll(numberedPattern)) {
|
|
715
|
+
const rawText = match[2];
|
|
716
|
+
if (!rawText) continue;
|
|
717
|
+
const text = rawText
|
|
718
|
+
.trim()
|
|
719
|
+
.replace(/\*{1,2}$/, "")
|
|
720
|
+
.trim();
|
|
721
|
+
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
|
|
722
|
+
const cleaned = cleanStepText(text);
|
|
723
|
+
if (cleaned.length > 3 && !isTemplatePlanStep(text) && !isTemplatePlanStep(cleaned)) {
|
|
724
|
+
items.push({ step: items.length + 1, text: cleaned, completed: false });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return items;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export function extractDoneSteps(message: string): number[] {
|
|
732
|
+
const steps: number[] = [];
|
|
733
|
+
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
|
|
734
|
+
const step = Number(match[1]);
|
|
735
|
+
if (Number.isFinite(step)) steps.push(step);
|
|
736
|
+
}
|
|
737
|
+
return steps;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export function markCompletedSteps(text: string, items: TodoItem[]): number {
|
|
741
|
+
const doneSteps = extractDoneSteps(text);
|
|
742
|
+
for (const step of doneSteps) {
|
|
743
|
+
const item = items.find((t) => t.step === step);
|
|
744
|
+
if (item) item.completed = true;
|
|
745
|
+
}
|
|
746
|
+
return doneSteps.length;
|
|
747
|
+
}
|