forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const ACCOUNT_KEY = 'github.account';
|
|
6
|
+
const HOST = 'github.com';
|
|
7
|
+
const LOGIN_RE = /^(?=.{1,39}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
|
|
8
|
+
const GITHUB_ENV_NAMES = new Set(['gh_token', 'github_token', 'gh_host', 'gh_repo']);
|
|
9
|
+
const GITHUB_SECRET_ENV_NAMES = new Set(['gh_token', 'github_token']);
|
|
10
|
+
|
|
11
|
+
class GithubContextError extends Error {
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'GithubContextError';
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
toJSON() {
|
|
19
|
+
return { code: this.code, message: this.message };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateGithubLogin(login) {
|
|
24
|
+
return typeof login === 'string' && LOGIN_RE.test(login);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeLogin(login) {
|
|
28
|
+
return validateGithubLogin(login) ? login : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function runnerFor(options) {
|
|
32
|
+
return options.runner || options._runner || options._exec || options.execFileSync || defaultRunner;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultRunner(command, args, options = {}) {
|
|
36
|
+
return execFileSync(command, args, {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
39
|
+
windowsHide: true,
|
|
40
|
+
...options,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function errorText(error) {
|
|
45
|
+
return [error?.message, error?.stderr?.toString?.(), error?.stdout?.toString?.()]
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.join(' ')
|
|
48
|
+
.toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isUnsupportedTokenCapability(error) {
|
|
52
|
+
const text = errorText(error);
|
|
53
|
+
return /unknown (?:flag|option)|invalid (?:flag|option)|unexpected argument|does not support/.test(text)
|
|
54
|
+
&& /user|hostname/.test(text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeCommandFailure(code, message) {
|
|
58
|
+
return new GithubContextError(code, message);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function safeGithubCommandFailure(error) {
|
|
62
|
+
const text = errorText(error);
|
|
63
|
+
const failure = safeCommandFailure(
|
|
64
|
+
'GITHUB_COMMAND_FAILED',
|
|
65
|
+
/rate limit|secondary rate/.test(text)
|
|
66
|
+
? 'The GitHub CLI command was rate limited.'
|
|
67
|
+
: 'The GitHub CLI command failed without exposing its output.',
|
|
68
|
+
);
|
|
69
|
+
const matchedStatus = /\bhttp\s+(401|403|429)\b/.exec(text)?.[1];
|
|
70
|
+
let httpStatus;
|
|
71
|
+
if ([401, 403, 429].includes(error?.httpStatus)) httpStatus = error.httpStatus;
|
|
72
|
+
else if ([401, 403, 429].includes(error?.status)) httpStatus = error.status;
|
|
73
|
+
else httpStatus = Number(matchedStatus) || undefined;
|
|
74
|
+
const retryAfter = Number(error?.retryAfter);
|
|
75
|
+
if (httpStatus) failure.httpStatus = httpStatus;
|
|
76
|
+
if (Number.isFinite(retryAfter) && retryAfter > 0) failure.retryAfter = retryAfter;
|
|
77
|
+
return failure;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function gitConfig(projectRoot, args, options, absentIsOkay = false) {
|
|
81
|
+
try {
|
|
82
|
+
return runnerFor(options)('git', ['config', '--local', ...args], {
|
|
83
|
+
cwd: projectRoot,
|
|
84
|
+
encoding: 'utf8',
|
|
85
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (absentIsOkay && (error?.status === 1 || /key does not contain|not found|no such key/i.test(errorText(error)))) {
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
throw safeCommandFailure('GIT_CONFIG_FAILED', 'Unable to read this clone-local GitHub account binding.');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readGithubAccount(projectRoot, options = {}) {
|
|
97
|
+
const output = gitConfig(projectRoot, ['--get', ACCOUNT_KEY], options, true);
|
|
98
|
+
const value = output == null ? '' : output.toString().trim();
|
|
99
|
+
return value || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function writeGithubAccount(projectRoot, login, options = {}) {
|
|
103
|
+
if (!validateGithubLogin(login)) {
|
|
104
|
+
throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'GitHub login must be 1-39 letters, numbers, or internal hyphens.');
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
runnerFor(options)('git', ['config', '--local', '--replace-all', ACCOUNT_KEY, login], {
|
|
108
|
+
cwd: projectRoot,
|
|
109
|
+
encoding: 'utf8',
|
|
110
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
111
|
+
windowsHide: true,
|
|
112
|
+
});
|
|
113
|
+
} catch (_error) {
|
|
114
|
+
throw safeCommandFailure('GIT_CONFIG_FAILED', 'Unable to write the clone-local GitHub account binding.');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function unsetGithubAccount(projectRoot, options = {}) {
|
|
119
|
+
try {
|
|
120
|
+
runnerFor(options)('git', ['config', '--local', '--unset-all', ACCOUNT_KEY], {
|
|
121
|
+
cwd: projectRoot,
|
|
122
|
+
encoding: 'utf8',
|
|
123
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
124
|
+
windowsHide: true,
|
|
125
|
+
});
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error?.status === 5 || /key does not contain|not found|no such key/i.test(errorText(error))) return;
|
|
128
|
+
throw safeCommandFailure('GIT_CONFIG_FAILED', 'Unable to remove the clone-local GitHub account binding.');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function withoutGithubEnvironment(environment) {
|
|
133
|
+
const result = {};
|
|
134
|
+
for (const [key, value] of Object.entries(environment || {})) {
|
|
135
|
+
if (!GITHUB_ENV_NAMES.has(key.toLowerCase())) result[key] = value;
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function selectedEnvironment(baseEnv, token, additions = {}) {
|
|
141
|
+
const result = withoutGithubEnvironment(baseEnv);
|
|
142
|
+
Object.assign(result, withoutGithubEnvironment(additions));
|
|
143
|
+
result.GH_TOKEN = token;
|
|
144
|
+
result.GITHUB_TOKEN = token;
|
|
145
|
+
result.GH_HOST = HOST;
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function passthroughEnvironment(baseEnv, additions = {}) {
|
|
150
|
+
return { ...baseEnv, ...additions };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function retrieveGithubToken(login, options) {
|
|
154
|
+
if (!validateGithubLogin(login)) {
|
|
155
|
+
throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'GitHub login must be 1-39 letters, numbers, or internal hyphens.');
|
|
156
|
+
}
|
|
157
|
+
const baseEnv = options.baseEnv || options.env || process.env;
|
|
158
|
+
let output;
|
|
159
|
+
try {
|
|
160
|
+
output = runnerFor(options)('gh', ['auth', 'token', '--hostname', HOST, '--user', login], {
|
|
161
|
+
env: withoutGithubEnvironment(baseEnv),
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
164
|
+
windowsHide: true,
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (isUnsupportedTokenCapability(error)) {
|
|
168
|
+
throw safeCommandFailure('GITHUB_CLI_UNSUPPORTED', 'Upgrade GitHub CLI, then authenticate the account with gh auth login.');
|
|
169
|
+
}
|
|
170
|
+
throw safeCommandFailure('GITHUB_NOT_AUTHENTICATED', 'GitHub account is not authenticated; run gh auth login for this account.');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const token = output == null ? '' : output.toString().trim();
|
|
174
|
+
if (!token) {
|
|
175
|
+
throw safeCommandFailure('GITHUB_NOT_AUTHENTICATED', 'GitHub account is not authenticated; run gh auth login for this account.');
|
|
176
|
+
}
|
|
177
|
+
return token;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function loginFromOutput(output) {
|
|
181
|
+
const text = output == null ? '' : output.toString().trim();
|
|
182
|
+
if (!text) return null;
|
|
183
|
+
try {
|
|
184
|
+
const parsed = JSON.parse(text);
|
|
185
|
+
return safeLogin(parsed?.login);
|
|
186
|
+
} catch {
|
|
187
|
+
// `gh --jq` normally returns plain text; JSON is only a compatibility path.
|
|
188
|
+
return safeLogin(text.split(/\r?\n/, 1)[0].trim());
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function resolveGithubLogin(token, baseEnv, options) {
|
|
193
|
+
try {
|
|
194
|
+
const output = runnerFor(options)('gh', ['api', '--hostname', HOST, 'user', '--jq', '.login'], {
|
|
195
|
+
env: selectedEnvironment(baseEnv, token),
|
|
196
|
+
encoding: 'utf8',
|
|
197
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
198
|
+
windowsHide: true,
|
|
199
|
+
});
|
|
200
|
+
const login = loginFromOutput(output);
|
|
201
|
+
if (!login) throw new Error('missing login');
|
|
202
|
+
return login;
|
|
203
|
+
} catch {
|
|
204
|
+
// Provider errors may contain response bodies or credentials; expose only a safe diagnosis.
|
|
205
|
+
throw safeCommandFailure('GITHUB_AUTH_FAILED', 'Unable to verify the selected GitHub account; run gh auth login and try again.');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function prepareGithubAccount(_projectRoot, account, options = {}) {
|
|
210
|
+
if (!validateGithubLogin(account)) {
|
|
211
|
+
throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'GitHub login must be 1-39 letters, numbers, or internal hyphens.');
|
|
212
|
+
}
|
|
213
|
+
const baseEnv = { ...(options.baseEnv || options.env || process.env) };
|
|
214
|
+
const token = retrieveGithubToken(account, { ...options, baseEnv });
|
|
215
|
+
const login = resolveGithubLogin(token, baseEnv, options);
|
|
216
|
+
if (login.toLowerCase() !== account.toLowerCase()) {
|
|
217
|
+
throw safeCommandFailure('GITHUB_ACCOUNT_MISMATCH', `GitHub account mismatch: expected ${account}, received ${login}.`);
|
|
218
|
+
}
|
|
219
|
+
return createPublicContext(publicStatus('ready', account, login), token, baseEnv, options);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function publicStatus(state, account, login) {
|
|
223
|
+
return Object.freeze({ state, account: account || null, login: login || null });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function createPublicContext(status, token, baseEnv, options) {
|
|
227
|
+
const context = {
|
|
228
|
+
status,
|
|
229
|
+
bound: status.state !== 'unbound',
|
|
230
|
+
account: status.account,
|
|
231
|
+
login: status.login,
|
|
232
|
+
};
|
|
233
|
+
const secrets = (status.state === 'unbound'
|
|
234
|
+
? Object.entries(baseEnv)
|
|
235
|
+
.filter(([key]) => GITHUB_SECRET_ENV_NAMES.has(key.toLowerCase()))
|
|
236
|
+
.map(([, value]) => value)
|
|
237
|
+
: [token])
|
|
238
|
+
.filter((value) => typeof value === 'string' && value);
|
|
239
|
+
const environmentForChild = (additions = {}) => status.state === 'unbound'
|
|
240
|
+
? passthroughEnvironment(baseEnv, additions)
|
|
241
|
+
: selectedEnvironment(baseEnv, token, additions);
|
|
242
|
+
const childRunner = options.childRunner || options._childRunner || runnerFor(options);
|
|
243
|
+
const runChild = (command, args, runOptions = {}) => {
|
|
244
|
+
if (typeof command !== 'string' || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
|
|
245
|
+
throw safeCommandFailure('INVALID_GH_ARGUMENTS', 'GitHub CLI arguments must be an array of strings.');
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
return childRunner(command, args, {
|
|
249
|
+
...runOptions,
|
|
250
|
+
env: environmentForChild(runOptions.env),
|
|
251
|
+
});
|
|
252
|
+
} catch (_error) {
|
|
253
|
+
throw safeCommandFailure('GITHUB_COMMAND_FAILED', 'The GitHub CLI command failed without exposing its output.');
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const runGh = (args, runOptions = {}) => {
|
|
257
|
+
if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
|
|
258
|
+
throw safeCommandFailure('INVALID_GH_ARGUMENTS', 'GitHub CLI arguments must be an array of strings.');
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const output = runnerFor(options)('gh', args, {
|
|
262
|
+
...runOptions,
|
|
263
|
+
env: environmentForChild(runOptions.env),
|
|
264
|
+
encoding: 'utf8',
|
|
265
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
266
|
+
windowsHide: true,
|
|
267
|
+
});
|
|
268
|
+
return redactTokens(output, secrets);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
throw safeGithubCommandFailure(error);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
Object.defineProperties(context, {
|
|
274
|
+
runChild: { value: runChild, enumerable: false },
|
|
275
|
+
runGh: { value: runGh, enumerable: false },
|
|
276
|
+
getStatus: { value: () => ({ ...status }), enumerable: false },
|
|
277
|
+
});
|
|
278
|
+
return context;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function redactTokens(value, tokens) {
|
|
282
|
+
if (value == null) return value;
|
|
283
|
+
return tokens.slice().sort((left, right) => right.length - left.length)
|
|
284
|
+
.reduce((redacted, token) => redacted.split(token).join('[REDACTED]'), value.toString());
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function createGithubContext(projectRoot, options = {}) {
|
|
288
|
+
const baseEnv = { ...(options.baseEnv || options.env || process.env) };
|
|
289
|
+
const account = readGithubAccount(projectRoot, options);
|
|
290
|
+
if (!account) return createPublicContext(publicStatus('unbound', null, null), null, baseEnv, options);
|
|
291
|
+
if (!validateGithubLogin(account)) {
|
|
292
|
+
throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'The clone-local github.account value is not a valid GitHub login.');
|
|
293
|
+
}
|
|
294
|
+
return prepareGithubAccount(projectRoot, account, { ...options, baseEnv });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = {
|
|
298
|
+
ACCOUNT_KEY,
|
|
299
|
+
GithubContextError,
|
|
300
|
+
createGithubContext,
|
|
301
|
+
prepareGithubAccount,
|
|
302
|
+
prepareGithubContext: createGithubContext,
|
|
303
|
+
readGithubAccount,
|
|
304
|
+
resolveGithubLogin,
|
|
305
|
+
unsetGithubAccount,
|
|
306
|
+
validateGithubLogin,
|
|
307
|
+
writeGithubAccount,
|
|
308
|
+
};
|
package/lib/global-flags.js
CHANGED
|
@@ -13,7 +13,7 @@ const CAPABILITY_IDS = [
|
|
|
13
13
|
'commands',
|
|
14
14
|
'agents',
|
|
15
15
|
'stages',
|
|
16
|
-
'
|
|
16
|
+
'issueState',
|
|
17
17
|
'typedMemory',
|
|
18
18
|
'patchOverrides',
|
|
19
19
|
'marketplaceTrust',
|
|
@@ -157,7 +157,7 @@ const CAPABILITY_ROWS = [
|
|
|
157
157
|
['codex', target('skill-first', '$CODEX_HOME/skills/<stage>/SKILL.md', { role: 'generated at setup from canonical skills/', evidence: ['S5'], knownIssue: 'Repo-local Codex discovery uses .agents/skills/<stage>/SKILL.md, the committed mirror (generated from skills/, kept in sync by a pre-commit hook + drift gate) (kernel issue 55dfeccf).' })],
|
|
158
158
|
['hermes', target('forge-owned', '.hermes/skills/<stage>/SKILL.md', { role: 'Forge-owned stage-skill projection consumed via forge orient/recap' })],
|
|
159
159
|
]),
|
|
160
|
-
row('
|
|
160
|
+
row('issueState', 'state-and-memory', 'issue and audit state authority', HARNESS_IDS.map(id => [id, target('forge-owned', 'forge CLI + Forge Kernel store')])),
|
|
161
161
|
row('typedMemory', 'state-and-memory', 'typed memory projection', [
|
|
162
162
|
['claude', target('not-delivered', null, { knownIssue: 'Typed memory is WRITE-ONLY (insights.js); no generator projects a memory section into any Claude instruction/rule file. Tracked as kernel issue dce9da46 (epic 90f2f631).' })],
|
|
163
163
|
['cursor', target('not-delivered', null, { knownIssue: 'Typed memory is write-only; no memory-projection renderer emits a Cursor memory section. Tracked as kernel issue dce9da46 (epic 90f2f631).' })],
|
|
@@ -312,7 +312,7 @@ const RENDERER_FAMILIES = [
|
|
|
312
312
|
['commands', 'Forge command shim manifest', ['shim points to canonical skill', 'no duplicated stage body']],
|
|
313
313
|
['agents', 'Forge agent role spec', ['role mapping', 'parallelism or fallback policy']],
|
|
314
314
|
['stage-graph', 'Forge skills-first stage graph', ['super skill target', 'subskill target list', 'gate mapping']],
|
|
315
|
-
['state-and-memory', '
|
|
315
|
+
['state-and-memory', 'Kernel issue state, typed memory, and patch override manifests', ['state authority', 'projection provenance', 'protected path policy']],
|
|
316
316
|
['distribution', 'Forge extension manifest and lock metadata', ['lock hash', 'trusted source', 'generated target inventory']],
|
|
317
317
|
['safety', 'Forge safety-surface manifest (permissions, ignore, sandbox)', ['permission scope (allow/deny/ask)', 'read/index ignore boundary', 'sandbox/approval policy or global-scope deferral note']],
|
|
318
318
|
];
|
package/lib/hook-renderer.js
CHANGED
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
* the protected-path set, and delegates the TDD gate to the real `check-tdd.js`.
|
|
34
34
|
*
|
|
35
35
|
* Dependency-free (JSON only; no TOML lib) so it runs under `bun test` and the
|
|
36
|
-
* release gates.
|
|
36
|
+
* release gates.
|
|
37
37
|
*
|
|
38
38
|
* @module hook-renderer
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
const fs = require('node:fs');
|
|
42
42
|
const path = require('node:path');
|
|
43
|
-
const {
|
|
43
|
+
const { assertNoAncestorSymlinkEscape, assertNoSymlinkEscape } = require('./protected-state-surfaces');
|
|
44
44
|
|
|
45
45
|
const HARNESS_HOOK_FILES = {
|
|
46
46
|
claude: '.claude/settings.json',
|
|
@@ -83,6 +83,7 @@ const FORGE_SHEPHERD_EVENTS_MARKER = 'hooks shepherd-events';
|
|
|
83
83
|
// The memory-recall context hook (UserPromptSubmit tier). Its own idempotency marker so a
|
|
84
84
|
// re-render recognizes and REPLACES the Forge-owned entry in place rather than duplicating it.
|
|
85
85
|
const FORGE_MEMORY_RECALL_MARKER = 'hooks memory-recall';
|
|
86
|
+
const FORGE_READ_ATTENTION_MARKER = 'hooks read-attention';
|
|
86
87
|
// The capture-on-exit context hook (PreCompact + Stop tier). A THIRD context marker so a
|
|
87
88
|
// re-merge recognizes + replaces the Forge-owned PreCompact/Stop entries in place. Both
|
|
88
89
|
// events share this one marker (they differ only by a --trigger suffix on the command).
|
|
@@ -134,6 +135,13 @@ const USER_PROMPT_SUBMIT_SUPPORT = Object.freeze({
|
|
|
134
135
|
hermes: Object.freeze({ rendered: false, reason: 'global-config' }),
|
|
135
136
|
});
|
|
136
137
|
|
|
138
|
+
const READ_ATTENTION_SUPPORT = Object.freeze({
|
|
139
|
+
claude: Object.freeze({ rendered: true }),
|
|
140
|
+
cursor: Object.freeze({ rendered: false, reason: 'no-read-context-surface' }),
|
|
141
|
+
codex: Object.freeze({ rendered: false, reason: 'global-config' }),
|
|
142
|
+
hermes: Object.freeze({ rendered: false, reason: 'global-config' }),
|
|
143
|
+
});
|
|
144
|
+
|
|
137
145
|
// Per-harness session-END (capture-on-exit) capability. Only Claude exposes native
|
|
138
146
|
// PreCompact + Stop hook surfaces where Forge can snapshot session learnings BEFORE
|
|
139
147
|
// context is compacted or the session ends. Cursor 1.7 hooks are deny-oriented with no
|
|
@@ -161,7 +169,7 @@ function adapterInvocation(harness) {
|
|
|
161
169
|
* (per-commit) so the rendered Claude PreToolUse groups read write-guard first.
|
|
162
170
|
*/
|
|
163
171
|
const FORGE_HOOK_CONTRACT = Object.freeze({
|
|
164
|
-
schemaVersion: '1.
|
|
172
|
+
schemaVersion: '1.3.0',
|
|
165
173
|
kind: 'forge.hookContract',
|
|
166
174
|
adapter: FORGE_HOOK_ADAPTER,
|
|
167
175
|
intents: Object.freeze([
|
|
@@ -215,6 +223,14 @@ const FORGE_HOOK_CONTRACT = Object.freeze({
|
|
|
215
223
|
lifecycle: 'user-prompt-submit',
|
|
216
224
|
command: `${FORGE_CLI} hooks memory-recall`,
|
|
217
225
|
}),
|
|
226
|
+
Object.freeze({
|
|
227
|
+
id: 'read-attention',
|
|
228
|
+
kind: 'context',
|
|
229
|
+
cliAction: 'read-attention',
|
|
230
|
+
enforces: 'Read attention: on supported PreToolUse:Read surfaces, inject only memory explicitly scoped to the path being opened. Content is fenced as untrusted, capped, additive, fail-open, and never mutates authority.',
|
|
231
|
+
lifecycle: 'pre-tool-read',
|
|
232
|
+
command: `${FORGE_CLI} hooks read-attention`,
|
|
233
|
+
}),
|
|
218
234
|
Object.freeze({
|
|
219
235
|
id: 'shepherd-events',
|
|
220
236
|
kind: 'context',
|
|
@@ -294,6 +310,10 @@ function userPromptSubmitCapability(harness) {
|
|
|
294
310
|
return USER_PROMPT_SUBMIT_SUPPORT[harness] || { rendered: false, reason: 'unknown-harness' };
|
|
295
311
|
}
|
|
296
312
|
|
|
313
|
+
function readAttentionCapability(harness) {
|
|
314
|
+
return READ_ATTENTION_SUPPORT[harness] || { rendered: false, reason: 'unknown-harness' };
|
|
315
|
+
}
|
|
316
|
+
|
|
297
317
|
/**
|
|
298
318
|
* Report the per-harness session-END (capture-on-exit) capability (the honest matrix for
|
|
299
319
|
* the PreCompact + Stop capture tier).
|
|
@@ -321,6 +341,10 @@ function renderClaudeHooks(contract) {
|
|
|
321
341
|
matcher: 'Bash',
|
|
322
342
|
hooks: [{ type: 'command', command: harnessCommand(contract, 'tdd-gate', 'claude') }],
|
|
323
343
|
},
|
|
344
|
+
{
|
|
345
|
+
matcher: 'Read',
|
|
346
|
+
hooks: [{ type: 'command', command: harnessCommand(contract, 'read-attention', 'claude') }],
|
|
347
|
+
},
|
|
324
348
|
],
|
|
325
349
|
// SessionStart context injection (memory push). No matcher → applies to every
|
|
326
350
|
// session source; the command emits { hookSpecificOutput.additionalContext }.
|
|
@@ -438,6 +462,7 @@ function isForgeCommand(command) {
|
|
|
438
462
|
|| command.includes(FORGE_INBOX_CONTEXT_MARKER)
|
|
439
463
|
|| command.includes(FORGE_SHEPHERD_EVENTS_MARKER)
|
|
440
464
|
|| command.includes(FORGE_MEMORY_RECALL_MARKER)
|
|
465
|
+
|| command.includes(FORGE_READ_ATTENTION_MARKER)
|
|
441
466
|
|| command.includes(FORGE_CAPTURE_CONTEXT_MARKER));
|
|
442
467
|
}
|
|
443
468
|
|
|
@@ -447,10 +472,30 @@ function isForgeClaudeGroup(group) {
|
|
|
447
472
|
return hooks.some(h => isForgeCommand(h?.command));
|
|
448
473
|
}
|
|
449
474
|
|
|
475
|
+
function withoutForgeCommands(group) {
|
|
476
|
+
const hooks = Array.isArray(group?.hooks) ? group.hooks : [];
|
|
477
|
+
const userHooks = hooks.filter(hook => !isForgeCommand(hook?.command));
|
|
478
|
+
if (userHooks.length === hooks.length) return group;
|
|
479
|
+
if (userHooks.length === 0) return null;
|
|
480
|
+
return { ...group, hooks: userHooks };
|
|
481
|
+
}
|
|
482
|
+
|
|
450
483
|
function isForgeCursorEntry(entry) {
|
|
451
484
|
return isForgeCommand(entry?.command);
|
|
452
485
|
}
|
|
453
486
|
|
|
487
|
+
function groupContains(actual, expected) {
|
|
488
|
+
if ((actual?.matcher || '') !== (expected?.matcher || '')) return false;
|
|
489
|
+
const actualHooks = Array.isArray(actual?.hooks) ? actual.hooks : [];
|
|
490
|
+
const expectedHooks = Array.isArray(expected?.hooks) ? expected.hooks : [];
|
|
491
|
+
const forgeHooks = actualHooks.filter(hook => isForgeCommand(hook?.command));
|
|
492
|
+
return forgeHooks.length === expectedHooks.length
|
|
493
|
+
&& expectedHooks.every(expectedHook =>
|
|
494
|
+
forgeHooks.filter(actualHook =>
|
|
495
|
+
actualHook?.type === expectedHook?.type
|
|
496
|
+
&& actualHook?.command === expectedHook?.command).length === 1);
|
|
497
|
+
}
|
|
498
|
+
|
|
454
499
|
function parseJsonConfig(existingText) {
|
|
455
500
|
if (!existingText || !existingText.trim()) return {};
|
|
456
501
|
let obj;
|
|
@@ -479,12 +524,55 @@ function mergeClaudeSettings(existingText, contract) {
|
|
|
479
524
|
const rendered = renderClaudeHooks(contract);
|
|
480
525
|
for (const [event, forgeGroups] of Object.entries(rendered)) {
|
|
481
526
|
const existingGroups = Array.isArray(obj.hooks[event]) ? obj.hooks[event] : [];
|
|
482
|
-
const userGroups = existingGroups.filter(
|
|
527
|
+
const userGroups = existingGroups.map(withoutForgeCommands).filter(Boolean);
|
|
483
528
|
obj.hooks[event] = [...userGroups, ...forgeGroups];
|
|
484
529
|
}
|
|
485
530
|
return JSON.stringify(obj, null, 2) + '\n';
|
|
486
531
|
}
|
|
487
532
|
|
|
533
|
+
function hasForgeClaudeHooks(existingText, contract) {
|
|
534
|
+
const obj = parseJsonConfig(existingText);
|
|
535
|
+
const hooks = obj.hooks && typeof obj.hooks === 'object' && !Array.isArray(obj.hooks)
|
|
536
|
+
? obj.hooks
|
|
537
|
+
: {};
|
|
538
|
+
return Object.entries(renderClaudeHooks(contract)).every(([event, expectedGroups]) => {
|
|
539
|
+
const actualGroups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
540
|
+
const forgeGroups = actualGroups.filter(isForgeClaudeGroup);
|
|
541
|
+
return forgeGroups.length === expectedGroups.length
|
|
542
|
+
&& expectedGroups.every(expected =>
|
|
543
|
+
forgeGroups.filter(actual => groupContains(actual, expected)).length === 1);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function backupHookConfig(filePath) {
|
|
548
|
+
const source = fs.readFileSync(filePath);
|
|
549
|
+
const sourceStat = fs.statSync(filePath);
|
|
550
|
+
for (let suffix = 0; ; suffix += 1) {
|
|
551
|
+
const candidate = `${filePath}.bak${suffix ? `.${suffix}` : ''}`;
|
|
552
|
+
let candidateStat;
|
|
553
|
+
try {
|
|
554
|
+
candidateStat = fs.lstatSync(candidate);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
if (error.code !== 'ENOENT') throw error;
|
|
557
|
+
try {
|
|
558
|
+
fs.copyFileSync(filePath, candidate, fs.constants.COPYFILE_EXCL);
|
|
559
|
+
return candidate;
|
|
560
|
+
} catch (copyError) {
|
|
561
|
+
if (copyError.code === 'EEXIST') continue;
|
|
562
|
+
throw copyError;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (candidateStat.isSymbolicLink()) continue;
|
|
566
|
+
if (sourceStat.dev != null && sourceStat.ino != null
|
|
567
|
+
&& sourceStat.dev === candidateStat.dev && sourceStat.ino === candidateStat.ino) continue;
|
|
568
|
+
try {
|
|
569
|
+
if (source.equals(fs.readFileSync(candidate))) return candidate;
|
|
570
|
+
} catch {
|
|
571
|
+
// An unreadable backup cannot prove the source is already safe.
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
488
576
|
/**
|
|
489
577
|
* Merge Forge's hooks into an existing `.cursor/hooks.json` string.
|
|
490
578
|
* Forces `version: 1`, preserves all non-Forge events and the user's own entries;
|
|
@@ -511,6 +599,26 @@ const MERGERS = {
|
|
|
511
599
|
cursor: mergeCursorHooks,
|
|
512
600
|
};
|
|
513
601
|
|
|
602
|
+
function assertSafeHookConfigPath(targetRoot, filePath) {
|
|
603
|
+
const escape =
|
|
604
|
+
assertNoAncestorSymlinkEscape(targetRoot, filePath) ||
|
|
605
|
+
assertNoSymlinkEscape(targetRoot, filePath);
|
|
606
|
+
if (escape) throw new Error(`Refusing to render hook config: ${escape.reason}`);
|
|
607
|
+
if (hasMultipleHardLinks(filePath)) {
|
|
608
|
+
throw new Error('Refusing to render hardlinked hook config');
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function hasMultipleHardLinks(filePath) {
|
|
613
|
+
try {
|
|
614
|
+
const stat = fs.lstatSync(filePath);
|
|
615
|
+
return stat.isFile() && stat.nlink > 1;
|
|
616
|
+
} catch (error) {
|
|
617
|
+
if (error.code === 'ENOENT') return false;
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
514
622
|
/**
|
|
515
623
|
* Render (merge) Forge's native hooks into one harness's native config on disk.
|
|
516
624
|
* Read → merge → write. Unparseable existing file → BACKED UP + left untouched
|
|
@@ -537,7 +645,9 @@ function renderHookConfig({ harness, targetRoot, contract = FORGE_HOOK_CONTRACT
|
|
|
537
645
|
if (!merge || !rel) throw new Error(`Unknown hook harness: ${harness}`);
|
|
538
646
|
|
|
539
647
|
const filePath = path.join(targetRoot, rel);
|
|
648
|
+
assertSafeHookConfigPath(targetRoot, filePath);
|
|
540
649
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
650
|
+
assertSafeHookConfigPath(targetRoot, filePath);
|
|
541
651
|
const existed = fs.existsSync(filePath);
|
|
542
652
|
const existing = existed ? fs.readFileSync(filePath, 'utf-8') : '';
|
|
543
653
|
|
|
@@ -546,12 +656,14 @@ function renderHookConfig({ harness, targetRoot, contract = FORGE_HOOK_CONTRACT
|
|
|
546
656
|
merged = merge(existing, contract);
|
|
547
657
|
} catch (err) {
|
|
548
658
|
if (err instanceof HookConfigParseError && existed) {
|
|
549
|
-
|
|
659
|
+
assertSafeHookConfigPath(targetRoot, filePath);
|
|
660
|
+
const backup = backupHookConfig(filePath);
|
|
550
661
|
return { file: filePath, existed, skipped: true, wrote: false, backup };
|
|
551
662
|
}
|
|
552
663
|
throw err;
|
|
553
664
|
}
|
|
554
665
|
|
|
666
|
+
assertSafeHookConfigPath(targetRoot, filePath);
|
|
555
667
|
fs.writeFileSync(filePath, merged, 'utf-8');
|
|
556
668
|
return { file: filePath, existed, skipped: false, wrote: true };
|
|
557
669
|
}
|
|
@@ -564,13 +676,16 @@ module.exports = {
|
|
|
564
676
|
FORGE_CONTEXT_MARKER,
|
|
565
677
|
FORGE_INBOX_CONTEXT_MARKER,
|
|
566
678
|
FORGE_MEMORY_RECALL_MARKER,
|
|
679
|
+
FORGE_READ_ATTENTION_MARKER,
|
|
567
680
|
FORGE_CAPTURE_CONTEXT_MARKER,
|
|
568
681
|
SESSION_START_SUPPORT,
|
|
569
682
|
BOOTSTRAP_DELIVERY,
|
|
570
683
|
USER_PROMPT_SUBMIT_SUPPORT,
|
|
684
|
+
READ_ATTENTION_SUPPORT,
|
|
571
685
|
SESSION_END_SUPPORT,
|
|
572
686
|
sessionStartCapability,
|
|
573
687
|
userPromptSubmitCapability,
|
|
688
|
+
readAttentionCapability,
|
|
574
689
|
sessionEndCapability,
|
|
575
690
|
HookConfigParseError,
|
|
576
691
|
renderClaudeHooks,
|
|
@@ -578,6 +693,8 @@ module.exports = {
|
|
|
578
693
|
renderCodexHooksToml,
|
|
579
694
|
renderHermesHooksYaml,
|
|
580
695
|
mergeClaudeSettings,
|
|
696
|
+
hasForgeClaudeHooks,
|
|
697
|
+
hasMultipleHardLinks,
|
|
581
698
|
mergeCursorHooks,
|
|
582
699
|
renderHookConfig,
|
|
583
700
|
};
|