wendkeep 0.75.2 → 0.76.0
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/CHANGELOG.md +27 -0
- package/README.en.md +6 -0
- package/README.md +6 -0
- package/docs/en/commands/getting-started.md +3 -1
- package/docs/en/commands/maintenance-and-diagnostics.md +3 -0
- package/docs/en/commands/worktrees.md +93 -0
- package/docs/pt-BR/commands/getting-started.md +3 -1
- package/docs/pt-BR/commands/maintenance-and-diagnostics.md +2 -0
- package/docs/pt-BR/commands/worktrees.md +91 -0
- package/hooks/flow-protected-policy.mjs +1 -1
- package/package.json +2 -1
- package/packages/cli/src/index.mjs +15 -3
- package/packages/integrations/src/host-hooks.mjs +6 -0
- package/packages/vault/src/index.mjs +1 -0
- package/packages/vault/src/project-vault.mjs +25 -2
- package/packages/vault/src/worktree-metadata.mjs +274 -0
- package/src/doctor.mjs +8 -0
- package/src/init.mjs +46 -10
- package/src/sync.mjs +4 -1
- package/src/taxonomy.mjs +2 -0
- package/src/worktree.mjs +708 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const WORKTREE_REGISTRY_SCHEMA = 1;
|
|
15
|
+
export const WORKTREE_REGISTRY_REL = 'wendkeep/worktrees-v1.json';
|
|
16
|
+
|
|
17
|
+
function worktreeError(code, message) {
|
|
18
|
+
const error = new Error(message);
|
|
19
|
+
error.code = code;
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function gitOutput(startDir, args, spawn = spawnSync) {
|
|
24
|
+
const result = spawn('git', args, {
|
|
25
|
+
cwd: startDir,
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
windowsHide: true,
|
|
28
|
+
});
|
|
29
|
+
if (result.status !== 0) {
|
|
30
|
+
throw worktreeError(
|
|
31
|
+
'WENDKEEP_WORKTREE_GIT_FAILED',
|
|
32
|
+
String(result.stderr || result.error?.message || `git ${args[0]} falhou`).trim(),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return String(result.stdout || '').trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function absoluteGitPath(repoRoot, value) {
|
|
39
|
+
return resolve(isAbsolute(value) ? value : join(repoRoot, value));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseWorktreeList(text) {
|
|
43
|
+
const records = [];
|
|
44
|
+
let current = null;
|
|
45
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
46
|
+
if (line.startsWith('worktree ')) {
|
|
47
|
+
current = { path: resolve(line.slice('worktree '.length)) };
|
|
48
|
+
records.push(current);
|
|
49
|
+
} else if (current && line.startsWith('HEAD ')) {
|
|
50
|
+
current.head = line.slice('HEAD '.length);
|
|
51
|
+
} else if (current && line.startsWith('branch ')) {
|
|
52
|
+
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
|
|
53
|
+
} else if (current && line === 'bare') {
|
|
54
|
+
current.bare = true;
|
|
55
|
+
} else if (current && line === 'detached') {
|
|
56
|
+
current.detached = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return records;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function discoverWorktreeRepository({ startDir = process.cwd(), spawn = spawnSync } = {}) {
|
|
63
|
+
const repoRoot = resolve(gitOutput(startDir, ['rev-parse', '--show-toplevel'], spawn));
|
|
64
|
+
const commonDir = absoluteGitPath(
|
|
65
|
+
repoRoot,
|
|
66
|
+
gitOutput(repoRoot, ['rev-parse', '--git-common-dir'], spawn),
|
|
67
|
+
);
|
|
68
|
+
const gitDir = absoluteGitPath(
|
|
69
|
+
repoRoot,
|
|
70
|
+
gitOutput(repoRoot, ['rev-parse', '--git-dir'], spawn),
|
|
71
|
+
);
|
|
72
|
+
const worktrees = parseWorktreeList(gitOutput(repoRoot, ['worktree', 'list', '--porcelain'], spawn));
|
|
73
|
+
const main = worktrees.find((entry) => !entry.bare) || null;
|
|
74
|
+
if (!main) {
|
|
75
|
+
throw worktreeError(
|
|
76
|
+
'WENDKEEP_WORKTREE_MAIN_UNRESOLVED',
|
|
77
|
+
'Não foi possível localizar a worktree principal do repositório.',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
repoRoot,
|
|
82
|
+
commonDir,
|
|
83
|
+
gitDir,
|
|
84
|
+
mainWorktree: main.path,
|
|
85
|
+
worktrees,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sleep(milliseconds) {
|
|
90
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function withWorktreeRegistryLock(registryPath, operation, {
|
|
94
|
+
timeoutMs = 5_000,
|
|
95
|
+
staleMs = 30_000,
|
|
96
|
+
now = () => Date.now(),
|
|
97
|
+
} = {}) {
|
|
98
|
+
const lockPath = `${registryPath}.lock`;
|
|
99
|
+
const startedAt = now();
|
|
100
|
+
mkdirSync(dirname(registryPath), { recursive: true });
|
|
101
|
+
while (true) {
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(lockPath);
|
|
104
|
+
break;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
107
|
+
try {
|
|
108
|
+
if (now() - statSync(lockPath).mtimeMs > staleMs) {
|
|
109
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
} catch (inspectError) {
|
|
113
|
+
if (inspectError?.code !== 'ENOENT') throw inspectError;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (now() - startedAt >= timeoutMs) {
|
|
117
|
+
throw worktreeError(
|
|
118
|
+
'WENDKEEP_WORKTREE_REGISTRY_BUSY',
|
|
119
|
+
`Registry de worktrees ocupado: ${registryPath}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
sleep(20);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return operation();
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function atomicJson(path, value) {
|
|
133
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
134
|
+
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
135
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === content) return false;
|
|
136
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
137
|
+
writeFileSync(temporary, content, { encoding: 'utf8', flag: 'wx' });
|
|
138
|
+
renameSync(temporary, path);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateRegistry(value, path) {
|
|
143
|
+
if (
|
|
144
|
+
value?.schemaVersion !== WORKTREE_REGISTRY_SCHEMA
|
|
145
|
+
|| typeof value.repositoryId !== 'string'
|
|
146
|
+
|| !value.repositoryId
|
|
147
|
+
|| typeof value.projectId !== 'string'
|
|
148
|
+
|| !value.projectId
|
|
149
|
+
|| typeof value.vaultPath !== 'string'
|
|
150
|
+
|| !value.vaultPath
|
|
151
|
+
|| !value.entries
|
|
152
|
+
|| typeof value.entries !== 'object'
|
|
153
|
+
|| Array.isArray(value.entries)
|
|
154
|
+
) {
|
|
155
|
+
throw worktreeError(
|
|
156
|
+
'WENDKEEP_WORKTREE_REGISTRY_INVALID',
|
|
157
|
+
`Registry de worktrees incompleto em "${path}".`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseRegistry(path) {
|
|
164
|
+
let value;
|
|
165
|
+
try {
|
|
166
|
+
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw worktreeError(
|
|
169
|
+
'WENDKEEP_WORKTREE_REGISTRY_INVALID',
|
|
170
|
+
`Registry de worktrees inválido em "${path}": ${error.message}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return validateRegistry(value, path);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function readWorktreeRegistry(repository) {
|
|
177
|
+
const path = join(repository.commonDir, ...WORKTREE_REGISTRY_REL.split('/'));
|
|
178
|
+
return existsSync(path) ? { path, registry: parseRegistry(path) } : { path, registry: null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function resolveWorktreeVaultBinding({ startDir = process.cwd(), projectId } = {}) {
|
|
182
|
+
let repository;
|
|
183
|
+
try {
|
|
184
|
+
repository = discoverWorktreeRepository({ startDir });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error?.code === 'WENDKEEP_WORKTREE_GIT_FAILED') return null;
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
const { path, registry } = readWorktreeRegistry(repository);
|
|
190
|
+
if (!registry) return null;
|
|
191
|
+
if (registry.projectId !== projectId) {
|
|
192
|
+
throw worktreeError(
|
|
193
|
+
'WENDKEEP_WORKTREE_PROJECT_MISMATCH',
|
|
194
|
+
`Registry "${path}" pertence ao projeto "${registry.projectId}", não "${projectId}".`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
base: resolve(registry.vaultPath),
|
|
199
|
+
projectId: registry.projectId,
|
|
200
|
+
repositoryId: registry.repositoryId,
|
|
201
|
+
registryPath: path,
|
|
202
|
+
projectRoot: repository.mainWorktree,
|
|
203
|
+
repository,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function mutateWorktreeRegistry(repository, mutator) {
|
|
208
|
+
if (!repository?.commonDir || typeof mutator !== 'function') {
|
|
209
|
+
throw worktreeError('WENDKEEP_WORKTREE_REPOSITORY_INVALID', 'Repositório Git inválido.');
|
|
210
|
+
}
|
|
211
|
+
const registryPath = join(repository.commonDir, ...WORKTREE_REGISTRY_REL.split('/'));
|
|
212
|
+
return withWorktreeRegistryLock(registryPath, () => {
|
|
213
|
+
if (!existsSync(registryPath)) {
|
|
214
|
+
throw worktreeError(
|
|
215
|
+
'WENDKEEP_WORKTREE_REGISTRY_MISSING',
|
|
216
|
+
`Registry de worktrees ausente em "${registryPath}".`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const current = parseRegistry(registryPath);
|
|
220
|
+
const next = validateRegistry(mutator(structuredClone(current)), registryPath);
|
|
221
|
+
atomicJson(registryPath, next);
|
|
222
|
+
return next;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function worktreeIdentity(repositoryId, gitDir) {
|
|
227
|
+
return createHash('sha256')
|
|
228
|
+
.update(`${repositoryId}\n${resolve(gitDir).replaceAll('\\', '/').toLowerCase()}\n`)
|
|
229
|
+
.digest('hex');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function ensureWorktreeMetadata({
|
|
233
|
+
repository,
|
|
234
|
+
projectId,
|
|
235
|
+
vaultPath,
|
|
236
|
+
worktreesRoot = '.worktrees',
|
|
237
|
+
} = {}) {
|
|
238
|
+
if (!repository?.commonDir || !repository?.gitDir) {
|
|
239
|
+
throw worktreeError('WENDKEEP_WORKTREE_REPOSITORY_INVALID', 'Repositório Git inválido.');
|
|
240
|
+
}
|
|
241
|
+
const registryPath = join(repository.commonDir, ...WORKTREE_REGISTRY_REL.split('/'));
|
|
242
|
+
const normalizedVault = resolve(String(vaultPath || ''));
|
|
243
|
+
const registry = withWorktreeRegistryLock(registryPath, () => {
|
|
244
|
+
const current = existsSync(registryPath) ? parseRegistry(registryPath) : null;
|
|
245
|
+
if (current && current.projectId !== projectId) {
|
|
246
|
+
throw worktreeError(
|
|
247
|
+
'WENDKEEP_WORKTREE_PROJECT_MISMATCH',
|
|
248
|
+
`Registry pertence a outro projeto: ${current.projectId}.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (current && resolve(current.vaultPath) !== normalizedVault) {
|
|
252
|
+
throw worktreeError(
|
|
253
|
+
'WENDKEEP_WORKTREE_VAULT_MISMATCH',
|
|
254
|
+
'Registry aponta para outro Vault canônico.',
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const next = current || {
|
|
258
|
+
schemaVersion: WORKTREE_REGISTRY_SCHEMA,
|
|
259
|
+
repositoryId: randomUUID(),
|
|
260
|
+
projectId,
|
|
261
|
+
vaultPath: normalizedVault,
|
|
262
|
+
worktreesRoot,
|
|
263
|
+
entries: {},
|
|
264
|
+
};
|
|
265
|
+
atomicJson(registryPath, next);
|
|
266
|
+
return next;
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
registryPath,
|
|
270
|
+
repositoryId: registry.repositoryId,
|
|
271
|
+
currentWorktreeId: worktreeIdentity(registry.repositoryId, repository.gitDir),
|
|
272
|
+
registry,
|
|
273
|
+
};
|
|
274
|
+
}
|
package/src/doctor.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// harness integrity check (hooks/harness-doctor.mjs). Exits 1 on any error.
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { checkHarness, checkVaultLinks, checkSessionActivity, checkStackedFrontmatter, renderStackedFrontmatterLines, checkUnpricedModels, renderUnpricedModelLines, checkStaleDerivedSections, renderStaleDerivedSectionLines, checkSessionObservability, renderSessionObservabilityLines } from '../hooks/harness-doctor.mjs';
|
|
5
|
+
import { diagnoseManagedWorktrees } from './worktree.mjs';
|
|
5
6
|
import { runVaultHealth } from '../hooks/vault-health.mjs';
|
|
6
7
|
import { checkSyncDefs } from './sync-defs.mjs';
|
|
7
8
|
import { resolveProjectVault } from './project-vault.mjs';
|
|
@@ -133,6 +134,12 @@ export function runDoctor(argv) {
|
|
|
133
134
|
for (const item of repairable) process.stdout.write(` → ${item}\n`);
|
|
134
135
|
for (const w of warnings) process.stdout.write(` ! ${w}\n`);
|
|
135
136
|
|
|
137
|
+
const worktrees = diagnoseManagedWorktrees({ startDir: projectRoot });
|
|
138
|
+
process.stdout.write(`\n[worktrees] ${worktrees.initialized ? `${worktrees.issues.length} problema(s)` : 'não inicializado'}\n`);
|
|
139
|
+
for (const issue of worktrees.issues) {
|
|
140
|
+
process.stdout.write(` → ${issue.slug}: ${issue.errorCode} — ${issue.repair}\n`);
|
|
141
|
+
}
|
|
142
|
+
|
|
136
143
|
// 3. Link/graph health — órfãos que o grafo do Obsidian mostraria, com o comando de reparo.
|
|
137
144
|
const links = checkVaultLinks(vaultBase);
|
|
138
145
|
const graphLabel = links.graphColors === true ? 'com cores' : links.graphColors === false ? 'sem cores' : 'sem graph.json';
|
|
@@ -181,6 +188,7 @@ export function runDoctor(argv) {
|
|
|
181
188
|
|| attention.length
|
|
182
189
|
|| repairable.length
|
|
183
190
|
|| warnings.length
|
|
191
|
+
|| worktrees.issues.length
|
|
184
192
|
|| links.derivedOrphans
|
|
185
193
|
|| links.artifactOrphans
|
|
186
194
|
|| links.graphColors === false
|
package/src/init.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
CHANGE_NUDGE_HOOKS,
|
|
14
14
|
CHANGE_GATE_HOOKS,
|
|
15
15
|
hookCommand,
|
|
16
|
+
hookCommandWorkingTree,
|
|
16
17
|
hookCommandLocal,
|
|
17
18
|
hookCommandLocalLegacy,
|
|
18
19
|
codexHookSpecs,
|
|
@@ -39,6 +40,7 @@ import { seedDotcontext, globalHasDotcontext, resolveDotcontextSkipMcp, renderSe
|
|
|
39
40
|
import { adoptSpecsState, ensureSpecsReadme, SPECS_STATE_FILE } from '../hooks/spec-core.mjs';
|
|
40
41
|
import { bindProjectVault, readProjectBinding } from './project-vault.mjs';
|
|
41
42
|
import { seedMemoryV2 } from './memory.mjs';
|
|
43
|
+
import { installVscodeWorktreeTasks } from './worktree.mjs';
|
|
42
44
|
import {
|
|
43
45
|
DEFAULT_OPERATING_PROFILE,
|
|
44
46
|
normalizeOperatingProfile,
|
|
@@ -66,6 +68,7 @@ function parseArgs(argv) {
|
|
|
66
68
|
else if (a === '--force') args.force = true;
|
|
67
69
|
else if (a === '--no-companions') args.noCompanions = true;
|
|
68
70
|
else if (a === '--no-colors') args.noColors = true;
|
|
71
|
+
else if (a === '--vscode-worktree-tasks') args.vscodeWorktreeTasks = true;
|
|
69
72
|
else if (a === '--dotcontext-mcp') args.dotcontextMcp = argv[++i];
|
|
70
73
|
else if (a.startsWith('--dotcontext-mcp=')) args.dotcontextMcp = a.slice(17);
|
|
71
74
|
else if (a === '--dotcontext-hooks') args.dotcontextHooks = argv[++i];
|
|
@@ -109,7 +112,19 @@ function backup(path) {
|
|
|
109
112
|
|
|
110
113
|
// Comando preferido para um hook: node-direto quando o projeto tem o pacote local (alta
|
|
111
114
|
// frequência sem cold-start de npx — ver R3 do design 0.31.0); senão o npx portátil.
|
|
115
|
+
export function isWendkeepSelfCheckout(projectPath) {
|
|
116
|
+
try {
|
|
117
|
+
if (!projectPath || !existsSync(join(projectPath, 'bin', 'wendkeep.mjs'))) return false;
|
|
118
|
+
const pkg = JSON.parse(readFileSync(join(projectPath, 'package.json'), 'utf8'));
|
|
119
|
+
const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.wendkeep;
|
|
120
|
+
return pkg.name === 'wendkeep' && String(bin || '').replace(/\\/g, '/') === 'bin/wendkeep.mjs';
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
112
126
|
export function hookCommandFor(name, projectPath) {
|
|
127
|
+
if (isWendkeepSelfCheckout(projectPath)) return hookCommandWorkingTree(name);
|
|
113
128
|
try {
|
|
114
129
|
if (projectPath && existsSync(join(projectPath, 'node_modules', 'wendkeep', 'hooks', `${name}.mjs`))) {
|
|
115
130
|
return hookCommandLocal(name);
|
|
@@ -142,13 +157,22 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
|
|
|
142
157
|
...CHANGE_GATE_HOOKS,
|
|
143
158
|
...companionHookSpecs(companions, { dotcontextHookLevel }),
|
|
144
159
|
].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
160
|
+
const selfCheckout = isWendkeepSelfCheckout(projectPath);
|
|
145
161
|
for (const h of allSpecs) {
|
|
162
|
+
const useWorkingTree = !h.command && selfCheckout;
|
|
146
163
|
const useLocal = !h.command && h.preferLocal && localHookAvailable(h.name, projectPath);
|
|
147
|
-
const command = h.command ?? (
|
|
148
|
-
|
|
164
|
+
const command = h.command ?? (useWorkingTree
|
|
165
|
+
? hookCommandWorkingTree(h.name)
|
|
166
|
+
: (useLocal ? 'node' : hookCommand(h.name)));
|
|
167
|
+
const args = !useWorkingTree && useLocal ? [localHookArg(h.name)] : undefined;
|
|
149
168
|
// Dual-recognition: um hook nomeado é reconhecido tanto na forma npx quanto na node-direta,
|
|
150
169
|
// para que trocar a forma preferida (ou re-initar noutra máquina) nunca duplique o grupo.
|
|
151
|
-
const candidates = h.command ? [h.command] : [
|
|
170
|
+
const candidates = h.command ? [h.command] : [
|
|
171
|
+
hookCommand(h.name),
|
|
172
|
+
hookCommandWorkingTree(h.name),
|
|
173
|
+
hookCommandLocal(h.name),
|
|
174
|
+
hookCommandLocalLegacy(h.name),
|
|
175
|
+
];
|
|
152
176
|
const ownsHook = (x) => candidates.includes(x.command)
|
|
153
177
|
|| (x.command === 'node' && Array.isArray(x.args) && x.args[0] === localHookArg(h.name));
|
|
154
178
|
const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
|
|
@@ -159,7 +183,8 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
|
|
|
159
183
|
// entry's fields in place — without disturbing any sibling hooks the user grouped with it.
|
|
160
184
|
const hk = owning.hooks.find(ownsHook);
|
|
161
185
|
const brokenRelative = hk?.command === hookCommandLocalLegacy(h.name);
|
|
162
|
-
|
|
186
|
+
const wrongRuntime = !h.command && hk?.command !== command;
|
|
187
|
+
if (force || brokenRelative || wrongRuntime) {
|
|
163
188
|
hk.command = command;
|
|
164
189
|
if (args) hk.args = args;
|
|
165
190
|
else delete hk.args;
|
|
@@ -208,14 +233,19 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
|
|
|
208
233
|
// it does handle is the legacy `timeout` key, which Codex silently ignores in favour of a
|
|
209
234
|
// 600s default; rewriting it to `timeoutSec` invalidates the stored trusted_hash and costs
|
|
210
235
|
// the user one "Hooks need review" prompt. That is the point.
|
|
211
|
-
export function mergeCodexHooks(existing, { force = false } = {}) {
|
|
236
|
+
export function mergeCodexHooks(existing, { force = false, projectPath = '' } = {}) {
|
|
212
237
|
const file = existing && typeof existing === 'object' ? { ...existing } : {};
|
|
213
238
|
file.hooks = { ...(file.hooks || {}) };
|
|
214
239
|
const specs = codexHookSpecs([...SESSION_HOOKS, ...CHANGE_NUDGE_HOOKS, ...CHANGE_GATE_HOOKS])
|
|
215
240
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
241
|
+
const selfCheckout = isWendkeepSelfCheckout(projectPath);
|
|
216
242
|
for (const h of specs) {
|
|
217
|
-
const entry =
|
|
218
|
-
|
|
243
|
+
const entry = {
|
|
244
|
+
...codexHookEntry(h),
|
|
245
|
+
...(selfCheckout ? { command: hookCommandWorkingTree(h.name) } : {}),
|
|
246
|
+
};
|
|
247
|
+
const candidates = new Set([hookCommand(h.name), hookCommandWorkingTree(h.name)]);
|
|
248
|
+
const owns = (x) => candidates.has(x.command);
|
|
219
249
|
const groups = Array.isArray(file.hooks[h.event]) ? [...file.hooks[h.event]] : [];
|
|
220
250
|
const owning = groups.find((g) => (g.hooks || []).some(owns));
|
|
221
251
|
if (owning) {
|
|
@@ -224,7 +254,8 @@ export function mergeCodexHooks(existing, { force = false } = {}) {
|
|
|
224
254
|
// `timeout` is the pre-0.46 key: Codex never read it. Migrate it even without --force,
|
|
225
255
|
// otherwise the hook keeps running at the 600s default forever.
|
|
226
256
|
const legacyTimeout = 'timeout' in hk;
|
|
227
|
-
|
|
257
|
+
const wrongRuntime = hk.command !== entry.command;
|
|
258
|
+
if (force || legacyTimeout || wrongRuntime) {
|
|
228
259
|
if (legacyTimeout) {
|
|
229
260
|
if (hk.timeoutSec === undefined) hk.timeoutSec = hk.timeout;
|
|
230
261
|
delete hk.timeout;
|
|
@@ -233,6 +264,7 @@ export function mergeCodexHooks(existing, { force = false } = {}) {
|
|
|
233
264
|
hk.timeoutSec = entry.timeoutSec;
|
|
234
265
|
if (entry.statusMessage) hk.statusMessage = entry.statusMessage;
|
|
235
266
|
}
|
|
267
|
+
if (wrongRuntime) hk.command = entry.command;
|
|
236
268
|
}
|
|
237
269
|
if (force && matcher) owning.matcher = matcher;
|
|
238
270
|
if (force && !matcher) delete owning.matcher;
|
|
@@ -450,6 +482,10 @@ export async function runInit(argv) {
|
|
|
450
482
|
?? (existingBinding ? undefined : DEFAULT_OPERATING_PROFILE);
|
|
451
483
|
const configPatch = profile === undefined ? {} : setOperatingProfile({}, profile);
|
|
452
484
|
bindProjectVault({ projectRoot: projectPath, vaultPath, configPatch });
|
|
485
|
+
if (args.vscodeWorktreeTasks) {
|
|
486
|
+
const tasks = installVscodeWorktreeTasks({ projectRoot: projectPath });
|
|
487
|
+
log(`VS Code worktree tasks: ${tasks.state} (${tasks.path})`);
|
|
488
|
+
}
|
|
453
489
|
|
|
454
490
|
// Companion plugins/MCP selection. --no-companions wins; --companions <csv> is
|
|
455
491
|
// explicit; an interactive TTY gets a multi-choice prompt (context-mode pre-checked);
|
|
@@ -593,12 +629,12 @@ export async function runInit(argv) {
|
|
|
593
629
|
const codexPath = join(projectPath, '.codex', 'hooks.json');
|
|
594
630
|
const codexRead = readJsonSafe(codexPath);
|
|
595
631
|
if (!codexRead.ok) {
|
|
596
|
-
writeJson(`${codexPath}.new`, mergeCodexHooks(null, { force: true }));
|
|
632
|
+
writeJson(`${codexPath}.new`, mergeCodexHooks(null, { force: true, projectPath }));
|
|
597
633
|
log(M.codexBadJson(codexPath));
|
|
598
634
|
} else {
|
|
599
635
|
const hadFile = codexRead.data !== null;
|
|
600
636
|
if (hadFile) backup(codexPath);
|
|
601
|
-
writeJson(codexPath, mergeCodexHooks(codexRead.data, { force: args.force }));
|
|
637
|
+
writeJson(codexPath, mergeCodexHooks(codexRead.data, { force: args.force, projectPath }));
|
|
602
638
|
log(M.codexHooks(hadFile ? M.merged : M.created, hadFile ? M.bakSaved : ''));
|
|
603
639
|
}
|
|
604
640
|
log(M.codexTrust);
|
package/src/sync.mjs
CHANGED
|
@@ -26,7 +26,10 @@ export async function runSync(argv) {
|
|
|
26
26
|
const profileRaw = opt(argv, '--profile');
|
|
27
27
|
const profileArgs = hasProfile ? ['--profile', profileRaw] : [];
|
|
28
28
|
const projectPath = resolve(projectRaw && !projectRaw.startsWith('--') ? projectRaw : process.cwd());
|
|
29
|
-
const passthrough = argv.filter((a) => a === '--yes'
|
|
29
|
+
const passthrough = argv.filter((a) => a === '--yes'
|
|
30
|
+
|| a === '-y'
|
|
31
|
+
|| a === '--force'
|
|
32
|
+
|| a === '--vscode-worktree-tasks');
|
|
30
33
|
|
|
31
34
|
// 1. init — idempotente: refaz a fiação sem sobrescrever o que já está configurado.
|
|
32
35
|
step(1, 'init');
|
package/src/taxonomy.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
codexHookEntry,
|
|
12
12
|
codexHookSpecs,
|
|
13
13
|
hookCommand,
|
|
14
|
+
hookCommandWorkingTree,
|
|
14
15
|
hookCommandLocal,
|
|
15
16
|
hookCommandLocalLegacy,
|
|
16
17
|
} from '../packages/integrations/src/host-hooks.mjs';
|
|
@@ -24,6 +25,7 @@ export {
|
|
|
24
25
|
codexHookEntry,
|
|
25
26
|
codexHookSpecs,
|
|
26
27
|
hookCommand,
|
|
28
|
+
hookCommandWorkingTree,
|
|
27
29
|
hookCommandLocal,
|
|
28
30
|
hookCommandLocalLegacy,
|
|
29
31
|
mcpServerEntry,
|