wendkeep 0.75.3 → 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.
@@ -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
@@ -40,6 +40,7 @@ import { seedDotcontext, globalHasDotcontext, resolveDotcontextSkipMcp, renderSe
40
40
  import { adoptSpecsState, ensureSpecsReadme, SPECS_STATE_FILE } from '../hooks/spec-core.mjs';
41
41
  import { bindProjectVault, readProjectBinding } from './project-vault.mjs';
42
42
  import { seedMemoryV2 } from './memory.mjs';
43
+ import { installVscodeWorktreeTasks } from './worktree.mjs';
43
44
  import {
44
45
  DEFAULT_OPERATING_PROFILE,
45
46
  normalizeOperatingProfile,
@@ -67,6 +68,7 @@ function parseArgs(argv) {
67
68
  else if (a === '--force') args.force = true;
68
69
  else if (a === '--no-companions') args.noCompanions = true;
69
70
  else if (a === '--no-colors') args.noColors = true;
71
+ else if (a === '--vscode-worktree-tasks') args.vscodeWorktreeTasks = true;
70
72
  else if (a === '--dotcontext-mcp') args.dotcontextMcp = argv[++i];
71
73
  else if (a.startsWith('--dotcontext-mcp=')) args.dotcontextMcp = a.slice(17);
72
74
  else if (a === '--dotcontext-hooks') args.dotcontextHooks = argv[++i];
@@ -480,6 +482,10 @@ export async function runInit(argv) {
480
482
  ?? (existingBinding ? undefined : DEFAULT_OPERATING_PROFILE);
481
483
  const configPatch = profile === undefined ? {} : setOperatingProfile({}, profile);
482
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
+ }
483
489
 
484
490
  // Companion plugins/MCP selection. --no-companions wins; --companions <csv> is
485
491
  // explicit; an interactive TTY gets a multi-choice prompt (context-mode pre-checked);
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' || a === '-y' || a === '--force');
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');