forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5
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 +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- 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 +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -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/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -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 +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +92 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/beads-nudge.js +0 -91
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- 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/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/skills-sync.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
const fs = require('node:fs');
|
|
36
36
|
const path = require('node:path');
|
|
37
|
+
const { parseFrontmatter } = require('./using-forge');
|
|
37
38
|
|
|
38
39
|
/** Canonical skills live under this directory at the repo/package root. */
|
|
39
40
|
const CANONICAL_SKILLS_DIR = 'skills';
|
|
@@ -71,7 +72,7 @@ function isValidSkillName(name) {
|
|
|
71
72
|
* @param {string} sourceRoot - Directory containing the canonical `skills/` dir.
|
|
72
73
|
* @param {object} [options]
|
|
73
74
|
* @param {Set<string>|string[]} [options.only] - Restrict to these skill names.
|
|
74
|
-
* @returns {{name: string, sourcePath: string}[]} Sorted list of skills.
|
|
75
|
+
* @returns {{name: string, sourcePath: string, invocation: string}[]} Sorted list of skills.
|
|
75
76
|
*/
|
|
76
77
|
function listCanonicalSkills(sourceRoot, options = {}) {
|
|
77
78
|
const skillsDir = path.join(sourceRoot, CANONICAL_SKILLS_DIR);
|
|
@@ -88,9 +89,11 @@ function listCanonicalSkills(sourceRoot, options = {}) {
|
|
|
88
89
|
if (only && !only.has(entry.name)) continue;
|
|
89
90
|
|
|
90
91
|
const sourcePath = path.join(skillsDir, entry.name);
|
|
91
|
-
|
|
92
|
+
const skillFile = path.join(sourcePath, 'SKILL.md');
|
|
93
|
+
if (!fs.existsSync(skillFile)) continue;
|
|
92
94
|
|
|
93
|
-
|
|
95
|
+
const { invocation } = parseFrontmatter(fs.readFileSync(skillFile, 'utf8'));
|
|
96
|
+
skills.push({ name: entry.name, sourcePath, invocation });
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
skills.sort((a, b) => a.name.localeCompare(b.name));
|
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
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { secureExecFileSync } = require('../shell-utils.js');
|
|
4
|
+
|
|
5
|
+
// Developer identity for the status read model. Extracted from the retired
|
|
6
|
+
// lib/status/beads-snapshot.js: the identity lookup is backend-agnostic (it reads
|
|
7
|
+
// git config, never an issue store), so it outlived the Beads reader it shipped in.
|
|
8
|
+
// Kept in its own module so lib/status/snapshot.js stays read-model-only.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read a single git config value, or '' when git is unavailable or the key is unset.
|
|
12
|
+
* Never throws — status must render even in a non-git directory.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} projectRoot
|
|
15
|
+
* @param {string} key — git config key (e.g. 'user.email')
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function getGitConfig(projectRoot, key) {
|
|
19
|
+
try {
|
|
20
|
+
return secureExecFileSync('git', ['config', key], {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
cwd: projectRoot,
|
|
23
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
24
|
+
}).trim();
|
|
25
|
+
} catch (_error) {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The current developer's git identity, used to match claims to "my" work.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} projectRoot
|
|
34
|
+
* @returns {{ email: string, name: string }}
|
|
35
|
+
*/
|
|
36
|
+
function getDeveloperIdentity(projectRoot) {
|
|
37
|
+
return {
|
|
38
|
+
email: getGitConfig(projectRoot, 'user.email'),
|
|
39
|
+
name: getGitConfig(projectRoot, 'user.name'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
getDeveloperIdentity,
|
|
45
|
+
getGitConfig,
|
|
46
|
+
};
|
package/lib/status/presenter.js
CHANGED
|
@@ -173,43 +173,8 @@ function buildPersonalStatusJson({ context, snapshot, workflowResult = null }) {
|
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
function formatBoard({ context, snapshot }) {
|
|
177
|
-
return [
|
|
178
|
-
'',
|
|
179
|
-
'Team Runtime Board',
|
|
180
|
-
`Source: local Beads runtime state`,
|
|
181
|
-
`Branch: ${context.branch}`,
|
|
182
|
-
`Working tree: ${context.workingTree.summary}`,
|
|
183
|
-
'',
|
|
184
|
-
...buildSection('Active', (snapshot.active || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
|
|
185
|
-
...buildSection('Ready', (snapshot.ready || []).map(issue => formatIssue(issue)), { limit: DEFAULT_SECTION_LIMIT }),
|
|
186
|
-
...buildSection('Blocked', (snapshot.blocked || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
|
|
187
|
-
...buildSection('Stale', (snapshot.stale || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
|
|
188
|
-
...buildSection('Parked', (snapshot.parked || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
|
|
189
|
-
...buildSection('Recent Completions', (snapshot.recentCompleted || []).map(issue => formatIssue(issue)), { limit: DEFAULT_SECTION_LIMIT }),
|
|
190
|
-
...buildSection('Limits', snapshot.limits || []),
|
|
191
|
-
].join('\n');
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function buildBoardJson({ context, snapshot }) {
|
|
195
|
-
return {
|
|
196
|
-
context,
|
|
197
|
-
board: {
|
|
198
|
-
active: (snapshot.active || []).map(toIssueSummary),
|
|
199
|
-
ready: (snapshot.ready || []).map(toIssueSummary),
|
|
200
|
-
blocked: (snapshot.blocked || []).map(toIssueSummary),
|
|
201
|
-
stale: (snapshot.stale || []).map(toIssueSummary),
|
|
202
|
-
parked: (snapshot.parked || []).map(toIssueSummary),
|
|
203
|
-
recentCompleted: (snapshot.recentCompleted || []).map(toIssueSummary),
|
|
204
|
-
},
|
|
205
|
-
limits: snapshot.limits || [],
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
176
|
module.exports = {
|
|
210
|
-
buildBoardJson,
|
|
211
177
|
buildPersonalStatusJson,
|
|
212
|
-
formatBoard,
|
|
213
178
|
formatRunNextLines,
|
|
214
179
|
formatZeroArgStatus,
|
|
215
180
|
toIssueSummary,
|
package/lib/status/snapshot.js
CHANGED
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
const { resolveIssueBackend } = require('../issue-backend.js');
|
|
4
4
|
const { runIssueOperation: defaultRunIssueOperation } = require('../forge-issues.js');
|
|
5
|
-
const {
|
|
5
|
+
const { getDeveloperIdentity } = require('./identity.js');
|
|
6
6
|
|
|
7
7
|
// Kernel status vocabulary (taxonomy-validator): 'open', 'in_progress', 'review',
|
|
8
8
|
// the parked 'backlog', and the terminal 'done' / 'cancelled'. `ready` / `blocked`
|
|
9
9
|
// are DERIVED read-model facts, never stored. An issue is treated as active here when
|
|
10
10
|
// it is OPEN and carries a live claim (claimed_by); parked (`backlog`) work is its own
|
|
11
11
|
// bucket so it stays visible instead of vanishing between ready and done. These buckets
|
|
12
|
-
//
|
|
13
|
-
// either backend's snapshot identically.
|
|
12
|
+
// are the contract lib/status/presenter.js renders against.
|
|
14
13
|
const KERNEL_LIMITS = Object.freeze([
|
|
15
14
|
'Reads Forge Kernel issue authority (ready/blocked/stale/active).',
|
|
16
15
|
'Does not read GitHub review, CI, project, or sync freshness state.',
|
|
@@ -102,7 +101,7 @@ function emptyKernelSnapshot(developer) {
|
|
|
102
101
|
* @param {object} [options]
|
|
103
102
|
* @param {function} [options.runIssueOperation] — injectable kernel read (tests)
|
|
104
103
|
* @param {object} [options.env]
|
|
105
|
-
* @returns {Promise<object>} snapshot
|
|
104
|
+
* @returns {Promise<object>} snapshot in the presenter's bucket contract
|
|
106
105
|
*/
|
|
107
106
|
async function readKernelSnapshot(projectRoot, options = {}) {
|
|
108
107
|
const runIssueOperation = options.runIssueOperation || defaultRunIssueOperation;
|
|
@@ -153,29 +152,25 @@ async function readKernelSnapshot(projectRoot, options = {}) {
|
|
|
153
152
|
}
|
|
154
153
|
|
|
155
154
|
/**
|
|
156
|
-
* Read the personal/board status snapshot from the
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
155
|
+
* Read the personal/board status snapshot from the Kernel — the only issue backend.
|
|
156
|
+
*
|
|
157
|
+
* A repo that still carries the retired `issueBackend: beads` signal alongside an
|
|
158
|
+
* unmigrated `.beads/*.jsonl` store now renders the (empty) kernel board rather than
|
|
159
|
+
* the legacy Beads one; `forge upgrade`'s advisory is what points those users at
|
|
160
|
+
* `forge migrate --from beads`.
|
|
161
161
|
*
|
|
162
162
|
* @param {string} projectRoot
|
|
163
|
-
* @param {object} [options] — forwarded to the backend reader; `
|
|
164
|
-
*
|
|
163
|
+
* @param {object} [options] — forwarded to the backend reader; `env` overrides
|
|
164
|
+
* process.env, `backend` short-circuits resolution entirely.
|
|
165
165
|
* @returns {Promise<object>} snapshot for lib/status/presenter.js
|
|
166
166
|
*/
|
|
167
167
|
async function readStatusSnapshot(projectRoot, options = {}) {
|
|
168
168
|
const backend = options.backend || resolveIssueBackend({
|
|
169
|
-
deps: options.issueBackend ? { issueBackend: options.issueBackend } : {},
|
|
170
169
|
env: options.env || process.env,
|
|
171
170
|
projectRoot,
|
|
172
171
|
warn: () => {},
|
|
173
172
|
});
|
|
174
173
|
|
|
175
|
-
if (backend === 'beads') {
|
|
176
|
-
return readBeadsSnapshot(projectRoot, options);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
174
|
return readKernelSnapshot(projectRoot, { ...options, backend });
|
|
180
175
|
}
|
|
181
176
|
|
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
|
|
|
@@ -67,12 +100,13 @@ function safeConfigBackend(projectRoot) {
|
|
|
67
100
|
}
|
|
68
101
|
|
|
69
102
|
// Detect the 0.0.10 -> current breaking boundary that hides a returning user's
|
|
70
|
-
// issues (kernel issue a5399f3d): a `.beads/*.jsonl` store still present
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
103
|
+
// issues (kernel issue a5399f3d): a `.beads/*.jsonl` store still present now that
|
|
104
|
+
// the Kernel is the only backend. There is no longer any way to opt back into
|
|
105
|
+
// Beads, so a leftover `issueBackend: beads` in `.forge/config.yaml` (or
|
|
106
|
+
// FORGE_ISSUE_BACKEND) can no longer suppress this advisory — resolveIssueBackend
|
|
107
|
+
// answers 'kernel' regardless, which is precisely when the user needs to migrate.
|
|
108
|
+
// `configBackend` is still reported so the surface can name the stale setting.
|
|
109
|
+
// Uses the single shared detector so the two surfaces cannot drift.
|
|
76
110
|
function buildBeadsMigrationSummary(projectRoot, env = process.env) {
|
|
77
111
|
const jsonlPresent = detectBeadsJsonlSource(projectRoot) !== null;
|
|
78
112
|
const configBackend = safeConfigBackend(projectRoot);
|
|
@@ -179,13 +213,11 @@ function appendBeadsMigration(lines, beadsMigration) {
|
|
|
179
213
|
lines.push(
|
|
180
214
|
'',
|
|
181
215
|
'Breaking change since 0.0.10 — action required',
|
|
182
|
-
'Detected a Beads issue store (.beads/*.jsonl).
|
|
216
|
+
'Detected a Beads issue store (.beads/*.jsonl). The Kernel is now the only',
|
|
183
217
|
'issue backend, so these issues will NOT appear until migrated (your data is safe',
|
|
184
218
|
'on disk in the meantime). To migrate:',
|
|
185
219
|
' forge migrate --from beads # import your Beads issues into the Kernel',
|
|
186
220
|
' forge setup # (re)wire hooks + provision the Kernel store',
|
|
187
|
-
'Prefer to stay on Beads? Set `issueBackend: beads` in .forge/config.yaml '
|
|
188
|
-
+ '(or FORGE_ISSUE_BACKEND=beads).',
|
|
189
221
|
);
|
|
190
222
|
}
|
|
191
223
|
|
|
@@ -236,6 +268,12 @@ function applySelfHeal(projectRoot, report) {
|
|
|
236
268
|
applied.push({ path: '.forge/log.jsonl' });
|
|
237
269
|
}
|
|
238
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
|
+
|
|
239
277
|
return {
|
|
240
278
|
refused: false,
|
|
241
279
|
applied,
|