@golden-frijoles/kit 0.0.0 → 0.2.0

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 (51) hide show
  1. package/README.md +25 -1
  2. package/bin.mjs +108 -0
  3. package/dist/LICENSE +202 -0
  4. package/dist/NOTICE +7 -0
  5. package/dist/babysit-pr.mjs +213 -0
  6. package/dist/build-order-sync.mjs +141 -0
  7. package/dist/build-order.mjs +178 -0
  8. package/dist/cross-panel.mjs +216 -0
  9. package/dist/cross-panel.prompt.md +115 -0
  10. package/dist/doc-format.enforced.json +4 -0
  11. package/dist/doc-format.mjs +856 -0
  12. package/dist/doc-hygiene.mjs +279 -0
  13. package/dist/lib/cross-agent-cli.mjs +695 -0
  14. package/dist/lib/gh-rest.mjs +229 -0
  15. package/dist/lib/golden-onboarding.mjs +179 -0
  16. package/dist/lib/jev.mjs +334 -0
  17. package/dist/lib/log-branch.mjs +106 -0
  18. package/dist/lib/pmo-benchmarks.mjs +61 -0
  19. package/dist/lib/pmo-delivery.mjs +102 -0
  20. package/dist/lib/pmo-metrics.mjs +200 -0
  21. package/dist/lib/pmo-templates.mjs +132 -0
  22. package/dist/lib/pmo-window-log.mjs +99 -0
  23. package/dist/lib/project-root.mjs +78 -0
  24. package/dist/lib/prose-brief.mjs +191 -0
  25. package/dist/lib/prose-guard.mjs +761 -0
  26. package/dist/lib/prose-writer.mjs +410 -0
  27. package/dist/lib/report-registry.mjs +469 -0
  28. package/dist/lib/reporting-config.mjs +266 -0
  29. package/dist/lib/roadmap-contract.mjs +305 -0
  30. package/dist/lib/roadmap-status-buckets.mjs +20 -0
  31. package/dist/lib/standup-deck.mjs +120 -0
  32. package/dist/lib/telegram-format.mjs +91 -0
  33. package/dist/live-smoke.mjs +303 -0
  34. package/dist/pmo/benchmarks.json +77 -0
  35. package/dist/pmo/templates/metrics-sheet.md +27 -0
  36. package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
  37. package/dist/pmo/templates/weekly-story-deck.md +66 -0
  38. package/dist/pmo-report.mjs +338 -0
  39. package/dist/preflight.mjs +444 -0
  40. package/dist/prose/cpo-persona.md +108 -0
  41. package/dist/prose/internal.task.md +48 -0
  42. package/dist/prose/standup.task.md +38 -0
  43. package/dist/prose/weekly.task.md +36 -0
  44. package/dist/prose-draft.mjs +198 -0
  45. package/dist/prose-lessons.md +59 -0
  46. package/dist/roadmap-extract.mjs +410 -0
  47. package/dist/standup/templates/daily-story-deck.md +46 -0
  48. package/dist/standup.mjs +634 -0
  49. package/dist/vercel-prune-previews.mjs +164 -0
  50. package/dist/weekly-recap.mjs +526 -0
  51. package/package.json +33 -1
