@tekyzinc/gsd-t 2.39.13 → 2.46.11

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 (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +19 -10
  3. package/bin/desktop.ini +2 -0
  4. package/bin/global-sync-manager.js +350 -0
  5. package/bin/gsd-t.js +592 -2
  6. package/bin/metrics-collector.js +167 -0
  7. package/bin/metrics-rollup.js +200 -0
  8. package/bin/patch-lifecycle.js +195 -0
  9. package/bin/rule-engine.js +160 -0
  10. package/commands/desktop.ini +2 -0
  11. package/commands/gsd-t-complete-milestone.md +194 -6
  12. package/commands/gsd-t-debug.md +38 -3
  13. package/commands/gsd-t-doc-ripple.md +148 -0
  14. package/commands/gsd-t-execute.md +328 -54
  15. package/commands/gsd-t-help.md +32 -10
  16. package/commands/gsd-t-integrate.md +59 -7
  17. package/commands/gsd-t-metrics.md +143 -0
  18. package/commands/gsd-t-plan.md +49 -2
  19. package/commands/gsd-t-qa.md +26 -5
  20. package/commands/gsd-t-quick.md +36 -3
  21. package/commands/gsd-t-status.md +78 -0
  22. package/commands/gsd-t-test-sync.md +23 -2
  23. package/commands/gsd-t-verify.md +142 -10
  24. package/commands/gsd-t-visualize.md +11 -1
  25. package/commands/gsd-t-wave.md +64 -18
  26. package/docs/GSD-T-README.md +10 -6
  27. package/docs/architecture.md +84 -2
  28. package/docs/ci-examples/desktop.ini +2 -0
  29. package/docs/ci-examples/github-actions.yml +104 -0
  30. package/docs/ci-examples/gitlab-ci.yml +116 -0
  31. package/docs/desktop.ini +2 -0
  32. package/docs/framework-comparison-scorecard.md +160 -0
  33. package/docs/infrastructure.md +87 -1
  34. package/docs/prd-graph-engine.md +2 -2
  35. package/docs/prd-gsd2-hybrid.md +258 -135
  36. package/docs/requirements.md +66 -2
  37. package/examples/.gsd-t/contracts/desktop.ini +2 -0
  38. package/examples/.gsd-t/desktop.ini +2 -0
  39. package/examples/.gsd-t/domains/desktop.ini +2 -0
  40. package/examples/.gsd-t/domains/example-domain/desktop.ini +2 -0
  41. package/examples/desktop.ini +2 -0
  42. package/examples/rules/.gitkeep +0 -0
  43. package/examples/rules/desktop.ini +2 -0
  44. package/package.json +40 -40
  45. package/scripts/desktop.ini +2 -0
  46. package/scripts/gsd-t-dashboard-server.js +19 -2
  47. package/scripts/gsd-t-dashboard.html +63 -0
  48. package/scripts/gsd-t-event-writer.js +1 -0
  49. package/templates/CLAUDE-global.md +92 -10
  50. package/templates/desktop.ini +2 -0
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * GSD-T Metrics Collector — Per-task telemetry writer
5
+ *
6
+ * Writes structured task-metrics records to .gsd-t/metrics/task-metrics.jsonl.
7
+ * Reads and filters metrics for pre-flight intelligence checks.
8
+ *
9
+ * Zero external dependencies (Node.js built-ins only).
10
+ */
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ // ── Signal type → weight mapping (per metrics-schema-contract.md) ────────────
16
+
17
+ const SIGNAL_WEIGHTS = {
18
+ "pass-through": 1.0,
19
+ "fix-cycle": -0.5,
20
+ "debug-invoked": -0.8,
21
+ "user-correction": -1.0,
22
+ "phase-skip": 0.3,
23
+ };
24
+
25
+ const VALID_SIGNAL_TYPES = new Set(Object.keys(SIGNAL_WEIGHTS));
26
+
27
+ const REQUIRED_FIELDS = [
28
+ "milestone", "domain", "task", "command", "duration_s",
29
+ "tokens_used", "context_pct", "pass", "fix_cycles", "signal_type",
30
+ ];
31
+
32
+ // ── Exports ──────────────────────────────────────────────────────────────────
33
+
34
+ module.exports = { collectTaskMetrics, readTaskMetrics, getPreFlightWarnings };
35
+
36
+ // ── collectTaskMetrics ───────────────────────────────────────────────────────
37
+
38
+ function collectTaskMetrics(data, projectDir) {
39
+ const dir = projectDir || process.env.GSD_T_PROJECT_DIR || process.cwd();
40
+ const error = validateRecord(data);
41
+ if (error) throw new Error(error);
42
+ const record = buildRecord(data);
43
+ const filePath = resolveMetricsFile(dir);
44
+ ensureDir(path.dirname(filePath));
45
+ fs.appendFileSync(filePath, JSON.stringify(record) + "\n");
46
+ return record;
47
+ }
48
+
49
+ // ── readTaskMetrics ──────────────────────────────────────────────────────────
50
+
51
+ function readTaskMetrics(filters, projectDir) {
52
+ const dir = projectDir || process.env.GSD_T_PROJECT_DIR || process.cwd();
53
+ const filePath = resolveMetricsFile(dir);
54
+ if (!fs.existsSync(filePath)) return [];
55
+ const lines = fs.readFileSync(filePath, "utf8").trim().split("\n");
56
+ return lines
57
+ .map(safeParse)
58
+ .filter(Boolean)
59
+ .filter((r) => matchesFilters(r, filters || {}));
60
+ }
61
+
62
+ // ── getPreFlightWarnings ─────────────────────────────────────────────────────
63
+
64
+ function getPreFlightWarnings(domain, projectDir) {
65
+ const records = readTaskMetrics({ domain }, projectDir);
66
+ const recent = records.slice(-10);
67
+ if (recent.length === 0) return [];
68
+ const warnings = [];
69
+ const passCount = recent.filter((r) => r.pass).length;
70
+ const rate = passCount / recent.length;
71
+ if (rate < 0.6) {
72
+ warnings.push(`Domain ${domain} has ${(rate * 100).toFixed(0)}% first-pass rate (last ${recent.length} tasks). Consider splitting tasks.`);
73
+ }
74
+ const avgFix = recent.reduce((s, r) => s + r.fix_cycles, 0) / recent.length;
75
+ if (avgFix > 2.0) {
76
+ warnings.push(`Domain ${domain} averaging ${avgFix.toFixed(1)} fix cycles. Review constraints.`);
77
+ }
78
+ return warnings;
79
+ }
80
+
81
+ // ── Internal helpers ─────────────────────────────────────────────────────────
82
+
83
+ function validateRecord(data) {
84
+ if (!data || typeof data !== "object") return "Data must be an object";
85
+ for (const f of REQUIRED_FIELDS) {
86
+ if (data[f] === undefined || data[f] === null) return `Missing required field: ${f}`;
87
+ }
88
+ if (!VALID_SIGNAL_TYPES.has(data.signal_type)) {
89
+ return `Invalid signal_type: "${data.signal_type}"`;
90
+ }
91
+ if (typeof data.duration_s !== "number" || data.duration_s < 0) {
92
+ return "duration_s must be a non-negative number";
93
+ }
94
+ if (typeof data.context_pct !== "number" || data.context_pct < 0 || data.context_pct > 100) {
95
+ return "context_pct must be 0-100";
96
+ }
97
+ if (typeof data.fix_cycles !== "number" || data.fix_cycles < 0) {
98
+ return "fix_cycles must be >= 0";
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function buildRecord(data) {
104
+ return {
105
+ ts: new Date().toISOString(),
106
+ milestone: data.milestone,
107
+ domain: data.domain,
108
+ task: data.task,
109
+ command: data.command,
110
+ duration_s: data.duration_s,
111
+ tokens_used: data.tokens_used,
112
+ context_pct: data.context_pct,
113
+ pass: Boolean(data.pass),
114
+ fix_cycles: data.fix_cycles,
115
+ signal_type: data.signal_type,
116
+ signal_weight: SIGNAL_WEIGHTS[data.signal_type],
117
+ notes: data.notes || null,
118
+ };
119
+ }
120
+
121
+ function resolveMetricsFile(projectDir) {
122
+ return path.join(projectDir, ".gsd-t", "metrics", "task-metrics.jsonl");
123
+ }
124
+
125
+ function ensureDir(dir) {
126
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
127
+ }
128
+
129
+ function safeParse(line) {
130
+ try { return JSON.parse(line); } catch { return null; }
131
+ }
132
+
133
+ function matchesFilters(record, filters) {
134
+ for (const [key, val] of Object.entries(filters)) {
135
+ if (record[key] !== val) return false;
136
+ }
137
+ return true;
138
+ }
139
+
140
+ // ── CLI Entry ────────────────────────────────────────────────────────────────
141
+
142
+ if (require.main === module) {
143
+ const args = parseCLIArgs(process.argv.slice(2));
144
+ try {
145
+ collectTaskMetrics(args);
146
+ process.exit(0);
147
+ } catch (err) {
148
+ process.stderr.write(err.message + "\n");
149
+ process.exit(1);
150
+ }
151
+ }
152
+
153
+ function parseCLIArgs(argv) {
154
+ const map = {};
155
+ for (let i = 0; i < argv.length - 1; i++) {
156
+ if (argv[i].startsWith("--")) {
157
+ const key = argv[i].slice(2).replace(/-/g, "_");
158
+ let val = argv[i + 1];
159
+ if (val === "true") val = true;
160
+ else if (val === "false") val = false;
161
+ else if (/^-?\d+(\.\d+)?$/.test(val)) val = Number(val);
162
+ map[key] = val;
163
+ i++;
164
+ }
165
+ }
166
+ return map;
167
+ }
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * GSD-T Metrics Rollup — Milestone-level aggregation, ELO, heuristics
5
+ *
6
+ * Reads task-metrics.jsonl, computes rollup stats and process ELO,
7
+ * runs 4 detection heuristics, writes to rollup.jsonl.
8
+ *
9
+ * Zero external dependencies (Node.js built-ins only).
10
+ */
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ // ── Constants ────────────────────────────────────────────────────────────────
16
+
17
+ const ELO_START = 1000;
18
+ const ELO_K = 32;
19
+
20
+ // ── Exports ──────────────────────────────────────────────────────────────────
21
+
22
+ module.exports = { generateRollup, computeELO, runHeuristics, readRollups };
23
+
24
+ // ── generateRollup ───────────────────────────────────────────────────────────
25
+
26
+ function generateRollup(milestone, version, projectDir) {
27
+ const dir = projectDir || process.env.GSD_T_PROJECT_DIR || process.cwd();
28
+ const tasks = readTaskMetrics(dir, milestone);
29
+ if (tasks.length === 0) throw new Error(`No task-metrics found for ${milestone}`);
30
+ const prev = getPreviousRollup(dir);
31
+ const eloBefore = prev ? prev.elo_after : ELO_START;
32
+ const eloAfter = computeELO(eloBefore, tasks);
33
+ const rollup = buildRollup(milestone, version, tasks, eloBefore, eloAfter, prev);
34
+ const filePath = resolveRollupFile(dir);
35
+ ensureDir(path.dirname(filePath));
36
+ fs.appendFileSync(filePath, JSON.stringify(rollup) + "\n");
37
+ return rollup;
38
+ }
39
+
40
+ // ── computeELO ───────────────────────────────────────────────────────────────
41
+
42
+ function computeELO(eloBefore, tasks) {
43
+ const total = tasks.length;
44
+ if (total === 0) return eloBefore;
45
+ const sumWeights = tasks.reduce((s, t) => s + (t.signal_weight || 0), 0);
46
+ const actual = (sumWeights + total) / (2 * total);
47
+ const expected = 1 / (1 + Math.pow(10, (ELO_START - eloBefore) / 400));
48
+ return Math.round((eloBefore + ELO_K * (actual - expected)) * 100) / 100;
49
+ }
50
+
51
+ // ── runHeuristics ────────────────────────────────────────────────────────────
52
+
53
+ function runHeuristics(current, previous, rawTasks) {
54
+ const flags = [];
55
+ if (previous) {
56
+ checkFirstPassSpike(current, previous, flags);
57
+ checkReworkAnomaly(current, previous, flags);
58
+ checkDurationRegression(current, previous, flags);
59
+ }
60
+ checkContextOverflow(rawTasks || [], flags);
61
+ return flags;
62
+ }
63
+
64
+ // ── readRollups ──────────────────────────────────────────────────────────────
65
+
66
+ function readRollups(filters, projectDir) {
67
+ const dir = projectDir || process.env.GSD_T_PROJECT_DIR || process.cwd();
68
+ const filePath = resolveRollupFile(dir);
69
+ if (!fs.existsSync(filePath)) return [];
70
+ return fs.readFileSync(filePath, "utf8").trim().split("\n")
71
+ .map(safeParse).filter(Boolean)
72
+ .filter((r) => matchFilters(r, filters || {}));
73
+ }
74
+
75
+ // ── Internal: buildRollup ────────────────────────────────────────────────────
76
+
77
+ function buildRollup(milestone, version, tasks, eloBefore, eloAfter, prev) {
78
+ const total = tasks.length;
79
+ const passCount = tasks.filter((t) => t.fix_cycles === 0 && t.pass).length;
80
+ const firstPassRate = Math.round((passCount / total) * 1000) / 1000;
81
+ const avgDur = Math.round(tasks.reduce((s, t) => s + t.duration_s, 0) / total * 100) / 100;
82
+ const avgCtx = Math.round(tasks.reduce((s, t) => s + t.context_pct, 0) / total * 100) / 100;
83
+ const totalFix = tasks.reduce((s, t) => s + t.fix_cycles, 0);
84
+ const totalTokens = tasks.reduce((s, t) => s + t.tokens_used, 0);
85
+ const sigDist = buildSignalDist(tasks);
86
+ const domBreak = buildDomainBreakdown(tasks);
87
+ const trend = prev ? buildTrendDelta(firstPassRate, avgDur, eloAfter - eloBefore, prev) : null;
88
+ const rollup = {
89
+ ts: new Date().toISOString(), milestone, version, total_tasks: total,
90
+ first_pass_rate: firstPassRate, avg_duration_s: avgDur, avg_context_pct: avgCtx,
91
+ total_fix_cycles: totalFix, total_tokens: totalTokens,
92
+ elo_before: eloBefore, elo_after: eloAfter, elo_delta: Math.round((eloAfter - eloBefore) * 100) / 100,
93
+ signal_distribution: sigDist, domain_breakdown: domBreak, trend_delta: trend,
94
+ heuristic_flags: [],
95
+ };
96
+ rollup.heuristic_flags = runHeuristics(rollup, prev, tasks);
97
+ return rollup;
98
+ }
99
+
100
+ // ── Internal: signal distribution ────────────────────────────────────────────
101
+
102
+ function buildSignalDist(tasks) {
103
+ const dist = {};
104
+ tasks.forEach((t) => { dist[t.signal_type] = (dist[t.signal_type] || 0) + 1; });
105
+ return dist;
106
+ }
107
+
108
+ // ── Internal: domain breakdown ───────────────────────────────────────────────
109
+
110
+ function buildDomainBreakdown(tasks) {
111
+ const groups = {};
112
+ tasks.forEach((t) => { (groups[t.domain] = groups[t.domain] || []).push(t); });
113
+ return Object.entries(groups).map(([domain, items]) => ({
114
+ domain, tasks: items.length,
115
+ first_pass_rate: Math.round(items.filter((t) => t.fix_cycles === 0 && t.pass).length / items.length * 1000) / 1000,
116
+ avg_duration_s: Math.round(items.reduce((s, t) => s + t.duration_s, 0) / items.length * 100) / 100,
117
+ }));
118
+ }
119
+
120
+ // ── Internal: trend delta ────────────────────────────────────────────────────
121
+
122
+ function buildTrendDelta(fpr, avgDur, eloDelta, prev) {
123
+ return {
124
+ first_pass_rate_delta: Math.round((fpr - prev.first_pass_rate) * 1000) / 1000,
125
+ avg_duration_delta: Math.round((avgDur - prev.avg_duration_s) * 100) / 100,
126
+ elo_delta: Math.round(eloDelta * 100) / 100,
127
+ };
128
+ }
129
+
130
+ // ── Internal: heuristics ─────────────────────────────────────────────────────
131
+
132
+ function checkFirstPassSpike(cur, prev, flags) {
133
+ if (prev.first_pass_rate - cur.first_pass_rate > 0.15) {
134
+ flags.push({ heuristic: "first-pass-failure-spike", severity: "HIGH",
135
+ description: `First-pass rate dropped from ${(prev.first_pass_rate * 100).toFixed(0)}% to ${(cur.first_pass_rate * 100).toFixed(0)}%` });
136
+ }
137
+ }
138
+
139
+ function checkReworkAnomaly(cur, prev, flags) {
140
+ const prevAvg = prev.total_tasks > 0 ? prev.total_fix_cycles / prev.total_tasks : 0;
141
+ const curAvg = cur.total_tasks > 0 ? cur.total_fix_cycles / cur.total_tasks : 0;
142
+ if (prevAvg > 0 && curAvg > 2 * prevAvg) {
143
+ flags.push({ heuristic: "rework-rate-anomaly", severity: "MEDIUM",
144
+ description: `Fix cycle avg ${curAvg.toFixed(1)} is >2x previous ${prevAvg.toFixed(1)}` });
145
+ }
146
+ }
147
+
148
+ function checkContextOverflow(tasks, flags) {
149
+ if (tasks.length === 0) return;
150
+ const failed = tasks.filter((t) => !t.pass);
151
+ if (failed.length === 0) return;
152
+ const highCtx = failed.filter((t) => t.context_pct > 80);
153
+ if (highCtx.length / failed.length > 0.3) {
154
+ flags.push({ heuristic: "context-overflow-correlation", severity: "MEDIUM",
155
+ description: `${highCtx.length}/${failed.length} failed tasks had context >80%` });
156
+ }
157
+ }
158
+
159
+ function checkDurationRegression(cur, prev, flags) {
160
+ if (prev.avg_duration_s > 0 && cur.avg_duration_s > 2 * prev.avg_duration_s) {
161
+ flags.push({ heuristic: "duration-regression", severity: "LOW",
162
+ description: `Avg duration ${cur.avg_duration_s.toFixed(0)}s is >2x previous ${prev.avg_duration_s.toFixed(0)}s` });
163
+ }
164
+ }
165
+
166
+ // ── Internal: file helpers ───────────────────────────────────────────────────
167
+
168
+ function readTaskMetrics(dir, milestone) {
169
+ const fp = path.join(dir, ".gsd-t", "metrics", "task-metrics.jsonl");
170
+ if (!fs.existsSync(fp)) return [];
171
+ return fs.readFileSync(fp, "utf8").trim().split("\n")
172
+ .map(safeParse).filter(Boolean)
173
+ .filter((r) => r.milestone === milestone);
174
+ }
175
+
176
+ function getPreviousRollup(dir) {
177
+ const all = readRollups({}, dir);
178
+ return all.length > 0 ? all[all.length - 1] : null;
179
+ }
180
+
181
+ function resolveRollupFile(dir) {
182
+ return path.join(dir, ".gsd-t", "metrics", "rollup.jsonl");
183
+ }
184
+
185
+ function ensureDir(d) { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); }
186
+ function safeParse(l) { try { return JSON.parse(l); } catch { return null; } }
187
+ function matchFilters(r, f) { return Object.entries(f).every(([k, v]) => r[k] === v); }
188
+
189
+ // ── CLI Entry ────────────────────────────────────────────────────────────────
190
+
191
+ if (require.main === module) {
192
+ const args = process.argv.slice(2);
193
+ const milestone = args[0];
194
+ const version = args[1] || "0.0.0";
195
+ if (!milestone) { process.stderr.write("Usage: metrics-rollup.js <milestone> [version]\n"); process.exit(1); }
196
+ try {
197
+ const rollup = generateRollup(milestone, version);
198
+ process.stdout.write(JSON.stringify(rollup, null, 2) + "\n");
199
+ } catch (err) { process.stderr.write(err.message + "\n"); process.exit(1); }
200
+ }
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * GSD-T Patch Lifecycle Manager — 5-stage patch lifecycle
5
+ *
6
+ * Manages patches through: candidate -> applied -> measured -> promoted -> graduated.
7
+ * Promotion gate requires >55% improvement over 2+ milestones.
8
+ * Graduation writes patch into permanent methodology artifact.
9
+ *
10
+ * Zero external dependencies (Node.js built-ins only).
11
+ */
12
+
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+
16
+ module.exports = {
17
+ createCandidate, applyPatch, recordMeasurement,
18
+ checkPromotionGate, promote, graduate, deprecate, getPatchesByStatus,
19
+ };
20
+
21
+ // ── createCandidate ──────────────────────────────────────────────────────────
22
+
23
+ /** @param {string} ruleId @param {string} templateId @param {number} metricBefore @param {string} [projectDir] @returns {object} */
24
+ function createCandidate(ruleId, templateId, metricBefore, projectDir) {
25
+ const dir = projectDir || process.cwd();
26
+ const patchDir = patchesDir(dir);
27
+ ensureDir(patchDir);
28
+ const id = nextPatchId(patchDir);
29
+ const patch = {
30
+ id, template_id: templateId, rule_id: ruleId, status: "candidate",
31
+ created_at: new Date().toISOString(), applied_at: null,
32
+ measured_milestones: [], metric_before: metricBefore, metric_after: null,
33
+ improvement_pct: null, promoted_at: null, graduated_at: null,
34
+ graduation_target: null, deprecated_at: null, deprecation_reason: null,
35
+ };
36
+ writePatch(patchDir, patch);
37
+ return patch;
38
+ }
39
+
40
+ // ── applyPatch ───────────────────────────────────────────────────────────────
41
+
42
+ /** @param {string} patchId @param {string} [projectDir] @returns {boolean} */
43
+ function applyPatch(patchId, projectDir) {
44
+ const dir = projectDir || process.cwd();
45
+ const patch = readPatch(patchesDir(dir), patchId);
46
+ if (!patch || patch.status !== "candidate") return false;
47
+ const { getPatchTemplate } = require("./rule-engine.js");
48
+ const tpl = getPatchTemplate(patch.template_id, dir);
49
+ if (!tpl) return false;
50
+ const targetPath = path.join(dir, tpl.target_file);
51
+ if (!fs.existsSync(targetPath)) return false;
52
+ const content = fs.readFileSync(targetPath, "utf8");
53
+ const edited = applyEdit(content, tpl);
54
+ if (edited === null) return false;
55
+ fs.writeFileSync(targetPath, edited);
56
+ patch.status = "applied";
57
+ patch.applied_at = new Date().toISOString();
58
+ writePatch(patchesDir(dir), patch);
59
+ return true;
60
+ }
61
+
62
+ // ── recordMeasurement ────────────────────────────────────────────────────────
63
+
64
+ /** @param {string} patchId @param {string} milestoneId @param {number} metricAfter @param {string} [projectDir] */
65
+ function recordMeasurement(patchId, milestoneId, metricAfter, projectDir) {
66
+ const dir = projectDir || process.cwd();
67
+ const patch = readPatch(patchesDir(dir), patchId);
68
+ if (!patch || (patch.status !== "applied" && patch.status !== "measured")) return;
69
+ if (!patch.measured_milestones.includes(milestoneId)) {
70
+ patch.measured_milestones.push(milestoneId);
71
+ }
72
+ patch.metric_after = metricAfter;
73
+ patch.improvement_pct = patch.metric_before !== 0
74
+ ? ((metricAfter - patch.metric_before) / Math.abs(patch.metric_before)) * 100
75
+ : metricAfter > 0 ? 100 : 0;
76
+ patch.status = "measured";
77
+ writePatch(patchesDir(dir), patch);
78
+ }
79
+
80
+ // ── checkPromotionGate ───────────────────────────────────────────────────────
81
+
82
+ /** @param {string} patchId @param {string} [projectDir] @returns {object} */
83
+ function checkPromotionGate(patchId, projectDir) {
84
+ const patch = readPatch(patchesDir(projectDir), patchId);
85
+ if (!patch) return { passes: false, improvement_pct: 0, reason: "Patch not found" };
86
+ if (patch.measured_milestones.length < 2) {
87
+ return { passes: false, improvement_pct: patch.improvement_pct || 0, reason: `Only ${patch.measured_milestones.length}/2 milestones measured` };
88
+ }
89
+ if ((patch.improvement_pct || 0) <= 55) {
90
+ return { passes: false, improvement_pct: patch.improvement_pct || 0, reason: `Improvement ${(patch.improvement_pct || 0).toFixed(1)}% <= 55% threshold` };
91
+ }
92
+ return { passes: true, improvement_pct: patch.improvement_pct, reason: "Gate passed" };
93
+ }
94
+
95
+ // ── promote ──────────────────────────────────────────────────────────────────
96
+
97
+ /** @param {string} patchId @param {string} [projectDir] */
98
+ function promote(patchId, projectDir) {
99
+ const dir = projectDir || process.cwd();
100
+ const patch = readPatch(patchesDir(dir), patchId);
101
+ if (!patch || patch.status !== "measured") return;
102
+ patch.status = "promoted";
103
+ patch.promoted_at = new Date().toISOString();
104
+ writePatch(patchesDir(dir), patch);
105
+ }
106
+
107
+ // ── graduate ─────────────────────────────────────────────────────────────────
108
+
109
+ /** @param {string} patchId @param {string} [projectDir] @returns {object} */
110
+ function graduate(patchId, projectDir) {
111
+ const dir = projectDir || process.cwd();
112
+ const patch = readPatch(patchesDir(dir), patchId);
113
+ if (!patch || patch.status !== "promoted") return { target: null, content: null };
114
+ // Check graduation criteria: promoted for 3+ additional milestones
115
+ const promotedMs = patch.measured_milestones.length;
116
+ if (promotedMs < 3) return { target: null, content: null };
117
+ const { getPatchTemplate } = require("./rule-engine.js");
118
+ const tpl = getPatchTemplate(patch.template_id, dir);
119
+ if (!tpl) return { target: null, content: null };
120
+ // Write to graduation target (the template's target file)
121
+ const target = tpl.target_file;
122
+ patch.status = "graduated";
123
+ patch.graduated_at = new Date().toISOString();
124
+ patch.graduation_target = target;
125
+ writePatch(patchesDir(dir), patch);
126
+ return { target, content: tpl.edit_content };
127
+ }
128
+
129
+ // ── deprecate ────────────────────────────────────────────────────────────────
130
+
131
+ /** @param {string} patchId @param {string} reason @param {string} [projectDir] */
132
+ function deprecate(patchId, reason, projectDir) {
133
+ const dir = projectDir || process.cwd();
134
+ const patch = readPatch(patchesDir(dir), patchId);
135
+ if (!patch) return;
136
+ patch.status = "deprecated";
137
+ patch.deprecated_at = new Date().toISOString();
138
+ patch.deprecation_reason = reason;
139
+ writePatch(patchesDir(dir), patch);
140
+ }
141
+
142
+ // ── getPatchesByStatus ───────────────────────────────────────────────────────
143
+
144
+ /** @param {string} status @param {string} [projectDir] @returns {object[]} */
145
+ function getPatchesByStatus(status, projectDir) {
146
+ const dir = patchesDir(projectDir);
147
+ if (!fs.existsSync(dir)) return [];
148
+ return fs.readdirSync(dir)
149
+ .filter((f) => f.startsWith("patch-") && f.endsWith(".json"))
150
+ .map((f) => safeParse(fs.readFileSync(path.join(dir, f), "utf8")))
151
+ .filter((p) => p && p.status === status);
152
+ }
153
+
154
+ // ── Edit operations ──────────────────────────────────────────────────────────
155
+
156
+ function applyEdit(content, tpl) {
157
+ const { edit_type, edit_anchor, edit_content } = tpl;
158
+ switch (edit_type) {
159
+ case "append": return content + "\n" + edit_content + "\n";
160
+ case "prepend": return edit_content + "\n" + content;
161
+ case "insert_after": {
162
+ const idx = content.indexOf(edit_anchor);
163
+ if (idx === -1) return null;
164
+ const lineEnd = content.indexOf("\n", idx);
165
+ if (lineEnd === -1) return content + "\n" + edit_content;
166
+ return content.slice(0, lineEnd + 1) + edit_content + "\n" + content.slice(lineEnd + 1);
167
+ }
168
+ case "replace": {
169
+ const i = content.indexOf(edit_anchor);
170
+ if (i === -1) return null;
171
+ return content.slice(0, i) + edit_content + content.slice(i + edit_anchor.length);
172
+ }
173
+ default: return null;
174
+ }
175
+ }
176
+
177
+ // ── Helpers ──────────────────────────────────────────────────────────────────
178
+
179
+ function patchesDir(d) { return path.join(d || process.cwd(), ".gsd-t", "metrics", "patches"); }
180
+ function ensureDir(d) { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); }
181
+
182
+ function nextPatchId(dir) {
183
+ if (!fs.existsSync(dir)) return "patch-001";
184
+ const files = fs.readdirSync(dir).filter((f) => f.startsWith("patch-") && f.endsWith(".json"));
185
+ const nums = files.map((f) => parseInt(f.match(/patch-(\d+)/)?.[1] || "0", 10));
186
+ const max = nums.length > 0 ? Math.max(...nums) : 0;
187
+ return `patch-${String(max + 1).padStart(3, "0")}`;
188
+ }
189
+
190
+ function readPatch(dir, id) {
191
+ const fp = path.join(dir, `${id}.json`);
192
+ return fs.existsSync(fp) ? safeParse(fs.readFileSync(fp, "utf8")) : null;
193
+ }
194
+ function writePatch(dir, p) { ensureDir(dir); fs.writeFileSync(path.join(dir, `${p.id}.json`), JSON.stringify(p, null, 2) + "\n"); }
195
+ function safeParse(s) { try { return JSON.parse(s); } catch { return null; } }