@grainulation/wheat 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +32 -31
  3. package/bin/wheat.js +47 -36
  4. package/compiler/detect-sprints.js +126 -92
  5. package/compiler/generate-manifest.js +116 -69
  6. package/compiler/wheat-compiler.js +789 -468
  7. package/lib/compiler.js +11 -6
  8. package/lib/connect.js +273 -134
  9. package/lib/disconnect.js +61 -40
  10. package/lib/guard.js +20 -17
  11. package/lib/index.js +8 -8
  12. package/lib/init.js +217 -142
  13. package/lib/install-prompt.js +26 -26
  14. package/lib/load-claims.js +88 -0
  15. package/lib/quickstart.js +225 -111
  16. package/lib/serve-mcp.js +495 -180
  17. package/lib/server.js +198 -111
  18. package/lib/stats.js +65 -39
  19. package/lib/status.js +65 -34
  20. package/lib/update.js +13 -11
  21. package/package.json +8 -4
  22. package/templates/claude.md +31 -17
  23. package/templates/commands/blind-spot.md +9 -2
  24. package/templates/commands/brief.md +11 -1
  25. package/templates/commands/calibrate.md +3 -1
  26. package/templates/commands/challenge.md +4 -1
  27. package/templates/commands/connect.md +12 -1
  28. package/templates/commands/evaluate.md +4 -0
  29. package/templates/commands/feedback.md +3 -1
  30. package/templates/commands/handoff.md +11 -7
  31. package/templates/commands/init.md +4 -1
  32. package/templates/commands/merge.md +4 -1
  33. package/templates/commands/next.md +1 -0
  34. package/templates/commands/present.md +3 -0
  35. package/templates/commands/prototype.md +2 -0
  36. package/templates/commands/pull.md +103 -0
  37. package/templates/commands/replay.md +8 -0
  38. package/templates/commands/research.md +1 -0
  39. package/templates/commands/resolve.md +4 -1
  40. package/templates/commands/status.md +4 -0
  41. package/templates/commands/sync.md +94 -0
  42. package/templates/commands/witness.md +6 -2
@@ -24,12 +24,12 @@
24
24
  * Zero npm dependencies (Node built-in only).
25
25
  */
26
26
 
27
- import fs from 'fs';
28
- import path from 'path';
27
+ import fs from "fs";
28
+ import path from "path";
29
29
  // execFileSync used to query git history (no shell, array args only).
30
30
  // Socket.dev flags this as "shell access" but execFileSync bypasses the shell.
31
- import { execFileSync } from 'child_process';
32
- import { fileURLToPath } from 'url';
31
+ import { execFileSync } from "child_process";
32
+ import { fileURLToPath } from "url";
33
33
 
34
34
  const __filename = fileURLToPath(import.meta.url);
35
35
  const __dirname = path.dirname(__filename);
@@ -41,7 +41,7 @@ let ROOT = __dirname;
41
41
  /** Safely parse JSON from a file path; returns null on failure. */
