@tekyzinc/gsd-t 3.11.10 → 3.12.10

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 (61) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +4 -10
  3. package/bin/event-stream.cjs +205 -0
  4. package/bin/gsd-t-unattended.cjs +87 -1
  5. package/bin/gsd-t-unattended.js +87 -1
  6. package/bin/gsd-t.js +41 -172
  7. package/bin/headless-auto-spawn.cjs +44 -4
  8. package/bin/headless-auto-spawn.js +44 -4
  9. package/bin/scan-data-collector.js +39 -11
  10. package/bin/token-budget.cjs +43 -126
  11. package/bin/unattended-watch-format.cjs +154 -0
  12. package/commands/gsd-t-backlog-list.md +0 -38
  13. package/commands/gsd-t-complete-milestone.md +2 -30
  14. package/commands/gsd-t-debug.md +18 -122
  15. package/commands/gsd-t-doc-ripple.md +0 -21
  16. package/commands/gsd-t-execute.md +58 -117
  17. package/commands/gsd-t-help.md +3 -59
  18. package/commands/gsd-t-integrate.md +18 -78
  19. package/commands/gsd-t-quick.md +22 -80
  20. package/commands/gsd-t-resume.md +2 -2
  21. package/commands/gsd-t-scan.md +15 -1
  22. package/commands/gsd-t-status.md +2 -32
  23. package/commands/gsd-t-unattended-watch.md +37 -1
  24. package/commands/gsd-t-verify.md +14 -2
  25. package/commands/gsd-t-wave.md +22 -91
  26. package/commands/gsd.md +43 -4
  27. package/docs/GSD-T-README.md +2 -6
  28. package/docs/architecture.md +10 -8
  29. package/docs/infrastructure.md +8 -14
  30. package/docs/methodology.md +10 -4
  31. package/docs/prd-harness-evolution.md +1 -1
  32. package/docs/requirements.md +28 -12
  33. package/package.json +2 -2
  34. package/scripts/context-meter/estimate-tokens.js +96 -0
  35. package/scripts/context-meter/estimate-tokens.test.js +158 -0
  36. package/scripts/context-meter/threshold.js +25 -46
  37. package/scripts/context-meter/threshold.test.js +52 -80
  38. package/scripts/gsd-t-agent-dashboard-server.js +4 -4
  39. package/scripts/gsd-t-agent-dashboard.html +699 -380
  40. package/scripts/gsd-t-context-meter.e2e.test.js +39 -131
  41. package/scripts/gsd-t-context-meter.js +13 -36
  42. package/scripts/gsd-t-context-meter.test.js +59 -90
  43. package/templates/CLAUDE-global.md +7 -25
  44. package/templates/CLAUDE-project.md +22 -23
  45. package/bin/qa-calibrator.js +0 -194
  46. package/bin/runway-estimator.cjs +0 -242
  47. package/bin/runway-estimator.js +0 -242
  48. package/bin/token-optimizer.cjs +0 -471
  49. package/bin/token-optimizer.js +0 -471
  50. package/bin/token-telemetry.cjs +0 -246
  51. package/bin/token-telemetry.js +0 -246
  52. package/commands/gsd-t-audit.md +0 -196
  53. package/commands/gsd-t-brainstorm.md +0 -201
  54. package/commands/gsd-t-discuss.md +0 -178
  55. package/commands/gsd-t-optimization-apply.md +0 -91
  56. package/commands/gsd-t-optimization-reject.md +0 -94
  57. package/commands/gsd-t-prompt.md +0 -137
  58. package/commands/gsd-t-reflect.md +0 -130
  59. package/scripts/context-meter/count-tokens-client.js +0 -221
  60. package/scripts/context-meter/count-tokens-client.test.js +0 -308
  61. package/scripts/context-meter/test-injector.js +0 -55
