@tekyzinc/gsd-t 3.29.11 → 4.0.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/bin/gsd-t-parallel.cjs +64 -13
  3. package/bin/gsd-t.js +48 -376
  4. package/bin/journey-coverage.cjs +6 -3
  5. package/bin/parallel-cli.cjs +13 -1
  6. package/commands/gsd-t-debug.md +47 -533
  7. package/commands/gsd-t-design-decompose.md +24 -497
  8. package/commands/gsd-t-doc-ripple.md +23 -139
  9. package/commands/gsd-t-execute.md +41 -958
  10. package/commands/gsd-t-help.md +0 -2
  11. package/commands/gsd-t-impact.md +26 -295
  12. package/commands/gsd-t-integrate.md +44 -369
  13. package/commands/gsd-t-milestone.md +25 -124
  14. package/commands/gsd-t-partition.md +28 -539
  15. package/commands/gsd-t-plan.md +26 -472
  16. package/commands/gsd-t-prd.md +25 -311
  17. package/commands/gsd-t-quick.md +1 -1
  18. package/commands/gsd-t-resume.md +0 -33
  19. package/commands/gsd-t-verify.md +52 -569
  20. package/commands/gsd-t-wave.md +41 -430
  21. package/package.json +1 -1
  22. package/scripts/gsd-t-calibration-hook.js +3 -1
  23. package/scripts/hooks/pre-commit-journey-coverage +0 -2
  24. package/scripts/hooks/pre-commit-playwright-gate +0 -2
  25. package/templates/CLAUDE-global.md +43 -173
  26. package/templates/CLAUDE-project.md +118 -104
  27. package/templates/prompts/design-verify-subagent.md +3 -0
  28. package/templates/prompts/qa-subagent.md +3 -0
  29. package/templates/prompts/red-team-subagent.md +5 -2
  30. package/templates/workflows/_lib.js +176 -0
  31. package/templates/workflows/gsd-t-debug.workflow.js +86 -0
  32. package/templates/workflows/gsd-t-execute.workflow.js +206 -0
  33. package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
  34. package/templates/workflows/gsd-t-phase.workflow.js +94 -0
  35. package/templates/workflows/gsd-t-quick.workflow.js +72 -0
  36. package/templates/workflows/gsd-t-verify.workflow.js +348 -0
  37. package/templates/workflows/gsd-t-wave.workflow.js +43 -0
  38. package/bin/check-headless-sessions.js +0 -140
  39. package/bin/context-budget-audit.cjs +0 -447
  40. package/bin/context-meter-config.cjs +0 -101
  41. package/bin/context-meter-config.test.cjs +0 -101
  42. package/bin/event-stream.cjs +0 -205
  43. package/bin/gsd-t-benchmark-orchestrator.js +0 -437
  44. package/bin/gsd-t-capture-lint.cjs +0 -440
  45. package/bin/gsd-t-economics.cjs +0 -315
  46. package/bin/gsd-t-in-session-usage.cjs +0 -213
  47. package/bin/gsd-t-orchestrator-config.cjs +0 -161
  48. package/bin/gsd-t-orchestrator-queue.cjs +0 -180
  49. package/bin/gsd-t-orchestrator-recover.cjs +0 -231
  50. package/bin/gsd-t-orchestrator-worker.cjs +0 -219
  51. package/bin/gsd-t-orchestrator.js +0 -535
  52. package/bin/gsd-t-parallel-probe.cjs +0 -132
  53. package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
  54. package/bin/gsd-t-ratelimit-probe.cjs +0 -648
  55. package/bin/gsd-t-report-tokens.cjs +0 -549
  56. package/bin/gsd-t-stream-feed-client.cjs +0 -151
  57. package/bin/gsd-t-token-backfill.cjs +0 -366
  58. package/bin/gsd-t-token-capture.cjs +0 -321
  59. package/bin/gsd-t-token-dashboard.cjs +0 -353
  60. package/bin/gsd-t-token-regenerate-log.cjs +0 -129
  61. package/bin/gsd-t-tool-attribution.cjs +0 -377
  62. package/bin/gsd-t-tool-cost.cjs +0 -195
  63. package/bin/gsd-t-transcript-tee.cjs +0 -246
  64. package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
  65. package/bin/gsd-t-unattended-platform.cjs +0 -551
  66. package/bin/gsd-t-unattended-platform.js +0 -381
  67. package/bin/gsd-t-unattended-safety.cjs +0 -773
  68. package/bin/gsd-t-unattended-safety.js +0 -788
  69. package/bin/gsd-t-unattended.cjs +0 -2109
  70. package/bin/gsd-t-unattended.js +0 -1367
  71. package/bin/gsd-t-worker-dispatch.cjs +0 -211
  72. package/bin/handoff-lock.cjs +0 -249
  73. package/bin/handoff-lock.js +0 -249
  74. package/bin/headless-auto-spawn.cjs +0 -711
  75. package/bin/headless-auto-spawn.js +0 -373
  76. package/bin/headless-exit-codes.cjs +0 -67
  77. package/bin/live-activity-report.cjs +0 -615
  78. package/bin/log-tail.cjs +0 -81
  79. package/bin/m44-proof-measure.cjs +0 -285
  80. package/bin/m46-iter-proof.cjs +0 -149
  81. package/bin/m46-worker-proof.cjs +0 -201
  82. package/bin/m55-substrate-proof.cjs +0 -134
  83. package/bin/metrics-rollup.js +0 -200
  84. package/bin/model-windows.cjs +0 -99
  85. package/bin/model-windows.test.cjs +0 -75
  86. package/bin/parallelism-report.cjs +0 -535
  87. package/bin/runway-estimator.cjs +0 -242
  88. package/bin/spawn-plan-derive.cjs +0 -163
  89. package/bin/spawn-plan-status-updater.cjs +0 -292
  90. package/bin/spawn-plan-writer.cjs +0 -204
  91. package/bin/supervisor-pid-fingerprint.cjs +0 -126
  92. package/bin/token-budget.cjs +0 -265
  93. package/bin/unattended-watch-format.cjs +0 -178
  94. package/bin/watch-progress.js +0 -155
  95. package/commands/gsd-t-unattended-stop.md +0 -85
  96. package/commands/gsd-t-unattended-watch.md +0 -465
  97. package/commands/gsd-t-unattended.md +0 -476
  98. package/commands/gsd-t-visualize.md +0 -116
  99. package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
  100. package/scripts/gsd-t-context-meter.js +0 -341
  101. package/scripts/gsd-t-context-meter.test.js +0 -471
  102. package/scripts/gsd-t-dashboard-server.js +0 -1203
  103. package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
  104. package/scripts/gsd-t-transcript.html +0 -1821
  105. package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
  106. package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
  107. package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
  108. package/templates/hooks/post-commit-spawn-plan.sh +0 -85
