@izkac/forgekit 0.3.12 → 0.3.14

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 (120) hide show
  1. package/bin/forge.mjs +119 -107
  2. package/bin/forgekit.mjs +83 -83
  3. package/bin/review.mjs +81 -81
  4. package/package.json +2 -1
  5. package/scripts/prepack.mjs +78 -78
  6. package/scripts/run-tests.mjs +50 -50
  7. package/src/adr.mjs +236 -236
  8. package/src/adr.test.mjs +170 -170
  9. package/src/change.mjs +327 -234
  10. package/src/change.test.mjs +145 -83
  11. package/src/cleanup-sessions.mjs +84 -84
  12. package/src/config.mjs +103 -103
  13. package/src/defer.mjs +75 -75
  14. package/src/doctor.mjs +350 -341
  15. package/src/doctor.test.mjs +114 -114
  16. package/src/fleet.test.mjs +50 -0
  17. package/src/init.mjs +680 -621
  18. package/src/install.mjs +815 -815
  19. package/src/install.test.mjs +180 -180
  20. package/src/integrity-check.mjs +60 -60
  21. package/src/integrity.mjs +688 -682
  22. package/src/integrity.test.mjs +612 -566
  23. package/src/lib/fleet.mjs +61 -4
  24. package/src/lib.mjs +160 -143
  25. package/src/lib.test.mjs +128 -0
  26. package/src/models.defaults.json +41 -41
  27. package/src/new-session.mjs +99 -99
  28. package/src/openspec-overlays/README.md +19 -19
  29. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
  30. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
  31. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
  32. package/src/paths.mjs +92 -92
  33. package/src/plan-engine.mjs +321 -278
  34. package/src/plan-engine.test.mjs +447 -283
  35. package/src/preferences.defaults.json +78 -78
  36. package/src/preferences.mjs +438 -438
  37. package/src/preferences.test.mjs +174 -174
  38. package/src/record-evidence.mjs +204 -204
  39. package/src/repo-root.mjs +33 -0
  40. package/src/resolve-model.mjs +312 -312
  41. package/src/resolve-model.test.mjs +194 -194
  42. package/src/review/cli.test.mjs +117 -117
  43. package/src/review/export.mjs +172 -172
  44. package/src/review/export.test.mjs +197 -197
  45. package/src/review/fixtures/valid-review.json +42 -42
  46. package/src/review/lib.mjs +894 -894
  47. package/src/review/lib.test.mjs +266 -266
  48. package/src/review/schema.json +196 -196
  49. package/src/review/signals.test.mjs +62 -62
  50. package/src/score-cli.mjs +68 -68
  51. package/src/score.mjs +568 -568
  52. package/src/score.test.mjs +366 -366
  53. package/src/session-reminder.mjs +207 -207
  54. package/src/session-status.mjs +70 -70
  55. package/src/set-models.mjs +186 -186
  56. package/src/set-phase.mjs +205 -205
  57. package/src/set-prefs.mjs +294 -294
  58. package/src/specs-sync.mjs +232 -0
  59. package/src/specs-sync.test.mjs +114 -0
  60. package/src/spine.mjs +93 -93
  61. package/src/triage-prompt.mjs +175 -175
  62. package/src/triage-prompt.test.mjs +50 -50
  63. package/src/vendor-openspec-overlays.mjs +176 -176
  64. package/src/vendor-openspec-overlays.test.mjs +62 -62
  65. package/vendor/skills/archive-to-adr/SKILL.md +149 -149
  66. package/vendor/skills/forge/SKILL.md +136 -136
  67. package/vendor/skills/forge/docs/forge.md +650 -647
  68. package/vendor/skills/forge/phases/brainstorm.md +23 -23
  69. package/vendor/skills/forge/phases/finish.md +90 -87
  70. package/vendor/skills/forge/phases/implement.md +77 -77
  71. package/vendor/skills/forge/phases/plan-openspec.md +60 -60
  72. package/vendor/skills/forge/phases/plan-specs.md +163 -117
  73. package/vendor/skills/forge/phases/review.md +25 -25
  74. package/vendor/skills/forge/phases/verify.md +124 -124
  75. package/vendor/skills/forge/references/forge-layout.md +85 -85
  76. package/vendor/skills/forge/references/pace.md +115 -115
  77. package/vendor/skills/forge/references/plan-routing.md +52 -51
  78. package/vendor/skills/forge/references/runtime-integrity.md +232 -232
  79. package/vendor/skills/forge/references/substantial-work.md +37 -37
  80. package/vendor/skills/forge/references/test-evidence.md +30 -30
  81. package/vendor/skills/forge/references/test-strategy.md +68 -68
  82. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
  83. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
  84. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
  85. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
  86. package/vendor/skills/thorough-code-review/SKILL.md +290 -290
  87. package/vendor/skills/thorough-code-review/examples.md +133 -133
  88. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
  89. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
  90. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
  91. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
  92. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
  93. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
  94. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
  95. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
  96. package/vendor/templates/adr/README.md +7 -7
  97. package/vendor/templates/adr/decisions.md +141 -141
  98. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
  99. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
  100. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
  101. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
  102. package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
  103. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
  104. package/vendor/templates/project/claude/commands/forge-build.md +17 -17
  105. package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
  106. package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
  107. package/vendor/templates/project/claude/commands/forge-status.md +16 -16
  108. package/vendor/templates/project/claude/commands/forge.md +16 -16
  109. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
  110. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
  111. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
  112. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
  113. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
  114. package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
  115. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
  116. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
  117. package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
  118. package/vendor/templates/project/cursor/commands/forge.md +16 -16
  119. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
  120. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