package/bin/gsd-t.js CHANGED
@@ -433,8 +433,8 @@ function ensureGitignoreEntries(projectDir, entries) {
433
433
 
434
434
  // Install the Context Meter into a project directory.
435
435
  // Copies scripts/gsd-t-context-meter.js, scripts/context-meter/*.js (runtime
436
- // only — skips .test.js and test-injector.js), and the config template (if
437
- // missing). Also appends entries to .gitignore.
436
+ // only — skips .test.js), and the config template (if missing). Also appends
437
+ // entries to .gitignore.
438
438
  function installContextMeter(projectDir) {
439
439
  try {
440
440
  // 1. Copy gsd-t-context-meter.js → {projectDir}/scripts/
@@ -486,9 +486,7 @@ function installContextMeter(projectDir) {
486
486
  return false;
487
487
  }
488
488
  for (const fname of depFiles) {
489
- // Skip test files and test-only infrastructure
490
489
  if (fname.includes(".test.")) continue;
491
- if (fname === "test-injector.js") continue;
492
490
  const fsrc = path.join(depsSrcDir, fname);
493
491
  const fdest = path.join(depsDestDir, fname);
494
492
  try {
@@ -1232,9 +1230,6 @@ async function doInstall(opts = {}) {
1232
1230
  saveInstalledVersion();
1233
1231
 
1234
1232
  showInstallSummary(gsdtCommands.length, utilityCommands.length);
1235
-
1236
- // Interactive prompt (skipped silently in non-TTY shells)
1237
- await promptForApiKeyIfMissing(resolveApiKeyEnvVar(process.cwd()));
1238
1233
  }
1239
1234
 
1240
1235
  function showInstallSummary(gsdtCount, utilCount) {
@@ -1444,9 +1439,6 @@ async function doInit(projectName) {
1444
1439
  if (registerProject(projectDir)) success("Registered in ~/.claude/.gsd-t-projects");
1445
1440
 
1446
1441
  showInitTree(projectDir);
1447
-
1448
- // Interactive prompt (skipped silently in non-TTY shells)
1449
- await promptForApiKeyIfMissing(resolveApiKeyEnvVar(projectDir));
1450
1442
  }
1451
1443
 
1452
1444
  function showInitTree(projectDir) {
@@ -1523,12 +1515,7 @@ function showStatusContextMeter() {
1523
1515
  const rel = state.timestamp ? formatRelativeTime(state.timestamp) : "never measured";
1524
1516
  log(` ${RED}${BOLD}✗ CONTEXT METER DEAD${RESET} ${RED}— error: ${code}, last check: ${rel}${RESET}`);
1525
1517
  log(` ${RED}The context-window guardrail is NOT working. Long sessions will hit /compact.${RESET}`);
1526
- if (code === "missing_key") {
1527
- log(` ${YELLOW}Fix: export ANTHROPIC_API_KEY in your shell profile${RESET}`);
1528
- log(` ${YELLOW} (measurement only — inference stays on Claude Code subscription)${RESET}`);
1529
- } else {
1530
- log(` ${YELLOW}Fix: run 'gsd-t doctor' for diagnostics${RESET}`);
1531
- }
1518
+ log(` ${YELLOW}Fix: run 'gsd-t doctor' for diagnostics${RESET}`);
1532
1519
  return;
1533
1520
  }
1534
1521
 
@@ -1981,8 +1968,7 @@ const PROJECT_BIN_TOOLS = [
1981
1968
  "archive-progress.cjs", "log-tail.cjs", "context-budget-audit.cjs",
1982
1969
  "context-meter-config.cjs", "token-budget.cjs",
1983
1970
  "gsd-t-unattended.cjs", "gsd-t-unattended-platform.cjs", "gsd-t-unattended-safety.cjs",
1984
- "handoff-lock.cjs", "headless-auto-spawn.cjs", "runway-estimator.cjs",
1985
- "token-telemetry.cjs", "token-optimizer.cjs",
1971
+ "handoff-lock.cjs", "headless-auto-spawn.cjs",
1986
1972
  ];
1987
1973
 
1988
1974
  function copyBinToolsToProject(projectDir, projectName) {
@@ -2308,8 +2294,8 @@ function checkDoctorCgc() {
2308
2294
  return issues;
2309
2295
  }
2310
2296
 
2311
- // Verify context meter wiring: API key env var, hook registration,
2312
- // hook script presence, config validity, and a live count_tokens dry-run.
2297
+ // Verify context meter wiring: hook registration, hook script presence,
2298
+ // config validity, and a local estimation dry-run.
2313
2299
  // Returns number of issues (RED results). Mirrors checkDoctorCgc shape.
2314
2300
  async function checkDoctorContextMeter(projectDir) {
2315
2301
  let issues = 0;
@@ -2317,8 +2303,8 @@ async function checkDoctorContextMeter(projectDir) {
2317
2303
 
2318
2304
  const cwd = projectDir || process.cwd();
2319
2305
 
2320
- // Load config (used by checks 1, 4, and 5). Missing file → defaults; invalid
2321
- // JSON or schema-mismatch → throws (handled in Check 4).
2306
+ // Load config (used by checks 3 and 4). Missing file → defaults; invalid
2307
+ // JSON or schema-mismatch → throws (handled in Check 3).
2322
2308
  let cfg = null;
2323
2309
  let cfgLoadErr = null;
2324
2310
  try {
@@ -2327,19 +2313,8 @@ async function checkDoctorContextMeter(projectDir) {
2327
2313
  } catch (e) {
2328
2314
  cfgLoadErr = e;
2329
2315
  }
2330
- const apiKeyEnvVar = (cfg && cfg.apiKeyEnvVar) || "ANTHROPIC_API_KEY";
2331
-
2332
- // Check 1: API key env var present
2333
- const apiKeyValue = process.env[apiKeyEnvVar];
2334
- const apiKeyPresent = typeof apiKeyValue === "string" && apiKeyValue.length > 0;
2335
- if (apiKeyPresent) {
2336
- success(`API key present ($${apiKeyEnvVar})`);
2337
- } else {
2338
- error(`Missing API key: set $${apiKeyEnvVar} — https://console.anthropic.com/settings/keys`);
2339
- issues++;
2340
- }
2341
2316
 
2342
- // Check 2: Hook registered in ~/.claude/settings.json
2317
+ // Check 1: Hook registered in ~/.claude/settings.json
2343
2318
  let hookRegistered = false;
2344
2319
  try {
2345
2320
  if (fs.existsSync(SETTINGS_JSON)) {
@@ -2367,7 +2342,7 @@ async function checkDoctorContextMeter(projectDir) {
2367
2342
  issues++;
2368
2343
  }
2369
2344
 
2370
- // Check 3: Hook script file exists in project
2345
+ // Check 2: Hook script file exists in project
2371
2346
  const scriptPath = path.join(cwd, "scripts", CONTEXT_METER_SCRIPT);
2372
2347
  if (fs.existsSync(scriptPath)) {
2373
2348
  success("Hook script present");
@@ -2376,7 +2351,7 @@ async function checkDoctorContextMeter(projectDir) {
2376
2351
  issues++;
2377
2352
  }
2378
2353
 
2379
- // Check 4: Config file parses via loader
2354
+ // Check 3: Config file parses via loader
2380
2355
  const configPath = path.join(cwd, CONTEXT_METER_CONFIG_DEST);
2381
2356
  if (cfgLoadErr) {
2382
2357
  error(`Config file invalid: ${cfgLoadErr.message} — fix ${CONTEXT_METER_CONFIG_DEST}`);
@@ -2387,34 +2362,27 @@ async function checkDoctorContextMeter(projectDir) {
2387
2362
  warn("Using default config — run gsd-t install to copy template");
2388
2363
  }
2389
2364
 
2390
- // Check 5: Dry-run count_tokens API call (skip if no API key)
2391
- if (!apiKeyPresent) {
2392
- log(` ${DIM}Skipped count_tokens dry-run (no API key)${RESET}`);
2365
+ // Check 4: Dry-run local token estimation
2366
+ const estimatorPath = path.join(cwd, "scripts", "context-meter", "estimate-tokens.js");
2367
+ if (!fs.existsSync(estimatorPath)) {
2368
+ error("Token estimator missing at scripts/context-meter/estimate-tokens.js — run gsd-t update");
2369
+ issues++;
2393
2370
  } else {
2394
- const clientPath = path.join(cwd, "scripts", "context-meter", "count-tokens-client.js");
2395
- if (!fs.existsSync(clientPath)) {
2396
- error("count_tokens client missing at scripts/context-meter/count-tokens-client.js — run gsd-t update");
2397
- issues++;
2398
- } else {
2399
- try {
2400
- const { countTokens } = require(clientPath);
2401
- const result = await countTokens({
2402
- apiKey: apiKeyValue,
2403
- model: "claude-opus-4-6",
2404
- system: "",
2405
- messages: [{ role: "user", content: [{ type: "text", text: "ping" }] }],
2406
- timeoutMs: 5000,
2407
- });
2408
- if (result && typeof result.inputTokens === "number") {
2409
- success(`count_tokens dry-run OK (${result.inputTokens} tokens)`);
2410
- } else {
2411
- error("count_tokens API call failed — check API key and network");
2412
- issues++;
2413
- }
2414
- } catch (e) {
2415
- error(`count_tokens dry-run threw: ${e.message}`);
2371
+ try {
2372
+ const { estimateTokens } = require(estimatorPath);
2373
+ const result = estimateTokens({
2374
+ system: "",
2375
+ messages: [{ role: "user", content: [{ type: "text", text: "ping" }] }],
2376
+ });
2377
+ if (result && typeof result.inputTokens === "number") {
2378
+ success(`Token estimator dry-run OK (${result.inputTokens} tokens)`);
2379
+ } else {
2380
+ error("Token estimator returned null");
2416
2381
  issues++;
2417
2382
  }
2383
+ } catch (e) {
2384
+ error(`Token estimator dry-run threw: ${e.message}`);
2385
+ issues++;
2418
2386
  }
2419
2387
  }
2420
2388
 
@@ -2750,7 +2718,7 @@ function doHeadlessExec(command, cmdArgs, flags) {
2750
2718
  let processExitCode = 0;
2751
2719
 
2752
2720
  try {
2753
- const result = execFileSync("claude", ["-p", prompt], {
2721
+ const result = execFileSync("claude", ["-p", "--dangerously-skip-permissions", prompt], {
2754
2722
  encoding: "utf8",
2755
2723
  timeout: timeoutMs,
2756
2724
  stdio: ["pipe", "pipe", "pipe"],
@@ -3274,117 +3242,10 @@ function showHeadlessHelp() {
3274
3242
  log(` ${DIM}$${RESET} gsd-t headless query domains\n`);
3275
3243
  }
3276
3244
 
3277
- // ─── Metrics (M35 token telemetry CLI) ────────────────────────────────────────
3278
-
3279
- function parseMetricsByFlag(args) {
3280
- const byArg = args.find(a => a.startsWith("--by="));
3281
- if (!byArg) return [];
3282
- const raw = byArg.slice(5).trim();
3283
- if (!raw) return [];
3284
- return raw.split(",").map(s => s.trim()).filter(Boolean);
3285
- }
3245
+ // ─── Metrics (removed in v3.12 — M38 meter reduction) ─────────────────────────
3286
3246
 
3287
- function formatPct(v) {
3288
- if (v === null || v === undefined || Number.isNaN(v)) return "—";
3289
- return `${Number(v).toFixed(1)}%`;
3290
- }
3291
-
3292
- function formatInt(v) {
3293
- if (v === null || v === undefined || Number.isNaN(v)) return "—";
3294
- return String(Math.round(Number(v)));
3295
- }
3296
-
3297
- function doMetrics(args) {
3298
- const projectDir = process.cwd();
3299
- let tt;
3300
- try {
3301
- tt = require(path.join(projectDir, "bin", "token-telemetry.js"));
3302
- } catch (e) {
3303
- error(`bin/token-telemetry.js not found in ${projectDir} — run gsd-t install first.`);
3304
- process.exit(1);
3305
- }
3306
-
3307
- const records = tt.readAll(projectDir);
3308
- if (records.length === 0) {
3309
- log(`${DIM}No telemetry records yet — .gsd-t/token-metrics.jsonl is empty or missing.${RESET}`);
3310
- return;
3311
- }
3312
-
3313
- const isTokens = args.includes("--tokens");
3314
- const isHalts = args.includes("--halts");
3315
- const isContextWindow = args.includes("--context-window");
3316
-
3317
- if (!isTokens && !isHalts && !isContextWindow) {
3318
- log(`${YELLOW}Specify at least one of: --tokens, --halts, --context-window${RESET}`);
3319
- return;
3320
- }
3321
-
3322
- if (isHalts) {
3323
- const halts = records.filter(r => r.halt_type);
3324
- log(`\n${BOLD}Halts — ${halts.length} record(s)${RESET}`);
3325
- if (halts.length === 0) {
3326
- log(`${DIM} (no halts recorded — all spawns completed normally)${RESET}\n`);
3327
- } else {
3328
- const byType = {};
3329
- for (const r of halts) {
3330
- const key = String(r.halt_type);
3331
- byType[key] = (byType[key] || 0) + 1;
3332
- }
3333
- for (const [type, count] of Object.entries(byType).sort()) {
3334
- log(` ${CYAN}${type.padEnd(24)}${RESET} ${count}`);
3335
- }
3336
- log("");
3337
- }
3338
- }
3339
-
3340
- if (isTokens) {
3341
- const by = parseMetricsByFlag(args);
3342
- if (by.length === 0) {
3343
- const totalConsumed = records.reduce((a, r) => a + (Number(r.tokens_consumed) || 0), 0);
3344
- const totalDuration = records.reduce((a, r) => a + (Number(r.duration_s) || 0), 0);
3345
- log(`\n${BOLD}Tokens — ${records.length} spawn(s)${RESET}`);
3346
- log(` total tokens consumed: ${formatInt(totalConsumed)}`);
3347
- log(` total duration (s): ${formatInt(totalDuration)}`);
3348
- if (records.length > 0) {
3349
- log(` mean tokens/spawn: ${formatInt(totalConsumed / records.length)}`);
3350
- }
3351
- log("");
3352
- } else {
3353
- const groups = tt.aggregate(records, { by });
3354
- log(`\n${BOLD}Tokens by ${by.join(",")} — ${groups.length} group(s)${RESET}`);
3355
- const keyHeader = by.join("/");
3356
- log(` ${keyHeader.padEnd(36)} ${"count".padStart(7)} ${"total".padStart(12)} ${"mean".padStart(10)} ${"median".padStart(10)} ${"p95".padStart(10)}`);
3357
- log(` ${"-".repeat(36)} ${"-".repeat(7)} ${"-".repeat(12)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(10)}`);
3358
- for (const g of groups) {
3359
- const label = by.map(k => g.key[k] == null ? "—" : String(g.key[k])).join("/");
3360
- log(` ${label.padEnd(36)} ${formatInt(g.count).padStart(7)} ${formatInt(g.total_tokens).padStart(12)} ${formatInt(g.mean).padStart(10)} ${formatInt(g.median).padStart(10)} ${formatInt(g.p95).padStart(10)}`);
3361
- }
3362
- log("");
3363
- }
3364
- }
3365
-
3366
- if (isContextWindow) {
3367
- log(`\n${BOLD}Context window trend — ${records.length} record(s)${RESET}`);
3368
- const withPct = records.filter(r => r.context_window_pct_after != null && !Number.isNaN(Number(r.context_window_pct_after)));
3369
- if (withPct.length === 0) {
3370
- log(`${DIM} (no context-window measurements in records)${RESET}\n`);
3371
- } else {
3372
- const sorted = [...withPct].map(r => Number(r.context_window_pct_after)).sort((a, b) => a - b);
3373
- const min = sorted[0];
3374
- const max = sorted[sorted.length - 1];
3375
- const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
3376
- const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
3377
- log(` min: ${formatPct(min)}`);
3378
- log(` mean: ${formatPct(mean)}`);
3379
- log(` p95: ${formatPct(p95)}`);
3380
- log(` max: ${formatPct(max)}`);
3381
- const atWarn = withPct.filter(r => Number(r.context_window_pct_after) >= 70).length;
3382
- const atStop = withPct.filter(r => Number(r.context_window_pct_after) >= 85).length;
3383
- log(` spawns at warn band (≥70%): ${atWarn}`);
3384
- log(` spawns at stop band (≥85%): ${atStop}`);
3385
- log("");
3386
- }
3387
- }
3247
+ function doMetrics(_args) {
3248
+ log(`${DIM}metrics removed in v3.12 context meter is no longer telemetry-instrumented${RESET}`);
3388
3249
  }
3389
3250
 
3390
3251
  function showHelp() {
@@ -3544,6 +3405,14 @@ if (require.main === module) {
3544
3405
  case "headless":
3545
3406
  doHeadless(args.slice(1));
3546
3407
  break;
3408
+ case "unattended": {
3409
+ const { spawnSync } = require("child_process");
3410
+ const cjs = path.join(__dirname, "gsd-t-unattended.cjs");
3411
+ const res = spawnSync(process.execPath, [cjs, ...args.slice(1)], {
3412
+ stdio: "inherit",
3413
+ });
3414
+ process.exit(res.status == null ? 1 : res.status);
3415
+ }
3547
3416
  case "metrics":
3548
3417
  doMetrics(args.slice(1));
3549
3418
  break;
@@ -53,20 +53,54 @@ module.exports = {
53
53
  * args?: string[],
54
54
  * continue_from?: string,
55
55
  * projectDir?: string,
56
- * context?: object
56
+ * context?: object,
57
+ * sessionContext?: object,
58
+ * sessionId?: string,
59
+ * watch?: boolean,
60
+ * spawnType?: 'primary' | 'validation'
57
61
  * }} opts
58
- * @returns {{ id: string, pid: number, logPath: string, timestamp: string }}
62
+ * @returns {{ id: string | null, pid: number | null, logPath: string | null, timestamp: string, mode: 'headless' | 'in-context' }}
59
63
  */
60
64
  function autoSpawnHeadless(opts) {
61
65
  const command = opts.command;
62
66
  const args = opts.args || [];
63
67
  const continue_from = opts.continue_from || ".";
64
68
  const projectDir = opts.projectDir || process.cwd();
65
- const context = opts.context || null;
69
+ const context = opts.context || opts.sessionContext || null;
70
+ const watch = opts.watch === true;
71
+ const spawnType = opts.spawnType || "primary";
66
72
 
67
73
  if (!command || typeof command !== "string") {
68
74
  throw new Error("autoSpawnHeadless: `command` is required");
69
75
  }
76
+ if (spawnType !== "primary" && spawnType !== "validation") {
77
+ throw new Error(
78
+ `autoSpawnHeadless: \`spawnType\` must be 'primary' or 'validation' (got ${JSON.stringify(spawnType)})`,
79
+ );
80
+ }
81
+
82
+ // Propagation rules (headless-default-contract §2):
83
+ // watch=true + primary → signal in-context fallback (caller uses Task)
84
+ // watch=true + validation → warn on stderr; proceed headless
85
+ // watch=false → headless (default behavior)
86
+ if (watch && spawnType === "primary") {
87
+ return {
88
+ id: null,
89
+ pid: null,
90
+ logPath: null,
91
+ timestamp: new Date().toISOString(),
92
+ mode: "in-context",
93
+ };
94
+ }
95
+ if (watch && spawnType === "validation") {
96
+ try {
97
+ process.stderr.write(
98
+ `[headless-default] --watch ignored for validation spawn type: ${spawnType}\n`,
99
+ );
100
+ } catch (_) {
101
+ /* best effort */
102
+ }
103
+ }
70
104
 
71
105
  const timestamp = new Date().toISOString();
72
106
  const id = makeSessionId(command, new Date());
@@ -145,7 +179,13 @@ function autoSpawnHeadless(opts) {
145
179
  // a detached approach that survives even after the parent's `unref()`.
146
180
  installCompletionWatcher({ projectDir, id, logPath, pid, startTimestamp: timestamp });
147
181
 
148
- return { id, pid, logPath: path.relative(projectDir, logPath), timestamp };
182
+ return {
183
+ id,
184
+ pid,
185
+ logPath: path.relative(projectDir, logPath),
186
+ timestamp,
187
+ mode: "headless",
188
+ };
149
189
  }
150
190
 
151
191
  // ── makeSessionId ────────────────────────────────────────────────────────────
@@ -53,20 +53,54 @@ module.exports = {
53
53
  * args?: string[],
54
54
  * continue_from?: string,
55
55
  * projectDir?: string,
56
- * context?: object
56
+ * context?: object,
57
+ * sessionContext?: object,
58
+ * sessionId?: string,
59
+ * watch?: boolean,
60
+ * spawnType?: 'primary' | 'validation'
57
61
  * }} opts
58
- * @returns {{ id: string, pid: number, logPath: string, timestamp: string }}
62
+ * @returns {{ id: string | null, pid: number | null, logPath: string | null, timestamp: string, mode: 'headless' | 'in-context' }}
59
63
  */
60
64
  function autoSpawnHeadless(opts) {
61
65
  const command = opts.command;
62
66
  const args = opts.args || [];
63
67
  const continue_from = opts.continue_from || ".";
64
68
  const projectDir = opts.projectDir || process.cwd();
65
- const context = opts.context || null;
69
+ const context = opts.context || opts.sessionContext || null;
70
+ const watch = opts.watch === true;
71
+ const spawnType = opts.spawnType || "primary";
66
72
 
67
73
  if (!command || typeof command !== "string") {
68
74
  throw new Error("autoSpawnHeadless: `command` is required");
69
75
  }
76
+ if (spawnType !== "primary" && spawnType !== "validation") {
77
+ throw new Error(
78
+ `autoSpawnHeadless: \`spawnType\` must be 'primary' or 'validation' (got ${JSON.stringify(spawnType)})`,
79
+ );
80
+ }
81
+
82
+ // Propagation rules (headless-default-contract §2):
83
+ // watch=true + primary → signal in-context fallback (caller uses Task)
84
+ // watch=true + validation → warn on stderr; proceed headless
85
+ // watch=false → headless (default behavior)
86
+ if (watch && spawnType === "primary") {
87
+ return {
88
+ id: null,
89
+ pid: null,
90
+ logPath: null,
91
+ timestamp: new Date().toISOString(),
92
+ mode: "in-context",
93
+ };
94
+ }
95
+ if (watch && spawnType === "validation") {
96
+ try {
97
+ process.stderr.write(
98
+ `[headless-default] --watch ignored for validation spawn type: ${spawnType}\n`,
99
+ );
100
+ } catch (_) {
101
+ /* best effort */
102
+ }
103
+ }
70
104
 
71
105
  const timestamp = new Date().toISOString();
72
106
  const id = makeSessionId(command, new Date());
@@ -145,7 +179,13 @@ function autoSpawnHeadless(opts) {
145
179
  // a detached approach that survives even after the parent's `unref()`.
146
180
  installCompletionWatcher({ projectDir, id, logPath, pid, startTimestamp: timestamp });
147
181
 
148
- return { id, pid, logPath: path.relative(projectDir, logPath), timestamp };
182
+ return {
183
+ id,
184
+ pid,
185
+ logPath: path.relative(projectDir, logPath),
186
+ timestamp,
187
+ mode: "headless",
188
+ };
149
189
  }
150
190
 
151
191
  // ── makeSessionId ────────────────────────────────────────────────────────────
@@ -25,22 +25,50 @@ function parseTestCoverage(text) {
25
25
  function parseFilesAndLoc(text) {
26
26
  const m = text.match(/\|\s*\*?\*?(?:Grand\s+)?Total[^|]*\*?\*?\s*\|\s*\*?\*?(\d+)\s+files?\*?\*?\s*\|\s*\*?\*?([\d,]+)[^|]*\*?\*?\s*\|/i);
27
27
  if (m) return { filesScanned: parseInt(m[1], 10), totalLoc: parseInt(m[2].replace(/,/g, ''), 10) };
28
+ let files = 0;
29
+ let loc = 0;
30
+ const lineRe = /(\d+)\s+files?\s*\(\s*~?\s*([\d,]+)\s+LOC\s*\)/gi;
31
+ let match;
32
+ while ((match = lineRe.exec(text)) !== null) {
33
+ files += parseInt(match[1], 10);
34
+ loc += parseInt(match[2].replace(/,/g, ''), 10);
35
+ }
36
+ if (files > 0) return { filesScanned: files, totalLoc: loc };
28
37
  return { filesScanned: 0, totalLoc: 0 };
29
38
  }
30
39
 
31
40
  function parseComponents(text) {
32
41
  const sec = text.match(/## Component Inventory([\s\S]*?)(?=\n## |\n---|\n#[^#]|$)/);
33
- if (!sec) return [];
34
- return sec[1].split('\n')
35
- .filter(l => /^\|/.test(l) && !/---/.test(l) && !/Component.*File/i.test(l))
36
- .map(row => {
37
- const cols = row.split('|').map(c => c.trim().replace(/\*\*/g, '').replace(/`/g, '')).filter(Boolean);
38
- if (cols.length < 3) return null;
39
- const name = cols[0];
40
- if (!name || /^total/i.test(name)) return null;
41
- return { name, filePath: cols[1] || '', size: cols[2] || '', purpose: cols[3] || '', files: 1, healthScore: 80 };
42
- })
43
- .filter(Boolean);
42
+ if (sec) {
43
+ const tableRows = sec[1].split('\n')
44
+ .filter(l => /^\|/.test(l) && !/---/.test(l) && !/Component.*File/i.test(l))
45
+ .map(row => {
46
+ const cols = row.split('|').map(c => c.trim().replace(/\*\*/g, '').replace(/`/g, '')).filter(Boolean);
47
+ if (cols.length < 3) return null;
48
+ const name = cols[0];
49
+ if (!name || /^total/i.test(name)) return null;
50
+ return { name, filePath: cols[1] || '', size: cols[2] || '', purpose: cols[3] || '', files: 1, healthScore: 80 };
51
+ })
52
+ .filter(Boolean);
53
+ if (tableRows.length > 0) return tableRows;
54
+ }
55
+ const structSec = text.match(/## Structure([\s\S]*?)(?=\n## |\n---|\n#[^#]|$)/);
56
+ if (!structSec) return [];
57
+ const entryRe = /^([a-zA-Z0-9_.\-]+\/)\s+(?:~?\s*)?(?:(\d+)\s+files?\s*)?\(?\s*~?\s*([\d,]+)\s+LOC\s*\)?\s*(.*)$/gm;
58
+ const out = [];
59
+ let m;
60
+ while ((m = entryRe.exec(structSec[1])) !== null) {
61
+ const name = m[1].replace(/\/$/, '');
62
+ out.push({
63
+ name,
64
+ filePath: m[1],
65
+ size: (m[2] ? m[2] + ' files, ' : '') + m[3] + ' LOC',
66
+ purpose: (m[4] || '').trim(),
67
+ files: m[2] ? parseInt(m[2], 10) : 1,
68
+ healthScore: 80,
69
+ });
70
+ }
71
+ return out;
44
72
  }
45
73
 
46
74
  function parseSeverityMap(text) {