@@ -1,242 +0,0 @@
1
- 'use strict';
2
- /**
3
- * GSD-T Runway Estimator (M43 D5 — dialog-channel growth meter)
4
- *
5
- * Under M43's channel-separation model, the only thing that runs in the
6
- * in-session channel is the `/gsd` router dialog. Everything else spawns.
7
- * This module reads the per-turn usage rows the M43 D1 capture writes to
8
- * `.gsd-t/metrics/token-usage.jsonl` (schema v2) and surfaces a one-line
9
- * "~N turns to `/compact`" warning to the router.
10
- *
11
- * Scope (revised 2026-04-21 per M43 partition.md §D5):
12
- * - Read-only. Never refuses, never reroutes. Under always-headless there
13
- * is nothing to reroute *to*.
14
- * - Median-of-deltas growth slope (outlier-resistant; a single spike turn
15
- * does not flip `shouldWarn`).
16
- * - Zero external deps. `.cjs` so it loads in both ESM-default projects
17
- * and CJS projects without transpilation.
18
- *
19
- * Contracts:
20
- * - .gsd-t/contracts/context-meter-contract.md §Dialog Growth Meter
21
- * - .gsd-t/contracts/metrics-schema-contract.md (schema v2)
22
- *
23
- * Consumers:
24
- * - commands/gsd.md (router warning footer)
25
- */
26
-
27
- const fs = require('fs');
28
- const path = require('path');
29
- const { SAFE_DEFAULT_WINDOW } = require('./model-windows.cjs');
30
-
31
- const DEFAULT_K = 5;
32
- // The model context cap IS the model's true window. Default to the model-aware
33
- // safe LARGE window (1M) — the old 200K literal was correct only for pre-4
34
- // models and made turn-to-compact predictions fire 5× too early on Opus/Sonnet.
35
- // Callers may still pass an explicit `modelContextCap`, and resolveContextCap()
36
- // below prefers a fresh Context Meter reading when available.
37
- const DEFAULT_MODEL_CONTEXT_CAP = SAFE_DEFAULT_WINDOW;
38
- // Claude Code starts auto-compacting ~8% before the model window fills, so the
39
- // effective dialog ceiling is 0.92 × modelContextCap.
40
- const PRE_COMPACT_HEADROOM = 0.92;
41
- // Context Meter state — its modelWindowSize is model-aware (bin/model-windows.cjs).
42
- const METER_STATE_REL = '.gsd-t/.context-meter-state.json';
43
- const METER_STATE_STALE_MS = 5 * 60 * 1000;
44
-
45
- /**
46
- * Resolve the effective model context cap. Priority:
47
- * 1. explicit opts.modelContextCap (caller override)
48
- * 2. fresh Context Meter state modelWindowSize (model-aware, written by the
49
- * meter hook from the running model)
50
- * 3. DEFAULT_MODEL_CONTEXT_CAP (safe large fallback)
51
- */
52
- function resolveContextCap(projectDir, optCap) {
53
- if (Number.isFinite(optCap) && optCap > 0) return optCap;
54
- try {
55
- const fp = path.join(projectDir || '.', METER_STATE_REL);
56
- const s = JSON.parse(fs.readFileSync(fp, 'utf8'));
57
- if (s && typeof s.modelWindowSize === 'number' && s.modelWindowSize > 0 && s.timestamp) {
58
- const age = Date.now() - Date.parse(s.timestamp);
59
- if (!isNaN(age) && age >= 0 && age <= METER_STATE_STALE_MS) {
60
- return s.modelWindowSize;
61
- }
62
- }
63
- } catch (_) { /* fall through to default */ }
64
- return DEFAULT_MODEL_CONTEXT_CAP;
65
- }
66
- const DEFAULT_WARN_THRESHOLD_TURNS = 5;
67
- const MIN_HISTORY = 3;
68
-
69
- // ── Row loading ──────────────────────────────────────────────────────
70
-
71
- function _safeParse(line) {
72
- const s = String(line || '').trim();
73
- if (!s || s[0] !== '{') return null;
74
- try { return JSON.parse(s); } catch (_) { return null; }
75
- }
76
-
77
- /**
78
- * Load in-session rows for the given session from the canonical sink.
79
- *
80
- * @param {string} projectDir
81
- * @param {string} sessionId
82
- * @returns {object[]} schema-v2 rows, unsorted
83
- */
84
- function _loadInSessionRows(projectDir, sessionId) {
85
- const p = path.join(projectDir, '.gsd-t', 'metrics', 'token-usage.jsonl');
86
- if (!fs.existsSync(p)) return [];
87
- let text;
88
- try { text = fs.readFileSync(p, 'utf8'); } catch (_) { return []; }
89
- const rows = [];
90
- for (const line of text.split(/\r?\n/)) {
91
- const j = _safeParse(line);
92
- if (!j) continue;
93
- if (j.sessionType !== 'in-session') continue;
94
- if (sessionId != null && j.session_id !== sessionId) continue;
95
- rows.push(j);
96
- }
97
- return rows;
98
- }
99
-
100
- // ── Math helpers ─────────────────────────────────────────────────────
101
-
102
- function _median(arr) {
103
- if (!arr.length) return 0;
104
- const sorted = arr.slice().sort((a, b) => a - b);
105
- const mid = Math.floor(sorted.length / 2);
106
- return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
107
- }
108
-
109
- /**
110
- * Deterministic ordering for per-turn rows. Prefers timestamp (`ts`) when
111
- * present (monotonic per session), falls back to `turn_id` string compare.
112
- */
113
- function _sortTurns(rows) {
114
- return rows.slice().sort((a, b) => {
115
- const ta = a.ts || '';
116
- const tb = b.ts || '';
117
- if (ta !== tb) return ta < tb ? -1 : 1;
118
- const ia = String(a.turn_id || '');
119
- const ib = String(b.turn_id || '');
120
- if (ia !== ib) return ia < ib ? -1 : 1;
121
- return 0;
122
- });
123
- }
124
-
125
- /**
126
- * Compute the dialog-growth signal for one session.
127
- *
128
- * Reads the last K in-session turns for `sessionId` from
129
- * `.gsd-t/metrics/token-usage.jsonl`, computes the median of turn-over-turn
130
- * `input_tokens` deltas (robust to single-turn spikes), then predicts how
131
- * many turns remain before dialog input crosses the pre-auto-compact ceiling
132
- * (`modelContextCap × 0.92`).
133
- *
134
- * Returns `{ shouldWarn: false, reason: 'insufficient_history' }` when fewer
135
- * than `MIN_HISTORY` in-session turns exist for the session.
136
- *
137
- * @param {object} opts
138
- * @param {string} opts.projectDir
139
- * @param {string} opts.sessionId required
140
- * @param {number} [opts.k] default 5 (last K turns)
141
- * @param {number} [opts.modelContextCap] default: fresh Context Meter
142
- * modelWindowSize if available, else the model-aware safe window (1M)
143
- * @param {number} [opts.warnThresholdTurns] default 5
144
- * @returns {{
145
- * shouldWarn: boolean,
146
- * slope: number,
147
- * median_delta: number,
148
- * latest_input_tokens: number,
149
- * predicted_turns_to_compact: number,
150
- * k: number,
151
- * history_len: number,
152
- * reason?: string
153
- * }}
154
- */
155
- function estimateDialogGrowth(opts) {
156
- const projectDir = (opts && opts.projectDir) || '.';
157
- const sessionId = opts && opts.sessionId;
158
- const k = (opts && Number.isFinite(opts.k) && opts.k > 0) ? Math.floor(opts.k) : DEFAULT_K;
159
- const cap = resolveContextCap(projectDir, opts && opts.modelContextCap);
160
- const warnThreshold = (opts && Number.isFinite(opts.warnThresholdTurns) && opts.warnThresholdTurns > 0)
161
- ? opts.warnThresholdTurns
162
- : DEFAULT_WARN_THRESHOLD_TURNS;
163
-
164
- const empty = {
165
- shouldWarn: false,
166
- slope: 0,
167
- median_delta: 0,
168
- latest_input_tokens: 0,
169
- predicted_turns_to_compact: Infinity,
170
- k,
171
- history_len: 0,
172
- };
173
-
174
- if (!sessionId) {
175
- return { ...empty, reason: 'missing_session_id' };
176
- }
177
-
178
- const allRows = _loadInSessionRows(projectDir, sessionId);
179
- if (allRows.length === 0) {
180
- return { ...empty, reason: 'no_rows' };
181
- }
182
-
183
- const sorted = _sortTurns(allRows);
184
- const window = sorted.slice(-k);
185
-
186
- if (window.length < MIN_HISTORY) {
187
- return { ...empty, history_len: window.length, reason: 'insufficient_history' };
188
- }
189
-
190
- // Per-turn input token footprint. Schema v2 writes `inputTokens` (camel)
191
- // via the token-capture wrapper; older rows may carry the raw envelope.
192
- const inputs = window.map(r => {
193
- if (Number.isFinite(r.inputTokens)) return r.inputTokens;
194
- if (r.usage && Number.isFinite(r.usage.input_tokens)) return r.usage.input_tokens;
195
- return 0;
196
- });
197
-
198
- const deltas = [];
199
- for (let i = 1; i < inputs.length; i++) {
200
- deltas.push(inputs[i] - inputs[i - 1]);
201
- }
202
-
203
- const median_delta = _median(deltas);
204
- const slope = median_delta;
205
- const latest_input_tokens = inputs[inputs.length - 1];
206
-
207
- const ceiling = cap * PRE_COMPACT_HEADROOM;
208
- let predicted_turns_to_compact;
209
- if (slope > 0) {
210
- const headroom = ceiling - latest_input_tokens;
211
- predicted_turns_to_compact = headroom <= 0 ? 0 : Math.ceil(headroom / slope);
212
- } else {
213
- predicted_turns_to_compact = Infinity;
214
- }
215
-
216
- const shouldWarn = Number.isFinite(predicted_turns_to_compact) && predicted_turns_to_compact <= warnThreshold;
217
-
218
- return {
219
- shouldWarn,
220
- slope,
221
- median_delta,
222
- latest_input_tokens,
223
- predicted_turns_to_compact,
224
- k,
225
- history_len: window.length,
226
- };
227
- }
228
-
229
- module.exports = {
230
- estimateDialogGrowth,
231
- // Exposed for unit tests; not part of the stable contract.
232
- _internal: {
233
- _median,
234
- _sortTurns,
235
- _loadInSessionRows,
236
- DEFAULT_K,
237
- DEFAULT_MODEL_CONTEXT_CAP,
238
- PRE_COMPACT_HEADROOM,
239
- DEFAULT_WARN_THRESHOLD_TURNS,
240
- MIN_HISTORY,
241
- },
242
- };
@@ -1,163 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * GSD-T Spawn Plan Derive (M44 D8 T4)
5
- *
6
- * Deterministic projection of `.gsd-t/partition.md` + `.gsd-t/domains/*\/tasks.md`
7
- * into the current "incomplete tasks" slice. No LLM calls, no prompts, no
8
- * heuristics — just parsing + slicing.
9
- *
10
- * Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
11
- *
12
- * Consumed by: `bin/spawn-plan-writer.cjs` (when caller does NOT pass an
13
- * explicit `tasks: [...]` array).
14
- */
15
-
16
- const fs = require('fs');
17
- const path = require('path');
18
-
19
- /**
20
- * Derive a `{milestone, wave, domains, tasks}` slice from on-disk partition
21
- * + tasks. The "current incomplete slice" is the next contiguous run of
22
- * tasks whose checkboxes are still unchecked (`[ ]`) across all domains in
23
- * the current wave.
24
- *
25
- * Gracefully returns an empty slice when partition.md is absent or no
26
- * incomplete tasks remain. Never throws for missing files.
27
- *
28
- * @param {object} [opts]
29
- * @param {string} [opts.projectDir='.']
30
- * @param {string} [opts.milestone] filter hint (e.g. 'M44'); defaults to parsed from partition
31
- * @param {string|number} [opts.currentIter] unused today; reserved for future iter-slicing
32
- * @returns {{ milestone: string|null, wave: string|null, domains: string[], tasks: Array<{id:string,title:string,status:string}> }}
33
- */
34
- function derivePlanFromPartition(opts) {
35
- const projectDir = (opts && opts.projectDir) || '.';
36
- const result = { milestone: null, wave: null, domains: [], tasks: [] };
37
-
38
- const partitionPath = path.join(projectDir, '.gsd-t', 'partition.md');
39
- if (!fs.existsSync(partitionPath)) {
40
- return result;
41
- }
42
-
43
- let partitionText;
44
- try {
45
- partitionText = fs.readFileSync(partitionPath, 'utf8');
46
- } catch (_) {
47
- return result;
48
- }
49
-
50
- // Parse the milestone id (first `M\d+` in heading) and the wave we care
51
- // about. For now we pick the first wave with any incomplete tasks.
52
- const milestoneMatch = partitionText.match(/\b(M\d+)\b/);
53
- if (milestoneMatch) result.milestone = milestoneMatch[1];
54
- if (opts && opts.milestone) result.milestone = String(opts.milestone);
55
-
56
- // Enumerate domain dirs under .gsd-t/domains/
57
- const domainsRoot = path.join(projectDir, '.gsd-t', 'domains');
58
- const domains = _listDomains(domainsRoot, result.milestone);
59
- if (!domains.length) return result;
60
-
61
- // Collect incomplete tasks from each domain's tasks.md, grouped by wave.
62
- // If no explicit wave header is present, "unknown" groups them.
63
- const byWave = new Map();
64
- for (const d of domains) {
65
- const tasks = _parseTasks(path.join(domainsRoot, d, 'tasks.md'));
66
- for (const t of tasks) {
67
- if (!byWave.has(t.wave)) byWave.set(t.wave, []);
68
- byWave.get(t.wave).push({ ...t, domain: d });
69
- }
70
- }
71
-
72
- // Pick the wave with the most incomplete tasks; ties: first seen.
73
- let pickWave = null;
74
- let pickCount = -1;
75
- for (const [w, arr] of byWave) {
76
- const incomplete = arr.filter((t) => t.status !== 'done').length;
77
- if (incomplete > pickCount) {
78
- pickCount = incomplete;
79
- pickWave = w;
80
- }
81
- }
82
- if (pickWave == null) return result;
83
-
84
- const waveTasks = byWave.get(pickWave) || [];
85
- result.wave = pickWave;
86
- const seenDomains = new Set();
87
- for (const t of waveTasks) {
88
- if (t.domain) seenDomains.add(t.domain);
89
- result.tasks.push({
90
- id: t.id,
91
- title: t.title,
92
- status: t.status,
93
- });
94
- }
95
- result.domains = [...seenDomains];
96
- return result;
97
- }
98
-
99
- // ── internal parsers ──────────────────────────────────────────────────────
100
-
101
- function _listDomains(dir, milestone) {
102
- if (!fs.existsSync(dir)) return [];
103
- let entries;
104
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return []; }
105
- const names = [];
106
- for (const e of entries) {
107
- if (!e.isDirectory()) continue;
108
- if (milestone) {
109
- // Normalize to lowercase m-prefix so we match `m44-d8-…`
110
- const prefix = String(milestone).toLowerCase() + '-';
111
- if (!e.name.toLowerCase().startsWith(prefix)) continue;
112
- }
113
- names.push(e.name);
114
- }
115
- return names.sort();
116
- }
117
-
118
- /**
119
- * Parse a domain tasks.md into a list of `{id, title, status, wave}`.
120
- * Recognizes:
121
- * `- [ ] **M44-D8-T1** — title…`
122
- * `- [x] **M44-D8-T1** — title…`
123
- * `- [x] done (2026-04-23 · commit abc123) **M44-D8-T1** — title…`
124
- * Task-id pattern is `M\d+-D\d+-T\d+`. Wave comes from the nearest
125
- * preceding `## Wave N` header.
126
- */
127
- function _parseTasks(tasksPath) {
128
- if (!fs.existsSync(tasksPath)) return [];
129
- let text;
130
- try { text = fs.readFileSync(tasksPath, 'utf8'); } catch (_) { return []; }
131
-
132
- const out = [];
133
- let currentWave = 'unknown';
134
- const lines = text.split('\n');
135
- for (const line of lines) {
136
- const waveMatch = line.match(/^##\s+Wave\s+(\d+)\b/i);
137
- if (waveMatch) {
138
- currentWave = 'wave-' + waveMatch[1];
139
- continue;
140
- }
141
- const m = line.match(/^-\s*\[([ xX])\][^\*]*\*\*(M\d+-D\d+-T\d+)\*\*\s*[—-]\s*(.+)$/);
142
- if (!m) continue;
143
- const checked = m[1].toLowerCase() === 'x';
144
- const id = m[2];
145
- let title = m[3].trim();
146
- // Strip trailing " — sub-note" if present; keep only the primary title.
147
- // The task title may contain em-dashes; safer to just use the first line.
148
- title = title.split(/\s{2,}/)[0].trim();
149
- out.push({
150
- id,
151
- title,
152
- status: checked ? 'done' : 'pending',
153
- wave: currentWave,
154
- });
155
- }
156
- return out;
157
- }
158
-
159
- module.exports = {
160
- derivePlanFromPartition,
161
- _parseTasks,
162
- _listDomains,
163
- };
@@ -1,292 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * GSD-T Spawn Plan Status Updater (M44 D8 T2)
5
- *
6
- * Pure module — patches spawn-plan files written by spawn-plan-writer.cjs.
7
- * Called by the post-commit git hook and by `captureSpawn` on completion.
8
- *
9
- * Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
10
- *
11
- * Hard rules:
12
- * - Atomic rewrites only (temp file + rename)
13
- * - No-op on unknown spawnId or unknown taskId — observability is best-effort
14
- * - Never throw for missing files; callers rely on silent-fail behavior
15
- */
16
-
17
- const fs = require('fs');
18
- const path = require('path');
19
-
20
- const SPAWNS_SUBDIR = path.join('.gsd-t', 'spawns');
21
-
22
- /**
23
- * Patch `{spawnId}.json`: find the task with the given id and flip its
24
- * status to `done`, recording the commit SHA and (optional) token
25
- * attribution. If the next pending task exists, promote it to
26
- * `in_progress` so the single-active-task invariant is preserved.
27
- *
28
- * @param {object} opts
29
- * @param {string} opts.spawnId
30
- * @param {string} opts.taskId
31
- * @param {string} [opts.commit] short or full SHA
32
- * @param {object|null} [opts.tokens] `{in,out,cr,cc,cost_usd}` or null
33
- * @param {string} [opts.projectDir='.']
34
- * @returns {{ patched: boolean, path: string }}
35
- */
36
- function markTaskDone(opts) {
37
- if (!opts || typeof opts !== 'object') return { patched: false, path: null };
38
- const { spawnId, taskId } = opts;
39
- if (!spawnId || !taskId) return { patched: false, path: null };
40
-
41
- const projectDir = opts.projectDir || '.';
42
- const fp = _planPath(projectDir, spawnId);
43
- const plan = _readPlan(fp);
44
- if (!plan) return { patched: false, path: fp };
45
-
46
- const tasks = Array.isArray(plan.tasks) ? plan.tasks : [];
47
- const idx = tasks.findIndex((t) => t && t.id === taskId);
48
- if (idx < 0) return { patched: false, path: fp };
49
-
50
- const task = tasks[idx];
51
- task.status = 'done';
52
- if (typeof opts.commit === 'string' && opts.commit) task.commit = opts.commit;
53
- // Token attribution is "null when absent" per constraints §Format rules.
54
- // The caller passes `null` or an object; we never silently overwrite
55
- // an already-populated tokens field with null.
56
- if (Object.prototype.hasOwnProperty.call(opts, 'tokens')) {
57
- if (opts.tokens && typeof opts.tokens === 'object') {
58
- task.tokens = _normalizeTokens(opts.tokens);
59
- } else if (task.tokens == null) {
60
- task.tokens = null;
61
- }
62
- }
63
-
64
- // Promote the next pending task to in_progress (single-active invariant).
65
- const anyInProgress = tasks.some((t) => t && t.status === 'in_progress');
66
- if (!anyInProgress) {
67
- for (let j = idx + 1; j < tasks.length; j++) {
68
- if (tasks[j] && tasks[j].status === 'pending') {
69
- tasks[j].status = 'in_progress';
70
- break;
71
- }
72
- }
73
- }
74
-
75
- _atomicWriteJson(fp, plan);
76
- return { patched: true, path: fp };
77
- }
78
-
79
- /**
80
- * Mark a spawn plan as ended. Sets `endedAt` to now-ISO and records
81
- * `endedReason` ('success' | 'error' | caller-supplied). No-op on missing
82
- * file. Atomic rewrite.
83
- *
84
- * @param {object} opts
85
- * @param {string} opts.spawnId
86
- * @param {string} [opts.endedReason='success']
87
- * @param {string} [opts.projectDir='.']
88
- * @param {Date} [opts.now]
89
- * @returns {{ patched: boolean, path: string }}
90
- */
91
- function markSpawnEnded(opts) {
92
- if (!opts || typeof opts !== 'object') return { patched: false, path: null };
93
- const { spawnId } = opts;
94
- if (!spawnId) return { patched: false, path: null };
95
-
96
- const projectDir = opts.projectDir || '.';
97
- const fp = _planPath(projectDir, spawnId);
98
- const plan = _readPlan(fp);
99
- if (!plan) return { patched: false, path: fp };
100
-
101
- const now = opts.now instanceof Date ? opts.now : new Date();
102
- plan.endedAt = now.toISOString();
103
- plan.endedReason = opts.endedReason || 'success';
104
- _atomicWriteJson(fp, plan);
105
- return { patched: true, path: fp };
106
- }
107
-
108
- /**
109
- * Enumerate active plan files (those where `endedAt === null`). Returns
110
- * absolute file paths, sorted by startedAt descending.
111
- *
112
- * @param {string} [projectDir='.']
113
- * @returns {string[]}
114
- */
115
- function listActivePlans(projectDir) {
116
- const dir = path.join(projectDir || '.', SPAWNS_SUBDIR);
117
- if (!fs.existsSync(dir)) return [];
118
- let files;
119
- try { files = fs.readdirSync(dir); } catch (_) { return []; }
120
- const results = [];
121
- for (const f of files) {
122
- if (!f.endsWith('.json')) continue;
123
- const fp = path.resolve(path.join(dir, f));
124
- const plan = _readPlan(fp);
125
- if (plan && plan.endedAt == null) results.push({ fp, plan });
126
- }
127
- results.sort((a, b) => {
128
- const ta = Date.parse(a.plan && a.plan.startedAt || '') || 0;
129
- const tb = Date.parse(b.plan && b.plan.startedAt || '') || 0;
130
- return tb - ta;
131
- });
132
- return results.map((r) => r.fp);
133
- }
134
-
135
- // ── token-log attribution lookup ───────────────────────────────────────────
136
-
137
- /**
138
- * Parse `.gsd-t/token-log.md` and return the sum of `{in,out,cr,cc,cost_usd}`
139
- * across all rows whose `Task` column matches the given id and whose
140
- * `Datetime-start` is >= the spawn's startedAt.
141
- *
142
- * Returns `null` when no matching rows exist (per the "zero is a
143
- * measurement, dash is acknowledged gap" rule).
144
- *
145
- * @param {object} opts
146
- * @param {string} opts.projectDir
147
- * @param {string} opts.taskId
148
- * @param {string} opts.spawnStartedAt ISO-8601 (matches plan.startedAt)
149
- * @param {string} [opts.tokenLogPath] override for tests
150
- * @returns {{in:number, out:number, cr:number, cc:number, cost_usd:number}|null}
151
- */
152
- function sumTokensForTask(opts) {
153
- if (!opts || typeof opts !== 'object') return null;
154
- const { projectDir, taskId } = opts;
155
- if (!projectDir || !taskId) return null;
156
-
157
- const fp = opts.tokenLogPath || path.join(projectDir, '.gsd-t', 'token-log.md');
158
- if (!fs.existsSync(fp)) return null;
159
-
160
- const startMs = _parseDatetime(opts.spawnStartedAt);
161
-
162
- let text;
163
- try { text = fs.readFileSync(fp, 'utf8'); } catch (_) { return null; }
164
-
165
- const lines = text.split('\n');
166
- // Find header row to locate column indices.
167
- const headerIdx = lines.findIndex((l) => /^\|\s*Datetime-start\s*\|/.test(l));
168
- if (headerIdx < 0) return null;
169
- const header = lines[headerIdx].split('|').map((s) => s.trim());
170
- const iStart = header.indexOf('Datetime-start');
171
- const iTokens = header.indexOf('Tokens');
172
- const iTask = header.indexOf('Task');
173
- if (iStart < 0 || iTask < 0) return null;
174
-
175
- const sum = { in: 0, out: 0, cr: 0, cc: 0, cost_usd: 0 };
176
- let matched = 0;
177
- for (let i = headerIdx + 2; i < lines.length; i++) {
178
- const row = lines[i];
179
- if (!row || !row.startsWith('|')) continue;
180
- const cells = row.split('|').map((s) => s.trim());
181
- if (cells.length < header.length) continue;
182
- const startedAt = cells[iStart] || '';
183
- const task = cells[iTask] || '';
184
- if (task !== taskId) continue;
185
- // Only rows whose Datetime-start >= spawn.startedAt count.
186
- if (startMs != null) {
187
- const rowMs = _parseDatetime(startedAt);
188
- if (rowMs != null && rowMs < startMs) continue;
189
- }
190
- const tokensCell = iTokens >= 0 ? (cells[iTokens] || '') : '';
191
- const parsed = _parseTokensCell(tokensCell);
192
- if (parsed) {
193
- sum.in += parsed.in;
194
- sum.out += parsed.out;
195
- sum.cr += parsed.cr;
196
- sum.cc += parsed.cc;
197
- sum.cost_usd += parsed.cost_usd;
198
- matched++;
199
- } else {
200
- // No token data in the row; still counts as a "match" for the task
201
- // but contributes zero. Keep going.
202
- matched++;
203
- }
204
- }
205
- if (matched === 0) return null;
206
- // Round cost to 2 decimals for determinism.
207
- sum.cost_usd = Math.round(sum.cost_usd * 100) / 100;
208
- return sum;
209
- }
210
-
211
- // ── internal helpers ───────────────────────────────────────────────────────
212
-
213
- function _planPath(projectDir, spawnId) {
214
- return path.resolve(path.join(projectDir, SPAWNS_SUBDIR, spawnId + '.json'));
215
- }
216
-
217
- function _readPlan(fp) {
218
- try {
219
- const raw = fs.readFileSync(fp, 'utf8');
220
- const parsed = JSON.parse(raw);
221
- if (!parsed || typeof parsed !== 'object') return null;
222
- return parsed;
223
- } catch (_) {
224
- return null;
225
- }
226
- }
227
-
228
- function _atomicWriteJson(targetPath, obj) {
229
- const dir = path.dirname(targetPath);
230
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
231
- const tmp = targetPath + '.tmp-' + process.pid + '-' + Date.now();
232
- const json = JSON.stringify(obj, null, 2) + '\n';
233
- fs.writeFileSync(tmp, json);
234
- fs.renameSync(tmp, targetPath);
235
- }
236
-
237
- function _normalizeTokens(t) {
238
- const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : Number(v) || 0);
239
- return {
240
- in: num(t.in != null ? t.in : t.input_tokens),
241
- out: num(t.out != null ? t.out : t.output_tokens),
242
- cr: num(t.cr != null ? t.cr : t.cache_read_input_tokens),
243
- cc: num(t.cc != null ? t.cc : t.cache_creation_input_tokens),
244
- cost_usd: num(
245
- t.cost_usd != null
246
- ? t.cost_usd
247
- : (t.total_cost_usd != null ? t.total_cost_usd : 0)
248
- ),
249
- };
250
- }
251
-
252
- function _parseDatetime(s) {
253
- if (!s) return null;
254
- if (typeof s === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(s)) {
255
- const [d, t] = s.split(' ');
256
- const ms = Date.parse(`${d}T${t}:00`);
257
- return Number.isFinite(ms) ? ms : null;
258
- }
259
- const ms = Date.parse(s);
260
- return Number.isFinite(ms) ? ms : null;
261
- }
262
-
263
- /**
264
- * Parse a tokens cell of the form `in=N out=N cr=N cc=N $X.XX` or `—`
265
- * or a mixed form. Returns null when no numeric data is present.
266
- */
267
- function _parseTokensCell(cell) {
268
- if (!cell || cell === '—' || cell === '-') return null;
269
- const num = (re) => {
270
- const m = cell.match(re);
271
- return m ? Number(m[1]) : 0;
272
- };
273
- const inp = num(/\bin=(\d+)/);
274
- const out = num(/\bout=(\d+)/);
275
- const cr = num(/\bcr=(\d+)/);
276
- const cc = num(/\bcc=(\d+)/);
277
- const costM = cell.match(/\$(\d+(?:\.\d+)?)/);
278
- const cost_usd = costM ? Number(costM[1]) : 0;
279
- if (!inp && !out && !cr && !cc && !cost_usd) return null;
280
- return { in: inp, out, cr, cc, cost_usd };
281
- }
282
-
283
- module.exports = {
284
- markTaskDone,
285
- markSpawnEnded,
286
- listActivePlans,
287
- sumTokensForTask,
288
- // test-only
289
- _parseTokensCell,
290
- _parseDatetime,
291
- _normalizeTokens,
292
- };