@@ -0,0 +1,200 @@
1
+ const HOUR_MS = 60 * 60 * 1000;
2
+ const DAY_MS = 24 * HOUR_MS;
3
+
4
+ function toMs(value) {
5
+ if (!value) return null;
6
+ const ms = new Date(value).getTime();
7
+ return Number.isFinite(ms) ? ms : null;
8
+ }
9
+
10
+ function round(value, digits = 1) {
11
+ if (value === null || value === undefined || Number.isNaN(value)) return null;
12
+ const factor = 10 ** digits;
13
+ return Math.round(value * factor) / factor;
14
+ }
15
+
16
+ export function inWindow(value, sinceISO, untilISO) {
17
+ const t = toMs(value);
18
+ const since = toMs(sinceISO);
19
+ const until = toMs(untilISO);
20
+ if (t === null || since === null || until === null) return false;
21
+ return t >= since && t < until;
22
+ }
23
+
24
+ export function median(values) {
25
+ const nums = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
26
+ if (!nums.length) return null;
27
+ const mid = Math.floor(nums.length / 2);
28
+ return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
29
+ }
30
+
31
+ export function percentile(values, p) {
32
+ const nums = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
33
+ if (!nums.length) return null;
34
+ if (nums.length === 1) return nums[0];
35
+ const idx = Math.ceil((p / 100) * nums.length) - 1;
36
+ return nums[Math.max(0, Math.min(nums.length - 1, idx))];
37
+ }
38
+
39
+ export function parseStoryProgress(value) {
40
+ const m = /^(\d+)\/(\d+)\s+stories\b/i.exec(String(value ?? '').trim());
41
+ if (!m) return { done: 0, total: 0 };
42
+ return { done: Number(m[1]), total: Number(m[2]) };
43
+ }
44
+
45
+ export function summarizePrCycleTimes(prs, { sinceISO, untilISO } = {}) {
46
+ const merged = prs.filter((pr) => {
47
+ if (!pr.mergedAt || !pr.createdAt) return false;
48
+ return sinceISO && untilISO ? inWindow(pr.mergedAt, sinceISO, untilISO) : true;
49
+ });
50
+ const hours = merged
51
+ .map((pr) => (toMs(pr.mergedAt) - toMs(pr.createdAt)) / HOUR_MS)
52
+ .filter((v) => Number.isFinite(v) && v >= 0);
53
+ const total = hours.reduce((sum, v) => sum + v, 0);
54
+ return {
55
+ count: hours.length,
56
+ averageHours: hours.length ? round(total / hours.length) : null,
57
+ medianHours: round(median(hours)),
58
+ p90Hours: round(percentile(hours, 90)),
59
+ };
60
+ }
61
+
62
+ export function summarizeEpicLeadTime(epics, { sinceISO, untilISO } = {}) {
63
+ const shipped = epics.filter((epic) => {
64
+ if (!epic.scaffoldedAt || !epic.shippedAt) return false;
65
+ return sinceISO && untilISO ? inWindow(epic.shippedAt, sinceISO, untilISO) : true;
66
+ });
67
+ const days = shipped
68
+ .map((epic) => (toMs(epic.shippedAt) - toMs(epic.scaffoldedAt)) / DAY_MS)
69
+ .filter((v) => Number.isFinite(v) && v >= 0);
70
+ const total = days.reduce((sum, v) => sum + v, 0);
71
+ return {
72
+ count: days.length,
73
+ averageDays: days.length ? round(total / days.length) : null,
74
+ medianDays: round(median(days)),
75
+ };
76
+ }
77
+
78
+ export function summarizeDeployFrequency(repoResults, { deployRepos = [] } = {}) {
79
+ const allowed = deployRepos.length ? new Set(deployRepos) : null;
80
+ const byRepo = {};
81
+ let total = 0;
82
+ let unavailable = 0;
83
+ for (const result of repoResults) {
84
+ if (allowed && !allowed.has(result.repo)) continue;
85
+ if (!result.available) {
86
+ byRepo[result.repo] = null;
87
+ unavailable += 1;
88
+ continue;
89
+ }
90
+ const count = result.prs?.length ?? 0;
91
+ byRepo[result.repo] = count;
92
+ total += count;
93
+ }
94
+ return { total, byRepo, unavailable };
95
+ }
96
+
97
+ export function summarizeChangeFailProxy(items, { sinceISO, untilISO } = {}) {
98
+ const matches = [];
99
+ for (const item of items) {
100
+ const date = item.mergedAt || item.date || item.createdAt;
101
+ if (sinceISO && untilISO && !inWindow(date, sinceISO, untilISO)) continue;
102
+ const text = `${item.title ?? ''} ${item.message ?? ''}`.toLowerCase();
103
+ if (/\brevert\b/.test(text) || /\bhotfix\b/.test(text)) {
104
+ matches.push(item);
105
+ }
106
+ }
107
+ return { count: matches.length, items: matches };
108
+ }
109
+
110
+ export function summarizeThroughput({
111
+ roadmapRows = [],
112
+ epicStatusFlips = [],
113
+ storyShipEvents = [],
114
+ sinceISO,
115
+ untilISO,
116
+ }) {
117
+ const shippedEpicFlips = epicStatusFlips.filter(
118
+ (flip) => flip.status === 'shipped' && inWindow(flip.date, sinceISO, untilISO)
119
+ );
120
+ const closedEpicFlips = epicStatusFlips.filter(
121
+ (flip) => ['shipped', 'archived'].includes(flip.status) && inWindow(flip.date, sinceISO, untilISO)
122
+ );
123
+ const shippedStories = storyShipEvents.filter((event) =>
124
+ inWindow(event.shippedAt || event.date, sinceISO, untilISO)
125
+ );
126
+ const currentStoryProgress = roadmapRows
127
+ .filter((row) => row.grain === 'Sprint' || row.grain === 'Epic')
128
+ .map((row) => parseStoryProgress(row.sprint_progress))
129
+ .reduce(
130
+ (acc, progress) => ({
131
+ done: acc.done + progress.done,
132
+ total: acc.total + progress.total,
133
+ }),
134
+ { done: 0, total: 0 }
135
+ );
136
+
137
+ return {
138
+ shippedStories: shippedStories.length,
139
+ shippedEpics: shippedEpicFlips.length,
140
+ closedEpics: closedEpicFlips.length,
141
+ currentStoryProgress,
142
+ };
143
+ }
144
+
145
+ export function summarizeDocOps({ docChanges = [], learningsPromotions = [], shippedEpics = [] } = {}) {
146
+ const docsTouchedByEpic = {};
147
+ for (const change of docChanges) {
148
+ if (!change.epicSlug) continue;
149
+ if (!docsTouchedByEpic[change.epicSlug]) docsTouchedByEpic[change.epicSlug] = new Set();
150
+ docsTouchedByEpic[change.epicSlug].add(change.path);
151
+ }
152
+ const docsTouchedPerEpic = Object.fromEntries(
153
+ Object.entries(docsTouchedByEpic).map(([slug, paths]) => [slug, paths.size])
154
+ );
155
+ const retroCovered = shippedEpics.filter((epic) => epic.hasRetrospective).length;
156
+ return {
157
+ docsTouchedPerEpic,
158
+ learningsPromotions: learningsPromotions.length,
159
+ retroCoverage: {
160
+ covered: retroCovered,
161
+ total: shippedEpics.length,
162
+ percent: shippedEpics.length ? round((retroCovered / shippedEpics.length) * 100, 0) : null,
163
+ },
164
+ };
165
+ }
166
+
167
+ export function baselineSummary({ repoResults = [], roadmapRows = [], docChanges = [] } = {}) {
168
+ const openPrs = repoResults.reduce((sum, result) => sum + (result.openPrs?.length ?? 0), 0);
169
+ const recentlyMergedPrs = repoResults.reduce((sum, result) => sum + (result.prs?.length ?? 0), 0);
170
+ const roadmapItems = roadmapRows.length;
171
+ return {
172
+ openPrs,
173
+ recentlyMergedPrs,
174
+ roadmapItems,
175
+ docChanges: docChanges.length,
176
+ };
177
+ }
178
+
179
+ export function formatBaselineSummary(summary) {
180
+ return [
181
+ 'PMO baseline established',
182
+ `${summary.openPrs} open PRs`,
183
+ `${summary.recentlyMergedPrs} recently merged PRs`,
184
+ `${summary.roadmapItems} Roadmap rows`,
185
+ `${summary.docChanges} doc changes`,
186
+ ].join(' · ');
187
+ }
188
+
189
+ export function summarizePmoMetrics(input) {
190
+ const { sinceISO, untilISO } = input;
191
+ return {
192
+ window: { sinceISO, untilISO },
193
+ throughput: summarizeThroughput(input),
194
+ prCycleTime: summarizePrCycleTimes(input.prs || [], { sinceISO, untilISO }),
195
+ epicLeadTime: summarizeEpicLeadTime(input.epics || [], { sinceISO, untilISO }),
196
+ deployFrequency: summarizeDeployFrequency(input.repoResults || [], input),
197
+ changeFailProxy: summarizeChangeFailProxy(input.changeItems || input.prs || [], { sinceISO, untilISO }),
198
+ docOps: summarizeDocOps(input),
199
+ };
200
+ }
@@ -0,0 +1,132 @@
1
+ import { brotliCompressSync, constants as zlibConstants } from 'node:zlib';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { kitRoot } from './project-root.mjs';
5
+
6
+
7
+ const TEMPLATE_DIR = join(kitRoot(), 'pmo', 'templates'); // D2: a kit asset
8
+
9
+ const TEMPLATE_FILES = {
10
+ weekly: 'weekly-story-deck.md',
11
+ monthly: 'monthly-stakeholder-packet.md',
12
+ sheet: 'metrics-sheet.md',
13
+ };
14
+
15
+ function getPath(obj, path) {
16
+ return path.split('.').reduce((cur, part) => (cur == null ? undefined : cur[part]), obj);
17
+ }
18
+
19
+ function formatNumber(value, fallback = 0) {
20
+ if (value === null || value === undefined || Number.isNaN(value)) return fallback;
21
+ return value;
22
+ }
23
+
24
+ function round(value, digits = 1) {
25
+ if (!Number.isFinite(value)) return 0;
26
+ const factor = 10 ** digits;
27
+ return Math.round(value * factor) / factor;
28
+ }
29
+
30
+ function windowDays(metrics) {
31
+ const since = new Date(metrics.window.sinceISO).getTime();
32
+ const until = new Date(metrics.window.untilISO).getTime();
33
+ if (!Number.isFinite(since) || !Number.isFinite(until) || until <= since) return 7;
34
+ return (until - since) / (24 * 60 * 60 * 1000);
35
+ }
36
+
37
+ function benchmarkSourceLine(benchmarks) {
38
+ const firstSource = benchmarks.sources?.[0];
39
+ if (!firstSource) return 'Benchmarks no cargados.';
40
+ return `${firstSource.publisher} (${firstSource.publishedDate}; accessed ${firstSource.accessedDate})`;
41
+ }
42
+
43
+ export function buildTemplateData(metrics, { benchmarks = {}, generatedAt = new Date() } = {}) {
44
+ const generatedDate = generatedAt.toISOString().slice(0, 10);
45
+ const days = windowDays(metrics);
46
+ const deploysPerWeek = round((formatNumber(metrics.deployFrequency.total) / days) * 7);
47
+ const changeFailureRatePercent = metrics.deployFrequency.total
48
+ ? round((formatNumber(metrics.changeFailProxy.count) / metrics.deployFrequency.total) * 100)
49
+ : 0;
50
+ return {
51
+ window: {
52
+ sinceISO: metrics.window.sinceISO,
53
+ untilISO: metrics.window.untilISO,
54
+ label: `${metrics.window.sinceISO.slice(0, 10)} to ${metrics.window.untilISO.slice(0, 10)}`,
55
+ generatedDate,
56
+ },
57
+ throughput: {
58
+ shippedStories: formatNumber(metrics.throughput.shippedStories),
59
+ shippedEpics: formatNumber(metrics.throughput.shippedEpics),
60
+ closedEpics: formatNumber(metrics.throughput.closedEpics),
61
+ },
62
+ cycle: {
63
+ medianHours: formatNumber(metrics.prCycleTime.medianHours),
64
+ averageHours: formatNumber(metrics.prCycleTime.averageHours),
65
+ p90Hours: formatNumber(metrics.prCycleTime.p90Hours),
66
+ },
67
+ epics: {
68
+ medianDays: formatNumber(metrics.epicLeadTime.medianDays),
69
+ averageDays: formatNumber(metrics.epicLeadTime.averageDays),
70
+ },
71
+ deploys: {
72
+ total: formatNumber(metrics.deployFrequency.total),
73
+ perWeek: deploysPerWeek,
74
+ },
75
+ quality: {
76
+ changeFailProxy: formatNumber(metrics.changeFailProxy.count),
77
+ changeFailureRatePercent,
78
+ },
79
+ docOps: {
80
+ learningsPromotions: formatNumber(metrics.docOps.learningsPromotions),
81
+ retroCovered: formatNumber(metrics.docOps.retroCoverage.covered),
82
+ retroTotal: formatNumber(metrics.docOps.retroCoverage.total),
83
+ retroPercent: formatNumber(metrics.docOps.retroCoverage.percent),
84
+ },
85
+ benchmarks: {
86
+ deploysPerWeek: formatNumber(benchmarks.deploysPerWeek),
87
+ prCycleMedianHours: formatNumber(benchmarks.prCycleMedianHours),
88
+ epicLeadMedianDays: formatNumber(benchmarks.epicLeadMedianDays),
89
+ changeFailureRatePercent: formatNumber(benchmarks.changeFailureRatePercent),
90
+ restoreTimeHours: formatNumber(benchmarks.restoreTimeHours),
91
+ framing: benchmarks.framing || 'Diferencial operativo, no experimento controlado.',
92
+ sourceLine: benchmarkSourceLine(benchmarks),
93
+ },
94
+ };
95
+ }
96
+
97
+ export function fillTemplate(template, metrics, options = {}) {
98
+ const data = buildTemplateData(metrics, options);
99
+ return template.replace(/\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g, (match, key) => {
100
+ const value = getPath(data, key);
101
+ return value === undefined ? match : String(value);
102
+ });
103
+ }
104
+
105
+ export function loadPmoTemplate(name) {
106
+ const file = TEMPLATE_FILES[name];
107
+ if (!file) throw new Error(`Unknown PMO template "${name}"`);
108
+ return readFileSync(join(TEMPLATE_DIR, file), 'utf8');
109
+ }
110
+
111
+ export function fillPmoTemplate(name, metrics, options = {}) {
112
+ return fillTemplate(loadPmoTemplate(name), metrics, options);
113
+ }
114
+
115
+ function toBase64Url(buffer) {
116
+ return Buffer.from(buffer).toString('base64url');
117
+ }
118
+
119
+ // A URL-hash markdown viewer: the whole document rides in the fragment (brotli + base64url), so the
120
+ // viewer never stores anything and the link IS the artifact. `baseUrl` is the project's own viewer
121
+ // deployment (reporting.config.json → artifacts.docViewerUrl) and is REQUIRED — there is no default,
122
+ // because a default points every project's reports at one project's hosting.
123
+ export function buildDocViewerUrl(markdown, { baseUrl, present = false } = {}) {
124
+ if (!baseUrl)
125
+ throw new Error('buildDocViewerUrl: no baseUrl — set artifacts.docViewerUrl in reporting.config.json');
126
+ const compressed = brotliCompressSync(Buffer.from(markdown, 'utf8'), {
127
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 },
128
+ });
129
+ const params = new URLSearchParams({ md: toBase64Url(compressed) });
130
+ if (present) params.set('present', '0');
131
+ return `${baseUrl}/#${params.toString()}`;
132
+ }
@@ -0,0 +1,99 @@
1
+ const DEFAULT_WINDOW_DAYS = 7;
2
+ const DAY_MS = 24 * 60 * 60 * 1000;
3
+ const HOUR_MS = 60 * 60 * 1000;
4
+
5
+ function floorToHour(date) {
6
+ return new Date(Math.floor(date.getTime() / HOUR_MS) * HOUR_MS);
7
+ }
8
+
9
+ export function parsePmoLog(content) {
10
+ if (!content) return [];
11
+ return content
12
+ .trim()
13
+ .split('\n')
14
+ .filter(Boolean)
15
+ .flatMap((line) => {
16
+ try {
17
+ return [JSON.parse(line)];
18
+ } catch {
19
+ return [];
20
+ }
21
+ });
22
+ }
23
+
24
+ export function lastPmoLogEntry(content) {
25
+ const entries = parsePmoLog(content);
26
+ return entries.length ? entries[entries.length - 1] : null;
27
+ }
28
+
29
+ export function computePmoWindow(lastLogEntry, now, { sinceISO = null, untilISO = null } = {}) {
30
+ const roundedNow = floorToHour(now);
31
+ const end = untilISO || roundedNow.toISOString();
32
+ if (sinceISO) return { sinceISO, untilISO: end };
33
+ if (lastLogEntry?.windowEnd) return { sinceISO: lastLogEntry.windowEnd, untilISO: end };
34
+ return {
35
+ sinceISO: new Date(new Date(end).getTime() - DEFAULT_WINDOW_DAYS * DAY_MS).toISOString(),
36
+ untilISO: end,
37
+ baseline: true,
38
+ };
39
+ }
40
+
41
+ export function pmoLogLine({ window, metrics, baselineEstablished }) {
42
+ return (
43
+ JSON.stringify({
44
+ ts: new Date().toISOString(),
45
+ windowStart: window.sinceISO,
46
+ windowEnd: window.untilISO,
47
+ baselineEstablished: !!baselineEstablished,
48
+ summary: {
49
+ shippedStories: metrics.throughput.shippedStories,
50
+ shippedEpics: metrics.throughput.shippedEpics,
51
+ deploys: metrics.deployFrequency.total,
52
+ changeFailProxy: metrics.changeFailProxy.count,
53
+ learningsPromotions: metrics.docOps.learningsPromotions,
54
+ },
55
+ }) + '\n'
56
+ );
57
+ }
58
+
59
+ export function formatPmoReport({ metrics, baselineLine = null }) {
60
+ const { sinceISO, untilISO } = metrics.window;
61
+ const hours = (value) => (value === null || value === undefined ? 'n/a' : `${value}h`);
62
+ const lines = [`PMO operational report · ${sinceISO} → ${untilISO}`];
63
+ if (baselineLine) lines.push(baselineLine);
64
+
65
+ const progress = metrics.throughput.currentStoryProgress;
66
+ lines.push(
67
+ `Throughput: ${metrics.throughput.shippedStories} stories shipped · ${metrics.throughput.shippedEpics} epics shipped · ${metrics.throughput.closedEpics} epics closed · Roadmap progress ${progress.done}/${progress.total} stories`
68
+ );
69
+ lines.push(
70
+ `PR cycle time: ${metrics.prCycleTime.count} merged PRs · median ${hours(metrics.prCycleTime.medianHours)} · avg ${hours(metrics.prCycleTime.averageHours)} · p90 ${hours(metrics.prCycleTime.p90Hours)}`
71
+ );
72
+
73
+ const repoCounts = Object.entries(metrics.deployFrequency.byRepo)
74
+ .map(([repo, count]) => `${repo.split('/').pop()}: ${count ?? 'unavailable'}`)
75
+ .join(' · ');
76
+ lines.push(
77
+ `Deploy frequency: ${metrics.deployFrequency.total} merges to main${repoCounts ? ` (${repoCounts})` : ''}`
78
+ );
79
+ lines.push(`Change-fail proxy: ${metrics.changeFailProxy.count} revert/hotfix PRs`);
80
+
81
+ const docCounts = Object.entries(metrics.docOps.docsTouchedPerEpic);
82
+ const shownDocCounts = docCounts.slice(0, 12);
83
+ const docSummary = docCounts.length
84
+ ? shownDocCounts
85
+ .map(([slug, count]) => `${slug}: ${count}`)
86
+ .concat(
87
+ docCounts.length > shownDocCounts.length
88
+ ? [`and ${docCounts.length - shownDocCounts.length} more`]
89
+ : []
90
+ )
91
+ .join(' · ')
92
+ : 'none';
93
+ const retro = metrics.docOps.retroCoverage;
94
+ lines.push(
95
+ `Doc-ops: Roadmap docs touched per epic: ${docSummary} · LEARNINGS promotions ${metrics.docOps.learningsPromotions} · retro coverage ${retro.covered}/${retro.total}${retro.percent === null ? '' : ` (${retro.percent}%)`}`
96
+ );
97
+
98
+ return lines.join('\n');
99
+ }
@@ -0,0 +1,78 @@
1
+ // project-root.mjs — the three path classes every kit script resolves through (golden-frijoles-plugin D2).
2
+ //
3
+ // A script in this set runs in one of two places:
4
+ // • COPIED: the project's own `scripts/<x>.mjs` (a project spawned from the template, or a deliberate fork).
5
+ // • INSTALLED: `@golden-frijoles/kit`, run by `npx … gf-kit <x>` from npm's cache, far from the project.
6
+ // The same bytes run in both. What differs is only where "the project" and "my own files" are, so every
7
+ // path goes through exactly one of:
8
+ //
9
+ // kitRoot() the directory holding this script set: the project's `scripts/` when copied, the kit's
10
+ // `dist/` when installed. Kit assets (templates, task prompts, benchmarks) and SIBLING
11
+ // scripts a script spawns resolve here. Never the project's files.
12
+ // projectRoot() the user's project. `GF_PROJECT_ROOT` wins. Otherwise, COPIED: exactly `scripts/..`, as
13
+ // every script computed it before this module existed. INSTALLED: walk up from cwd to the
14
+ // nearest dir holding `Roadmap/` or `.git`, falling back to cwd.
15
+ // projectAsset(rel) a file the PROJECT owns but the kit supplies a default for (a TEMPLATE FILL-IN persona, a
16
+ // lessons file that grows per project, a per-project enforcement list): the project's
17
+ // `scripts/<rel>` when it has one, else the kit's copy. In copied mode those are one file.
18
+ //
19
+ // Why copied mode does NOT walk up from cwd: this repo's CI runs `node template/scripts/<x>.mjs` from the repo
20
+ // root, and consumers' tests pass explicit roots. A walk from cwd would silently retarget both at a different
21
+ // directory. The mode is a detected fact (the kit's package.json sits above dist/), never a guess.
22
+ //
23
+ // `--root <dir>` is parsed by kit/bin.mjs only, which exports it as GF_PROJECT_ROOT — no script's own argument
24
+ // parser ever sees a new flag. Zero deps; no side effects at import.
25
+
26
+ import { existsSync, readFileSync } from 'node:fs';
27
+ import { dirname, join, resolve } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ export const KIT_PACKAGE_NAME = '@golden-frijoles/kit';
31
+
32
+ const HERE = dirname(fileURLToPath(import.meta.url)); // <set>/lib
33
+
34
+ /** The directory holding this script set: the project's `scripts/` (copied) or the kit's `dist/` (installed). */
35
+ export function kitRoot() {
36
+ return resolve(HERE, '..');
37
+ }
38
+
39
+ /** True when this set runs from the published kit rather than a project's own `scripts/`. */
40
+ export function isInstalled({ root = kitRoot(), read = readFileSync, exists = existsSync } = {}) {
41
+ const pkg = join(root, '..', 'package.json');
42
+ if (!exists(pkg)) return false;
43
+ try {
44
+ return JSON.parse(read(pkg, 'utf8')).name === KIT_PACKAGE_NAME;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /** Walk up from `start` to the nearest directory holding `Roadmap/` or `.git`; null when there is none. */
51
+ export function findProjectRoot(start, { exists = existsSync } = {}) {
52
+ let dir = resolve(start);
53
+ for (;;) {
54
+ if (exists(join(dir, 'Roadmap')) || exists(join(dir, '.git'))) return dir;
55
+ const up = dirname(dir);
56
+ if (up === dir) return null;
57
+ dir = up;
58
+ }
59
+ }
60
+
61
+ /** The user's project. See the header for the precedence. */
62
+ export function projectRoot({
63
+ env = process.env,
64
+ cwd = process.cwd(),
65
+ root = kitRoot(),
66
+ installed = isInstalled({ root }),
67
+ exists = existsSync,
68
+ } = {}) {
69
+ if (env.GF_PROJECT_ROOT) return resolve(cwd, env.GF_PROJECT_ROOT);
70
+ if (!installed) return resolve(root, '..');
71
+ return findProjectRoot(cwd, { exists }) ?? resolve(cwd);
72
+ }
73
+
74
+ /** A project-owned file with a kit default: the project's `scripts/<rel>` if present, else the kit's `<rel>`. */
75
+ export function projectAsset(rel, { project = projectRoot(), root = kitRoot(), exists = existsSync } = {}) {
76
+ const own = join(project, 'scripts', rel);
77
+ return exists(own) ? own : join(root, rel);
78
+ }