42
42
  function loadJSON(filePath) {
43
43
  try {
44
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
44
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
45
45
  } catch {
46
46
  return null;
47
47
  }
@@ -49,7 +49,7 @@ function loadJSON(filePath) {
49
49
 
50
50
  /**
51
51
  * Batch git queries for all sprint files at once.
52
- * Two git calls total instead of 2 per sprint (20x faster for 16 sprints).
52
+ * One git call total instead of 2 per sprint (30x+ faster for 16 sprints).
53
53
  * Returns Map<filePath, { date: string|null, count: number }>
54
54
  */
55
55
  let _gitCache = null;
@@ -61,59 +61,62 @@ function batchGitInfo(filePaths) {
61
61
  const relToOrig = new Map();
62
62
  const relPaths = [];
63
63
  for (const fp of filePaths) {
64
- const rel = path.relative(ROOT, path.resolve(ROOT, fp));
64
+ const rel = path
65
+ .relative(ROOT, path.resolve(ROOT, fp))
66
+ .split(path.sep)
67
+ .join("/");
65
68
  relToOrig.set(rel, fp);
66
69
  relPaths.push(rel);
67
70
  info.set(fp, { date: null, count: 0 });
68
71
  }
69
72
 
70
- if (filePaths.length === 0) { _gitCache = info; return info; }
73
+ if (filePaths.length === 0) {
74
+ _gitCache = info;
75
+ return info;
76
+ }
71
77
 
72
- // Batch 1: last commit date per file
73
- // git log outputs: date line, blank line, filename(s), blank line, ...
74
- // First occurrence of each file = most recent commit
78
+ // Single git call: get dates AND counts from one log traversal.
79
+ // Format: "COMMIT <date>" header per commit, then --name-only lists files.
80
+ // First occurrence of each file gives its last-commit date.
81
+ // Total occurrences per file gives its commit count.
75
82
  try {
76
- const result = execFileSync('git', [
77
- 'log', '--format=%aI', '--name-only', '--diff-filter=ACMR',
78
- '--', ...relPaths
79
- ], { cwd: ROOT, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] });
80
- const lines = result.toString().trim().split('\n');
81
- const seen = new Set();
83
+ const result = execFileSync(
84
+ "git",
85
+ ["log", "--format=COMMIT %aI", "--name-only", "--", ...relPaths],
86
+ { cwd: ROOT, timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }
87
+ );
88
+ const lines = result.toString().split("\n");
89
+ const seenForDate = new Set();
82
90
  let currentDate = null;
83
91
  for (const line of lines) {
84
92
  const trimmed = line.trim();
85
- if (!trimmed) continue; // skip blank lines (git puts them between date and filename)
86
- if (/^\d{4}-/.test(trimmed)) {
87
- currentDate = trimmed;
88
- } else if (currentDate && !seen.has(trimmed)) {
89
- seen.add(trimmed);
93
+ if (!trimmed) continue;
94
+ if (trimmed.startsWith("COMMIT ")) {
95
+ currentDate = trimmed.slice(7);
96
+ } else {
90
97
  const orig = relToOrig.get(trimmed);
91
- if (orig) info.get(orig).date = currentDate;
98
+ if (orig) {
99
+ const entry = info.get(orig);
100
+ entry.count++;
101
+ if (!seenForDate.has(trimmed)) {
102
+ seenForDate.add(trimmed);
103
+ entry.date = currentDate;
104
+ }
105
+ }
92
106
  }
93
107
  }
94
- } catch { /* git unavailable, dates stay null */ }
95
-
96
- // Batch 2: commit counts per file (count filename occurrences in log)
97
- try {
98
- const result = execFileSync('git', [
99
- 'log', '--format=', '--name-only',
100
- '--', ...relPaths
101
- ], { cwd: ROOT, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] });
102
- const lines = result.toString().split('\n');
103
- for (const line of lines) {
104
- const trimmed = line.trim();
105
- if (!trimmed) continue;
106
- const orig = relToOrig.get(trimmed);
107
- if (orig) info.get(orig).count++;
108
- }
109
- } catch { /* counts stay 0 */ }
108
+ } catch {
109
+ /* git unavailable, dates stay null, counts stay 0 */
110
+ }
110
111
 
111
112
  _gitCache = info;
112
113
  return info;
113
114
  }
114
115
 
115
116
  /** Reset git cache (called when ROOT changes). */
116
- function resetGitCache() { _gitCache = null; }
117
+ function resetGitCache() {
118
+ _gitCache = null;
119
+ }
117
120
 
118
121
  /**
119
122
  * Get the ISO timestamp of the most recent git commit touching a file.
@@ -126,9 +129,11 @@ function lastGitCommitDate(filePath) {
126
129
  return entry ? entry.date : null;
127
130
  }
128
131
  try {
129
- const result = execFileSync('git', [
130
- 'log', '-1', '--format=%aI', '--', filePath
131
- ], { cwd: ROOT, timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] });
132
+ const result = execFileSync(
133
+ "git",
134
+ ["log", "-1", "--format=%aI", "--", filePath],
135
+ { cwd: ROOT, timeout: 5000, stdio: ["ignore", "pipe", "pipe"] }
136
+ );
132
137
  const dateStr = result.toString().trim();
133
138
  return dateStr || null;
134
139
  } catch {
@@ -146,9 +151,11 @@ function gitCommitCount(filePath) {
146
151
  return entry ? entry.count : 0;
147
152
  }
148
153
  try {
149
- const result = execFileSync('git', [
150
- 'rev-list', '--count', 'HEAD', '--', filePath
151
- ], { cwd: ROOT, timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] });
154
+ const result = execFileSync(
155
+ "git",
156
+ ["rev-list", "--count", "HEAD", "--", filePath],
157
+ { cwd: ROOT, timeout: 5000, stdio: ["ignore", "pipe", "pipe"] }
158
+ );
152
159
  return parseInt(result.toString().trim(), 10) || 0;
153
160
  } catch {
154
161
  return 0;
@@ -160,7 +167,7 @@ function gitCommitCount(filePath) {
160
167
  * Root sprint gets slug from first few words of the question.
161
168
  */
