forge-workflow 0.0.5 → 0.0.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/.claude/commands/dev.md +6 -1
- package/.claude/commands/plan.md +59 -14
- package/.claude/commands/premerge.md +10 -0
- package/.claude/commands/review.md +7 -1
- package/.claude/commands/ship.md +95 -47
- package/.claude/commands/status.md +42 -0
- package/.claude/commands/validate.md +7 -1
- package/.claude/commands/verify.md +52 -4
- package/.claude/rules/workflow.md +16 -0
- package/.claude/scripts/greptile-resolve.sh +32 -0
- package/.cline/workflows/dev.md +6 -1
- package/.cline/workflows/plan.md +59 -14
- package/.cline/workflows/premerge.md +10 -0
- package/.cline/workflows/review.md +7 -1
- package/.cline/workflows/ship.md +95 -47
- package/.cline/workflows/status.md +42 -0
- package/.cline/workflows/validate.md +7 -1
- package/.cline/workflows/verify.md +52 -4
- package/.codex/skills/dev/SKILL.md +6 -1
- package/.codex/skills/plan/SKILL.md +59 -14
- package/.codex/skills/premerge/SKILL.md +10 -0
- package/.codex/skills/review/SKILL.md +7 -1
- package/.codex/skills/ship/SKILL.md +95 -47
- package/.codex/skills/status/SKILL.md +42 -0
- package/.codex/skills/validate/SKILL.md +7 -1
- package/.codex/skills/verify/SKILL.md +52 -4
- package/.cursor/commands/dev.md +6 -1
- package/.cursor/commands/plan.md +59 -14
- package/.cursor/commands/premerge.md +10 -0
- package/.cursor/commands/review.md +7 -1
- package/.cursor/commands/ship.md +95 -47
- package/.cursor/commands/status.md +42 -0
- package/.cursor/commands/validate.md +7 -1
- package/.cursor/commands/verify.md +52 -4
- package/.github/prompts/dev.prompt.md +6 -1
- package/.github/prompts/plan.prompt.md +59 -14
- package/.github/prompts/premerge.prompt.md +10 -0
- package/.github/prompts/review.prompt.md +7 -1
- package/.github/prompts/ship.prompt.md +95 -47
- package/.github/prompts/status.prompt.md +42 -0
- package/.github/prompts/validate.prompt.md +7 -1
- package/.github/prompts/verify.prompt.md +52 -4
- package/.kilocode/workflows/dev.md +6 -1
- package/.kilocode/workflows/plan.md +59 -14
- package/.kilocode/workflows/premerge.md +10 -0
- package/.kilocode/workflows/review.md +7 -1
- package/.kilocode/workflows/ship.md +95 -47
- package/.kilocode/workflows/status.md +42 -0
- package/.kilocode/workflows/validate.md +7 -1
- package/.kilocode/workflows/verify.md +52 -4
- package/.opencode/commands/dev.md +6 -1
- package/.opencode/commands/plan.md +59 -14
- package/.opencode/commands/premerge.md +10 -0
- package/.opencode/commands/review.md +7 -1
- package/.opencode/commands/ship.md +95 -47
- package/.opencode/commands/status.md +42 -0
- package/.opencode/commands/validate.md +7 -1
- package/.opencode/commands/verify.md +52 -4
- package/.roo/commands/dev.md +6 -1
- package/.roo/commands/plan.md +59 -14
- package/.roo/commands/premerge.md +10 -0
- package/.roo/commands/review.md +7 -1
- package/.roo/commands/ship.md +95 -47
- package/.roo/commands/status.md +42 -0
- package/.roo/commands/validate.md +7 -1
- package/.roo/commands/verify.md +52 -4
- package/AGENTS.md +97 -0
- package/CLAUDE.md +10 -0
- package/README.md +2 -2
- package/bin/forge-cmd.js +5 -1
- package/bin/forge-preflight.js +15 -2
- package/bin/forge.js +178 -9
- package/docs/ENHANCED_ONBOARDING.md +96 -86
- package/docs/ROADMAP.md +2 -2
- package/docs/TOOLCHAIN.md +23 -0
- package/docs/VALIDATION.md +1 -1
- package/lefthook.yml +11 -0
- package/lib/agents-config.js +2 -2
- package/lib/commands/_registry.js +134 -0
- package/lib/commands/clean.js +181 -0
- package/lib/commands/dev.js +58 -0
- package/lib/commands/push.js +196 -0
- package/lib/commands/recommend.js +1 -1
- package/lib/commands/sync.js +55 -0
- package/lib/commands/team.js +37 -0
- package/lib/commands/test.js +207 -0
- package/lib/commands/worktree.js +310 -0
- package/lib/docs-command.js +51 -0
- package/lib/docs-copy.js +50 -0
- package/lib/freshness-token.js +148 -0
- package/lib/greptile-match.js +80 -0
- package/lib/reset.js +309 -0
- package/lib/task-ownership.js +117 -0
- package/package.json +2 -2
- package/scripts/beads-context.sh +157 -22
- package/scripts/beads-context.test.js +5 -1
- package/scripts/check-forge-token.js +98 -0
- package/scripts/conflict-detect.sh +2 -2
- package/scripts/dep-guard.sh +6 -28
- package/scripts/file-index.sh +117 -23
- package/scripts/forge-team/index.sh +86 -0
- package/scripts/forge-team/lib/agent-prompt.sh +52 -0
- package/scripts/forge-team/lib/claim.sh +256 -0
- package/scripts/forge-team/lib/dashboard.sh +341 -0
- package/scripts/forge-team/lib/epic.sh +332 -0
- package/scripts/forge-team/lib/hooks.sh +253 -0
- package/scripts/forge-team/lib/identity.sh +235 -0
- package/scripts/forge-team/lib/sync-github.sh +317 -0
- package/scripts/forge-team/lib/verify.sh +284 -0
- package/scripts/forge-team/lib/workload.sh +296 -0
- package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
- package/scripts/forge-team/tests/claim.test.sh +179 -0
- package/scripts/forge-team/tests/dashboard.test.sh +170 -0
- package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
- package/scripts/forge-team/tests/epic.test.sh +176 -0
- package/scripts/forge-team/tests/hooks.test.sh +239 -0
- package/scripts/forge-team/tests/identity.test.sh +176 -0
- package/scripts/forge-team/tests/integration.test.sh +371 -0
- package/scripts/forge-team/tests/sync-github.test.sh +209 -0
- package/scripts/forge-team/tests/verify.test.sh +314 -0
- package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
- package/scripts/forge-team/tests/workload.test.sh +209 -0
- package/scripts/lib/eval-runner.js +39 -0
- package/scripts/lib/jsonl-lock.sh +48 -0
- package/scripts/lib/sanitize.sh +116 -0
- package/scripts/pr-coordinator.sh +706 -0
- package/scripts/smart-status.sh +37 -10
- package/scripts/sync-utils.sh +24 -29
- package/scripts/test.js +3 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freshness Token — Validate/Ship stage freshness state
|
|
3
|
+
*
|
|
4
|
+
* Tracks whether `main` has moved forward since /validate ran,
|
|
5
|
+
* so /ship can warn if validation results are stale.
|
|
6
|
+
*
|
|
7
|
+
* Token file: `<projectRoot>/.forge-freshness` (ephemeral, gitignored)
|
|
8
|
+
*
|
|
9
|
+
* @module freshness-token
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { execFileSync } = require('node:child_process');
|
|
15
|
+
|
|
16
|
+
const TOKEN_FILENAME = '.forge-freshness';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the current git branch name.
|
|
20
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
21
|
+
* @returns {string} Current branch name.
|
|
22
|
+
*/
|
|
23
|
+
function getCurrentBranch(projectRoot) {
|
|
24
|
+
// Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
|
|
25
|
+
return execFileSync('git', ['-C', projectRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
26
|
+
encoding: 'utf8'
|
|
27
|
+
}).trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Detect the default branch (main, master, develop, trunk).
|
|
32
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
33
|
+
* @returns {string} Default branch name.
|
|
34
|
+
*/
|
|
35
|
+
function getDefaultBranch(projectRoot) {
|
|
36
|
+
try {
|
|
37
|
+
// Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
|
|
38
|
+
return execFileSync('git', ['-C', projectRoot, 'rev-parse', '--abbrev-ref', 'origin/HEAD'], {
|
|
39
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
|
|
40
|
+
}).trim().replace('origin/', '');
|
|
41
|
+
} catch (_e) { /* intentional: origin/HEAD not set, probe common branch names */ // NOSONAR S2486
|
|
42
|
+
for (const name of ['main', 'master', 'develop', 'trunk']) {
|
|
43
|
+
try {
|
|
44
|
+
// Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
|
|
45
|
+
execFileSync('git', ['-C', projectRoot, 'rev-parse', '--verify', name], { stdio: 'pipe' });
|
|
46
|
+
return name;
|
|
47
|
+
} catch (_e2) { /* intentional: branch doesn't exist, try next name */ } // NOSONAR S2486
|
|
48
|
+
}
|
|
49
|
+
return 'main';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the merge-base commit between HEAD and the default branch.
|
|
55
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
56
|
+
* @returns {string} The merge-base commit SHA.
|
|
57
|
+
* @throws {Error} If git merge-base fails (no common ancestor, detached HEAD, etc.)
|
|
58
|
+
*/
|
|
59
|
+
function getMergeBase(projectRoot) {
|
|
60
|
+
const base = getDefaultBranch(projectRoot);
|
|
61
|
+
try {
|
|
62
|
+
// Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
|
|
63
|
+
return execFileSync('git', ['-C', projectRoot, 'merge-base', 'HEAD', base], {
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
66
|
+
}).trim();
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Failed to compute merge-base for HEAD and ${base} in "${projectRoot}": ${err.message}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write a freshness token to `<projectRoot>/.forge-freshness`.
|
|
76
|
+
*
|
|
77
|
+
* Records the current branch, base commit (merge-base HEAD main),
|
|
78
|
+
* and a timestamp so /ship can detect if validation is stale.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
81
|
+
* @returns {{ timestamp: number, branch: string, baseCommit: string }} The written token.
|
|
82
|
+
* @throws {Error} If git merge-base fails (no common ancestor, detached HEAD, etc.)
|
|
83
|
+
*/
|
|
84
|
+
function writeFreshnessToken(projectRoot) {
|
|
85
|
+
const branch = getCurrentBranch(projectRoot);
|
|
86
|
+
const baseCommit = getMergeBase(projectRoot);
|
|
87
|
+
|
|
88
|
+
const token = {
|
|
89
|
+
timestamp: Date.now(),
|
|
90
|
+
branch,
|
|
91
|
+
baseCommit
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const tokenPath = path.join(projectRoot, TOKEN_FILENAME);
|
|
95
|
+
fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2), 'utf8');
|
|
96
|
+
|
|
97
|
+
return token;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read and parse the freshness token from `<projectRoot>/.forge-freshness`.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
104
|
+
* @returns {{ timestamp: number, branch: string, baseCommit: string } | null}
|
|
105
|
+
* The token object, or null if the file is missing or corrupted.
|
|
106
|
+
*/
|
|
107
|
+
function readFreshnessToken(projectRoot) {
|
|
108
|
+
const tokenPath = path.join(projectRoot, TOKEN_FILENAME);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const content = fs.readFileSync(tokenPath, 'utf8');
|
|
112
|
+
return JSON.parse(content);
|
|
113
|
+
} catch (_err) { /* intentional: missing file (ENOENT), corrupted JSON, or empty file — return null */ // NOSONAR S2486
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if a freshness token is stale.
|
|
120
|
+
*
|
|
121
|
+
* A token is stale when:
|
|
122
|
+
* - It is null (missing or corrupted)
|
|
123
|
+
* - The current merge-base differs from the token's baseCommit
|
|
124
|
+
* (meaning main has moved forward since validation)
|
|
125
|
+
* - git merge-base fails (can't verify freshness)
|
|
126
|
+
*
|
|
127
|
+
* @param {{ timestamp: number, branch: string, baseCommit: string } | null} token
|
|
128
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
129
|
+
* @returns {boolean} True if validation results are stale and should be re-run.
|
|
130
|
+
*/
|
|
131
|
+
function isStale(token, projectRoot) {
|
|
132
|
+
if (token === null) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const currentBase = getMergeBase(projectRoot);
|
|
138
|
+
return currentBase !== token.baseCommit;
|
|
139
|
+
} catch (_err) { /* intentional: merge-base failed, treat as stale since freshness can't be verified */ // NOSONAR S2486
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
writeFreshnessToken,
|
|
146
|
+
readFreshnessToken,
|
|
147
|
+
isStale
|
|
148
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Match unresolved review threads to recent commits that touched the same files.
|
|
7
|
+
*
|
|
8
|
+
* For each thread, runs `git log --oneline --follow -- <file>` to find
|
|
9
|
+
* recent commits. If a commit exists, the thread is considered resolved
|
|
10
|
+
* (the file was modified after the review comment).
|
|
11
|
+
*
|
|
12
|
+
* Uses execFileSync (not exec) to prevent shell injection (OWASP A03).
|
|
13
|
+
*
|
|
14
|
+
* @param {Array<{file: string, line: number}>} threads - Review threads with file paths
|
|
15
|
+
* @param {string} projectRoot - Absolute path to the project root (used with git -C)
|
|
16
|
+
* @param {object} [opts] - Options
|
|
17
|
+
* @param {Function} [opts._exec] - Injected exec function for testing (defaults to execFileSync)
|
|
18
|
+
* @param {string} [opts.sinceCommit] - Merge-base SHA to restrict log to PR commits only
|
|
19
|
+
* @returns {Array<{file: string, line: number, resolved: boolean, sha?: string, reason?: string}>}
|
|
20
|
+
*/
|
|
21
|
+
function matchThreadsToCommits(threads, projectRoot, opts = {}) {
|
|
22
|
+
if (!threads || threads.length === 0) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const exec = opts._exec || ((cmd, args) => {
|
|
27
|
+
return execFileSync(cmd, args, { encoding: 'utf8' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Determine commit range — restrict to PR commits when sinceCommit provided
|
|
31
|
+
let sinceCommit = opts.sinceCommit;
|
|
32
|
+
if (!sinceCommit) {
|
|
33
|
+
try {
|
|
34
|
+
sinceCommit = exec('git', ['-C', projectRoot, 'merge-base', 'HEAD', 'main']).trim();
|
|
35
|
+
} catch (_e) { // NOSONAR S2486
|
|
36
|
+
/* intentional: main branch not found, try master instead */
|
|
37
|
+
try {
|
|
38
|
+
sinceCommit = exec('git', ['-C', projectRoot, 'merge-base', 'HEAD', 'master']).trim();
|
|
39
|
+
} catch (_e2) { /* intentional: no merge-base available, fall back to unbounded log */ // NOSONAR S2486
|
|
40
|
+
sinceCommit = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return threads.map((thread) => {
|
|
46
|
+
const { file, line } = thread;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const logArgs = [
|
|
50
|
+
'-C', projectRoot,
|
|
51
|
+
'log',
|
|
52
|
+
'--oneline',
|
|
53
|
+
'--follow',
|
|
54
|
+
];
|
|
55
|
+
if (sinceCommit) {
|
|
56
|
+
logArgs.push(`${sinceCommit}..HEAD`);
|
|
57
|
+
}
|
|
58
|
+
logArgs.push('--', file);
|
|
59
|
+
|
|
60
|
+
const output = exec('git', logArgs);
|
|
61
|
+
|
|
62
|
+
const trimmed = (output || '').trim();
|
|
63
|
+
|
|
64
|
+
if (!trimmed) {
|
|
65
|
+
return { file, line, resolved: false, reason: 'no matching commit' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// git log --oneline format: "<sha> <message>"
|
|
69
|
+
// Take the first (most recent) line
|
|
70
|
+
const firstLine = trimmed.split('\n')[0];
|
|
71
|
+
const sha = firstLine.split(' ')[0];
|
|
72
|
+
|
|
73
|
+
return { file, line, resolved: true, sha };
|
|
74
|
+
} catch (_err) { /* intentional: git log failed (file missing or git unavailable), mark unresolved */ // NOSONAR S2486
|
|
75
|
+
return { file, line, resolved: false, reason: 'no matching commit' };
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { matchThreadsToCommits };
|
package/lib/reset.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Known forge-created file patterns.
|
|
6
|
+
* Used to distinguish forge files from user-created files.
|
|
7
|
+
*/
|
|
8
|
+
const FORGE_COMMANDS = [
|
|
9
|
+
'.claude/commands/plan.md',
|
|
10
|
+
'.claude/commands/dev.md',
|
|
11
|
+
'.claude/commands/validate.md',
|
|
12
|
+
'.claude/commands/ship.md',
|
|
13
|
+
'.claude/commands/review.md',
|
|
14
|
+
'.claude/commands/premerge.md',
|
|
15
|
+
'.claude/commands/verify.md',
|
|
16
|
+
'.claude/commands/status.md',
|
|
17
|
+
'.claude/commands/preflight.md',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const FORGE_RULES = [
|
|
21
|
+
'.claude/rules/workflow.md',
|
|
22
|
+
'.claude/rules/greptile-review-process.md',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const FORGE_SCRIPTS = [
|
|
26
|
+
'.claude/scripts/greptile-resolve.sh',
|
|
27
|
+
'.claude/scripts/validate.sh',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const FORGE_WORKFLOWS = [
|
|
31
|
+
'.github/workflows/beads-to-github.yml',
|
|
32
|
+
'.github/workflows/github-to-beads.yml',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const FORGE_DOCS = [
|
|
36
|
+
'docs/forge/TOOLCHAIN.md',
|
|
37
|
+
'docs/forge/VALIDATION.md',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const FORGE_AGENT_DIRS = [
|
|
41
|
+
'.cursor',
|
|
42
|
+
'.cline',
|
|
43
|
+
'.roo',
|
|
44
|
+
'.codex',
|
|
45
|
+
'.kilocode',
|
|
46
|
+
'.opencode',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const FORGE_SYNC_SCRIPTS_DIR = 'scripts/github-beads-sync';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get categorized lists of forge-managed files in a project.
|
|
53
|
+
* Only returns files that actually exist on disk and are known forge templates.
|
|
54
|
+
* User-created files are excluded.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} projectRoot - Project root directory
|
|
57
|
+
* @returns {{
|
|
58
|
+
* config: string[],
|
|
59
|
+
* commands: string[],
|
|
60
|
+
* rules: string[],
|
|
61
|
+
* scripts: string[],
|
|
62
|
+
* docs: string[],
|
|
63
|
+
* agentDirs: string[],
|
|
64
|
+
* workflows: string[],
|
|
65
|
+
* syncScripts: string[]
|
|
66
|
+
* }}
|
|
67
|
+
*/
|
|
68
|
+
function getForgeFiles(projectRoot) {
|
|
69
|
+
const result = {
|
|
70
|
+
config: [],
|
|
71
|
+
commands: [],
|
|
72
|
+
rules: [],
|
|
73
|
+
scripts: [],
|
|
74
|
+
docs: [],
|
|
75
|
+
agentDirs: [],
|
|
76
|
+
workflows: [],
|
|
77
|
+
syncScripts: [],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// .forge/ directory
|
|
81
|
+
if (fs.existsSync(path.join(projectRoot, '.forge'))) {
|
|
82
|
+
result.config.push('.forge');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Known forge commands (static list)
|
|
86
|
+
for (const f of FORGE_COMMANDS) {
|
|
87
|
+
if (fs.existsSync(path.join(projectRoot, f))) {
|
|
88
|
+
result.commands.push(f);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Dynamic discovery: scan the package's .claude/commands/ directory to find
|
|
93
|
+
// any commands that setup may have copied but aren't in the static list.
|
|
94
|
+
// Only files that exist in both the package source AND the project are included.
|
|
95
|
+
const packageCommandsDir = path.join(__dirname, '..', '.claude', 'commands');
|
|
96
|
+
try {
|
|
97
|
+
const packageEntries = fs.readdirSync(packageCommandsDir);
|
|
98
|
+
for (const entry of packageEntries) {
|
|
99
|
+
if (entry.endsWith('.md')) {
|
|
100
|
+
const relPath = `.claude/commands/${entry}`;
|
|
101
|
+
if (!result.commands.includes(relPath) &&
|
|
102
|
+
fs.existsSync(path.join(projectRoot, relPath))) {
|
|
103
|
+
result.commands.push(relPath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch (_error) {
|
|
108
|
+
// Package commands directory read failed, skip
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Known forge rules
|
|
112
|
+
for (const f of FORGE_RULES) {
|
|
113
|
+
if (fs.existsSync(path.join(projectRoot, f))) {
|
|
114
|
+
result.rules.push(f);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Known forge scripts
|
|
119
|
+
for (const f of FORGE_SCRIPTS) {
|
|
120
|
+
if (fs.existsSync(path.join(projectRoot, f))) {
|
|
121
|
+
result.scripts.push(f);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Known forge docs
|
|
126
|
+
for (const f of FORGE_DOCS) {
|
|
127
|
+
if (fs.existsSync(path.join(projectRoot, f))) {
|
|
128
|
+
result.docs.push(f);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Agent directories (only if they exist)
|
|
133
|
+
for (const dir of FORGE_AGENT_DIRS) {
|
|
134
|
+
if (fs.existsSync(path.join(projectRoot, dir))) {
|
|
135
|
+
result.agentDirs.push(dir);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Beads workflow files
|
|
140
|
+
for (const f of FORGE_WORKFLOWS) {
|
|
141
|
+
if (fs.existsSync(path.join(projectRoot, f))) {
|
|
142
|
+
result.workflows.push(f);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Sync scripts directory
|
|
147
|
+
const syncDir = path.join(projectRoot, FORGE_SYNC_SCRIPTS_DIR);
|
|
148
|
+
if (fs.existsSync(syncDir)) {
|
|
149
|
+
try {
|
|
150
|
+
const entries = fs.readdirSync(syncDir);
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
const relPath = [FORGE_SYNC_SCRIPTS_DIR, entry].join('/');
|
|
153
|
+
result.syncScripts.push(relPath);
|
|
154
|
+
}
|
|
155
|
+
} catch (_error) {
|
|
156
|
+
// Directory read failed, skip
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Remove .forge/ directory only (soft reset).
|
|
165
|
+
* Requires force=true to proceed.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} projectRoot - Project root directory
|
|
168
|
+
* @param {{ force?: boolean }} options
|
|
169
|
+
* @returns {{ removed: string[], preserved: string[] }}
|
|
170
|
+
*/
|
|
171
|
+
function resetSoft(projectRoot, { force = false } = {}) {
|
|
172
|
+
if (!force) {
|
|
173
|
+
throw new Error('Soft reset requires --force flag. This will remove .forge/ directory.');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const removed = [];
|
|
177
|
+
const preserved = [];
|
|
178
|
+
|
|
179
|
+
const forgePath = path.join(projectRoot, '.forge');
|
|
180
|
+
if (fs.existsSync(forgePath)) {
|
|
181
|
+
fs.rmSync(forgePath, { recursive: true, force: true });
|
|
182
|
+
removed.push('.forge');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Record what was preserved
|
|
186
|
+
const inventory = getForgeFiles(projectRoot);
|
|
187
|
+
for (const f of [...inventory.commands, ...inventory.rules, ...inventory.scripts]) {
|
|
188
|
+
preserved.push(f);
|
|
189
|
+
}
|
|
190
|
+
for (const dir of inventory.agentDirs) {
|
|
191
|
+
preserved.push(dir);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { removed, preserved };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Remove ALL forge-managed files (hard reset).
|
|
199
|
+
* Preserves user-created files not in forge template list.
|
|
200
|
+
* Requires force=true to proceed.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} projectRoot - Project root directory
|
|
203
|
+
* @param {{ force?: boolean }} options
|
|
204
|
+
* @returns {{ removed: string[], preserved: string[] }}
|
|
205
|
+
*/
|
|
206
|
+
function resetHard(projectRoot, { force = false } = {}) {
|
|
207
|
+
if (!force) {
|
|
208
|
+
throw new Error('Hard reset requires --force flag. This will remove ALL forge files.');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const removed = [];
|
|
212
|
+
const preserved = [];
|
|
213
|
+
const inventory = getForgeFiles(projectRoot);
|
|
214
|
+
|
|
215
|
+
// Remove .forge/ directory
|
|
216
|
+
for (const dir of inventory.config) {
|
|
217
|
+
const fullPath = path.join(projectRoot, dir);
|
|
218
|
+
if (fs.existsSync(fullPath)) {
|
|
219
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
220
|
+
removed.push(dir);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Remove individual forge files
|
|
225
|
+
const allFiles = [
|
|
226
|
+
...inventory.commands,
|
|
227
|
+
...inventory.rules,
|
|
228
|
+
...inventory.scripts,
|
|
229
|
+
...inventory.docs,
|
|
230
|
+
...inventory.workflows,
|
|
231
|
+
...inventory.syncScripts,
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
for (const f of allFiles) {
|
|
235
|
+
const fullPath = path.join(projectRoot, f);
|
|
236
|
+
if (fs.existsSync(fullPath)) {
|
|
237
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
238
|
+
removed.push(f);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Remove sync scripts directory if it still exists (empty after file removal)
|
|
243
|
+
const syncDir = path.join(projectRoot, FORGE_SYNC_SCRIPTS_DIR);
|
|
244
|
+
if (fs.existsSync(syncDir)) {
|
|
245
|
+
fs.rmSync(syncDir, { recursive: true, force: true });
|
|
246
|
+
removed.push(FORGE_SYNC_SCRIPTS_DIR);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Remove docs/forge/ directory if empty after file removal
|
|
250
|
+
const docsForgeDir = path.join(projectRoot, 'docs', 'forge');
|
|
251
|
+
if (fs.existsSync(docsForgeDir)) {
|
|
252
|
+
try {
|
|
253
|
+
const remaining = fs.readdirSync(docsForgeDir);
|
|
254
|
+
if (remaining.length === 0) {
|
|
255
|
+
fs.rmSync(docsForgeDir, { recursive: true, force: true });
|
|
256
|
+
removed.push('docs/forge');
|
|
257
|
+
}
|
|
258
|
+
} catch (_error) {
|
|
259
|
+
// Directory read failed, skip
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Remove agent directories
|
|
264
|
+
for (const dir of inventory.agentDirs) {
|
|
265
|
+
const fullPath = path.join(projectRoot, dir);
|
|
266
|
+
if (fs.existsSync(fullPath)) {
|
|
267
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
268
|
+
removed.push(dir);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { removed, preserved };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Remove all forge files and re-run setup (reinstall).
|
|
277
|
+
* Accepts a setupFn parameter for testability.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} projectRoot - Project root directory
|
|
280
|
+
* @param {{ force?: boolean, setupFn?: Function }} options
|
|
281
|
+
* @returns {Promise<{ resetResult: object, setupResult: any }>}
|
|
282
|
+
*/
|
|
283
|
+
async function reinstall(projectRoot, { force = false, setupFn } = {}) {
|
|
284
|
+
if (!force) {
|
|
285
|
+
throw new Error('Reinstall requires --force flag.');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const resetResult = resetHard(projectRoot, { force: true });
|
|
289
|
+
|
|
290
|
+
let setupResult = null;
|
|
291
|
+
if (setupFn) {
|
|
292
|
+
setupResult = await setupFn(projectRoot);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { resetResult, setupResult };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = {
|
|
299
|
+
getForgeFiles,
|
|
300
|
+
resetSoft,
|
|
301
|
+
resetHard,
|
|
302
|
+
reinstall,
|
|
303
|
+
FORGE_COMMANDS,
|
|
304
|
+
FORGE_RULES,
|
|
305
|
+
FORGE_SCRIPTS,
|
|
306
|
+
FORGE_DOCS,
|
|
307
|
+
FORGE_WORKFLOWS,
|
|
308
|
+
FORGE_AGENT_DIRS,
|
|
309
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a task list markdown file and validate file ownership.
|
|
5
|
+
* Within each wave, no two tasks may own the same file.
|
|
6
|
+
* Cross-wave ownership is allowed (sequential execution prevents conflicts).
|
|
7
|
+
*
|
|
8
|
+
* @param {string} content - task list markdown content
|
|
9
|
+
* @returns {{ valid: boolean, violations: Array<{wave: number, task1: number, task2: number, file: string}> }}
|
|
10
|
+
*/
|
|
11
|
+
function validateOwnership(content) {
|
|
12
|
+
const lines = content.split('\n');
|
|
13
|
+
const violations = [];
|
|
14
|
+
|
|
15
|
+
const wavePattern = /^## Wave (\d+)/;
|
|
16
|
+
const taskPattern = /^### Task (\d+)/;
|
|
17
|
+
const ownsPattern = /\*\*OWNS\*\*:\s*(.+)/;
|
|
18
|
+
|
|
19
|
+
let currentWave = null;
|
|
20
|
+
let currentTask = null;
|
|
21
|
+
// Map: wave number -> Map of file -> first task number that owns it
|
|
22
|
+
const waveOwnership = new Map();
|
|
23
|
+
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
const parsed = parseWaveAndTask(line, wavePattern, taskPattern, currentWave, currentTask, waveOwnership);
|
|
26
|
+
if (parsed.matched) {
|
|
27
|
+
currentWave = parsed.currentWave;
|
|
28
|
+
currentTask = parsed.currentTask;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (currentWave !== null && currentTask !== null) {
|
|
33
|
+
const ownedFiles = extractOwnedFiles(line, ownsPattern);
|
|
34
|
+
if (ownedFiles) {
|
|
35
|
+
checkOwnership(ownedFiles, waveOwnership.get(currentWave), currentWave, currentTask, violations);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
valid: violations.length === 0,
|
|
42
|
+
violations,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse a line for wave or task headers and update state accordingly.
|
|
48
|
+
* @param {string} line - Current line of markdown
|
|
49
|
+
* @param {RegExp} wavePattern - Pattern to match wave headers
|
|
50
|
+
* @param {RegExp} taskPattern - Pattern to match task headers
|
|
51
|
+
* @param {number|null} currentWave - Current wave number
|
|
52
|
+
* @param {number|null} currentTask - Current task number
|
|
53
|
+
* @param {Map} waveOwnership - Wave ownership map to initialize new waves
|
|
54
|
+
* @returns {{ matched: boolean, currentWave: number|null, currentTask: number|null }}
|
|
55
|
+
*/
|
|
56
|
+
function parseWaveAndTask(line, wavePattern, taskPattern, currentWave, currentTask, waveOwnership) {
|
|
57
|
+
const waveMatch = wavePattern.exec(line);
|
|
58
|
+
if (waveMatch) {
|
|
59
|
+
const wave = Number.parseInt(waveMatch[1], 10);
|
|
60
|
+
if (!waveOwnership.has(wave)) {
|
|
61
|
+
waveOwnership.set(wave, new Map());
|
|
62
|
+
}
|
|
63
|
+
return { matched: true, currentWave: wave, currentTask: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const taskMatch = taskPattern.exec(line);
|
|
67
|
+
if (taskMatch) {
|
|
68
|
+
return { matched: true, currentWave, currentTask: Number.parseInt(taskMatch[1], 10) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { matched: false, currentWave, currentTask };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract owned file paths from an OWNS line.
|
|
76
|
+
* @param {string} line - Current line of markdown
|
|
77
|
+
* @param {RegExp} ownsPattern - Pattern to match OWNS declarations
|
|
78
|
+
* @returns {string[]|null} Array of file paths, or null if line is not an OWNS line
|
|
79
|
+
*/
|
|
80
|
+
function extractOwnedFiles(line, ownsPattern) {
|
|
81
|
+
const ownsMatch = ownsPattern.exec(line);
|
|
82
|
+
if (!ownsMatch) return null;
|
|
83
|
+
|
|
84
|
+
const filesRaw = ownsMatch[1];
|
|
85
|
+
const fileMatches = filesRaw.match(/`([^`]+)`/g);
|
|
86
|
+
if (!fileMatches) return null;
|
|
87
|
+
|
|
88
|
+
return fileMatches.map((f) => f.replaceAll('`', ''));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check for ownership violations and record them.
|
|
93
|
+
* @param {string[]} files - Files declared as owned
|
|
94
|
+
* @param {Map<string, number>} ownership - File-to-task ownership map for the current wave
|
|
95
|
+
* @param {number} currentWave - Current wave number
|
|
96
|
+
* @param {number} currentTask - Current task number
|
|
97
|
+
* @param {Array} violations - Array to push violations into
|
|
98
|
+
*/
|
|
99
|
+
function checkOwnership(files, ownership, currentWave, currentTask, violations) {
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
if (ownership.has(file)) {
|
|
102
|
+
const firstTask = ownership.get(file);
|
|
103
|
+
if (firstTask !== currentTask) {
|
|
104
|
+
violations.push({
|
|
105
|
+
wave: currentWave,
|
|
106
|
+
task1: firstTask,
|
|
107
|
+
task2: currentTask,
|
|
108
|
+
file,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
ownership.set(file, currentTask);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { validateOwnership };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-workflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "7-stage TDD workflow for ALL AI coding agents (Claude, Cursor, Cline, OpenCode, Copilot, Kilo Code, Roo Code, Codex)",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forge": "bin/forge.js",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"packages/*"
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
|
-
"test": "bun test",
|
|
13
|
+
"test": "bun test --timeout 15000",
|
|
14
14
|
"test:setup": "bash test-env/automation/setup-fixtures.sh",
|
|
15
15
|
"test:all": "bun run test:setup && bun test",
|
|
16
16
|
"test:coverage": "c8 --check-coverage bun test",
|