@@ -1,99 +1,99 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Create a new Forge session under .forge/sessions/ and set it active.
4
- *
5
- * Usage:
6
- * forge new mercury-console-validation
7
- * forge new <slug> [--chat-id <id>] [--signal <text>]
8
- */
9
-
10
- import {
11
- defaultSession,
12
- defaultStatus,
13
- ensureForgeLayout,
14
- FORGE_DIR,
15
- makeSessionId,
16
- saveSession,
17
- scaffoldSessionDirs,
18
- sessionPath,
19
- writeActive,
20
- } from './lib.mjs';
21
- import { resolveSessionPaceFields } from './preferences.mjs';
22
- import { warnIfDoctorFails } from './doctor.mjs';
23
- import { liveOverlaps, queueMessage, sessionDirFor } from './lib/fleet.mjs';
24
-
25
- function usage() {
26
- process.stderr.write(
27
- 'Usage: forge new <slug> [--chat-id <id>] [--signal <text>]\n',
28
- );
29
- process.exit(1);
30
- }
31
-
32
- const args = process.argv.slice(2);
33
- if (args.length === 0 || args[0] === '--help' || args[0] === '-h') usage();
34
-
35
- const slug = args[0];
36
- let cursorChatId = null;
37
- let signalText = null;
38
- for (let i = 1; i < args.length; i += 1) {
39
- if (args[i] === '--chat-id' && args[i + 1]) {
40
- cursorChatId = args[i + 1];
41
- i += 1;
42
- } else if (args[i] === '--signal' && args[i + 1]) {
43
- signalText = args[i + 1];
44
- i += 1;
45
- }
46
- }
47
-
48
- ensureForgeLayout();
49
- warnIfDoctorFails({ cwd: process.cwd() });
50
-
51
- const sessionId = makeSessionId(slug);
52
- const dir = sessionPath(sessionId);
53
- scaffoldSessionDirs(dir);
54
-
55
- const session = defaultSession(sessionId, slug);
56
- if (cursorChatId) session.cursorChatId = cursorChatId;
57
-
58
- const paceFields = resolveSessionPaceFields({
59
- forgeDir: FORGE_DIR,
60
- slug: session.slug,
61
- signalText: signalText || session.slug,
62
- });
63
- Object.assign(session, paceFields);
64
-
65
- saveSession(dir, session);
66
- writeActive(sessionId);
67
-
68
- // Fleet coordination: another live session in this working tree risks
69
- // conflicting edits — surface it here and notify the other sessions' inboxes.
70
- const overlaps = liveOverlaps(process.cwd(), sessionId);
71
- for (const o of overlaps) {
72
- queueMessage(
73
- sessionDirFor(o),
74
- `Fleet overlap: session "${session.slug}" (${sessionId}) just started in this project. Coordinate with the user to avoid conflicting edits.`,
75
- );
76
- }
77
-
78
- const out = {
79
- sessionId,
80
- dir,
81
- session: defaultStatus(session),
82
- pace: {
83
- requested: session.pace,
84
- resolved: session.resolvedPace,
85
- reason: session.paceReason,
86
- },
87
- };
88
- if (overlaps.length > 0) {
89
- out.overlaps = overlaps.map((o) => ({
90
- sessionId: o.sessionId,
91
- slug: o.slug,
92
- phase: o.phase,
93
- engine: o.engine,
94
- lastSeen: o.lastSeen ?? o.updatedAt,
95
- }));
96
- out.overlapAdvice =
97
- 'Other live sessions are working in this project. Tell the user and ask: continue anyway, use a git worktree, or pause one session.';
98
- }
99
- process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Create a new Forge session under .forge/sessions/ and set it active.
4
+ *
5
+ * Usage:
6
+ * forge new mercury-console-validation
7
+ * forge new <slug> [--chat-id <id>] [--signal <text>]
8
+ */
9
+
10
+ import {
11
+ defaultSession,
12
+ defaultStatus,
13
+ ensureForgeLayout,
14
+ FORGE_DIR,
15
+ makeSessionId,
16
+ saveSession,
17
+ scaffoldSessionDirs,
18
+ sessionPath,
19
+ writeActive,
20
+ } from './lib.mjs';
21
+ import { resolveSessionPaceFields } from './preferences.mjs';
22
+ import { warnIfDoctorFails } from './doctor.mjs';
23
+ import { liveOverlaps, queueMessage, sessionDirFor } from './lib/fleet.mjs';
24
+
25
+ function usage() {
26
+ process.stderr.write(
27
+ 'Usage: forge new <slug> [--chat-id <id>] [--signal <text>]\n',
28
+ );
29
+ process.exit(1);
30
+ }
31
+
32
+ const args = process.argv.slice(2);
33
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') usage();
34
+
35
+ const slug = args[0];
36
+ let cursorChatId = null;
37
+ let signalText = null;
38
+ for (let i = 1; i < args.length; i += 1) {
39
+ if (args[i] === '--chat-id' && args[i + 1]) {
40
+ cursorChatId = args[i + 1];
41
+ i += 1;
42
+ } else if (args[i] === '--signal' && args[i + 1]) {
43
+ signalText = args[i + 1];
44
+ i += 1;
45
+ }
46
+ }
47
+
48
+ ensureForgeLayout();
49
+ warnIfDoctorFails({ cwd: process.cwd() });
50
+
51
+ const sessionId = makeSessionId(slug);
52
+ const dir = sessionPath(sessionId);
53
+ scaffoldSessionDirs(dir);
54
+
55
+ const session = defaultSession(sessionId, slug);
56
+ if (cursorChatId) session.cursorChatId = cursorChatId;
57
+
58
+ const paceFields = resolveSessionPaceFields({
59
+ forgeDir: FORGE_DIR,
60
+ slug: session.slug,
61
+ signalText: signalText || session.slug,
62
+ });
63
+ Object.assign(session, paceFields);
64
+
65
+ saveSession(dir, session);
66
+ writeActive(sessionId);
67
+
68
+ // Fleet coordination: another live session in this working tree risks
69
+ // conflicting edits — surface it here and notify the other sessions' inboxes.
70
+ const overlaps = liveOverlaps(process.cwd(), sessionId);
71
+ for (const o of overlaps) {
72
+ queueMessage(
73
+ sessionDirFor(o),
74
+ `Fleet overlap: session "${session.slug}" (${sessionId}) just started in this project. Coordinate with the user to avoid conflicting edits.`,
75
+ );
76
+ }
77
+
78
+ const out = {
79
+ sessionId,
80
+ dir,
81
+ session: defaultStatus(session),
82
+ pace: {
83
+ requested: session.pace,
84
+ resolved: session.resolvedPace,
85
+ reason: session.paceReason,
86
+ },
87
+ };
88
+ if (overlaps.length > 0) {
89
+ out.overlaps = overlaps.map((o) => ({
90
+ sessionId: o.sessionId,
91
+ slug: o.slug,
92
+ phase: o.phase,
93
+ engine: o.engine,
94
+ lastSeen: o.lastSeen ?? o.updatedAt,
95
+ }));
96
+ out.overlapAdvice =
97
+ 'Other live sessions are working in this project. Tell the user and ask: continue anyway, use a git worktree, or pause one session.';
98
+ }
99
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
@@ -1,19 +1,19 @@
1
- # OpenSpec Forge overlays
2
-
3
- Vendor OpenSpec skills and `/opsx:*` commands are upgraded in place. **Do not hand-edit** them.
4
-
5
- Forge adaptations live here and are applied by:
6
-
7
- ```bash
8
- forge overlay
9
- ```
10
-
11
- Run after refreshing OpenSpec skills/commands from upstream (usually via `forge init --overlay`).
12
-
13
- | Overlay | Targets |
14
- | ------- | ------- |
15
- | `opsx-apply-implement-step.md` | Step 6 in `opsx-apply` / `opsx/apply` commands |
16
- | `opsx-apply-completion-step.md` | Step 7 “all done” bullet in same commands |
17
- | `openspec-apply-change-footer.md` | Footer on `openspec-apply-change/SKILL.md` (all agents) |
18
-
19
- **Forge-owned (never overwritten):** `/forge:apply` command, `forge/` skill tree.
1
+ # OpenSpec Forge overlays
2
+
3
+ Vendor OpenSpec skills and `/opsx:*` commands are upgraded in place. **Do not hand-edit** them.
4
+
5
+ Forge adaptations live here and are applied by:
6
+
7
+ ```bash
8
+ forge overlay
9
+ ```
10
+
11
+ Run after refreshing OpenSpec skills/commands from upstream (usually via `forge init --overlay`).
12
+
13
+ | Overlay | Targets |
14
+ | ------- | ------- |
15
+ | `opsx-apply-implement-step.md` | Step 6 in `opsx-apply` / `opsx/apply` commands |
16
+ | `opsx-apply-completion-step.md` | Step 7 “all done” bullet in same commands |
17
+ | `openspec-apply-change-footer.md` | Footer on `openspec-apply-change/SKILL.md` (all agents) |
18
+
19
+ **Forge-owned (never overwritten):** `/forge:apply` command, `forge/` skill tree.
@@ -1,14 +1,14 @@
1
-
2
- ---
3
-
4
- <!-- forgekit:openspec-overlay:start -->
5
-
6
- ## Forge overlay (re-applied by `forge overlay`)
7
-
8
- **Do not implement tasks inline in coordinator context.**
9
-
10
- 1. Prefer **`/forge:apply`** for OpenSpec implement + verify + review.
11
- 2. Otherwise follow vendor steps 1–5 above, then **`{{PHASES_IMPLEMENT}}`** for step 6+.
12
- 3. Re-apply this overlay after upgrading OpenSpec skills: `forge overlay`
13
-
14
- <!-- forgekit:openspec-overlay:end -->
1
+
2
+ ---
3
+
4
+ <!-- forgekit:openspec-overlay:start -->
5
+
6
+ ## Forge overlay (re-applied by `forge overlay`)
7
+
8
+ **Do not implement tasks inline in coordinator context.**
9
+
10
+ 1. Prefer **`/forge:apply`** for OpenSpec implement + verify + review.
11
+ 2. Otherwise follow vendor steps 1–5 above, then **`{{PHASES_IMPLEMENT}}`** for step 6+.
12
+ 3. Re-apply this overlay after upgrading OpenSpec skills: `forge overlay`
13
+
14
+ <!-- forgekit:openspec-overlay:end -->
@@ -1 +1 @@
1
- - If all done: proceed to Forge [verify]({{PHASES_VERIFY}}) and [review]({{PHASES_REVIEW}}) when in a Forge session (or user invoked `/forge:apply`), then suggest `/opsx:archive`
1
+ - If all done: proceed to Forge [verify]({{PHASES_VERIFY}}) and [review]({{PHASES_REVIEW}}) when in a Forge session (or user invoked `/forge:apply`), then suggest `/opsx:archive`
@@ -1,11 +1,11 @@
1
- **REQUIRED (Forge):** Wrap this loop in the implement phase —
2
- `{{PHASES_IMPLEMENT}}`.
3
- Use bundled `skills/subagent-driven-development` + `skills/test-driven-development` per task.
4
- Prefer **`/forge:apply`** over bare `/opsx:apply` — same OpenSpec CLI steps plus verify and review.
5
-
6
- For each pending task:
7
- - Show which task is being worked on
8
- - Dispatch **implementer** subagent (TDD first), then **spec** + **quality** reviewers
9
- - Keep changes minimal and focused
10
- - Mark task complete in the tasks file: `- [ ]` → `- [x]`
11
- - Continue to next task
1
+ **REQUIRED (Forge):** Wrap this loop in the implement phase —
2
+ `{{PHASES_IMPLEMENT}}`.
3
+ Use bundled `skills/subagent-driven-development` + `skills/test-driven-development` per task.
4
+ Prefer **`/forge:apply`** over bare `/opsx:apply` — same OpenSpec CLI steps plus verify and review.
5
+
6
+ For each pending task:
7
+ - Show which task is being worked on
8
+ - Dispatch **implementer** subagent (TDD first), then **spec** + **quality** reviewers
9
+ - Keep changes minimal and focused
10
+ - Mark task complete in the tasks file: `- [ ]` → `- [x]`
11
+ - Continue to next task
package/src/paths.mjs CHANGED
@@ -1,92 +1,92 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Resolve forgekit asset roots (skills, templates).
4
- *
5
- * Search order for assets:
6
- * 1. FORGEKIT_ROOT env (dev / linked monorepo)
7
- * 2. Monorepo root (packages/cli/../../..)
8
- * 3. Vendored copy inside the published package (packages/cli/vendor)
9
- */
10
-
11
- import fs from 'node:fs';
12
- import path from 'node:path';
13
- import { fileURLToPath } from 'node:url';
14
- import { createHash } from 'node:crypto';
15
-
16
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
-
18
- /** @returns {string} Absolute path to packages/cli */
19
- export function cliPackageRoot() {
20
- return path.resolve(__dirname, '..');
21
- }
22
-
23
- /**
24
- * Candidate forgekit roots that may contain skills/ and templates/.
25
- * @returns {string[]}
26
- */
27
- export function forgekitRootCandidates() {
28
- const roots = [];
29
- if (process.env.FORGEKIT_ROOT) roots.push(process.env.FORGEKIT_ROOT);
30
- // Monorepo: packages/cli/src → ../../../
31
- roots.push(path.resolve(__dirname, '..', '..', '..'));
32
- // Published package: vendor/ next to src/
33
- roots.push(path.join(cliPackageRoot(), 'vendor'));
34
- return [...new Set(roots.map((r) => path.resolve(r)))];
35
- }
36
-
37
- /**
38
- * @param {string} relativePath e.g. 'skills/forge' or 'templates/project'
39
- * @param {{ requireFile?: string }} [opts] file that must exist under the candidate
40
- * @returns {string}
41
- */
42
- export function resolveAsset(relativePath, opts = {}) {
43
- const requireFile = opts.requireFile;
44
- const tried = [];
45
- for (const root of forgekitRootCandidates()) {
46
- const candidate = path.join(root, relativePath);
47
- tried.push(candidate);
48
- const check = requireFile ? path.join(candidate, requireFile) : candidate;
49
- if (fs.existsSync(check)) return candidate;
50
- }
51
- throw new Error(
52
- `Asset not found: ${relativePath}${requireFile ? ` (${requireFile})` : ''}.\nTried:\n ${tried.join('\n ')}`,
53
- );
54
- }
55
-
56
- /**
57
- * @returns {string} package version from packages/cli/package.json
58
- */
59
- export function packageVersion() {
60
- const pkgPath = path.join(cliPackageRoot(), 'package.json');
61
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
62
- return pkg.version ?? '0.0.0';
63
- }
64
-
65
- /**
66
- * Stable content hash of a directory (sorted relative paths + file bytes).
67
- * @param {string} dir
68
- * @returns {string} sha256 hex
69
- */
70
- export function hashDirectory(dir) {
71
- const hash = createHash('sha256');
72
- /** @type {string[]} */
73
- const files = [];
74
- const walk = (d, prefix = '') => {
75
- for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
76
- if (entry.name === '.forgekit.json') continue;
77
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
78
- const full = path.join(d, entry.name);
79
- if (entry.isDirectory()) walk(full, rel);
80
- else files.push(rel);
81
- }
82
- };
83
- walk(dir);
84
- files.sort();
85
- for (const rel of files) {
86
- hash.update(rel);
87
- hash.update('\0');
88
- hash.update(fs.readFileSync(path.join(dir, ...rel.split('/'))));
89
- hash.update('\0');
90
- }
91
- return hash.digest('hex');
92
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Resolve forgekit asset roots (skills, templates).
4
+ *
5
+ * Search order for assets:
6
+ * 1. FORGEKIT_ROOT env (dev / linked monorepo)
7
+ * 2. Monorepo root (packages/cli/../../..)
8
+ * 3. Vendored copy inside the published package (packages/cli/vendor)
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { createHash } from 'node:crypto';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ /** @returns {string} Absolute path to packages/cli */
19
+ export function cliPackageRoot() {
20
+ return path.resolve(__dirname, '..');
21
+ }
22
+
23
+ /**
24
+ * Candidate forgekit roots that may contain skills/ and templates/.
25
+ * @returns {string[]}
26
+ */
27
+ export function forgekitRootCandidates() {
28
+ const roots = [];
29
+ if (process.env.FORGEKIT_ROOT) roots.push(process.env.FORGEKIT_ROOT);
30
+ // Monorepo: packages/cli/src → ../../../
31
+ roots.push(path.resolve(__dirname, '..', '..', '..'));
32
+ // Published package: vendor/ next to src/
33
+ roots.push(path.join(cliPackageRoot(), 'vendor'));
34
+ return [...new Set(roots.map((r) => path.resolve(r)))];
35
+ }
36
+
37
+ /**
38
+ * @param {string} relativePath e.g. 'skills/forge' or 'templates/project'
39
+ * @param {{ requireFile?: string }} [opts] file that must exist under the candidate
40
+ * @returns {string}
41
+ */
42
+ export function resolveAsset(relativePath, opts = {}) {
43
+ const requireFile = opts.requireFile;
44
+ const tried = [];
45
+ for (const root of forgekitRootCandidates()) {
46
+ const candidate = path.join(root, relativePath);
47
+ tried.push(candidate);
48
+ const check = requireFile ? path.join(candidate, requireFile) : candidate;
49
+ if (fs.existsSync(check)) return candidate;
50
+ }
51
+ throw new Error(
52
+ `Asset not found: ${relativePath}${requireFile ? ` (${requireFile})` : ''}.\nTried:\n ${tried.join('\n ')}`,
53
+ );
54
+ }
55
+
56
+ /**
57
+ * @returns {string} package version from packages/cli/package.json
58
+ */
59
+ export function packageVersion() {
60
+ const pkgPath = path.join(cliPackageRoot(), 'package.json');
61
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
62
+ return pkg.version ?? '0.0.0';
63
+ }
64
+
65
+ /**
66
+ * Stable content hash of a directory (sorted relative paths + file bytes).
67
+ * @param {string} dir
68
+ * @returns {string} sha256 hex
69
+ */
70
+ export function hashDirectory(dir) {
71
+ const hash = createHash('sha256');
72
+ /** @type {string[]} */
73
+ const files = [];
74
+ const walk = (d, prefix = '') => {
75
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
76
+ if (entry.name === '.forgekit.json') continue;
77
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
78
+ const full = path.join(d, entry.name);
79
+ if (entry.isDirectory()) walk(full, rel);
80
+ else files.push(rel);
81
+ }
82
+ };
83
+ walk(dir);
84
+ files.sort();
85
+ for (const rel of files) {
86
+ hash.update(rel);
87
+ hash.update('\0');
88
+ hash.update(fs.readFileSync(path.join(dir, ...rel.split('/'))));
89
+ hash.update('\0');
90
+ }
91
+ return hash.digest('hex');
92
+ }