@dennisrongo/dsh-todo 0.4.0 → 0.5.0

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/lib/scan.js ADDED
@@ -0,0 +1,276 @@
1
+ // src/scan.ts
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join, relative, sep } from "node:path";
4
+ var DIGEST_BYTE_CAP = 24e3;
5
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([
6
+ ".git",
7
+ ".hg",
8
+ ".svn",
9
+ "node_modules",
10
+ "bower_components",
11
+ "jspm_packages",
12
+ "lib",
13
+ "dist",
14
+ "build",
15
+ "out",
16
+ "coverage",
17
+ ".next",
18
+ ".nuxt",
19
+ ".svelte-kit",
20
+ ".output",
21
+ ".parcel-cache",
22
+ ".turbo",
23
+ ".cache",
24
+ ".venv",
25
+ "venv",
26
+ "__pycache__",
27
+ ".tox",
28
+ ".mypy_cache",
29
+ ".pytest_cache",
30
+ "target",
31
+ "vendor",
32
+ "vendored",
33
+ "third_party",
34
+ "thirdparty",
35
+ "generated",
36
+ "__generated__",
37
+ "Pods",
38
+ "Carthage",
39
+ "DerivedData"
40
+ ]);
41
+ var SOURCE_EXT = /* @__PURE__ */ new Set([
42
+ ".ts",
43
+ ".tsx",
44
+ ".js",
45
+ ".jsx",
46
+ ".mjs",
47
+ ".cjs",
48
+ ".py",
49
+ ".go",
50
+ ".rs",
51
+ ".java",
52
+ ".rb",
53
+ ".php",
54
+ ".cs",
55
+ ".swift",
56
+ ".kt",
57
+ ".scala",
58
+ ".sh"
59
+ ]);
60
+ var MAX_FILES_WALKED = 4e3;
61
+ var MAX_TREE_ENTRIES = 300;
62
+ var MAX_COMMENTS = 80;
63
+ var MAX_UNTESTED = 40;
64
+ var MAX_COMMENT_LINE = 160;
65
+ var README_BYTES = 4e3;
66
+ var MANIFEST_BYTES = 2e3;
67
+ var MAX_DEPTH = 8;
68
+ var SCAN_CEILING_FACTOR = 10;
69
+ var MAX_FILES_READ = 400;
70
+ var MAX_READ_BYTES = 2 * 1024 * 1024;
71
+ function posix(path) {
72
+ return path.split(sep).join("/");
73
+ }
74
+ function walk(root) {
75
+ const files = [];
76
+ let truncated = false;
77
+ const visit = (dir, depth) => {
78
+ if (depth > MAX_DEPTH || files.length >= MAX_FILES_WALKED) {
79
+ truncated = true;
80
+ return;
81
+ }
82
+ let entries;
83
+ try {
84
+ entries = readdirSync(dir, { withFileTypes: true });
85
+ } catch {
86
+ return;
87
+ }
88
+ for (const entry of entries) {
89
+ if (files.length >= MAX_FILES_WALKED) {
90
+ truncated = true;
91
+ return;
92
+ }
93
+ if (entry.isDirectory()) {
94
+ if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
95
+ visit(join(dir, entry.name), depth + 1);
96
+ } else if (entry.isFile()) {
97
+ files.push(posix(relative(root, join(dir, entry.name))));
98
+ }
99
+ }
100
+ };
101
+ try {
102
+ if (!statSync(root).isDirectory()) return { files: [], truncated: false };
103
+ } catch {
104
+ return { files: [], truncated: false };
105
+ }
106
+ visit(root, 0);
107
+ return { files, truncated };
108
+ }
109
+ function readText(path, limit = Number.MAX_SAFE_INTEGER) {
110
+ let raw;
111
+ try {
112
+ if (statSync(path).size > MAX_READ_BYTES) return "";
113
+ raw = readFileSync(path, "utf8");
114
+ } catch {
115
+ return "";
116
+ }
117
+ if (raw.includes("\0")) return "";
118
+ return raw.length > limit ? raw.slice(0, limit) : raw;
119
+ }
120
+ function skippedForSize(path) {
121
+ try {
122
+ return statSync(path).size > MAX_READ_BYTES;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+ var COMMENT_RE = /(?:^|\s)(?:\/\/|#|\/\*|\*)\s*(TODO|FIXME|HACK)\b[:\s]?(.*)$/;
128
+ function collectComments(root, files) {
129
+ const ceiling = MAX_COMMENTS * SCAN_CEILING_FACTOR;
130
+ const kept = [];
131
+ let total = 0;
132
+ let read = 0;
133
+ let skipped = 0;
134
+ let bounded = false;
135
+ for (const rel of files) {
136
+ const dot = rel.lastIndexOf(".");
137
+ if (dot < 0 || !SOURCE_EXT.has(rel.slice(dot))) continue;
138
+ if (total >= ceiling || read >= MAX_FILES_READ) {
139
+ bounded = true;
140
+ break;
141
+ }
142
+ const full = join(root, rel);
143
+ if (skippedForSize(full)) {
144
+ skipped += 1;
145
+ continue;
146
+ }
147
+ read += 1;
148
+ const text = readText(full);
149
+ if (text === "") continue;
150
+ const lines = text.split(/\r?\n/);
151
+ for (let i = 0; i < lines.length; i += 1) {
152
+ const match = COMMENT_RE.exec(lines[i]);
153
+ if (match === null) continue;
154
+ total += 1;
155
+ if (kept.length >= MAX_COMMENTS) continue;
156
+ const body = match[2].trim().slice(0, MAX_COMMENT_LINE);
157
+ kept.push(`${rel}:${i + 1} ${match[1]} ${body}`.trimEnd());
158
+ }
159
+ }
160
+ return { kept, total, bounded, skippedForSize: skipped };
161
+ }
162
+ function hasTest(base, testNames) {
163
+ return testNames.has(`${base}.test`) || testNames.has(`${base}.spec`) || testNames.has(`test_${base}`) || testNames.has(`${base}_test`) || testNames.has(base);
164
+ }
165
+ function collectUntested(files) {
166
+ const testNames = /* @__PURE__ */ new Set();
167
+ for (const rel of files) {
168
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
169
+ const stem = name.replace(/\.[^.]+$/, "");
170
+ if (/(^|[./_-])(test|spec)([./_-]|$)/i.test(rel)) {
171
+ testNames.add(stem);
172
+ testNames.add(stem.replace(/\.(test|spec)$/i, ""));
173
+ }
174
+ }
175
+ const ceiling = MAX_UNTESTED * SCAN_CEILING_FACTOR;
176
+ const kept = [];
177
+ let total = 0;
178
+ let bounded = false;
179
+ for (const rel of files) {
180
+ const dot = rel.lastIndexOf(".");
181
+ if (dot < 0 || !SOURCE_EXT.has(rel.slice(dot))) continue;
182
+ if (/(^|[./_-])(test|spec)([./_-]|$)/i.test(rel)) continue;
183
+ const stem = rel.slice(rel.lastIndexOf("/") + 1).replace(/\.[^.]+$/, "");
184
+ if (/^(index|main|types|constants)$/i.test(stem)) continue;
185
+ if (hasTest(stem, testNames)) continue;
186
+ if (total >= ceiling) {
187
+ bounded = true;
188
+ break;
189
+ }
190
+ total += 1;
191
+ if (kept.length < MAX_UNTESTED) kept.push(rel);
192
+ }
193
+ return { kept, total, bounded, skippedForSize: 0 };
194
+ }
195
+ function sectionHeader(title, total, kept, options = {}) {
196
+ const bound = options.bounded === true ? "+" : "";
197
+ const skipped = options.skippedForSize ?? 0;
198
+ const note = skipped > 0 ? ` (${skipped} file(s) too large to read)` : "";
199
+ const counts = kept < total || options.bounded === true ? `(${total}${bound} found, showing ${kept})` : `(${total})`;
200
+ return `### ${title} ${counts}${note}`;
201
+ }
202
+ function fileHeader(name, text, limit) {
203
+ if (text.length < limit) return `### ${name}`;
204
+ return `### ${name} (clipped to first ${Math.round(limit / 1e3)} KB)`;
205
+ }
206
+ function assemble(sections, walkTruncated) {
207
+ const parts = walkTruncated ? sections.concat(
208
+ "[walk truncated \u2014 this workspace is deeper or larger than one scan walks; files below the depth or count limit were never examined]"
209
+ ) : sections;
210
+ const joined = parts.join("\n\n");
211
+ if (joined.length <= DIGEST_BYTE_CAP) {
212
+ return { digest: joined, truncated: walkTruncated };
213
+ }
214
+ const marker = "\n\n[digest truncated \u2014 the workspace is larger than one scan can carry]";
215
+ return { digest: joined.slice(0, DIGEST_BYTE_CAP - marker.length) + marker, truncated: true };
216
+ }
217
+ function buildDigest(root) {
218
+ const { files, truncated } = walk(root);
219
+ const sections = [];
220
+ let sectionTruncated = false;
221
+ const tree = files.slice(0, MAX_TREE_ENTRIES);
222
+ if (tree.length > 0) {
223
+ if (tree.length < files.length) sectionTruncated = true;
224
+ sections.push(`${sectionHeader("Files", files.length, tree.length)}
225
+ ${tree.join("\n")}`);
226
+ }
227
+ const readmeName = files.find((f) => /^readme(\.md|\.txt)?$/i.test(f));
228
+ if (readmeName !== void 0) {
229
+ const raw = readText(join(root, readmeName), README_BYTES);
230
+ const text = raw.trim();
231
+ if (text !== "") {
232
+ if (raw.length >= README_BYTES) sectionTruncated = true;
233
+ sections.push(`${fileHeader(readmeName, raw, README_BYTES)}
234
+ ${text}`);
235
+ }
236
+ }
237
+ const manifest = files.find((f) => f === "package.json");
238
+ if (manifest !== void 0) {
239
+ const raw = readText(join(root, manifest), MANIFEST_BYTES);
240
+ const text = raw.trim();
241
+ if (text !== "") {
242
+ if (raw.length >= MANIFEST_BYTES) sectionTruncated = true;
243
+ sections.push(`${fileHeader("package.json", raw, MANIFEST_BYTES)}
244
+ ${text}`);
245
+ }
246
+ }
247
+ const comments = collectComments(root, files);
248
+ if (comments.kept.length > 0 || comments.skippedForSize > 0) {
249
+ if (comments.kept.length < comments.total || comments.bounded || comments.skippedForSize > 0) sectionTruncated = true;
250
+ sections.push(
251
+ sectionHeader(
252
+ "Unresolved comments (TODO/FIXME/HACK)",
253
+ comments.total,
254
+ comments.kept.length,
255
+ { bounded: comments.bounded, skippedForSize: comments.skippedForSize }
256
+ ) + (comments.kept.length > 0 ? "\n" + comments.kept.join("\n") : "")
257
+ );
258
+ }
259
+ const untested = collectUntested(files);
260
+ if (untested.kept.length > 0) {
261
+ if (untested.kept.length < untested.total || untested.bounded) sectionTruncated = true;
262
+ sections.push(
263
+ sectionHeader(
264
+ "Untested modules (name-based hint, not a coverage run)",
265
+ untested.total,
266
+ untested.kept.length,
267
+ { bounded: untested.bounded }
268
+ ) + "\n" + untested.kept.join("\n")
269
+ );
270
+ }
271
+ return assemble(sections, truncated || sectionTruncated);
272
+ }
273
+ export {
274
+ DIGEST_BYTE_CAP,
275
+ buildDigest
276
+ };
package/lib/suggest.js ADDED
@@ -0,0 +1,93 @@
1
+ // src/types.ts
2
+ var PRIORITIES = ["p0", "p1", "p2", "p3"];
3
+ var DEFAULT_PRIORITY = "p2";
4
+ function toPriority(value) {
5
+ return typeof value === "string" && PRIORITIES.includes(value) ? value : DEFAULT_PRIORITY;
6
+ }
7
+ var MAX_TEXT = 500;
8
+ var MAX_DESC = 5e3;
9
+ var MAX_LABEL = 60;
10
+ var SUGGESTIONS_DIR = ".dsh";
11
+ var SUGGESTIONS_FILE = `${SUGGESTIONS_DIR}/suggestions.json`;
12
+ function makeRunId(now = Date.now(), rand = Math.random) {
13
+ return `${now.toString(36)}${Math.floor(rand() * 1e6).toString(36)}`;
14
+ }
15
+ function suggestionsFileFor(runId) {
16
+ return `${SUGGESTIONS_DIR}/suggestions-${runId}.json`;
17
+ }
18
+ var MAX_SUGGESTIONS = 12;
19
+
20
+ // src/suggest.ts
21
+ function composeScanPrompt(digest, excludeTitles, runId) {
22
+ const parts = [
23
+ "# Propose work for this codebase",
24
+ "You are reviewing a workspace to propose concrete next tasks. Base every suggestion on the evidence below \u2014 do not speculate about code you cannot see.",
25
+ "Look for: unresolved TODO/FIXME/HACK comments, features the docs promise but the code does not implement, and modules with no tests.",
26
+ "## Evidence",
27
+ digest
28
+ ];
29
+ const exclusions = excludeTitles.map((t) => t.trim()).filter((t) => t.length > 0);
30
+ if (exclusions.length > 0) {
31
+ parts.push(
32
+ "## Already planned \u2014 do NOT suggest these or close variants of them",
33
+ exclusions.map((t) => `- ${t}`).join("\n")
34
+ );
35
+ }
36
+ parts.push(
37
+ "## Output",
38
+ `Write ONLY a JSON array to \`${suggestionsFileFor(runId)}\` (create the directory if needed).`,
39
+ 'Each element: {"title": string, "rationale": string, "priority": "p0"|"p1"|"p2"|"p3", "evidence": string}',
40
+ "`evidence` is a `file:line` pointer where one exists; omit it otherwise.",
41
+ `Produce at most ${MAX_SUGGESTIONS} suggestions. Write the file and stop \u2014 do not implement anything.`
42
+ );
43
+ return parts.join("\n\n");
44
+ }
45
+ function unfence(raw) {
46
+ const open = /```[ \t]*[A-Za-z0-9_-]*[ \t]*\r?\n?/.exec(raw);
47
+ if (open === null) return raw;
48
+ const lead = raw.slice(0, open.index);
49
+ if (lead.includes("[") || lead.includes("{")) return raw;
50
+ const body = raw.slice(open.index + open[0].length);
51
+ const close = body.lastIndexOf("```");
52
+ return close === -1 ? raw : body.slice(0, close);
53
+ }
54
+ function parseSuggestions(raw) {
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(unfence(raw));
58
+ } catch (cause) {
59
+ return { ok: false, error: `the scan wrote invalid JSON: ${cause instanceof Error ? cause.message : String(cause)}` };
60
+ }
61
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.suggestions) ? parsed.suggestions : void 0;
62
+ if (list === void 0) {
63
+ return { ok: false, error: "the scan did not write a list of suggestions" };
64
+ }
65
+ const suggestions = [];
66
+ const seen = /* @__PURE__ */ new Set();
67
+ for (const entry of list) {
68
+ if (entry === null || typeof entry !== "object") continue;
69
+ const row = entry;
70
+ const title = typeof row.title === "string" ? row.title.trim() : "";
71
+ if (title.length === 0) continue;
72
+ const stored = title.slice(0, MAX_TEXT);
73
+ const key = stored.toLowerCase();
74
+ if (seen.has(key)) continue;
75
+ seen.add(key);
76
+ const evidence = typeof row.evidence === "string" ? row.evidence.trim().slice(0, MAX_LABEL) : "";
77
+ suggestions.push({
78
+ title: stored,
79
+ rationale: typeof row.rationale === "string" ? row.rationale.trim().slice(0, MAX_DESC) : "",
80
+ priority: toPriority(row.priority),
81
+ // Absent optional fields are ABSENT KEYS, never '', matching TodoItem.
82
+ ...evidence.length > 0 ? { evidence } : {}
83
+ });
84
+ if (suggestions.length >= MAX_SUGGESTIONS) break;
85
+ }
86
+ return { ok: true, suggestions };
87
+ }
88
+ export {
89
+ composeScanPrompt,
90
+ makeRunId,
91
+ parseSuggestions,
92
+ suggestionsFileFor
93
+ };
@@ -12,6 +12,7 @@ var todoItemSchema = z.object({
12
12
  release: z.string().optional(),
13
13
  sprint: z.string().optional(),
14
14
  dueDate: z.string().optional(),
15
+ sessionId: z.string().optional(),
15
16
  createdAt: z.number(),
16
17
  completedAt: z.number().optional(),
17
18
  archivedAt: z.number().optional()
@@ -36,6 +37,26 @@ var replaceResultSchema = z.union([
36
37
  list: todoListSchema
37
38
  })
38
39
  ]);
40
+ var scanRequestSchema = z.object({ workspaceId: z.string() });
41
+ var readSuggestionsRequestSchema = z.object({
42
+ workspaceId: z.string(),
43
+ runId: z.string()
44
+ });
45
+ var scanDigestResultSchema = z.object({
46
+ digest: z.string(),
47
+ truncated: z.boolean()
48
+ });
49
+ var suggestionSchema = z.object({
50
+ title: z.string(),
51
+ rationale: z.string(),
52
+ priority: z.enum(["p0", "p1", "p2", "p3"]),
53
+ evidence: z.string().optional()
54
+ });
55
+ var readSuggestionsResultSchema = z.object({
56
+ status: z.enum(["pending", "ready", "error"]),
57
+ suggestions: z.array(suggestionSchema).optional(),
58
+ error: z.string().optional()
59
+ });
39
60
  var PACKAGE = "@dennisrongo/dsh-todo";
40
61
  function descriptor(method, request, result) {
41
62
  return {
@@ -71,7 +92,9 @@ var TODO_REMOTE = {
71
92
  package: PACKAGE,
72
93
  descriptors: [
73
94
  descriptor("list", listRequestSchema, listResultSchema),
74
- descriptor("replace", replaceRequestSchema, replaceResultSchema)
95
+ descriptor("replace", replaceRequestSchema, replaceResultSchema),
96
+ descriptor("scanDigest", scanRequestSchema, scanDigestResultSchema),
97
+ descriptor("readSuggestions", readSuggestionsRequestSchema, readSuggestionsResultSchema)
75
98
  ]
76
99
  };
77
100
 
@@ -102,6 +125,18 @@ var TYPERT = {
102
125
  name: "replace",
103
126
  signature: "@Remote replace(request: TodoReplaceRequest): Promise<TodoReplaceResult>",
104
127
  summary: "Replace one workspace's list, guarded by the observed revision."
128
+ },
129
+ {
130
+ kind: "method",
131
+ name: "scanDigest",
132
+ signature: "@Remote scanDigest(request: SuggestScanRequest): Promise<ScanDigestResult>",
133
+ summary: "Build the bounded workspace evidence a scan session reasons over."
134
+ },
135
+ {
136
+ kind: "method",
137
+ name: "readSuggestions",
138
+ signature: "@Remote readSuggestions(request: ReadSuggestionsRequest): Promise<ReadSuggestionsResult>",
139
+ summary: "Read and consume whatever a scan session has written so far."
105
140
  }
106
141
  ],
107
142
  types: [
@@ -128,6 +163,29 @@ var TYPERT = {
128
163
  {
129
164
  name: "TodoReplaceResult",
130
165
  declaration: "export type TodoReplaceResult = { ok: true; list: TodoList } | { ok: false; code: 'revision-conflict'; list: TodoList };"
166
+ },
167
+ {
168
+ name: "SuggestScanRequest",
169
+ declaration: "export interface SuggestScanRequest {\n workspaceId: string;\n}"
170
+ },
171
+ {
172
+ // `runId` is REQUIRED. A per-run result path is what stops a scan
173
+ // that timed out — archived, but never actually cancelled — writing
174
+ // its answer where the NEXT run reads it as fresh.
175
+ name: "ReadSuggestionsRequest",
176
+ declaration: "export interface ReadSuggestionsRequest {\n workspaceId: string;\n runId: string;\n}"
177
+ },
178
+ {
179
+ name: "ScanDigestResult",
180
+ declaration: "export interface ScanDigestResult {\n digest: string;\n truncated: boolean;\n}"
181
+ },
182
+ {
183
+ name: "Suggestion",
184
+ declaration: "export interface Suggestion {\n title: string;\n rationale: string;\n priority: TodoPriority;\n evidence?: string;\n}"
185
+ },
186
+ {
187
+ name: "ReadSuggestionsResult",
188
+ declaration: "export interface ReadSuggestionsResult {\n status: 'pending' | 'ready' | 'error';\n suggestions?: Suggestion[];\n error?: string;\n}"
131
189
  }
132
190
  ]
133
191
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dennisrongo/dsh-todo",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Todo list for DeepSeek Harness (dsh) — a per-workspace task list persisted on disk by the host",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,10 +34,12 @@
34
34
  "scripts": {
35
35
  "build": "node build/build.mjs",
36
36
  "typecheck": "tsc --noEmit",
37
- "test": "node build/build.mjs && node test/smoke.mjs && node test/cli.test.mjs",
37
+ "test": "node build/build.mjs && node test/smoke.mjs && node test/context-probe.mjs && node test/launch-effect.mjs && node test/launch-lifecycle.mjs && node test/suggest-lifecycle.mjs && node test/suggest.test.mjs && node test/scan.test.mjs && node test/cli.test.mjs && node test/cli-integration.mjs",
38
38
  "test:icons": "node test/icon-probe.mjs",
39
39
  "test:modal": "node test/modal-probe.mjs",
40
- "test:cli": "node test/cli.test.mjs"
40
+ "test:cli": "node test/cli.test.mjs",
41
+ "test:integration": "node test/cli-integration.mjs",
42
+ "test:agent": "node test/verify-agent.mjs"
41
43
  },
42
44
  "dsh": {
43
45
  "bundle": {