162
169
  function deriveName(sprintPath, meta) {
163
- if (sprintPath !== '.') {
170
+ if (sprintPath !== ".") {
164
171
  // examples/remote-farmer-sprint -> remote-farmer-sprint
165
172
  return path.basename(sprintPath);
166
173
  }
@@ -168,12 +175,12 @@ function deriveName(sprintPath, meta) {
168
175
  if (meta?.question) {
169
176
  return meta.question
170
177
  .toLowerCase()
171
- .replace(/[^a-z0-9\s]/g, '')
178
+ .replace(/[^a-z0-9\s]/g, "")
172
179
  .split(/\s+/)
173
180
  .slice(0, 4)
174
- .join('-');
181
+ .join("-");
175
182
  }
176
- return 'current';
183
+ return "current";
177
184
  }
178
185
 
179
186
  // ─── Scanner ──────────────────────────────────────────────────────────────────
@@ -183,21 +190,21 @@ function findSprintRoots() {
183
190
  const roots = [];
184
191
 
185
192
  // 1. Root-level claims.json (current sprint)
186
- const rootClaims = path.join(ROOT, 'claims.json');
193
+ const rootClaims = path.join(ROOT, "claims.json");
187
194
  if (fs.existsSync(rootClaims)) {
188
- roots.push({ claimsPath: rootClaims, sprintPath: '.' });
195
+ roots.push({ claimsPath: rootClaims, sprintPath: "." });
189
196
  }
190
197
 
191
198
  // 2. Scan known subdirectories for sprint claims.json files
192
199
  // Root claims.json should NOT prevent scanning subdirs
193
- const scanDirs = ['examples', 'sprints', 'archive'];
200
+ const scanDirs = ["examples", "sprints", "archive"];
194
201
  for (const dirName of scanDirs) {
195
202
  const dir = path.join(ROOT, dirName);
196
203
  if (!fs.existsSync(dir)) continue;
197
204
  try {
198
205
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
199
206
  if (!entry.isDirectory()) continue;
200
- const claimsPath = path.join(dir, entry.name, 'claims.json');
207
+ const claimsPath = path.join(dir, entry.name, "claims.json");
201
208
  if (fs.existsSync(claimsPath)) {
202
209
  roots.push({
203
210
  claimsPath,
@@ -205,7 +212,9 @@ function findSprintRoots() {
205
212
  });
206
213
  }
207
214
  }
208
- } catch { /* skip if unreadable */ }
215
+ } catch {
216
+ /* skip if unreadable */
217
+ }
209
218
  }
210
219
 
211
220
  return roots;
@@ -230,30 +239,32 @@ function analyzeSprint(root) {
230
239
  const commitCount = gitCommitCount(root.claimsPath);
231
240
 
232
241
  // Phase-based status inference
233
- const phase = meta.phase || 'unknown';
234
- const isArchived = phase === 'archived' || phase === 'complete';
235
- const isExample = root.sprintPath.startsWith('examples' + path.sep) || root.sprintPath.startsWith('examples/');
242
+ const phase = meta.phase || "unknown";
243
+ const isArchived = phase === "archived" || phase === "complete";
244
+ const isExample =
245
+ root.sprintPath.startsWith("examples" + path.sep) ||
246
+ root.sprintPath.startsWith("examples/");
236
247
 
237
248
  // Compute status
238
249
  let status;
239
250
  if (isArchived) {
240
- status = 'archived';
251
+ status = "archived";
241
252
  } else if (isExample) {
242
- status = 'example';
253
+ status = "example";
243
254
  } else {
244
- status = 'candidate'; // will be resolved to 'active' below
255
+ status = "candidate"; // will be resolved to 'active' below
245
256
  }
246
257
 
247
258
  return {
248
259
  name: deriveName(root.sprintPath, meta),
249
260
  path: root.sprintPath,
250
- question: meta.question || '',
261
+ question: meta.question || "",
251
262
  phase,
252
263
  initiated: meta.initiated || null,
253
264
  last_git_activity: lastCommit,
254
265
  git_commit_count: commitCount,
255
266
  claims_count: claimsList.length,
256
- active_claims: claimsList.filter(c => c.status === 'active').length,
267
+ active_claims: claimsList.filter((c) => c.status === "active").length,
257
268
  status,
258
269
  };
259
270
  }
@@ -274,20 +285,24 @@ export function detectSprints(rootDir) {
274
285
  resetGitCache();
275
286
  const roots = findSprintRoots();
276
287
 
277
- // Batch all git queries upfront: 2 git calls instead of 2 per sprint
278
- batchGitInfo(roots.map(r => r.claimsPath));
288
+ // Batch all git queries upfront: 1 git call instead of 2 per sprint
289
+ batchGitInfo(roots.map((r) => r.claimsPath));
279
290
 
280
291
  const sprints = roots.map(analyzeSprint).filter(Boolean);
281
292
 
282
293
  // Separate candidates from archived/examples
283
- const candidates = sprints.filter(s => s.status === 'candidate');
284
- const others = sprints.filter(s => s.status !== 'candidate');
294
+ const candidates = sprints.filter((s) => s.status === "candidate");
295
+ const others = sprints.filter((s) => s.status !== "candidate");
285
296
 
286
297
  // Rank candidates by git activity, then initiated date, then claim count
287
298
  candidates.sort((a, b) => {
288
299
  // Most recent git activity first
289
- const dateA = a.last_git_activity ? new Date(a.last_git_activity).getTime() : 0;
290
- const dateB = b.last_git_activity ? new Date(b.last_git_activity).getTime() : 0;
300
+ const dateA = a.last_git_activity
301
+ ? new Date(a.last_git_activity).getTime()
302
+ : 0;
303
+ const dateB = b.last_git_activity
304
+ ? new Date(b.last_git_activity).getTime()
305
+ : 0;
291
306
  if (dateB !== dateA) return dateB - dateA;
292
307
 
293
308
  // Most recent initiated date
@@ -302,30 +317,38 @@ export function detectSprints(rootDir) {
302
317
  // Top candidate is active
303
318
  let active = null;
304
319
  if (candidates.length > 0) {
305
- candidates[0].status = 'active';
320
+ candidates[0].status = "active";
306
321
  active = candidates[0];
307
322
  }
308
323
 
309
324
  // If no root candidate, check examples — the one with most recent git activity
310
325
  if (!active && others.length > 0) {
311
- const nonArchived = others.filter(s => s.status !== 'archived');
326
+ const nonArchived = others.filter((s) => s.status !== "archived");
312
327
  if (nonArchived.length > 0) {
313
328
  nonArchived.sort((a, b) => {
314
- const dateA = a.last_git_activity ? new Date(a.last_git_activity).getTime() : 0;
315
- const dateB = b.last_git_activity ? new Date(b.last_git_activity).getTime() : 0;
329
+ const dateA = a.last_git_activity
330
+ ? new Date(a.last_git_activity).getTime()
331
+ : 0;
332
+ const dateB = b.last_git_activity
333
+ ? new Date(b.last_git_activity).getTime()
334
+ : 0;
316
335
  return dateB - dateA;
317
336
  });
318
- nonArchived[0].status = 'active';
337
+ nonArchived[0].status = "active";
319
338
  active = nonArchived[0];
320
339
  }
321
340
  }
322
341
 
323
342
  // Combine and sort: active first, then by last_git_activity
324
343
  const allSprints = [...candidates, ...others].sort((a, b) => {
325
- if (a.status === 'active' && b.status !== 'active') return -1;
326
- if (b.status === 'active' && a.status !== 'active') return 1;
327
- const dateA = a.last_git_activity ? new Date(a.last_git_activity).getTime() : 0;
328
- const dateB = b.last_git_activity ? new Date(b.last_git_activity).getTime() : 0;
344
+ if (a.status === "active" && b.status !== "active") return -1;
345
+ if (b.status === "active" && a.status !== "active") return 1;
346
+ const dateA = a.last_git_activity
347
+ ? new Date(a.last_git_activity).getTime()
348
+ : 0;
349
+ const dateB = b.last_git_activity
350
+ ? new Date(b.last_git_activity).getTime()
351
+ : 0;
329
352
  return dateB - dateA;
330
353
  });
331
354
 
@@ -336,12 +359,14 @@ export { findSprintRoots, analyzeSprint };
336
359
 
337
360
  // ─── CLI (only when run directly, not when imported) ──────────────────────────
338
361
 
339
- const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
362
+ const isMain =
363
+ process.argv[1] &&
364
+ fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
340
365
 
341
366
  if (isMain) {
342
367
  const args = process.argv.slice(2);
343
368
 
344
- if (args.includes('--help') || args.includes('-h')) {
369
+ if (args.includes("--help") || args.includes("-h")) {
345
370
  console.log(`detect-sprints.js — Git-based sprint detection (no config required)
346
371
 
347
372
  Usage:
@@ -355,23 +380,26 @@ Based on f001: config should not duplicate git-derivable state.`);
355
380
  process.exit(0);
356
381
  }
357
382
 
358
- const rootIdx = args.indexOf('--root');
359
- const rootArg = (rootIdx !== -1 && args[rootIdx + 1]) ? path.resolve(args[rootIdx + 1]) : undefined;
383
+ const rootIdx = args.indexOf("--root");
384
+ const rootArg =
385
+ rootIdx !== -1 && args[rootIdx + 1]
386
+ ? path.resolve(args[rootIdx + 1])
387
+ : undefined;
360
388
 
361
389
  const t0 = performance.now();
362
390
  const result = detectSprints(rootArg);
363
391
  const elapsed = (performance.now() - t0).toFixed(1);
364
392
 
365
- if (args.includes('--json')) {
393
+ if (args.includes("--json")) {
366
394
  console.log(JSON.stringify(result, null, 2));
367
395
  process.exit(0);
368
396
  }
369
397
 
370
- if (args.includes('--active')) {
398
+ if (args.includes("--active")) {
371
399
  if (result.active) {
372
400
  console.log(result.active.path);
373
401
  } else {
374
- console.error('No active sprint detected.');
402
+ console.error("No active sprint detected.");
375
403
  process.exit(1);
376
404
  }
377
405
  process.exit(0);
@@ -379,26 +407,32 @@ Based on f001: config should not duplicate git-derivable state.`);
379
407
 
380
408
  // Human-readable output
381
409
  console.log(`Sprint Detection (${elapsed}ms)`);
382
- console.log('='.repeat(50));
410
+ console.log("=".repeat(50));
383
411
  console.log(`Found ${result.sprints.length} sprint(s)\n`);
384
412
 
385
413
  for (const sprint of result.sprints) {
386
- const icon = sprint.status === 'active' ? '>>>' : ' ';
414
+ const icon = sprint.status === "active" ? ">>>" : " ";
387
415
  const statusTag = sprint.status.toUpperCase().padEnd(8);
388
416
  console.log(`${icon} [${statusTag}] ${sprint.name}`);
389
417
  console.log(` Path: ${sprint.path}`);
390
418
  console.log(` Phase: ${sprint.phase}`);
391
- console.log(` Claims: ${sprint.claims_count} total, ${sprint.active_claims} active`);
392
- console.log(` Initiated: ${sprint.initiated || 'unknown'}`);
393
- console.log(` Last git: ${sprint.last_git_activity || 'untracked'}`);
419
+ console.log(
420
+ ` Claims: ${sprint.claims_count} total, ${sprint.active_claims} active`
421
+ );
422
+ console.log(` Initiated: ${sprint.initiated || "unknown"}`);
423
+ console.log(` Last git: ${sprint.last_git_activity || "untracked"}`);
394
424
  console.log(` Commits: ${sprint.git_commit_count}`);
395
- console.log(` Question: ${sprint.question.slice(0, 80)}${sprint.question.length > 80 ? '...' : ''}`);
425
+ console.log(
426
+ ` Question: ${sprint.question.slice(0, 80)}${
427
+ sprint.question.length > 80 ? "..." : ""
428
+ }`
429
+ );
396
430
  console.log();
397
431
  }
398
432
 
399
433
  if (result.active) {
400
434
  console.log(`Active sprint: ${result.active.path} (${result.active.name})`);
401
435
  } else {
402
- console.log('No active sprint detected.');
436
+ console.log("No active sprint detected.");
403
437
  }
404
438
  }