forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/skills-sync.js
CHANGED
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
|
|
35
35
|
const fs = require('node:fs');
|
|
36
36
|
const path = require('node:path');
|
|
37
|
+
const { execFileSync } = require('node:child_process');
|
|
38
|
+
const { parseFrontmatter } = require('./using-forge');
|
|
37
39
|
|
|
38
40
|
/** Canonical skills live under this directory at the repo/package root. */
|
|
39
41
|
const CANONICAL_SKILLS_DIR = 'skills';
|
|
@@ -71,7 +73,7 @@ function isValidSkillName(name) {
|
|
|
71
73
|
* @param {string} sourceRoot - Directory containing the canonical `skills/` dir.
|
|
72
74
|
* @param {object} [options]
|
|
73
75
|
* @param {Set<string>|string[]} [options.only] - Restrict to these skill names.
|
|
74
|
-
* @returns {{name: string, sourcePath: string}[]} Sorted list of skills.
|
|
76
|
+
* @returns {{name: string, sourcePath: string, invocation: string}[]} Sorted list of skills.
|
|
75
77
|
*/
|
|
76
78
|
function listCanonicalSkills(sourceRoot, options = {}) {
|
|
77
79
|
const skillsDir = path.join(sourceRoot, CANONICAL_SKILLS_DIR);
|
|
@@ -88,9 +90,11 @@ function listCanonicalSkills(sourceRoot, options = {}) {
|
|
|
88
90
|
if (only && !only.has(entry.name)) continue;
|
|
89
91
|
|
|
90
92
|
const sourcePath = path.join(skillsDir, entry.name);
|
|
91
|
-
|
|
93
|
+
const skillFile = path.join(sourcePath, 'SKILL.md');
|
|
94
|
+
if (!fs.existsSync(skillFile)) continue;
|
|
92
95
|
|
|
93
|
-
|
|
96
|
+
const { invocation } = parseFrontmatter(fs.readFileSync(skillFile, 'utf8'));
|
|
97
|
+
skills.push({ name: entry.name, sourcePath, invocation });
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -131,6 +135,23 @@ function normalizeContent(buffer) {
|
|
|
131
135
|
return buffer.toString('utf8').replace(/\r\n/g, '\n');
|
|
132
136
|
}
|
|
133
137
|
|
|
138
|
+
function gitIgnoredCanonicalPaths(repoRoot, runGit = execFileSync, { strict = false } = {}) {
|
|
139
|
+
if (!fs.existsSync(path.join(repoRoot, '.git'))) return new Set();
|
|
140
|
+
try {
|
|
141
|
+
const output = runGit('git', [
|
|
142
|
+
'ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', 'skills',
|
|
143
|
+
], {
|
|
144
|
+
cwd: repoRoot,
|
|
145
|
+
encoding: 'utf8',
|
|
146
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
147
|
+
});
|
|
148
|
+
return new Set(output.split('\0').filter(Boolean).map(repoPath => repoPath.normalize('NFC')));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (strict) throw error;
|
|
151
|
+
return new Set();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
134
155
|
/**
|
|
135
156
|
* Remove a symlink sitting at a target path so a real directory can be written
|
|
136
157
|
* in its place. Only ever removes the link entry itself — never a real
|
|
@@ -174,13 +195,18 @@ function clearSymlinkAtPath(targetPath) {
|
|
|
174
195
|
* @param {string} sourcePath - Source skill directory.
|
|
175
196
|
* @param {string} targetPath - Target skill directory.
|
|
176
197
|
*/
|
|
177
|
-
function copySkillDir(sourcePath, targetPath) {
|
|
198
|
+
function copySkillDir(sourcePath, targetPath, excludeRelativePaths = new Set(), prefix = '') {
|
|
178
199
|
fs.mkdirSync(targetPath, { recursive: true });
|
|
179
200
|
for (const entry of fs.readdirSync(sourcePath, { withFileTypes: true })) {
|
|
201
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
180
202
|
const src = path.join(sourcePath, entry.name);
|
|
181
203
|
const dest = path.join(targetPath, entry.name);
|
|
204
|
+
if (excludeRelativePaths.has(relative.normalize('NFC'))) {
|
|
205
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
182
208
|
if (entry.isDirectory()) {
|
|
183
|
-
copySkillDir(src, dest);
|
|
209
|
+
copySkillDir(src, dest, excludeRelativePaths, relative);
|
|
184
210
|
} else if (entry.isFile()) {
|
|
185
211
|
fs.copyFileSync(src, dest);
|
|
186
212
|
}
|
|
@@ -201,7 +227,12 @@ function copySkillDir(sourcePath, targetPath) {
|
|
|
201
227
|
* @param {boolean} [params.clean=false] - Remove canonical-managed stale dirs first.
|
|
202
228
|
* @returns {{written: string[]}} Names of skills written.
|
|
203
229
|
*/
|
|
204
|
-
function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false }) {
|
|
230
|
+
function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false, excludeRelativePaths }) {
|
|
231
|
+
if (excludeRelativePaths === undefined) {
|
|
232
|
+
excludeRelativePaths = new Set([...gitIgnoredCanonicalPaths(sourceRoot)]
|
|
233
|
+
.map(repoPath => repoPath.slice('skills/'.length))
|
|
234
|
+
.filter(relative => !/^[^/]+\/SKILL\.md$/.test(relative)));
|
|
235
|
+
}
|
|
205
236
|
const skills = listCanonicalSkills(sourceRoot, { only });
|
|
206
237
|
fs.mkdirSync(targetSkillsDir, { recursive: true });
|
|
207
238
|
|
|
@@ -222,7 +253,7 @@ function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false
|
|
|
222
253
|
// Defensive: clear a pre-existing (possibly dangling) symlink so the copy
|
|
223
254
|
// doesn't fail. Only symlinks are removed — real dirs are overwritten in place.
|
|
224
255
|
clearSymlinkAtPath(targetPath);
|
|
225
|
-
copySkillDir(skill.sourcePath, targetPath);
|
|
256
|
+
copySkillDir(skill.sourcePath, targetPath, excludeRelativePaths, skill.name);
|
|
226
257
|
written.push(skill.name);
|
|
227
258
|
}
|
|
228
259
|
return { written };
|
|
@@ -235,9 +266,12 @@ function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false
|
|
|
235
266
|
* @param {string} targetPath - Target (generated) skill dir.
|
|
236
267
|
* @returns {{file: string, status: 'missing'|'changed'|'extra'}[]} Drift entries.
|
|
237
268
|
*/
|
|
238
|
-
function diffSkillDir(sourcePath, targetPath) {
|
|
269
|
+
function diffSkillDir(sourcePath, targetPath, excludeRelativePaths = new Set(), prefix = '') {
|
|
239
270
|
const drift = [];
|
|
240
|
-
const sourceFiles = new Set(listFilesRecursive(sourcePath)
|
|
271
|
+
const sourceFiles = new Set(listFilesRecursive(sourcePath).filter(rel => {
|
|
272
|
+
const relative = prefix ? `${prefix}/${rel}` : rel;
|
|
273
|
+
return !excludeRelativePaths.has(relative.normalize('NFC'));
|
|
274
|
+
}));
|
|
241
275
|
const targetFiles = new Set(listFilesRecursive(targetPath));
|
|
242
276
|
|
|
243
277
|
for (const rel of sourceFiles) {
|
|
@@ -261,6 +295,28 @@ function diffSkillDir(sourcePath, targetPath) {
|
|
|
261
295
|
return drift;
|
|
262
296
|
}
|
|
263
297
|
|
|
298
|
+
function collectAgentSkillDrift({ repoRoot, rel, skills, canonicalByName, excludeRelativePaths }) {
|
|
299
|
+
const agentDir = path.join(repoRoot, rel);
|
|
300
|
+
const drift = [];
|
|
301
|
+
const targetNames = fs.readdirSync(agentDir, { withFileTypes: true })
|
|
302
|
+
.filter((entry) => entry.isDirectory())
|
|
303
|
+
.map((entry) => entry.name);
|
|
304
|
+
for (const skill of skills) {
|
|
305
|
+
const targetPath = path.join(agentDir, skill.name);
|
|
306
|
+
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
|
|
307
|
+
drift.push({ agent: rel, skill: skill.name, file: 'SKILL.md', status: 'missing' });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
for (const entry of diffSkillDir(skill.sourcePath, targetPath, excludeRelativePaths, skill.name)) {
|
|
311
|
+
drift.push({ agent: rel, skill: skill.name, ...entry });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for (const name of targetNames) {
|
|
315
|
+
if (!canonicalByName.has(name)) drift.push({ agent: rel, skill: name, file: '*', status: 'stale' });
|
|
316
|
+
}
|
|
317
|
+
return drift;
|
|
318
|
+
}
|
|
319
|
+
|
|
264
320
|
/**
|
|
265
321
|
* Check that existing agent skill mirrors match the canonical source.
|
|
266
322
|
*
|
|
@@ -276,9 +332,12 @@ function diffSkillDir(sourcePath, targetPath) {
|
|
|
276
332
|
* @param {string[]} [params.agentSkillDirs] - Override the agent dirs to scan.
|
|
277
333
|
* @returns {{inSync: boolean, checkedAgents: string[], drift: object[]}}
|
|
278
334
|
*/
|
|
279
|
-
function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS }) {
|
|
335
|
+
function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS, runGit = execFileSync }) {
|
|
280
336
|
const skills = listCanonicalSkills(repoRoot, { only });
|
|
281
337
|
const canonicalByName = new Map(skills.map((s) => [s.name, s]));
|
|
338
|
+
const excludeRelativePaths = new Set([...gitIgnoredCanonicalPaths(repoRoot, runGit)]
|
|
339
|
+
.map(repoPath => repoPath.slice('skills/'.length))
|
|
340
|
+
.filter(relative => !/^[^/]+\/SKILL\.md$/.test(relative)));
|
|
282
341
|
const drift = [];
|
|
283
342
|
const checkedAgents = [];
|
|
284
343
|
|
|
@@ -287,31 +346,7 @@ function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS })
|
|
|
287
346
|
if (!fs.existsSync(agentDir)) continue; // populated at setup time; absence ≠ drift
|
|
288
347
|
checkedAgents.push(rel);
|
|
289
348
|
|
|
290
|
-
|
|
291
|
-
.readdirSync(agentDir, { withFileTypes: true })
|
|
292
|
-
.filter((e) => e.isDirectory())
|
|
293
|
-
.map((e) => e.name);
|
|
294
|
-
|
|
295
|
-
// Skills present canonically but missing/changed in the mirror.
|
|
296
|
-
for (const skill of skills) {
|
|
297
|
-
const targetPath = path.join(agentDir, skill.name);
|
|
298
|
-
// A regular file at the skill path is corruption, not a valid mirror:
|
|
299
|
-
// treat it as drift instead of letting diffSkillDir() throw ENOTDIR.
|
|
300
|
-
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
|
|
301
|
-
drift.push({ agent: rel, skill: skill.name, file: 'SKILL.md', status: 'missing' });
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
for (const entry of diffSkillDir(skill.sourcePath, targetPath)) {
|
|
305
|
-
drift.push({ agent: rel, skill: skill.name, ...entry });
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Skill dirs in the mirror with no canonical source = stale.
|
|
310
|
-
for (const name of targetNames) {
|
|
311
|
-
if (!canonicalByName.has(name)) {
|
|
312
|
-
drift.push({ agent: rel, skill: name, file: '*', status: 'stale' });
|
|
313
|
-
}
|
|
314
|
-
}
|
|
349
|
+
drift.push(...collectAgentSkillDrift({ repoRoot, rel, skills, canonicalByName, excludeRelativePaths }));
|
|
315
350
|
}
|
|
316
351
|
|
|
317
352
|
return { inSync: drift.length === 0, checkedAgents, drift };
|
|
@@ -324,6 +359,7 @@ module.exports = {
|
|
|
324
359
|
listCanonicalSkills,
|
|
325
360
|
listFilesRecursive,
|
|
326
361
|
clearSymlinkAtPath,
|
|
362
|
+
gitIgnoredCanonicalPaths,
|
|
327
363
|
populateAgentSkills,
|
|
328
364
|
diffSkillDir,
|
|
329
365
|
checkSkillsSync,
|
package/lib/smart-merge.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const IMPROVEMENT_FOOTER = `---\n\n## Improving This Workflow\n\nEvery time you give the same instruction twice, add it to this file:\n1. User-specific rules: Add to USER:START section above\n2. Forge workflow improvements: Suggest to forge maintainers\n\n**Keep this file updated as you learn about the project.**\n\n---\n\nSee \`AGENTS.md\` for complete workflow guide.\nSee \`docs/TOOLCHAIN.md\` for comprehensive tool reference.\n`;
|
|
4
|
+
|
|
5
|
+
function extractUnmanagedContent(existingContent) {
|
|
6
|
+
let unmanagedContent = existingContent
|
|
7
|
+
.replace(/\r\n?/g, '\n')
|
|
8
|
+
.replace(/<!-- FORGE:SETUP-INSTRUCTIONS[\s\S]*?-->/g, '')
|
|
9
|
+
.replace(/<!-- FORGE:START.*?-->[\s\S]*?<!-- FORGE:END -->/g, '')
|
|
10
|
+
.trim();
|
|
11
|
+
|
|
12
|
+
unmanagedContent = unmanagedContent.replace(/^# AGENTS\.md\s*/, '').trim();
|
|
13
|
+
|
|
14
|
+
const footer = IMPROVEMENT_FOOTER.trim();
|
|
15
|
+
// The generated footer may precede user-appended text; remove every exact managed copy.
|
|
16
|
+
unmanagedContent = unmanagedContent.split(footer).join('').trim();
|
|
17
|
+
|
|
18
|
+
return unmanagedContent;
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
/**
|
|
4
22
|
* Smart merge for AGENTS.md - preserves USER sections, updates FORGE sections.
|
|
5
23
|
*
|
|
6
|
-
* Handles
|
|
24
|
+
* Handles five cases:
|
|
7
25
|
* 1. No markers at all: Wrap existing content in USER markers, append FORGE section
|
|
8
26
|
* 2. USER markers but no FORGE markers: Keep USER section, insert FORGE section
|
|
9
27
|
* 3. Both markers present: Preserve USER section, update FORGE section (existing behavior)
|
|
10
|
-
* 4.
|
|
28
|
+
* 4. Forge markers without USER markers: Preserve surrounding unmanaged content as USER content
|
|
29
|
+
* 5. Empty existing content: Return only FORGE section (no empty USER block)
|
|
11
30
|
*
|
|
12
31
|
* @param {string} existingContent - The current AGENTS.md content
|
|
13
32
|
* @param {string} newContent - The new template content containing FORGE section
|
|
@@ -20,6 +39,7 @@ function smartMergeAgentsMd(existingContent, newContent) {
|
|
|
20
39
|
|
|
21
40
|
// Check if existing content has markers
|
|
22
41
|
const hasUserMarkers = existingContent.includes('<!-- USER:START') && existingContent.includes('<!-- USER:END');
|
|
42
|
+
const hasForgeMarkers = existingContent.includes('<!-- FORGE:START') && existingContent.includes('<!-- FORGE:END');
|
|
23
43
|
|
|
24
44
|
let userSection;
|
|
25
45
|
|
|
@@ -28,8 +48,12 @@ function smartMergeAgentsMd(existingContent, newContent) {
|
|
|
28
48
|
const userMatch = (/(<!-- USER:START.*?-->[\s\S]*?<!-- USER:END -->)/).exec(existingContent);
|
|
29
49
|
userSection = userMatch ? userMatch[0] : '';
|
|
30
50
|
} else if (existingContent.trim() === '') {
|
|
31
|
-
// Empty existing content: no USER block at all
|
|
32
51
|
userSection = null;
|
|
52
|
+
} else if (hasForgeMarkers) {
|
|
53
|
+
const unmanagedContent = extractUnmanagedContent(existingContent);
|
|
54
|
+
userSection = unmanagedContent
|
|
55
|
+
? `<!-- USER:START -->\n${unmanagedContent}\n<!-- USER:END -->`
|
|
56
|
+
: null;
|
|
33
57
|
} else {
|
|
34
58
|
// No markers: wrap entire existing content in USER markers
|
|
35
59
|
userSection = `<!-- USER:START -->\n${existingContent.trim()}\n<!-- USER:END -->`;
|
|
@@ -56,7 +80,7 @@ function smartMergeAgentsMd(existingContent, newContent) {
|
|
|
56
80
|
merged += forgeSection + '\n\n';
|
|
57
81
|
|
|
58
82
|
// Add footer
|
|
59
|
-
merged +=
|
|
83
|
+
merged += IMPROVEMENT_FOOTER;
|
|
60
84
|
|
|
61
85
|
return merged;
|
|
62
86
|
}
|
package/lib/symlink-utils.js
CHANGED
|
@@ -18,6 +18,40 @@ const path = require('node:path');
|
|
|
18
18
|
const HEADER_COMMENT =
|
|
19
19
|
'<!-- This file is a copy of AGENTS.md. Keep in sync manually or use: bunx forge setup --symlink -->';
|
|
20
20
|
|
|
21
|
+
const SYMLINK_FALLBACK_ERRORS = new Set(['EACCES', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM']);
|
|
22
|
+
|
|
23
|
+
function inspectExistingDestination(target, linkPath) {
|
|
24
|
+
let stat;
|
|
25
|
+
try {
|
|
26
|
+
stat = fs.lstatSync(linkPath);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err.code === 'ENOENT') return null;
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (stat.isSymbolicLink()) {
|
|
33
|
+
let linkedTarget;
|
|
34
|
+
try {
|
|
35
|
+
linkedTarget = fs.readlinkSync(linkPath);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (err.code === 'ENOENT') return null;
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
if (path.resolve(path.dirname(linkPath), linkedTarget) === path.resolve(target)) {
|
|
41
|
+
return 'linked';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (stat.isDirectory()) {
|
|
45
|
+
console.warn(` Warning: Skipped ${linkPath} because it is a directory. Remove it manually and re-run setup.`);
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
if (stat.isFile() && fs.readFileSync(linkPath, 'utf8').trim() === '@AGENTS.md') {
|
|
49
|
+
return 'existing-import';
|
|
50
|
+
}
|
|
51
|
+
console.warn(` Warning: Skipped ${linkPath} because an existing destination must be preserved.`);
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
|
|
21
55
|
/**
|
|
22
56
|
* Create a symlink from `linkPath` pointing to `target`.
|
|
23
57
|
* If symlink creation fails (e.g., EPERM on Windows without admin),
|
|
@@ -28,7 +62,7 @@ const HEADER_COMMENT =
|
|
|
28
62
|
* @param {string} linkPath - Absolute path for the symlink/copy (e.g., CLAUDE.md)
|
|
29
63
|
* @param {Object} [options={}] - Options
|
|
30
64
|
* @param {boolean} [options.symlinkOnly=false] - When true, skip copy fallback (--symlink flag)
|
|
31
|
-
* @returns {'linked'|'copied'|''} Result indicator
|
|
65
|
+
* @returns {'linked'|'copied'|'existing-import'|''} Result indicator
|
|
32
66
|
*/
|
|
33
67
|
function createSymlinkOrCopy(target, linkPath, options = {}) {
|
|
34
68
|
try {
|
|
@@ -38,17 +72,9 @@ function createSymlinkOrCopy(target, linkPath, options = {}) {
|
|
|
38
72
|
return '';
|
|
39
73
|
}
|
|
40
74
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (stat.isDirectory()) {
|
|
45
|
-
console.warn(
|
|
46
|
-
` ⚠ Skipped ${linkPath} (a directory exists at this path). Remove it manually and re-run setup.`
|
|
47
|
-
);
|
|
48
|
-
return '';
|
|
49
|
-
}
|
|
50
|
-
fs.unlinkSync(linkPath);
|
|
51
|
-
}
|
|
75
|
+
// Existing agent files belong to the user. lstat also sees dangling links.
|
|
76
|
+
const existing = inspectExistingDestination(target, linkPath);
|
|
77
|
+
if (existing !== null) return existing;
|
|
52
78
|
|
|
53
79
|
// Ensure parent directory exists
|
|
54
80
|
const linkDir = path.dirname(linkPath);
|
|
@@ -56,22 +82,44 @@ function createSymlinkOrCopy(target, linkPath, options = {}) {
|
|
|
56
82
|
fs.mkdirSync(linkDir, { recursive: true });
|
|
57
83
|
}
|
|
58
84
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
85
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
86
|
+
// Attempt symlink (relative path for portability)
|
|
87
|
+
try {
|
|
88
|
+
const relPath = path.relative(linkDir, target);
|
|
89
|
+
fs.symlinkSync(relPath, linkPath);
|
|
90
|
+
return 'linked';
|
|
91
|
+
} catch (symlinkErr) {
|
|
92
|
+
if (symlinkErr.code === 'EEXIST') {
|
|
93
|
+
const racedExisting = inspectExistingDestination(target, linkPath);
|
|
94
|
+
if (racedExisting !== null) return racedExisting;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!SYMLINK_FALLBACK_ERRORS.has(symlinkErr.code)) {
|
|
98
|
+
throw symlinkErr;
|
|
99
|
+
}
|
|
100
|
+
if (options.symlinkOnly) {
|
|
101
|
+
console.warn(` ⚠ Symlink failed for ${linkPath} (--symlink requires symlink support)`);
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
const content = fs.readFileSync(target, 'utf-8');
|
|
105
|
+
try {
|
|
106
|
+
fs.writeFileSync(linkPath, HEADER_COMMENT + '\n' + content, {
|
|
107
|
+
encoding: 'utf8',
|
|
108
|
+
flag: 'wx',
|
|
109
|
+
});
|
|
110
|
+
return 'copied';
|
|
111
|
+
} catch (copyErr) {
|
|
112
|
+
if (copyErr.code === 'EEXIST') {
|
|
113
|
+
const racedExisting = inspectExistingDestination(target, linkPath);
|
|
114
|
+
if (racedExisting !== null) return racedExisting;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
throw copyErr;
|
|
118
|
+
}
|
|
69
119
|
}
|
|
70
|
-
// Fall back to copy with header
|
|
71
|
-
const content = fs.readFileSync(target, 'utf-8');
|
|
72
|
-
fs.writeFileSync(linkPath, HEADER_COMMENT + '\n' + content, 'utf-8');
|
|
73
|
-
return 'copied';
|
|
74
120
|
}
|
|
121
|
+
console.warn(` ⚠ Could not create ${linkPath} after repeated conflicts; please re-run setup.`);
|
|
122
|
+
return '';
|
|
75
123
|
} catch (err) {
|
|
76
124
|
console.error(` ✗ Failed to link/copy ${target} -> ${linkPath}: ${err.message}`);
|
|
77
125
|
return '';
|
package/lib/upgrade-safety.js
CHANGED
|
@@ -8,6 +8,14 @@ const { resolvePatchIntentRecords } = require('./patch-intent');
|
|
|
8
8
|
const { verifyForgeLock, readForgeLock } = require('./forge-lock');
|
|
9
9
|
const { readConfigBackend, resolveIssueBackend } = require('./issue-backend');
|
|
10
10
|
const { detectBeadsJsonlSource } = require('./beads-detect');
|
|
11
|
+
const {
|
|
12
|
+
FORGE_HOOK_CONTRACT,
|
|
13
|
+
hasForgeClaudeHooks,
|
|
14
|
+
hasMultipleHardLinks,
|
|
15
|
+
HookConfigParseError,
|
|
16
|
+
renderHookConfig,
|
|
17
|
+
} = require('./hook-renderer');
|
|
18
|
+
const { assertNoAncestorSymlinkEscape, assertNoSymlinkEscape } = require('./protected-state-surfaces');
|
|
11
19
|
|
|
12
20
|
function checkStatus(ok) {
|
|
13
21
|
return ok ? 'pass' : 'fail';
|
|
@@ -55,6 +63,31 @@ function buildSelfHealCandidates(projectRoot) {
|
|
|
55
63
|
description: 'Create missing Forge audit log file',
|
|
56
64
|
});
|
|
57
65
|
}
|
|
66
|
+
const claudeDir = path.join(projectRoot, '.claude');
|
|
67
|
+
if (fs.existsSync(claudeDir)) {
|
|
68
|
+
const claudeSettings = path.join(claudeDir, 'settings.json');
|
|
69
|
+
const unsafePath =
|
|
70
|
+
assertNoAncestorSymlinkEscape(projectRoot, claudeSettings) ||
|
|
71
|
+
assertNoSymlinkEscape(projectRoot, claudeSettings);
|
|
72
|
+
if (unsafePath || hasMultipleHardLinks(claudeSettings)) return candidates;
|
|
73
|
+
const existing = fs.existsSync(claudeSettings) ? fs.readFileSync(claudeSettings, 'utf8') : '';
|
|
74
|
+
try {
|
|
75
|
+
if (!hasForgeClaudeHooks(existing, FORGE_HOOK_CONTRACT)) {
|
|
76
|
+
candidates.push({
|
|
77
|
+
id: 'claude-hooks',
|
|
78
|
+
path: '.claude/settings.json',
|
|
79
|
+
description: 'Merge missing Forge-owned Claude lifecycle hooks',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (!(error instanceof HookConfigParseError)) throw error;
|
|
84
|
+
candidates.push({
|
|
85
|
+
id: 'claude-hooks',
|
|
86
|
+
path: '.claude/settings.json',
|
|
87
|
+
description: 'Back up malformed Claude settings before hook repair',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
58
91
|
return candidates;
|
|
59
92
|
}
|
|
60
93
|
|
|
@@ -235,6 +268,12 @@ function applySelfHeal(projectRoot, report) {
|
|
|
235
268
|
applied.push({ path: '.forge/log.jsonl' });
|
|
236
269
|
}
|
|
237
270
|
|
|
271
|
+
if (report.selfHealCandidates.some(candidate => candidate.id === 'claude-hooks')) {
|
|
272
|
+
const result = renderHookConfig({ harness: 'claude', targetRoot: projectRoot });
|
|
273
|
+
if (result.wrote) applied.push({ path: '.claude/settings.json' });
|
|
274
|
+
else if (result.backup) applied.push({ path: path.relative(projectRoot, result.backup) });
|
|
275
|
+
}
|
|
276
|
+
|
|
238
277
|
return {
|
|
239
278
|
refused: false,
|
|
240
279
|
applied,
|
package/lib/using-forge.js
CHANGED
|
@@ -100,10 +100,10 @@ function applyFrontmatterLine(state, line) {
|
|
|
100
100
|
if (state.inDescription) {
|
|
101
101
|
if (/^\s+\S/.test(line)) {
|
|
102
102
|
state.descParts.push(line.trim());
|
|
103
|
-
|
|
104
|
-
state.inDescription = false;
|
|
103
|
+
return;
|
|
105
104
|
}
|
|
106
|
-
return;
|
|
105
|
+
if (line.trim() === '') return;
|
|
106
|
+
state.inDescription = false;
|
|
107
107
|
}
|
|
108
108
|
// Detect the `name:` key without a regex: SonarCloud flags every /^name:.../ variant for
|
|
109
109
|
// super-linear backtracking. startsWith + slice is behavior-identical — the remainder is
|
|
@@ -113,6 +113,15 @@ function applyFrontmatterLine(state, line) {
|
|
|
113
113
|
if (value) state.name = unquote(value);
|
|
114
114
|
return;
|
|
115
115
|
}
|
|
116
|
+
if (line.startsWith('invocation:')) {
|
|
117
|
+
const value = line.slice('invocation:'.length).trim();
|
|
118
|
+
const quote = value[0];
|
|
119
|
+
const hasMatchingQuotes = value.length >= 2
|
|
120
|
+
&& (quote === '"' || quote === "'")
|
|
121
|
+
&& value.endsWith(quote);
|
|
122
|
+
state.invocation = hasMatchingQuotes ? value.slice(1, -1) : value;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
116
125
|
const descMatch = /^description:\s*(.*)$/.exec(line);
|
|
117
126
|
if (descMatch) {
|
|
118
127
|
state.inDescription = true;
|
|
@@ -124,10 +133,14 @@ function applyFrontmatterLine(state, line) {
|
|
|
124
133
|
/** Parse the name and (flattened) description from a SKILL.md frontmatter block. */
|
|
125
134
|
function parseFrontmatter(raw) {
|
|
126
135
|
const block = frontmatterBlock(raw);
|
|
127
|
-
if (block === null) return { name: null, description: '' };
|
|
128
|
-
const state = { name: null, descParts: [], inDescription: false };
|
|
136
|
+
if (block === null) return { name: null, description: '', invocation: 'model' };
|
|
137
|
+
const state = { name: null, descParts: [], inDescription: false, invocation: 'model' };
|
|
129
138
|
for (const line of block.split(/\r?\n/)) applyFrontmatterLine(state, line);
|
|
130
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
name: state.name,
|
|
141
|
+
description: state.descParts.join(' ').replace(/\s+/g, ' ').trim(),
|
|
142
|
+
invocation: state.invocation,
|
|
143
|
+
};
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
/** Read one skill dir into a `{ name, description }` catalog entry, or null when unreadable. */
|