forge-workflow 0.1.0-beta.4 → 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 +20 -0
- package/bin/forge.js +16 -374
- 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/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 +54 -25
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +344 -142
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +223 -25
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +21 -8
- 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 +17 -2
- package/lib/commands/setup.js +191 -94
- package/lib/commands/shepherd.js +13 -1
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +119 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/test.js +24 -34
- package/lib/commands/worktree.js +220 -42
- 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/forge-context.js +1 -4
- package/lib/forge-issues.js +56 -32
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +93 -4
- package/lib/insights.js +96 -80
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +16 -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/sqlite-driver.js +153 -29
- 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 +71 -10
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +68 -43
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +224 -41
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +10 -1
- package/lib/pr-monitor/watch-lifecycle.js +13 -1
- package/lib/pr-pull.js +33 -14
- 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 +33 -1
- 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/shell-utils.js +1 -1
- package/lib/skills-sync.js +6 -3
- 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/package.json +6 -7
- package/scripts/doc-asserting-tests.js +158 -0
- 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 +176 -43
- package/skills/review/SKILL.md +4 -11
- package/skills/review/evals/scorecard.json +3 -3
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +20 -14
- package/skills/shepherd/evals/scorecard.json +2 -2
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- 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
|
@@ -39,7 +39,14 @@ const DEFAULT_IGNORE_GLOBS = Object.freeze(['tmp/*', 'spike/*', 'wip/*', 'throwa
|
|
|
39
39
|
const AUTO_STUB_LABEL = 'auto-stub';
|
|
40
40
|
// Branch prefixes stripped before deriving a title / probing for an encoded issue id.
|
|
41
41
|
const BRANCH_PREFIX = /^(feat|feature|fix|bugfix|hotfix|chore|refactor|docs|test|spike|wip)\//i;
|
|
42
|
-
//
|
|
42
|
+
// A full Kernel issue id (UUID) named anywhere in the branch, e.g.
|
|
43
|
+
// `feat/18f1988e-...-close-on-merge`. Kernel ids ARE UUIDs, so this is the shape a
|
|
44
|
+
// real branch->issue reference actually takes; kept identical to the UUID_RE that
|
|
45
|
+
// resolveActiveIssueId (lib/workflow/enforce-stage.js) matches, so every resolver
|
|
46
|
+
// agrees on which issue a branch names.
|
|
47
|
+
const ENCODED_UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
48
|
+
// Legacy Beads-era issue key encoded in a branch slug, e.g. `feat/kap-7-foo` -> `kap-7`.
|
|
49
|
+
// Still honored for stores imported via `forge migrate --from beads`.
|
|
43
50
|
const ENCODED_ISSUE_ID = /^([a-z][a-z0-9]*-\d+)\b/i;
|
|
44
51
|
// Regex metacharacters escaped when compiling an ignore glob.
|
|
45
52
|
const GLOB_METACHARS = '.+^${}()|[]\\';
|
|
@@ -145,12 +152,17 @@ function deriveTitle(branch) {
|
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
/**
|
|
148
|
-
* Extract an issue id
|
|
155
|
+
* Extract an issue id named in the branch: a Kernel UUID anywhere in the name
|
|
156
|
+
* (`feat/18f1988e-...-foo`), else a legacy Beads key at the head of the slug
|
|
157
|
+
* (`feat/kap-7-foo` -> `kap-7`). The caller VERIFIES the id exists before linking,
|
|
158
|
+
* so a branch that merely looks like an id never binds to a phantom issue.
|
|
149
159
|
*
|
|
150
160
|
* @param {string} branch
|
|
151
161
|
* @returns {string|null}
|
|
152
162
|
*/
|
|
153
163
|
function extractEncodedIssueId(branch) {
|
|
164
|
+
const uuid = ENCODED_UUID.exec(String(branch));
|
|
165
|
+
if (uuid) return uuid[0];
|
|
154
166
|
const match = ENCODED_ISSUE_ID.exec(stripPrefix(branch));
|
|
155
167
|
return match ? match[1] : null;
|
|
156
168
|
}
|
package/lib/kernel/broker.js
CHANGED
|
@@ -782,6 +782,12 @@ async function buildIssueMutationEvent(driver, operation, args, context, config)
|
|
|
782
782
|
const payload = operation === 'comment'
|
|
783
783
|
? buildCommentPayload(issueId, args)
|
|
784
784
|
: buildUpdatePayload(flags);
|
|
785
|
+
// `close` already means the terminal `done` transition. Treat an explicit
|
|
786
|
+
// `--status done` as that same default close intent so it does not get checked
|
|
787
|
+
// as the generic (and illegal) open -> done update transition below.
|
|
788
|
+
if (operation === 'close' && payload.status === 'done') {
|
|
789
|
+
delete payload.status;
|
|
790
|
+
}
|
|
785
791
|
// update/close validate only the supplied taxonomy fields (no mandatory title);
|
|
786
792
|
// comment carries no taxonomy fields, so it is exempt.
|
|
787
793
|
if (operation !== 'comment') {
|
|
@@ -1177,6 +1183,16 @@ function createLocalBroker(options = {}) {
|
|
|
1177
1183
|
return driver.importIssues(kernel, options, {}, config);
|
|
1178
1184
|
},
|
|
1179
1185
|
|
|
1186
|
+
// Bulk activity read for `forge insights` (Slice C2). Read-only SELECT over
|
|
1187
|
+
// kernel_events, newest first, bounded by an optional `since` ISO cutoff + `limit`.
|
|
1188
|
+
// Additive: deliberately NOT registered in GUARDED_DRIVER_METHODS (the write-path
|
|
1189
|
+
// contract), so existing driver stubs that never call it stay valid. Imported beads
|
|
1190
|
+
// interactions live here as `beads.interaction.<kind>` events. Creates/migrates nothing.
|
|
1191
|
+
async listRecentEvents(options = {}, context = {}) {
|
|
1192
|
+
requireDriverMethod(driver, 'listRecentKernelEvents');
|
|
1193
|
+
return driver.listRecentKernelEvents(options, context, getConfig());
|
|
1194
|
+
},
|
|
1195
|
+
|
|
1180
1196
|
// PR reconcile-ledger read (autonomous-shepherd design §3.4). Read-only SELECT of
|
|
1181
1197
|
// the open `pr` rows for a repo (keyed by git_common_dir); creates/migrates nothing.
|
|
1182
1198
|
// Consumed later by prime and the reconciler to enumerate PRs under shepherd.
|
|
@@ -113,7 +113,18 @@ async function buildMigratedKernelIssueDeps(options = {}) {
|
|
|
113
113
|
databasePath: deps.kernelDatabasePath,
|
|
114
114
|
driver: deps.kernelDriver,
|
|
115
115
|
});
|
|
116
|
-
|
|
116
|
+
const close = async () => {
|
|
117
|
+
if (typeof deps.kernelDriver.close === 'function') {
|
|
118
|
+
await deps.kernelDriver.close();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
broker.close = close;
|
|
122
|
+
try {
|
|
123
|
+
await ensureKernelMigrated(broker);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
try { await close(); } catch { /* preserve the migration failure */ }
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
117
128
|
return {
|
|
118
129
|
useKernelBroker: true,
|
|
119
130
|
kernelBroker: broker,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Close-on-merge linkage (kernel issue 18f1988e).
|
|
5
|
+
*
|
|
6
|
+
* The branch->issue linkage backbone shipped (kernel_worktrees rows written by
|
|
7
|
+
* `forge worktree create` and the auto-file rail), but nothing ever consumed it:
|
|
8
|
+
* a merged PR closed no kernel issue, so finished work stayed open forever. A
|
|
9
|
+
* hygiene sweep on 2026-07-25 closed 104 such issues, 61 of them auto-stubs
|
|
10
|
+
* minted on branch push whose merged PR closed nothing.
|
|
11
|
+
*
|
|
12
|
+
* This module is the consumer. Given a branch whose PR is known to be MERGED, it
|
|
13
|
+
* comments the merge evidence onto the linked kernel issue and closes it.
|
|
14
|
+
*
|
|
15
|
+
* Safety properties (each covered by test/kernel/close-on-merge.test.js):
|
|
16
|
+
* - LINKED ONLY. The issue comes from the kernel_worktrees linkage registry via
|
|
17
|
+
* resolveActiveIssueId — the same authoritative resolver `forge ship` stage
|
|
18
|
+
* state uses. An issue is never guessed from the branch/PR title, so a merge
|
|
19
|
+
* can only ever close the issue the branch was actually bound to.
|
|
20
|
+
* - IDEMPOTENT. The issue's status is read back first; a terminal issue
|
|
21
|
+
* (`done` / `cancelled` — the kernel's real terminal vocabulary) is a pure
|
|
22
|
+
* no-op that writes neither a comment nor a close. Running clean twice
|
|
23
|
+
* closes once.
|
|
24
|
+
* - NEVER CLOSES BLIND. An unreadable status is a skip, not an assumption.
|
|
25
|
+
* - BEST EFFORT. Every failure path — no kernel, a throwing driver, a rejected
|
|
26
|
+
* mutation — resolves to `{ closed: false, reason }`. Nothing throws, so
|
|
27
|
+
* `forge clean` still removes worktrees when tracking is broken.
|
|
28
|
+
*
|
|
29
|
+
* Writes go through the supported `runIssueOperation` seam (lib/forge-issues.js),
|
|
30
|
+
* the same path `forge inbox ack` uses, so kernel events/provenance are recorded
|
|
31
|
+
* exactly as a hand-run `forge comment` / `forge close` would be.
|
|
32
|
+
*
|
|
33
|
+
* @module kernel/close-on-merge
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const { resolveActiveIssueId } = require('../workflow/enforce-stage');
|
|
37
|
+
const { isWorkableStatus } = require('./readiness-model');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Render the merge-evidence comment posted onto the issue before it is closed.
|
|
41
|
+
* Stays honest when the PR could not be resolved: `forge clean` also detects a
|
|
42
|
+
* merge by git patch-equivalence, with no GitHub data to cite.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} params
|
|
45
|
+
* @param {string} params.branch - Merged branch name
|
|
46
|
+
* @param {{number: number, title?: string, mergeCommitOid?: string}|null} [params.pr]
|
|
47
|
+
* @returns {string} Comment body
|
|
48
|
+
*/
|
|
49
|
+
function buildMergeEvidence({ branch, pr }) {
|
|
50
|
+
const lines = ['Closed automatically: the branch backing this issue was merged.', '', `branch: ${branch}`];
|
|
51
|
+
if (pr && pr.number) {
|
|
52
|
+
lines.push(`pull request: #${pr.number}${pr.title ? ` — ${pr.title}` : ''}`);
|
|
53
|
+
if (pr.mergeCommitOid) lines.push(`merge commit: ${pr.mergeCommitOid}`);
|
|
54
|
+
} else {
|
|
55
|
+
lines.push('pull request: no pull request resolved (merge detected from git history)');
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Render the `--reason` recorded on the close event.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} params
|
|
64
|
+
* @param {string} params.branch
|
|
65
|
+
* @param {{number: number}|null} [params.pr]
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function buildCloseReason({ branch, pr }) {
|
|
69
|
+
return pr && pr.number ? `merged in PR #${pr.number}` : `branch ${branch} merged`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read an issue's current status through the supported read path.
|
|
74
|
+
*
|
|
75
|
+
* @param {Function} runIssueOperation
|
|
76
|
+
* @param {string} issueId
|
|
77
|
+
* @param {string} projectRoot
|
|
78
|
+
* @returns {Promise<string|null>} the status, or null when it could not be read.
|
|
79
|
+
*/
|
|
80
|
+
async function readIssueStatus(runIssueOperation, issueId, projectRoot) {
|
|
81
|
+
const result = await runIssueOperation('show', [issueId, '--json'], projectRoot);
|
|
82
|
+
if (!result || result.ok !== true || !result.data) return null;
|
|
83
|
+
const status = result.data.status;
|
|
84
|
+
return typeof status === 'string' && status ? status : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @returns {boolean} whether a mutation envelope reports success. */
|
|
88
|
+
function mutationSucceeded(result) {
|
|
89
|
+
return Boolean(result && (result.ok === true || result.success === true));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Comment the merge evidence onto the linked kernel issue and close it.
|
|
94
|
+
* See the module doc for the full contract. NEVER throws.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} options
|
|
97
|
+
* @param {string} options.branch - The merged branch.
|
|
98
|
+
* @param {string} [options.projectRoot] - Repo root threaded to the issue runner.
|
|
99
|
+
* @param {{number: number, title?: string, mergeCommitOid?: string}|null} [options.pr]
|
|
100
|
+
* Merge evidence from GitHub, when available.
|
|
101
|
+
* @param {object} options.driver - Kernel driver exposing the worktree linkage registry.
|
|
102
|
+
* @param {Function} options.runIssueOperation - (operation, args, projectRoot) => envelope.
|
|
103
|
+
* @param {Function} [options.resolveIssueId] - Override the branch->issue resolver (tests).
|
|
104
|
+
* @returns {Promise<{closed: boolean, issueId?: string|null, reason?: string,
|
|
105
|
+
* commented?: boolean, status?: string, error?: string}>}
|
|
106
|
+
*/
|
|
107
|
+
async function closeLinkedIssueOnMerge(options = {}) {
|
|
108
|
+
const { branch, projectRoot, pr = null, driver, runIssueOperation } = options;
|
|
109
|
+
try {
|
|
110
|
+
if (!branch || typeof branch !== 'string') return { closed: false, reason: 'no-branch' };
|
|
111
|
+
if (!driver || typeof runIssueOperation !== 'function') {
|
|
112
|
+
return { closed: false, reason: 'unavailable' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Linked only — never a title/heuristic match.
|
|
116
|
+
const resolveIssueId = options.resolveIssueId || resolveActiveIssueId;
|
|
117
|
+
const issueId = await resolveIssueId(driver, branch);
|
|
118
|
+
if (!issueId) return { closed: false, reason: 'not-linked' };
|
|
119
|
+
|
|
120
|
+
// Idempotency + never-close-blind: read the current status first.
|
|
121
|
+
const status = await readIssueStatus(runIssueOperation, issueId, projectRoot);
|
|
122
|
+
if (!status) return { closed: false, reason: 'read-failed', issueId };
|
|
123
|
+
if (!isWorkableStatus(status)) {
|
|
124
|
+
return { closed: false, reason: 'already-closed', issueId, status };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Evidence first, so it survives even if the close is rejected.
|
|
128
|
+
const commentResult = await runIssueOperation(
|
|
129
|
+
'comment',
|
|
130
|
+
[issueId, buildMergeEvidence({ branch, pr })],
|
|
131
|
+
projectRoot,
|
|
132
|
+
);
|
|
133
|
+
const commented = mutationSucceeded(commentResult);
|
|
134
|
+
|
|
135
|
+
const closeResult = await runIssueOperation(
|
|
136
|
+
'close',
|
|
137
|
+
[issueId, '--reason', buildCloseReason({ branch, pr })],
|
|
138
|
+
projectRoot,
|
|
139
|
+
);
|
|
140
|
+
if (!mutationSucceeded(closeResult)) {
|
|
141
|
+
return { closed: false, reason: 'close-failed', issueId, commented };
|
|
142
|
+
}
|
|
143
|
+
return { closed: true, issueId, commented };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// Best-effort: cleanup must never break on tracking.
|
|
146
|
+
return { closed: false, reason: 'error', error: error && error.message };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
closeLinkedIssueOnMerge,
|
|
152
|
+
buildMergeEvidence,
|
|
153
|
+
buildCloseReason,
|
|
154
|
+
};
|
package/lib/kernel/fs-class.js
CHANGED
|
@@ -284,7 +284,7 @@ function driveLetterOf(absPath) {
|
|
|
284
284
|
|
|
285
285
|
// Bounded exec options for the Windows drive probes. The timeout is the BLOCKER-1
|
|
286
286
|
// fix: execFileSync's try/catch only catches throws, never hangs, so an
|
|
287
|
-
// unresponsive `net use`
|
|
287
|
+
// an unresponsive `net use` probe (e.g. a wedged network redirector) would
|
|
288
288
|
// hang the whole process indefinitely. With a timeout, a hang becomes a throw
|
|
289
289
|
// that the gatherSignals catch turns into driveType='unknown' → warn (never
|
|
290
290
|
// refuse), symmetric with the G1 fail-safe. killSignal forces the child dead.
|
|
@@ -310,18 +310,21 @@ function parseNetUseDriveType(stdout, driveLetter) {
|
|
|
310
310
|
return null;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
-
// PURE (M5): a non-empty PowerShell DisplayRoot means the PSDrive is backed by a
|
|
314
|
-
// network/remote root. Returns 'network' when non-blank, else null.
|
|
315
|
-
function parseDisplayRoot(stdout) {
|
|
316
|
-
return String(stdout || '').trim() ? 'network' : null;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
313
|
// Windows: detect whether a drive letter is a mapped network drive.
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
314
|
+
// The conventional local C: drive is fixed without a subprocess. `net use` is
|
|
315
|
+
// authoritative for other mapped drive letters, while a no-match remains
|
|
316
|
+
// unknown because it does not prove that an SMB/cloud redirector is absent. The unknown
|
|
317
|
+
// result is fail-open (warn), while UNC and cloud path signals still refuse.
|
|
318
|
+
// Do not launch a second shell process for every kernel child. Under a heavily
|
|
319
|
+
// sharded suite those redundant fallback probes contend for host resources and
|
|
320
|
+
// can leave a shard blocked indefinitely.
|
|
321
|
+
// A probe failure still propagates to the gatherer, which maps it to the
|
|
322
|
+
// fail-safe `unknown`/warn classification.
|
|
323
|
+
// The probe uses an arg ARRAY (no shell) so it remains injection-free.
|
|
324
324
|
function defaultProbeDriveType(driveLetter, deps = {}) {
|
|
325
|
+
const normalizedDriveLetter = String(driveLetter || '').trim().toUpperCase();
|
|
326
|
+
if (normalizedDriveLetter === 'C:') return 'fixed';
|
|
327
|
+
|
|
325
328
|
const exec = deps.execFileSync || execFileSync;
|
|
326
329
|
|
|
327
330
|
// Primary: `net use` lists mapped drive letters (and their remote paths).
|
|
@@ -330,17 +333,9 @@ function defaultProbeDriveType(driveLetter, deps = {}) {
|
|
|
330
333
|
return 'network';
|
|
331
334
|
}
|
|
332
335
|
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
`(Get-PSDrive -Name '${psLetter}' -ErrorAction SilentlyContinue).DisplayRoot`;
|
|
337
|
-
const psOut = exec('powershell', ['-NoProfile', '-Command', psCmd], WIN_PROBE_EXEC_OPTIONS);
|
|
338
|
-
if (parseDisplayRoot(psOut) === 'network') {
|
|
339
|
-
return 'network';
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Letter exists, no network backing detected → fixed.
|
|
343
|
-
return 'fixed';
|
|
336
|
+
// `net use` completed successfully without listing this non-system letter.
|
|
337
|
+
// A no-match is not evidence that an SMB/cloud redirector is absent.
|
|
338
|
+
return 'unknown';
|
|
344
339
|
}
|
|
345
340
|
|
|
346
341
|
// Linux: read /proc/mounts, return fs type of the longest mountpoint prefixing absPath.
|
|
@@ -443,27 +438,49 @@ function isUnsafeFsOverrideActive(env = {}) {
|
|
|
443
438
|
return value === '1' || value === 'true';
|
|
444
439
|
}
|
|
445
440
|
|
|
441
|
+
// The gate runs on every broker instance, and one command builds several
|
|
442
|
+
// (`forge status` and `forge prime` each build four), so an un-memoized advisory
|
|
443
|
+
// printed four times per command. The advisory describes the process's database
|
|
444
|
+
// path, not the broker, so it is emitted once per (tier, class, path) per
|
|
445
|
+
// process. Keyed rather than a single flag so a second path — or a path that
|
|
446
|
+
// reclassifies — is still reported. Warnings only: `refuse` still throws on
|
|
447
|
+
// every call, so deduping can never turn a refusal into a pass.
|
|
448
|
+
const warnedFilesystemKeys = new Set();
|
|
449
|
+
|
|
450
|
+
function resetFilesystemWarningMemo() {
|
|
451
|
+
warnedFilesystemKeys.clear();
|
|
452
|
+
}
|
|
453
|
+
|
|
446
454
|
function assertFilesystemSafeForKernel(dbPath, deps = {}) {
|
|
447
455
|
const env = deps.env || process.env;
|
|
456
|
+
// console.warn writes to stderr, which is the contract here: `--json`
|
|
457
|
+
// consumers parse stdout, so an advisory on stdout would corrupt the envelope.
|
|
448
458
|
const warn = deps.warn || (msg => console.warn(msg));
|
|
449
459
|
const classify = deps.classifyFilesystem || classifyFilesystem;
|
|
450
460
|
|
|
451
461
|
const classification = classify(dbPath, deps);
|
|
452
462
|
const remediation = REMEDIATION[classification.remediationKey] || REMEDIATION.unknown;
|
|
453
463
|
|
|
464
|
+
const warnOnce = message => {
|
|
465
|
+
const key = `${classification.riskTier}|${classification.class}|${dbPath}`;
|
|
466
|
+
if (warnedFilesystemKeys.has(key)) return;
|
|
467
|
+
warnedFilesystemKeys.add(key);
|
|
468
|
+
warn(message);
|
|
469
|
+
};
|
|
470
|
+
|
|
454
471
|
if (classification.riskTier === 'safe') {
|
|
455
472
|
return classification;
|
|
456
473
|
}
|
|
457
474
|
|
|
458
475
|
if (classification.riskTier === 'warn') {
|
|
459
|
-
|
|
476
|
+
warnOnce(`forge kernel filesystem warning (${classification.class}):\n${remediation}`);
|
|
460
477
|
return classification;
|
|
461
478
|
}
|
|
462
479
|
|
|
463
480
|
// refuse
|
|
464
481
|
const overrideActive = isUnsafeFsOverrideActive(env);
|
|
465
482
|
if (overrideActive) {
|
|
466
|
-
|
|
483
|
+
warnOnce(
|
|
467
484
|
`forge kernel filesystem REFUSE downgraded by FORGE_KERNEL_ALLOW_UNSAFE_FS ` +
|
|
468
485
|
`(${classification.class}) — override active:\n${remediation}`,
|
|
469
486
|
);
|
|
@@ -487,9 +504,9 @@ module.exports = {
|
|
|
487
504
|
gatherSignals,
|
|
488
505
|
classifyFilesystem,
|
|
489
506
|
assertFilesystemSafeForKernel,
|
|
507
|
+
resetFilesystemWarningMemo,
|
|
490
508
|
isUnsafeFsOverrideActive,
|
|
491
509
|
defaultProbeDriveType,
|
|
492
510
|
parseNetUseDriveType,
|
|
493
|
-
parseDisplayRoot,
|
|
494
511
|
REMEDIATION,
|
|
495
512
|
};
|
|
@@ -17,6 +17,7 @@ const { buildMemoryProjectionMigration, memoryFtsDdl } = require('./migrations')
|
|
|
17
17
|
const { rankForPriorityLabel } = require('./taxonomy-validator');
|
|
18
18
|
const { isLeaseExpired } = require('./lease-enforcer');
|
|
19
19
|
const { CONFLICT_SIGNAL, classifyConflictSignal } = require('./conflict-signal');
|
|
20
|
+
const { normalizeRecallHit } = require('../memory-recall');
|
|
20
21
|
|
|
21
22
|
const BUILTIN_SQLITE_RUNTIME_ORDER = Object.freeze(['bun:sqlite', 'node:sqlite']);
|
|
22
23
|
let probeCounter = 0;
|
|
@@ -800,6 +801,28 @@ function listKernelEventRows(runtime, db, entityType, entityId) {
|
|
|
800
801
|
);
|
|
801
802
|
}
|
|
802
803
|
|
|
804
|
+
// Bulk activity read (Slice C2, additive + read-only) across ALL entities, newest first,
|
|
805
|
+
// for `forge insights`. `since` is an optional ISO cutoff (created_at >= since); `limit`
|
|
806
|
+
// bounds the row count (default 1000). Imported beads interactions live here as
|
|
807
|
+
// `beads.interaction.<kind>` events, so insights derives interaction patterns from this
|
|
808
|
+
// instead of the retired legacy interactions log. Creates/migrates nothing.
|
|
809
|
+
function listRecentKernelEventRows(runtime, db, since, limit) {
|
|
810
|
+
const params = [];
|
|
811
|
+
let where = '';
|
|
812
|
+
if (since) {
|
|
813
|
+
where = 'WHERE created_at >= ?';
|
|
814
|
+
params.push(since);
|
|
815
|
+
}
|
|
816
|
+
const cap = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : 1000;
|
|
817
|
+
params.push(cap);
|
|
818
|
+
return allParams(
|
|
819
|
+
runtime,
|
|
820
|
+
db,
|
|
821
|
+
`SELECT * FROM kernel_events ${where} ORDER BY created_at DESC LIMIT ?`,
|
|
822
|
+
params,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
803
826
|
// Look up the committed event for an idempotency key (the duplicate-replay probe).
|
|
804
827
|
// The broker calls this unconditionally inside a Promise.all even for keyless
|
|
805
828
|
// events, so guard a falsy key up front rather than binding undefined.
|
|
@@ -2049,6 +2072,17 @@ function buildMemoryFtsMatch(query) {
|
|
|
2049
2072
|
return tokens.map(token => `"${token}"`).join(' AND ');
|
|
2050
2073
|
}
|
|
2051
2074
|
|
|
2075
|
+
// Like buildMemoryFtsMatch but OR-joins the tokens: a natural-language prompt matches a note
|
|
2076
|
+
// containing ANY of its keywords, not EVERY one. Used ONLY by the relevance-only SCORED read
|
|
2077
|
+
// (the per-turn recall hook) — a raw prompt ("why is my forge push taking so long") token-ANDed
|
|
2078
|
+
// required every word in one note and matched nothing (0% recall). Each token is double-quoted
|
|
2079
|
+
// for FTS5 safety (tokens may be non-Latin unicode). Returns '' when the query has no tokens.
|
|
2080
|
+
function buildMemoryFtsMatchOr(query) {
|
|
2081
|
+
const tokens = String(query ?? '').match(/[\p{L}\p{N}]+/gu);
|
|
2082
|
+
if (!tokens || tokens.length === 0) return '';
|
|
2083
|
+
return tokens.map(token => `"${token}"`).join(' OR ');
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2052
2086
|
// BM25 top-N recall over the kernel_memories_fts index (migration 008). Joins the FTS
|
|
2053
2087
|
// rowid back to the memory row and orders by bm25 (lower = better match). An empty/tokenless
|
|
2054
2088
|
// query falls back to recent entries so `recall` never returns a bare full dump.
|
|
@@ -2076,22 +2110,97 @@ function searchMemoryRowsRanked(runtime, db, query, limit) {
|
|
|
2076
2110
|
// Unlike searchMemoryRowsRanked, a no-match (or empty) query returns [] with NO recency
|
|
2077
2111
|
// fallback: the whole point is to avoid surfacing recent-but-irrelevant notes. bm25()
|
|
2078
2112
|
// returns more-negative for stronger matches, so rows come back best (lowest) first.
|
|
2079
|
-
function
|
|
2113
|
+
function confirmedMemorySql(alias) {
|
|
2114
|
+
return `(EXISTS (
|
|
2115
|
+
SELECT 1 FROM json_each(${alias}.tags_json)
|
|
2116
|
+
WHERE lower(json_each.value) = 'trust:confirmed'
|
|
2117
|
+
) OR (${alias}.source_agent = 'forge remember'
|
|
2118
|
+
AND json_type(${alias}.value_json) = 'text'
|
|
2119
|
+
AND NOT EXISTS (
|
|
2120
|
+
SELECT 1 FROM json_each(${alias}.tags_json)
|
|
2121
|
+
WHERE lower(json_each.value) LIKE 'trust:%'
|
|
2122
|
+
OR lower(json_each.value) = 'forge:auto-capture'
|
|
2123
|
+
)))`;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function projectMemoryScopeSql(alias) {
|
|
2127
|
+
return `(${alias}.scope IS NULL OR ${alias}.scope = 'project' OR ${alias}.scope = ?)`;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function suggestedFreshnessCutoff(now) {
|
|
2131
|
+
const timestamp = Date.parse(now || new Date().toISOString());
|
|
2132
|
+
return new Date(timestamp - (7 * 24 * 60 * 60 * 1000)).toISOString();
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
function searchMemoryRowsRankedScored(runtime, db, query, limit, options = {}) {
|
|
2080
2136
|
const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
|
|
2081
|
-
|
|
2137
|
+
// keyword-OR (NOT the token-AND of searchMemoryRowsRanked): this relevance-only read backs
|
|
2138
|
+
// the per-turn recall hook, where a natural-language prompt must match on ANY keyword.
|
|
2139
|
+
const match = buildMemoryFtsMatchOr(query);
|
|
2082
2140
|
if (!match) {
|
|
2083
2141
|
return [];
|
|
2084
2142
|
}
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2143
|
+
const projectId = options.projectId;
|
|
2144
|
+
if (typeof projectId !== 'string' || !projectId) return [];
|
|
2145
|
+
const cutoff = suggestedFreshnessCutoff(options.now);
|
|
2146
|
+
const excludeKeys = Array.isArray(options.excludeKeys)
|
|
2147
|
+
? [...new Set(options.excludeKeys.filter(key => typeof key === 'string'))].slice(0, 256)
|
|
2148
|
+
: [];
|
|
2149
|
+
const seenSql = excludeKeys.length > 0
|
|
2150
|
+
? `AND m.key NOT IN (${excludeKeys.map(() => '?').join(', ')})`
|
|
2151
|
+
: '';
|
|
2152
|
+
const confirmed = confirmedMemorySql('m');
|
|
2153
|
+
const supersederConfirmed = confirmedMemorySql('s');
|
|
2154
|
+
// Expand eligible supersession edges once. A correlated json_each scan repeated the
|
|
2155
|
+
// entire memory table for every FTS candidate and dominated the 1,000-row prompt path.
|
|
2156
|
+
// Recall temporarily waits less than the connection default so a real lock cannot outlive
|
|
2157
|
+
// the prompt hook; the finally block restores the caller's normal connection behavior.
|
|
2158
|
+
const previousBusyTimeout = Number(queryOne(runtime, db, 'PRAGMA busy_timeout;').timeout) || 0;
|
|
2159
|
+
const requestedBusyTimeout = Number(options.busyTimeoutMs);
|
|
2160
|
+
const busyTimeout = Number.isFinite(requestedBusyTimeout) && requestedBusyTimeout >= 0
|
|
2161
|
+
? Math.floor(requestedBusyTimeout)
|
|
2162
|
+
: Math.min(previousBusyTimeout, 2_500);
|
|
2163
|
+
if (busyTimeout !== previousBusyTimeout) {
|
|
2164
|
+
execSql(runtime, db, `PRAGMA busy_timeout=${busyTimeout};`);
|
|
2165
|
+
}
|
|
2166
|
+
try {
|
|
2167
|
+
return allParams(
|
|
2168
|
+
runtime,
|
|
2169
|
+
db,
|
|
2170
|
+
`WITH eligible_superseders AS MATERIALIZED (
|
|
2171
|
+
SELECT superseded.value AS memory_key,
|
|
2172
|
+
CASE WHEN ${supersederConfirmed} THEN 1 ELSE 0 END AS is_confirmed
|
|
2173
|
+
FROM kernel_memories s,
|
|
2174
|
+
json_each(COALESCE(s.supersedes_json, '[]')) superseded
|
|
2175
|
+
WHERE ${projectMemoryScopeSql('s')}
|
|
2176
|
+
AND (${supersederConfirmed} OR s.updated_at >= ?)
|
|
2177
|
+
)
|
|
2178
|
+
SELECT m.*, bm25(kernel_memories_fts) AS __score FROM kernel_memories m
|
|
2089
2179
|
JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
|
|
2090
2180
|
WHERE kernel_memories_fts MATCH ?
|
|
2091
|
-
|
|
2181
|
+
AND ${projectMemoryScopeSql('m')}
|
|
2182
|
+
AND (${confirmed} OR m.updated_at >= ?)
|
|
2183
|
+
${seenSql}
|
|
2184
|
+
AND NOT EXISTS (
|
|
2185
|
+
SELECT 1
|
|
2186
|
+
FROM eligible_superseders superseder
|
|
2187
|
+
WHERE superseder.memory_key = m.key
|
|
2188
|
+
AND (superseder.is_confirmed = 1 OR NOT ${confirmed})
|
|
2189
|
+
)
|
|
2190
|
+
ORDER BY bm25(kernel_memories_fts),
|
|
2191
|
+
CASE WHEN ${confirmed} THEN 0 ELSE 1 END,
|
|
2192
|
+
m.source_agent ASC, m.updated_at DESC, m.key ASC
|
|
2092
2193
|
LIMIT ?`,
|
|
2093
|
-
|
|
2094
|
-
|
|
2194
|
+
[projectId, cutoff, match, projectId, cutoff, ...excludeKeys, capped],
|
|
2195
|
+
).map(row => normalizeRecallHit(
|
|
2196
|
+
{ ...memoryRowToEntry(row), score: row.__score },
|
|
2197
|
+
projectId,
|
|
2198
|
+
));
|
|
2199
|
+
} finally {
|
|
2200
|
+
if (busyTimeout !== previousBusyTimeout) {
|
|
2201
|
+
execSql(runtime, db, `PRAGMA busy_timeout=${previousBusyTimeout};`);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2095
2204
|
}
|
|
2096
2205
|
|
|
2097
2206
|
function closeDatabase(db) {
|
|
@@ -2109,12 +2218,17 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2109
2218
|
// synchronous project-memory facade writes WITHOUT first running migrations. Lazily
|
|
2110
2219
|
// ensure the table (idempotent CREATE IF NOT EXISTS, rendered from the same migration)
|
|
2111
2220
|
// plus a busy_timeout for the second connection the issue backend may hold open.
|
|
2112
|
-
function ensureMemorySchema(database) {
|
|
2221
|
+
function ensureMemorySchema(database, busyTimeoutMs) {
|
|
2113
2222
|
if (memorySchemaEnsured) return;
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2223
|
+
const requestedBusyTimeout = Number(busyTimeoutMs);
|
|
2224
|
+
const busyTimeout = Number.isFinite(requestedBusyTimeout) && requestedBusyTimeout >= 0
|
|
2225
|
+
? Math.floor(requestedBusyTimeout)
|
|
2226
|
+
: 5_000;
|
|
2227
|
+
execSql(runtime, database, `PRAGMA busy_timeout=${busyTimeout};`);
|
|
2228
|
+
try {
|
|
2229
|
+
for (const statement of buildMemoryProjectionMigration().apply) {
|
|
2230
|
+
execSql(runtime, database, statement);
|
|
2231
|
+
}
|
|
2118
2232
|
// FTS5 recall index (migration 008): create the virtual table + sync triggers
|
|
2119
2233
|
// idempotently so a synchronous memory write stays indexed without a prior
|
|
2120
2234
|
// broker.initialize(). When the index is NEWLY created, rebuild once to backfill any
|
|
@@ -2125,20 +2239,25 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2125
2239
|
// Staleness is detected by TABLE EXISTENCE (sqlite_master), never by count(*): on an
|
|
2126
2240
|
// external-content FTS5 table `count(*)` returns the CONTENT row count, not the
|
|
2127
2241
|
// indexed-doc count, so it can never reveal an un-backfilled index.
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2242
|
+
const ftsDdl = memoryFtsDdl();
|
|
2243
|
+
const ftsExisted = Number(queryOne(
|
|
2244
|
+
runtime,
|
|
2245
|
+
database,
|
|
2246
|
+
"SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'kernel_memories_fts'",
|
|
2247
|
+
).count) > 0;
|
|
2248
|
+
execSql(runtime, database, ftsDdl.create);
|
|
2249
|
+
for (const trigger of ftsDdl.triggers) {
|
|
2250
|
+
execSql(runtime, database, trigger);
|
|
2251
|
+
}
|
|
2252
|
+
if (!ftsExisted) {
|
|
2253
|
+
execSql(runtime, database, ftsDdl.rebuild);
|
|
2254
|
+
}
|
|
2255
|
+
memorySchemaEnsured = true;
|
|
2256
|
+
} finally {
|
|
2257
|
+
if (busyTimeout !== 5_000) {
|
|
2258
|
+
execSql(runtime, database, 'PRAGMA busy_timeout=5000;');
|
|
2259
|
+
}
|
|
2140
2260
|
}
|
|
2141
|
-
memorySchemaEnsured = true;
|
|
2142
2261
|
}
|
|
2143
2262
|
|
|
2144
2263
|
function resolveDatabasePath(config) {
|
|
@@ -2335,6 +2454,11 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2335
2454
|
async listKernelEvents(entityType, entityId, _context = {}, config = {}) {
|
|
2336
2455
|
return listKernelEventRows(runtime, getDatabase(config), entityType, entityId);
|
|
2337
2456
|
},
|
|
2457
|
+
// Additive bulk read for `forge insights` (Slice C2). Read-only; NOT part of the
|
|
2458
|
+
// GUARDED_DRIVER_METHODS write-path contract, so existing driver stubs stay valid.
|
|
2459
|
+
async listRecentKernelEvents({ since = null, limit = null } = {}, _context = {}, config = {}) {
|
|
2460
|
+
return listRecentKernelEventRows(runtime, getDatabase(config), since, limit);
|
|
2461
|
+
},
|
|
2338
2462
|
async loadKernelEventByIdempotencyKey(idempotencyKey, _context = {}, config = {}) {
|
|
2339
2463
|
return loadKernelEventByIdempotencyKeyRow(runtime, getDatabase(config), idempotencyKey);
|
|
2340
2464
|
},
|
|
@@ -2460,8 +2584,8 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2460
2584
|
// fallback). Used by the per-turn memory-recall hook.
|
|
2461
2585
|
searchMemoriesRankedScored(query, limit, config = {}) {
|
|
2462
2586
|
const database = getDatabase(config);
|
|
2463
|
-
ensureMemorySchema(database);
|
|
2464
|
-
return searchMemoryRowsRankedScored(runtime, database, query, limit);
|
|
2587
|
+
ensureMemorySchema(database, config.busyTimeoutMs);
|
|
2588
|
+
return searchMemoryRowsRankedScored(runtime, database, query, limit, config);
|
|
2465
2589
|
},
|
|
2466
2590
|
// The newest `limit` entries (default recall with no query). `options.agents` scopes
|
|
2467
2591
|
// the read to a source_agent allow-list (e.g. human `remember` notes only).
|
package/lib/lefthook-wiring.js
CHANGED
|
@@ -55,6 +55,20 @@ pre-push:
|
|
|
55
55
|
run: npm test --if-present
|
|
56
56
|
`;
|
|
57
57
|
|
|
58
|
+
// The same config MINUS the TDD pre-commit job, written when the project already has its own
|
|
59
|
+
// pre-commit TDD/coupling gate (kernel 5b425a85 / 2699b234). Stacking a second gate on the same
|
|
60
|
+
// commit is the reported bug, so Forge defers on pre-commit but still wires pre-push.
|
|
61
|
+
const FORGE_USER_LEFTHOOK_YML_NO_TDD = `# Forge git hooks — installed by \`forge setup\` / \`forge init\`.
|
|
62
|
+
# Your project already has its own pre-commit TDD/coupling gate, so Forge did NOT add a second
|
|
63
|
+
# one here. To use Forge's gate instead, remove yours and run: forge gate enable rail.tdd_intent
|
|
64
|
+
# Edit freely to add your own checks — Forge only replaces a fully-commented stub.
|
|
65
|
+
|
|
66
|
+
pre-push:
|
|
67
|
+
commands:
|
|
68
|
+
tests:
|
|
69
|
+
run: npm test --if-present
|
|
70
|
+
`;
|
|
71
|
+
|
|
58
72
|
/**
|
|
59
73
|
* A fresh `lefthook install` (run by lefthook's own npm postinstall) drops a stock
|
|
60
74
|
* EXAMPLE lefthook.yml with every hook commented out. That disposable stub used to
|
|
@@ -221,10 +235,13 @@ npm test --if-present || exit 1
|
|
|
221
235
|
* `skipped`) rather than destroyed.
|
|
222
236
|
*
|
|
223
237
|
* @param {string} projectRoot - Absolute path to the project root.
|
|
238
|
+
* @param {{ skipHooks?: string[] }} [options] - Hook names to leave alone entirely. `forge setup`
|
|
239
|
+
* passes `['pre-commit']` when the project already has its own pre-commit TDD/coupling gate,
|
|
240
|
+
* so Forge defers instead of stacking a second one (kernel 5b425a85 / 2699b234).
|
|
224
241
|
* @returns {{ installed: boolean, method?: string, hooksDir?: string,
|
|
225
242
|
* written?: string[], skipped?: string[], reason?: string }}
|
|
226
243
|
*/
|
|
227
|
-
function installNativeGitHooks(projectRoot) {
|
|
244
|
+
function installNativeGitHooks(projectRoot, options = {}) {
|
|
228
245
|
const hooksDir = resolveGitHooksDir(projectRoot);
|
|
229
246
|
if (!hooksDir) {
|
|
230
247
|
return { installed: false, reason: 'not-a-git-repo' };
|
|
@@ -235,9 +252,11 @@ function installNativeGitHooks(projectRoot) {
|
|
|
235
252
|
return { installed: false, reason: `hooks-dir-unwritable: ${error.message}` };
|
|
236
253
|
}
|
|
237
254
|
|
|
255
|
+
const skipHooks = new Set(Array.isArray(options.skipHooks) ? options.skipHooks : []);
|
|
238
256
|
const written = [];
|
|
239
257
|
const skipped = [];
|
|
240
258
|
for (const [name, body] of Object.entries(NATIVE_HOOK_BODIES)) {
|
|
259
|
+
if (skipHooks.has(name)) continue;
|
|
241
260
|
const outcome = writeNativeHook(path.join(hooksDir, name), body);
|
|
242
261
|
(outcome === 'written' ? written : skipped).push(name);
|
|
243
262
|
}
|
|
@@ -406,6 +425,7 @@ function verifyHooksActive(projectRoot) {
|
|
|
406
425
|
module.exports = {
|
|
407
426
|
FORGE_NATIVE_HOOK_SENTINEL,
|
|
408
427
|
FORGE_USER_LEFTHOOK_YML,
|
|
428
|
+
FORGE_USER_LEFTHOOK_YML_NO_TDD,
|
|
409
429
|
forgeShouldWriteLefthookConfig,
|
|
410
430
|
resolveGitHooksDir,
|
|
411
431
|
installNativeGitHooks,
|