@tekyzinc/gsd-t 3.11.11 → 3.12.12
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/CHANGELOG.md +111 -0
- package/README.md +4 -10
- package/bin/event-stream.cjs +205 -0
- package/bin/gsd-t-unattended.cjs +142 -2
- package/bin/gsd-t-unattended.js +87 -1
- package/bin/gsd-t.js +131 -124
- package/bin/headless-auto-spawn.cjs +102 -6
- package/bin/headless-auto-spawn.js +44 -4
- package/bin/scan-data-collector.js +39 -11
- package/bin/token-budget.cjs +43 -126
- package/bin/unattended-watch-format.cjs +154 -0
- package/commands/gsd-t-backlog-list.md +0 -38
- package/commands/gsd-t-complete-milestone.md +2 -30
- package/commands/gsd-t-debug.md +18 -122
- package/commands/gsd-t-doc-ripple.md +0 -21
- package/commands/gsd-t-execute.md +58 -117
- package/commands/gsd-t-help.md +3 -59
- package/commands/gsd-t-integrate.md +18 -78
- package/commands/gsd-t-quick.md +22 -80
- package/commands/gsd-t-resume.md +2 -2
- package/commands/gsd-t-scan.md +15 -1
- package/commands/gsd-t-status.md +2 -32
- package/commands/gsd-t-unattended-watch.md +37 -1
- package/commands/gsd-t-verify.md +14 -2
- package/commands/gsd-t-wave.md +22 -91
- package/commands/gsd.md +43 -4
- package/docs/GSD-T-README.md +2 -6
- package/docs/architecture.md +10 -8
- package/docs/infrastructure.md +8 -14
- package/docs/methodology.md +10 -4
- package/docs/prd-harness-evolution.md +1 -1
- package/docs/requirements.md +28 -12
- package/package.json +2 -2
- package/scripts/context-meter/threshold.js +25 -46
- package/scripts/context-meter/threshold.test.js +52 -80
- package/scripts/gsd-t-agent-dashboard-server.js +4 -4
- package/scripts/gsd-t-agent-dashboard.html +699 -380
- package/scripts/gsd-t-context-meter.e2e.test.js +4 -3
- package/scripts/gsd-t-context-meter.js +1 -1
- package/scripts/gsd-t-context-meter.test.js +58 -50
- package/scripts/gsd-t-event-writer.js +8 -2
- package/templates/CLAUDE-global.md +7 -25
- package/templates/CLAUDE-project.md +22 -23
- package/bin/qa-calibrator.js +0 -194
- package/bin/runway-estimator.cjs +0 -242
- package/bin/runway-estimator.js +0 -242
- package/bin/token-optimizer.cjs +0 -471
- package/bin/token-optimizer.js +0 -471
- package/bin/token-telemetry.cjs +0 -246
- package/bin/token-telemetry.js +0 -246
- package/commands/gsd-t-audit.md +0 -196
- package/commands/gsd-t-brainstorm.md +0 -201
- package/commands/gsd-t-discuss.md +0 -178
- package/commands/gsd-t-optimization-apply.md +0 -91
- package/commands/gsd-t-optimization-reject.md +0 -94
- package/commands/gsd-t-prompt.md +0 -137
- package/commands/gsd-t-reflect.md +0 -130
- package/scripts/context-meter/count-tokens-client.js +0 -221
- package/scripts/context-meter/count-tokens-client.test.js +0 -308
- package/scripts/context-meter/test-injector.js +0 -55
package/bin/gsd-t-unattended.js
CHANGED
|
@@ -56,6 +56,13 @@ const {
|
|
|
56
56
|
notify,
|
|
57
57
|
} = require("./gsd-t-unattended-platform.js");
|
|
58
58
|
|
|
59
|
+
// Event stream (M38 ES) — additive, non-blocking. `_emit` swallows its own
|
|
60
|
+
// errors per unattended-event-stream-contract.md §6.
|
|
61
|
+
const { appendEvent: _esAppendEvent } = require("./event-stream.cjs");
|
|
62
|
+
function _emit(projectDir, ev) {
|
|
63
|
+
try { _esAppendEvent(projectDir, ev); } catch (_) { /* never halt the loop */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
// ── Constants ───────────────────────────────────────────────────────────────
|
|
60
67
|
|
|
61
68
|
const CONTRACT_VERSION = "1.0.0";
|
|
@@ -477,7 +484,32 @@ function finalizeState(state, dir, terminalStatus) {
|
|
|
477
484
|
*/
|
|
478
485
|
function doUnattended(argv, deps) {
|
|
479
486
|
deps = deps || {};
|
|
480
|
-
const
|
|
487
|
+
const rawArgv = argv || [];
|
|
488
|
+
|
|
489
|
+
// --watch rejection (headless-default-contract §2) — unattended is detached
|
|
490
|
+
// by definition; passing --watch is a category error. Refuse fast so the
|
|
491
|
+
// user sees a clear message before any state.json / PID work happens.
|
|
492
|
+
if (
|
|
493
|
+
Array.isArray(rawArgv) &&
|
|
494
|
+
rawArgv.some(
|
|
495
|
+
(a) => typeof a === "string" && (a === "--watch" || a.startsWith("--watch=")),
|
|
496
|
+
)
|
|
497
|
+
) {
|
|
498
|
+
// eslint-disable-next-line no-console
|
|
499
|
+
console.error(
|
|
500
|
+
"[gsd-t-unattended] --watch is incompatible with unattended.\n" +
|
|
501
|
+
"Unattended supervisor is detached by definition.\n" +
|
|
502
|
+
"Run /user:gsd-t-unattended-watch from your interactive session to see live activity.",
|
|
503
|
+
);
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
dryRun: false,
|
|
507
|
+
exitCode: 2,
|
|
508
|
+
reason: "--watch is incompatible with unattended",
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const opts = parseArgs(rawArgv);
|
|
481
513
|
const projectDir = path.resolve(opts.project || ".");
|
|
482
514
|
|
|
483
515
|
// ── Resolve injection points (real impls by default) ─────────────────────
|
|
@@ -884,6 +916,16 @@ function runMainLoop(state, dir, opts, deps, ctx) {
|
|
|
884
916
|
state.lastWorkerStartedAt = workerStart.toISOString();
|
|
885
917
|
writeState(state, dir);
|
|
886
918
|
|
|
919
|
+
_emit(projectDir, {
|
|
920
|
+
ts: workerStart.toISOString(),
|
|
921
|
+
iter: state.iter,
|
|
922
|
+
type: "task_start",
|
|
923
|
+
source: "supervisor",
|
|
924
|
+
milestone: state.milestone || "",
|
|
925
|
+
wave: state.wave || "",
|
|
926
|
+
task: state.nextTask || "",
|
|
927
|
+
});
|
|
928
|
+
|
|
887
929
|
let res;
|
|
888
930
|
try {
|
|
889
931
|
res = spawnWorker(state, {
|
|
@@ -921,6 +963,29 @@ function runMainLoop(state, dir, opts, deps, ctx) {
|
|
|
921
963
|
state.lastElapsedMs = elapsedMs;
|
|
922
964
|
writeState(state, dir);
|
|
923
965
|
|
|
966
|
+
// Event-stream: task_complete on success, error on non-zero.
|
|
967
|
+
const durationS = Math.round(elapsedMs / 1000);
|
|
968
|
+
if (exitCode === 0) {
|
|
969
|
+
_emit(projectDir, {
|
|
970
|
+
ts: workerEnd.toISOString(),
|
|
971
|
+
iter: state.iter,
|
|
972
|
+
type: "task_complete",
|
|
973
|
+
source: "supervisor",
|
|
974
|
+
task: state.nextTask || "",
|
|
975
|
+
verdict: "pass",
|
|
976
|
+
duration_s: durationS,
|
|
977
|
+
});
|
|
978
|
+
} else {
|
|
979
|
+
_emit(projectDir, {
|
|
980
|
+
ts: workerEnd.toISOString(),
|
|
981
|
+
iter: state.iter,
|
|
982
|
+
type: "error",
|
|
983
|
+
source: "supervisor",
|
|
984
|
+
error: `worker exit ${exitCode}`,
|
|
985
|
+
recoverable: exitCode !== 4 && exitCode !== 5,
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
924
989
|
// ── POST-WORKER HOOK (contract §12) ────────────────────────────────────
|
|
925
990
|
// Read the tail of run.log for pattern detection. ~200 lines is enough
|
|
926
991
|
// to span the last several iteration blocks for the gutter detector.
|
|
@@ -959,6 +1024,13 @@ function runMainLoop(state, dir, opts, deps, ctx) {
|
|
|
959
1024
|
break;
|
|
960
1025
|
}
|
|
961
1026
|
// Not yet done — continue relay.
|
|
1027
|
+
_emit(projectDir, {
|
|
1028
|
+
iter: state.iter,
|
|
1029
|
+
type: "retry",
|
|
1030
|
+
source: "supervisor",
|
|
1031
|
+
attempt: state.iter,
|
|
1032
|
+
reason: "milestone_incomplete",
|
|
1033
|
+
});
|
|
962
1034
|
continue;
|
|
963
1035
|
}
|
|
964
1036
|
if (exitCode === 4) {
|
|
@@ -975,9 +1047,23 @@ function runMainLoop(state, dir, opts, deps, ctx) {
|
|
|
975
1047
|
}
|
|
976
1048
|
if (exitCode === 124) {
|
|
977
1049
|
// Timeout — continue unless the iter cap is hit on the next check.
|
|
1050
|
+
_emit(projectDir, {
|
|
1051
|
+
iter: state.iter,
|
|
1052
|
+
type: "retry",
|
|
1053
|
+
source: "supervisor",
|
|
1054
|
+
attempt: state.iter,
|
|
1055
|
+
reason: "timeout",
|
|
1056
|
+
});
|
|
978
1057
|
continue;
|
|
979
1058
|
}
|
|
980
1059
|
// Non-terminal (1/2/3) — continue the relay.
|
|
1060
|
+
_emit(projectDir, {
|
|
1061
|
+
iter: state.iter,
|
|
1062
|
+
type: "retry",
|
|
1063
|
+
source: "supervisor",
|
|
1064
|
+
attempt: state.iter,
|
|
1065
|
+
reason: `exit_${exitCode}`,
|
|
1066
|
+
});
|
|
981
1067
|
}
|
|
982
1068
|
|
|
983
1069
|
// If we exited because the user dropped a stop sentinel and no terminal
|
package/bin/gsd-t.js
CHANGED
|
@@ -388,8 +388,14 @@ const CONTEXT_METER_GITIGNORE_ENTRIES = [
|
|
|
388
388
|
".gsd-t/context-meter.log",
|
|
389
389
|
];
|
|
390
390
|
const CONTEXT_METER_HOOK_MARKER = "gsd-t-context-meter";
|
|
391
|
+
// Canonical global hook — runs the script from the globally-installed npm package.
|
|
392
|
+
// Guarded so it silently exits 0 if the package is not present (non-GSD-T projects).
|
|
391
393
|
const CONTEXT_METER_HOOK_COMMAND =
|
|
392
|
-
'node "$
|
|
394
|
+
'bash -c \'[ -f "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-context-meter.js" ] && node "$(npm root -g)/@tekyzinc/gsd-t/scripts/gsd-t-context-meter.js" || true\'';
|
|
395
|
+
// Legacy command patterns that must be migrated on install/update/init.
|
|
396
|
+
const CONTEXT_METER_STALE_PATTERNS = [
|
|
397
|
+
/node\s+"?\$CLAUDE_PROJECT_DIR\/scripts\/gsd-t-context-meter\.js"?/,
|
|
398
|
+
];
|
|
393
399
|
|
|
394
400
|
// Append entries to {projectDir}/.gitignore. Each entry added only if absent.
|
|
395
401
|
// Idempotent. Returns true if any entries were added, false otherwise.
|
|
@@ -433,8 +439,8 @@ function ensureGitignoreEntries(projectDir, entries) {
|
|
|
433
439
|
|
|
434
440
|
// Install the Context Meter into a project directory.
|
|
435
441
|
// Copies scripts/gsd-t-context-meter.js, scripts/context-meter/*.js (runtime
|
|
436
|
-
// only — skips .test.js
|
|
437
|
-
//
|
|
442
|
+
// only — skips .test.js), and the config template (if missing). Also appends
|
|
443
|
+
// entries to .gitignore.
|
|
438
444
|
function installContextMeter(projectDir) {
|
|
439
445
|
try {
|
|
440
446
|
// 1. Copy gsd-t-context-meter.js → {projectDir}/scripts/
|
|
@@ -486,9 +492,7 @@ function installContextMeter(projectDir) {
|
|
|
486
492
|
return false;
|
|
487
493
|
}
|
|
488
494
|
for (const fname of depFiles) {
|
|
489
|
-
// Skip test files and test-only infrastructure
|
|
490
495
|
if (fname.includes(".test.")) continue;
|
|
491
|
-
if (fname === "test-injector.js") continue;
|
|
492
496
|
const fsrc = path.join(depsSrcDir, fname);
|
|
493
497
|
const fdest = path.join(depsDestDir, fname);
|
|
494
498
|
try {
|
|
@@ -546,7 +550,9 @@ function installContextMeter(projectDir) {
|
|
|
546
550
|
|
|
547
551
|
// Register the Context Meter PostToolUse hook in ~/.claude/settings.json.
|
|
548
552
|
// Idempotent — if an existing hook references CONTEXT_METER_HOOK_MARKER the
|
|
549
|
-
// command string is refreshed in-place
|
|
553
|
+
// command string is refreshed/migrated in-place to the canonical form.
|
|
554
|
+
// Stale entries matching CONTEXT_METER_STALE_PATTERNS are migrated on the spot.
|
|
555
|
+
// All other settings/hooks are preserved.
|
|
550
556
|
// Returns { installed: bool, action: "added"|"updated"|"noop" }.
|
|
551
557
|
function configureContextMeterHooks(settingsPath) {
|
|
552
558
|
const targetPath = settingsPath || SETTINGS_JSON;
|
|
@@ -572,13 +578,17 @@ function configureContextMeterHooks(settingsPath) {
|
|
|
572
578
|
for (const entry of settings.hooks.PostToolUse) {
|
|
573
579
|
if (!entry || !Array.isArray(entry.hooks)) continue;
|
|
574
580
|
for (const h of entry.hooks) {
|
|
575
|
-
if (h
|
|
581
|
+
if (!h || typeof h.command !== "string") continue;
|
|
582
|
+
const isCurrentCanonical = h.command === cmd;
|
|
583
|
+
const isMarkerMatch = h.command.includes(CONTEXT_METER_HOOK_MARKER);
|
|
584
|
+
const isStaleMatch = !isCurrentCanonical &&
|
|
585
|
+
CONTEXT_METER_STALE_PATTERNS.some((re) => re.test(h.command));
|
|
586
|
+
|
|
587
|
+
if (isCurrentCanonical || isMarkerMatch || isStaleMatch) {
|
|
576
588
|
found = true;
|
|
577
|
-
if (
|
|
589
|
+
if (!isCurrentCanonical) {
|
|
578
590
|
h.command = cmd;
|
|
579
591
|
action = "updated";
|
|
580
|
-
} else if (action === "noop") {
|
|
581
|
-
action = "noop";
|
|
582
592
|
}
|
|
583
593
|
}
|
|
584
594
|
}
|
|
@@ -609,6 +619,46 @@ function configureContextMeterHooks(settingsPath) {
|
|
|
609
619
|
return { installed: true, action };
|
|
610
620
|
}
|
|
611
621
|
|
|
622
|
+
// Remove any context meter PostToolUse hooks from settings.json.
|
|
623
|
+
// Used during uninstall. Leaves all other hooks intact.
|
|
624
|
+
function removeContextMeterHook(settingsPath) {
|
|
625
|
+
const targetPath = settingsPath || SETTINGS_JSON;
|
|
626
|
+
if (!fs.existsSync(targetPath)) return false;
|
|
627
|
+
let settings;
|
|
628
|
+
try {
|
|
629
|
+
settings = JSON.parse(fs.readFileSync(targetPath, "utf8"));
|
|
630
|
+
if (!settings || typeof settings !== "object") return false;
|
|
631
|
+
} catch {
|
|
632
|
+
warn("settings.json has invalid JSON — cannot remove context meter hook");
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (!settings.hooks || !Array.isArray(settings.hooks.PostToolUse)) return false;
|
|
637
|
+
|
|
638
|
+
const before = settings.hooks.PostToolUse.length;
|
|
639
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((entry) => {
|
|
640
|
+
if (!entry || !Array.isArray(entry.hooks)) return true;
|
|
641
|
+
// Keep the entry only if NONE of its hooks reference the context meter
|
|
642
|
+
return !entry.hooks.some(
|
|
643
|
+
(h) => h && typeof h.command === "string" && h.command.includes(CONTEXT_METER_HOOK_MARKER)
|
|
644
|
+
);
|
|
645
|
+
});
|
|
646
|
+
const removed = before - settings.hooks.PostToolUse.length;
|
|
647
|
+
if (removed === 0) return false;
|
|
648
|
+
|
|
649
|
+
if (isSymlink(targetPath)) {
|
|
650
|
+
warn("Skipping settings.json write — target is a symlink");
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
fs.writeFileSync(targetPath, JSON.stringify(settings, null, 2));
|
|
655
|
+
return true;
|
|
656
|
+
} catch (e) {
|
|
657
|
+
warn(`Failed to write settings.json: ${e.message}`);
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
612
662
|
// Interactive prompt for the Anthropic API key env var.
|
|
613
663
|
// Skips if not a TTY or if the env var is already set.
|
|
614
664
|
// Never writes the key anywhere — just prints the export command for the user
|
|
@@ -1651,6 +1701,11 @@ function doUninstall() {
|
|
|
1651
1701
|
removeInstalledCommands();
|
|
1652
1702
|
removeVersionFile();
|
|
1653
1703
|
|
|
1704
|
+
// Remove context meter PostToolUse hook from settings.json
|
|
1705
|
+
if (removeContextMeterHook(SETTINGS_JSON)) {
|
|
1706
|
+
success("Context meter PostToolUse hook removed from settings.json");
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1654
1709
|
warn("~/.claude/CLAUDE.md was NOT removed (may contain your customizations)");
|
|
1655
1710
|
info("Remove manually if desired: delete the GSD-T section from ~/.claude/CLAUDE.md");
|
|
1656
1711
|
info("Project files (.gsd-t/, docs/, CLAUDE.md) were NOT removed");
|
|
@@ -1970,8 +2025,7 @@ const PROJECT_BIN_TOOLS = [
|
|
|
1970
2025
|
"archive-progress.cjs", "log-tail.cjs", "context-budget-audit.cjs",
|
|
1971
2026
|
"context-meter-config.cjs", "token-budget.cjs",
|
|
1972
2027
|
"gsd-t-unattended.cjs", "gsd-t-unattended-platform.cjs", "gsd-t-unattended-safety.cjs",
|
|
1973
|
-
"handoff-lock.cjs", "headless-auto-spawn.cjs",
|
|
1974
|
-
"token-telemetry.cjs", "token-optimizer.cjs",
|
|
2028
|
+
"handoff-lock.cjs", "headless-auto-spawn.cjs",
|
|
1975
2029
|
];
|
|
1976
2030
|
|
|
1977
2031
|
function copyBinToolsToProject(projectDir, projectName) {
|
|
@@ -2603,6 +2657,39 @@ function doGraph(args) {
|
|
|
2603
2657
|
}
|
|
2604
2658
|
}
|
|
2605
2659
|
|
|
2660
|
+
// ─── Token-Log Writer (Fix 1, v3.12.12) ─────────────────────────────────────
|
|
2661
|
+
|
|
2662
|
+
const _TL_HEADER =
|
|
2663
|
+
"| Datetime-start | Datetime-end | Command | Step | Model | Duration(s) | Notes | Domain | Task | Ctx% |\n" +
|
|
2664
|
+
"|---|---|---|---|---|---|---|---|---|---|\n";
|
|
2665
|
+
|
|
2666
|
+
/**
|
|
2667
|
+
* Append one row to {projectDir}/.gsd-t/token-log.md for a headless exec
|
|
2668
|
+
* invocation. Best-effort — never throws.
|
|
2669
|
+
*/
|
|
2670
|
+
function appendHeadlessTokenLog(projectDir, entry) {
|
|
2671
|
+
try {
|
|
2672
|
+
const logPath = path.join(projectDir, ".gsd-t", "token-log.md");
|
|
2673
|
+
const note = entry.exitCode === 0 ? "headless exec: ok" : `headless exec: exit ${entry.exitCode}`;
|
|
2674
|
+
const row =
|
|
2675
|
+
`| ${entry.dtStart} | ${entry.dtEnd} | ${entry.command} | headless | unknown | ${entry.durationS}s | ${note} | - | - | unknown |\n`;
|
|
2676
|
+
const gsdtDir = path.join(projectDir, ".gsd-t");
|
|
2677
|
+
if (!fs.existsSync(gsdtDir)) fs.mkdirSync(gsdtDir, { recursive: true });
|
|
2678
|
+
if (!fs.existsSync(logPath)) {
|
|
2679
|
+
fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${_TL_HEADER}${row}`);
|
|
2680
|
+
} else {
|
|
2681
|
+
const existing = fs.readFileSync(logPath, "utf8");
|
|
2682
|
+
if (!existing.includes("| Datetime-start |")) {
|
|
2683
|
+
fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${_TL_HEADER}${existing}${row}`);
|
|
2684
|
+
} else {
|
|
2685
|
+
fs.appendFileSync(logPath, row);
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
} catch (_) {
|
|
2689
|
+
/* best-effort */
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2606
2693
|
// ─── Headless Mode ────────────────────────────────────────────────────────────
|
|
2607
2694
|
|
|
2608
2695
|
/**
|
|
@@ -2720,12 +2807,20 @@ function doHeadlessExec(command, cmdArgs, flags) {
|
|
|
2720
2807
|
let output = "";
|
|
2721
2808
|
let processExitCode = 0;
|
|
2722
2809
|
|
|
2810
|
+
// Inject command/phase env vars so worker event-stream entries are tagged
|
|
2811
|
+
// (Fix 2, v3.12.12).
|
|
2812
|
+
const workerEnv = Object.assign({}, process.env, {
|
|
2813
|
+
GSD_T_COMMAND: `gsd-t-${command}`,
|
|
2814
|
+
GSD_T_PHASE: process.env.GSD_T_PHASE || "execute",
|
|
2815
|
+
});
|
|
2816
|
+
|
|
2723
2817
|
try {
|
|
2724
|
-
const result = execFileSync("claude", ["-p", prompt], {
|
|
2818
|
+
const result = execFileSync("claude", ["-p", "--dangerously-skip-permissions", prompt], {
|
|
2725
2819
|
encoding: "utf8",
|
|
2726
2820
|
timeout: timeoutMs,
|
|
2727
2821
|
stdio: ["pipe", "pipe", "pipe"],
|
|
2728
|
-
cwd: process.cwd()
|
|
2822
|
+
cwd: process.cwd(),
|
|
2823
|
+
env: workerEnv,
|
|
2729
2824
|
});
|
|
2730
2825
|
output = result;
|
|
2731
2826
|
} catch (e) {
|
|
@@ -2741,6 +2836,16 @@ function doHeadlessExec(command, cmdArgs, flags) {
|
|
|
2741
2836
|
const gsdtExitCode = mapHeadlessExitCode(processExitCode, output);
|
|
2742
2837
|
const duration = Date.now() - startTime;
|
|
2743
2838
|
|
|
2839
|
+
// Append to token-log.md (Fix 1, v3.12.12) — headless exec writes a row so
|
|
2840
|
+
// `gsd-t headless <command>` spawns are visible in the log.
|
|
2841
|
+
appendHeadlessTokenLog(process.cwd(), {
|
|
2842
|
+
dtStart: new Date(startTime).toISOString().slice(0, 16).replace("T", " "),
|
|
2843
|
+
dtEnd: new Date(startTime + duration).toISOString().slice(0, 16).replace("T", " "),
|
|
2844
|
+
command: `gsd-t-${command}`,
|
|
2845
|
+
durationS: Math.round(duration / 1000),
|
|
2846
|
+
exitCode: gsdtExitCode,
|
|
2847
|
+
});
|
|
2848
|
+
|
|
2744
2849
|
// Write log file if requested
|
|
2745
2850
|
if (logMode && logFile) {
|
|
2746
2851
|
try {
|
|
@@ -3245,117 +3350,10 @@ function showHeadlessHelp() {
|
|
|
3245
3350
|
log(` ${DIM}$${RESET} gsd-t headless query domains\n`);
|
|
3246
3351
|
}
|
|
3247
3352
|
|
|
3248
|
-
// ─── Metrics (
|
|
3249
|
-
|
|
3250
|
-
function parseMetricsByFlag(args) {
|
|
3251
|
-
const byArg = args.find(a => a.startsWith("--by="));
|
|
3252
|
-
if (!byArg) return [];
|
|
3253
|
-
const raw = byArg.slice(5).trim();
|
|
3254
|
-
if (!raw) return [];
|
|
3255
|
-
return raw.split(",").map(s => s.trim()).filter(Boolean);
|
|
3256
|
-
}
|
|
3257
|
-
|
|
3258
|
-
function formatPct(v) {
|
|
3259
|
-
if (v === null || v === undefined || Number.isNaN(v)) return "—";
|
|
3260
|
-
return `${Number(v).toFixed(1)}%`;
|
|
3261
|
-
}
|
|
3353
|
+
// ─── Metrics (removed in v3.12 — M38 meter reduction) ─────────────────────────
|
|
3262
3354
|
|
|
3263
|
-
function
|
|
3264
|
-
|
|
3265
|
-
return String(Math.round(Number(v)));
|
|
3266
|
-
}
|
|
3267
|
-
|
|
3268
|
-
function doMetrics(args) {
|
|
3269
|
-
const projectDir = process.cwd();
|
|
3270
|
-
let tt;
|
|
3271
|
-
try {
|
|
3272
|
-
tt = require(path.join(projectDir, "bin", "token-telemetry.js"));
|
|
3273
|
-
} catch (e) {
|
|
3274
|
-
error(`bin/token-telemetry.js not found in ${projectDir} — run gsd-t install first.`);
|
|
3275
|
-
process.exit(1);
|
|
3276
|
-
}
|
|
3277
|
-
|
|
3278
|
-
const records = tt.readAll(projectDir);
|
|
3279
|
-
if (records.length === 0) {
|
|
3280
|
-
log(`${DIM}No telemetry records yet — .gsd-t/token-metrics.jsonl is empty or missing.${RESET}`);
|
|
3281
|
-
return;
|
|
3282
|
-
}
|
|
3283
|
-
|
|
3284
|
-
const isTokens = args.includes("--tokens");
|
|
3285
|
-
const isHalts = args.includes("--halts");
|
|
3286
|
-
const isContextWindow = args.includes("--context-window");
|
|
3287
|
-
|
|
3288
|
-
if (!isTokens && !isHalts && !isContextWindow) {
|
|
3289
|
-
log(`${YELLOW}Specify at least one of: --tokens, --halts, --context-window${RESET}`);
|
|
3290
|
-
return;
|
|
3291
|
-
}
|
|
3292
|
-
|
|
3293
|
-
if (isHalts) {
|
|
3294
|
-
const halts = records.filter(r => r.halt_type);
|
|
3295
|
-
log(`\n${BOLD}Halts — ${halts.length} record(s)${RESET}`);
|
|
3296
|
-
if (halts.length === 0) {
|
|
3297
|
-
log(`${DIM} (no halts recorded — all spawns completed normally)${RESET}\n`);
|
|
3298
|
-
} else {
|
|
3299
|
-
const byType = {};
|
|
3300
|
-
for (const r of halts) {
|
|
3301
|
-
const key = String(r.halt_type);
|
|
3302
|
-
byType[key] = (byType[key] || 0) + 1;
|
|
3303
|
-
}
|
|
3304
|
-
for (const [type, count] of Object.entries(byType).sort()) {
|
|
3305
|
-
log(` ${CYAN}${type.padEnd(24)}${RESET} ${count}`);
|
|
3306
|
-
}
|
|
3307
|
-
log("");
|
|
3308
|
-
}
|
|
3309
|
-
}
|
|
3310
|
-
|
|
3311
|
-
if (isTokens) {
|
|
3312
|
-
const by = parseMetricsByFlag(args);
|
|
3313
|
-
if (by.length === 0) {
|
|
3314
|
-
const totalConsumed = records.reduce((a, r) => a + (Number(r.tokens_consumed) || 0), 0);
|
|
3315
|
-
const totalDuration = records.reduce((a, r) => a + (Number(r.duration_s) || 0), 0);
|
|
3316
|
-
log(`\n${BOLD}Tokens — ${records.length} spawn(s)${RESET}`);
|
|
3317
|
-
log(` total tokens consumed: ${formatInt(totalConsumed)}`);
|
|
3318
|
-
log(` total duration (s): ${formatInt(totalDuration)}`);
|
|
3319
|
-
if (records.length > 0) {
|
|
3320
|
-
log(` mean tokens/spawn: ${formatInt(totalConsumed / records.length)}`);
|
|
3321
|
-
}
|
|
3322
|
-
log("");
|
|
3323
|
-
} else {
|
|
3324
|
-
const groups = tt.aggregate(records, { by });
|
|
3325
|
-
log(`\n${BOLD}Tokens by ${by.join(",")} — ${groups.length} group(s)${RESET}`);
|
|
3326
|
-
const keyHeader = by.join("/");
|
|
3327
|
-
log(` ${keyHeader.padEnd(36)} ${"count".padStart(7)} ${"total".padStart(12)} ${"mean".padStart(10)} ${"median".padStart(10)} ${"p95".padStart(10)}`);
|
|
3328
|
-
log(` ${"-".repeat(36)} ${"-".repeat(7)} ${"-".repeat(12)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(10)}`);
|
|
3329
|
-
for (const g of groups) {
|
|
3330
|
-
const label = by.map(k => g.key[k] == null ? "—" : String(g.key[k])).join("/");
|
|
3331
|
-
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)}`);
|
|
3332
|
-
}
|
|
3333
|
-
log("");
|
|
3334
|
-
}
|
|
3335
|
-
}
|
|
3336
|
-
|
|
3337
|
-
if (isContextWindow) {
|
|
3338
|
-
log(`\n${BOLD}Context window trend — ${records.length} record(s)${RESET}`);
|
|
3339
|
-
const withPct = records.filter(r => r.context_window_pct_after != null && !Number.isNaN(Number(r.context_window_pct_after)));
|
|
3340
|
-
if (withPct.length === 0) {
|
|
3341
|
-
log(`${DIM} (no context-window measurements in records)${RESET}\n`);
|
|
3342
|
-
} else {
|
|
3343
|
-
const sorted = [...withPct].map(r => Number(r.context_window_pct_after)).sort((a, b) => a - b);
|
|
3344
|
-
const min = sorted[0];
|
|
3345
|
-
const max = sorted[sorted.length - 1];
|
|
3346
|
-
const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
|
|
3347
|
-
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
|
|
3348
|
-
log(` min: ${formatPct(min)}`);
|
|
3349
|
-
log(` mean: ${formatPct(mean)}`);
|
|
3350
|
-
log(` p95: ${formatPct(p95)}`);
|
|
3351
|
-
log(` max: ${formatPct(max)}`);
|
|
3352
|
-
const atWarn = withPct.filter(r => Number(r.context_window_pct_after) >= 70).length;
|
|
3353
|
-
const atStop = withPct.filter(r => Number(r.context_window_pct_after) >= 85).length;
|
|
3354
|
-
log(` spawns at warn band (≥70%): ${atWarn}`);
|
|
3355
|
-
log(` spawns at stop band (≥85%): ${atStop}`);
|
|
3356
|
-
log("");
|
|
3357
|
-
}
|
|
3358
|
-
}
|
|
3355
|
+
function doMetrics(_args) {
|
|
3356
|
+
log(`${DIM}metrics removed in v3.12 — context meter is no longer telemetry-instrumented${RESET}`);
|
|
3359
3357
|
}
|
|
3360
3358
|
|
|
3361
3359
|
function showHelp() {
|
|
@@ -3470,6 +3468,7 @@ module.exports = {
|
|
|
3470
3468
|
ensureGitignoreEntries,
|
|
3471
3469
|
installContextMeter,
|
|
3472
3470
|
configureContextMeterHooks,
|
|
3471
|
+
removeContextMeterHook,
|
|
3473
3472
|
promptForApiKeyIfMissing,
|
|
3474
3473
|
resolveApiKeyEnvVar,
|
|
3475
3474
|
runTaskCounterRetirementMigration,
|
|
@@ -3515,6 +3514,14 @@ if (require.main === module) {
|
|
|
3515
3514
|
case "headless":
|
|
3516
3515
|
doHeadless(args.slice(1));
|
|
3517
3516
|
break;
|
|
3517
|
+
case "unattended": {
|
|
3518
|
+
const { spawnSync } = require("child_process");
|
|
3519
|
+
const cjs = path.join(__dirname, "gsd-t-unattended.cjs");
|
|
3520
|
+
const res = spawnSync(process.execPath, [cjs, ...args.slice(1)], {
|
|
3521
|
+
stdio: "inherit",
|
|
3522
|
+
});
|
|
3523
|
+
process.exit(res.status == null ? 1 : res.status);
|
|
3524
|
+
}
|
|
3518
3525
|
case "metrics":
|
|
3519
3526
|
doMetrics(args.slice(1));
|
|
3520
3527
|
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());
|
|
@@ -101,11 +135,18 @@ function autoSpawnHeadless(opts) {
|
|
|
101
135
|
const gsdtCli = path.join(projectDir, "bin", "gsd-t.js");
|
|
102
136
|
const childArgs = [gsdtCli, "headless", stripGsdtPrefix(command), ...args, "--log"];
|
|
103
137
|
|
|
138
|
+
// Inject command/phase into worker env so event-stream entries are tagged
|
|
139
|
+
// (Fix 2, v3.12.12). GSD_T_PHASE defaults to "execute" for primary spawns.
|
|
140
|
+
const workerEnv = Object.assign({}, process.env, {
|
|
141
|
+
GSD_T_COMMAND: command,
|
|
142
|
+
GSD_T_PHASE: process.env.GSD_T_PHASE || "execute",
|
|
143
|
+
});
|
|
144
|
+
|
|
104
145
|
const child = spawn("node", childArgs, {
|
|
105
146
|
cwd: projectDir,
|
|
106
147
|
detached: true,
|
|
107
148
|
stdio: ["ignore", logFd, logFd],
|
|
108
|
-
env:
|
|
149
|
+
env: workerEnv,
|
|
109
150
|
});
|
|
110
151
|
|
|
111
152
|
child.unref();
|
|
@@ -145,7 +186,13 @@ function autoSpawnHeadless(opts) {
|
|
|
145
186
|
// a detached approach that survives even after the parent's `unref()`.
|
|
146
187
|
installCompletionWatcher({ projectDir, id, logPath, pid, startTimestamp: timestamp });
|
|
147
188
|
|
|
148
|
-
return {
|
|
189
|
+
return {
|
|
190
|
+
id,
|
|
191
|
+
pid,
|
|
192
|
+
logPath: path.relative(projectDir, logPath),
|
|
193
|
+
timestamp,
|
|
194
|
+
mode: "headless",
|
|
195
|
+
};
|
|
149
196
|
}
|
|
150
197
|
|
|
151
198
|
// ── makeSessionId ────────────────────────────────────────────────────────────
|
|
@@ -251,6 +298,7 @@ function installCompletionWatcher(opts) {
|
|
|
251
298
|
const POLL_MS = 2000;
|
|
252
299
|
const MAX_WAIT_MS = 60 * 60 * 1000; // 1 hour safety cap
|
|
253
300
|
const startMs = Date.now();
|
|
301
|
+
const dtStart = new Date(startTimestamp).toLocaleString("sv-SE", { hour12: false }).slice(0, 16);
|
|
254
302
|
|
|
255
303
|
const timer = setInterval(() => {
|
|
256
304
|
let alive = false;
|
|
@@ -265,9 +313,20 @@ function installCompletionWatcher(opts) {
|
|
|
265
313
|
// Exit code is unknown from a signal-based probe. Best-effort: read
|
|
266
314
|
// the log's last lines to guess, otherwise default to 0.
|
|
267
315
|
const exitCode = guessExitCodeFromLog(projectDir, id);
|
|
316
|
+
const endTimestamp = new Date().toISOString();
|
|
268
317
|
markSessionCompleted(projectDir, id, {
|
|
269
318
|
exitCode,
|
|
270
|
-
endTimestamp
|
|
319
|
+
endTimestamp,
|
|
320
|
+
});
|
|
321
|
+
// Append token-log row (Fix 1, v3.12.12)
|
|
322
|
+
const dtEnd = new Date(endTimestamp).toLocaleString("sv-SE", { hour12: false }).slice(0, 16);
|
|
323
|
+
const durationS = Math.round((Date.now() - startMs) / 1000);
|
|
324
|
+
appendTokenLog(projectDir, {
|
|
325
|
+
dtStart,
|
|
326
|
+
dtEnd,
|
|
327
|
+
command: extractCommand(id),
|
|
328
|
+
durationS,
|
|
329
|
+
exitCode,
|
|
271
330
|
});
|
|
272
331
|
fireMacNotification({ id, command: extractCommand(id), startTimestamp });
|
|
273
332
|
} else if (Date.now() - startMs > MAX_WAIT_MS) {
|
|
@@ -316,6 +375,43 @@ function fireMacNotification({ id, command }) {
|
|
|
316
375
|
}
|
|
317
376
|
}
|
|
318
377
|
|
|
378
|
+
// ── Token-Log Writer (Fix 1, v3.12.12) ───────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
const TOKEN_LOG_HEADER =
|
|
381
|
+
"| Datetime-start | Datetime-end | Command | Step | Model | Duration(s) | Notes | Domain | Task | Ctx% |\n" +
|
|
382
|
+
"|---|---|---|---|---|---|---|---|---|---|\n";
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Append one row to {projectDir}/.gsd-t/token-log.md matching the schema used
|
|
386
|
+
* by interactive command-file observability blocks.
|
|
387
|
+
*
|
|
388
|
+
* @param {string} projectDir
|
|
389
|
+
* @param {{ dtStart: string, dtEnd: string, command: string, durationS: number, exitCode: number }} entry
|
|
390
|
+
*/
|
|
391
|
+
function appendTokenLog(projectDir, entry) {
|
|
392
|
+
try {
|
|
393
|
+
const logPath = path.join(projectDir, ".gsd-t", "token-log.md");
|
|
394
|
+
const note = entry.exitCode === 0 ? "headless spawn: ok" : `headless spawn: exit ${entry.exitCode}`;
|
|
395
|
+
const row =
|
|
396
|
+
`| ${entry.dtStart} | ${entry.dtEnd} | ${entry.command} | headless | unknown | ${entry.durationS}s | ${note} | - | - | unknown |\n`;
|
|
397
|
+
if (!fs.existsSync(logPath)) {
|
|
398
|
+
// Create with header
|
|
399
|
+
ensureDir(path.dirname(logPath));
|
|
400
|
+
fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${TOKEN_LOG_HEADER}${row}`);
|
|
401
|
+
} else {
|
|
402
|
+
// Check if header row exists; if not prepend it (migration for files created before this fix)
|
|
403
|
+
const existing = fs.readFileSync(logPath, "utf8");
|
|
404
|
+
if (!existing.includes("| Datetime-start |")) {
|
|
405
|
+
fs.writeFileSync(logPath, `# GSD-T Token Log\n\n${TOKEN_LOG_HEADER}${existing}${row}`);
|
|
406
|
+
} else {
|
|
407
|
+
fs.appendFileSync(logPath, row);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} catch (_) {
|
|
411
|
+
/* best-effort — never halt the completion watcher */
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
319
415
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
320
416
|
|
|
321
417
|
function ensureDir(d) {
|