@kynetic-ai/spec 0.1.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/README.md +263 -0
- package/dist/acp/client.d.ts +159 -0
- package/dist/acp/client.d.ts.map +1 -0
- package/dist/acp/client.js +255 -0
- package/dist/acp/client.js.map +1 -0
- package/dist/acp/framing.d.ts +119 -0
- package/dist/acp/framing.d.ts.map +1 -0
- package/dist/acp/framing.js +302 -0
- package/dist/acp/framing.js.map +1 -0
- package/dist/acp/index.d.ts +14 -0
- package/dist/acp/index.d.ts.map +1 -0
- package/dist/acp/index.js +13 -0
- package/dist/acp/index.js.map +1 -0
- package/dist/acp/types.d.ts +89 -0
- package/dist/acp/types.d.ts.map +1 -0
- package/dist/acp/types.js +99 -0
- package/dist/acp/types.js.map +1 -0
- package/dist/agents/adapters.d.ts +55 -0
- package/dist/agents/adapters.d.ts.map +1 -0
- package/dist/agents/adapters.js +84 -0
- package/dist/agents/adapters.js.map +1 -0
- package/dist/agents/index.d.ts +8 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +10 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/agents/spawner.d.ts +53 -0
- package/dist/agents/spawner.d.ts.map +1 -0
- package/dist/agents/spawner.js +83 -0
- package/dist/agents/spawner.js.map +1 -0
- package/dist/cli/batch.d.ts +82 -0
- package/dist/cli/batch.d.ts.map +1 -0
- package/dist/cli/batch.js +162 -0
- package/dist/cli/batch.js.map +1 -0
- package/dist/cli/commands/clone-for-testing.d.ts +6 -0
- package/dist/cli/commands/clone-for-testing.d.ts.map +1 -0
- package/dist/cli/commands/clone-for-testing.js +176 -0
- package/dist/cli/commands/clone-for-testing.js.map +1 -0
- package/dist/cli/commands/derive.d.ts +6 -0
- package/dist/cli/commands/derive.d.ts.map +1 -0
- package/dist/cli/commands/derive.js +450 -0
- package/dist/cli/commands/derive.js.map +1 -0
- package/dist/cli/commands/help.d.ts +6 -0
- package/dist/cli/commands/help.d.ts.map +1 -0
- package/dist/cli/commands/help.js +196 -0
- package/dist/cli/commands/help.js.map +1 -0
- package/dist/cli/commands/inbox.d.ts +6 -0
- package/dist/cli/commands/inbox.d.ts.map +1 -0
- package/dist/cli/commands/inbox.js +235 -0
- package/dist/cli/commands/inbox.js.map +1 -0
- package/dist/cli/commands/index.d.ts +20 -0
- package/dist/cli/commands/index.d.ts.map +1 -0
- package/dist/cli/commands/index.js +21 -0
- package/dist/cli/commands/index.js.map +1 -0
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +245 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/item.d.ts +6 -0
- package/dist/cli/commands/item.d.ts.map +1 -0
- package/dist/cli/commands/item.js +1311 -0
- package/dist/cli/commands/item.js.map +1 -0
- package/dist/cli/commands/link.d.ts +6 -0
- package/dist/cli/commands/link.d.ts.map +1 -0
- package/dist/cli/commands/link.js +288 -0
- package/dist/cli/commands/link.js.map +1 -0
- package/dist/cli/commands/log.d.ts +16 -0
- package/dist/cli/commands/log.d.ts.map +1 -0
- package/dist/cli/commands/log.js +291 -0
- package/dist/cli/commands/log.js.map +1 -0
- package/dist/cli/commands/meta.d.ts +15 -0
- package/dist/cli/commands/meta.d.ts.map +1 -0
- package/dist/cli/commands/meta.js +1378 -0
- package/dist/cli/commands/meta.js.map +1 -0
- package/dist/cli/commands/module.d.ts +6 -0
- package/dist/cli/commands/module.d.ts.map +1 -0
- package/dist/cli/commands/module.js +102 -0
- package/dist/cli/commands/module.js.map +1 -0
- package/dist/cli/commands/ralph.d.ts +9 -0
- package/dist/cli/commands/ralph.d.ts.map +1 -0
- package/dist/cli/commands/ralph.js +465 -0
- package/dist/cli/commands/ralph.js.map +1 -0
- package/dist/cli/commands/search.d.ts +6 -0
- package/dist/cli/commands/search.d.ts.map +1 -0
- package/dist/cli/commands/search.js +134 -0
- package/dist/cli/commands/search.js.map +1 -0
- package/dist/cli/commands/session.d.ts +164 -0
- package/dist/cli/commands/session.d.ts.map +1 -0
- package/dist/cli/commands/session.js +745 -0
- package/dist/cli/commands/session.js.map +1 -0
- package/dist/cli/commands/setup.d.ts +26 -0
- package/dist/cli/commands/setup.d.ts.map +1 -0
- package/dist/cli/commands/setup.js +586 -0
- package/dist/cli/commands/setup.js.map +1 -0
- package/dist/cli/commands/shadow.d.ts +6 -0
- package/dist/cli/commands/shadow.d.ts.map +1 -0
- package/dist/cli/commands/shadow.js +299 -0
- package/dist/cli/commands/shadow.js.map +1 -0
- package/dist/cli/commands/task.d.ts +6 -0
- package/dist/cli/commands/task.d.ts.map +1 -0
- package/dist/cli/commands/task.js +1514 -0
- package/dist/cli/commands/task.js.map +1 -0
- package/dist/cli/commands/tasks.d.ts +6 -0
- package/dist/cli/commands/tasks.d.ts.map +1 -0
- package/dist/cli/commands/tasks.js +347 -0
- package/dist/cli/commands/tasks.js.map +1 -0
- package/dist/cli/commands/trait.d.ts +10 -0
- package/dist/cli/commands/trait.d.ts.map +1 -0
- package/dist/cli/commands/trait.js +295 -0
- package/dist/cli/commands/trait.js.map +1 -0
- package/dist/cli/commands/validate.d.ts +6 -0
- package/dist/cli/commands/validate.d.ts.map +1 -0
- package/dist/cli/commands/validate.js +626 -0
- package/dist/cli/commands/validate.js.map +1 -0
- package/dist/cli/exit-codes.d.ts +62 -0
- package/dist/cli/exit-codes.d.ts.map +1 -0
- package/dist/cli/exit-codes.js +65 -0
- package/dist/cli/exit-codes.js.map +1 -0
- package/dist/cli/help/content.d.ts +35 -0
- package/dist/cli/help/content.d.ts.map +1 -0
- package/dist/cli/help/content.js +312 -0
- package/dist/cli/help/content.js.map +1 -0
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +85 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/introspection.d.ts +87 -0
- package/dist/cli/introspection.d.ts.map +1 -0
- package/dist/cli/introspection.js +127 -0
- package/dist/cli/introspection.js.map +1 -0
- package/dist/cli/output.d.ts +56 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +467 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/suggest.d.ts +16 -0
- package/dist/cli/suggest.d.ts.map +1 -0
- package/dist/cli/suggest.js +72 -0
- package/dist/cli/suggest.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/alignment.d.ts +113 -0
- package/dist/parser/alignment.d.ts.map +1 -0
- package/dist/parser/alignment.js +261 -0
- package/dist/parser/alignment.js.map +1 -0
- package/dist/parser/assess.d.ts +81 -0
- package/dist/parser/assess.d.ts.map +1 -0
- package/dist/parser/assess.js +197 -0
- package/dist/parser/assess.js.map +1 -0
- package/dist/parser/convention-validation.d.ts +48 -0
- package/dist/parser/convention-validation.d.ts.map +1 -0
- package/dist/parser/convention-validation.js +167 -0
- package/dist/parser/convention-validation.js.map +1 -0
- package/dist/parser/fix.d.ts +38 -0
- package/dist/parser/fix.d.ts.map +1 -0
- package/dist/parser/fix.js +185 -0
- package/dist/parser/fix.js.map +1 -0
- package/dist/parser/index.d.ts +12 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +13 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/items.d.ts +138 -0
- package/dist/parser/items.d.ts.map +1 -0
- package/dist/parser/items.js +321 -0
- package/dist/parser/items.js.map +1 -0
- package/dist/parser/meta.d.ts +120 -0
- package/dist/parser/meta.d.ts.map +1 -0
- package/dist/parser/meta.js +441 -0
- package/dist/parser/meta.js.map +1 -0
- package/dist/parser/refs.d.ts +185 -0
- package/dist/parser/refs.d.ts.map +1 -0
- package/dist/parser/refs.js +404 -0
- package/dist/parser/refs.js.map +1 -0
- package/dist/parser/shadow.d.ts +253 -0
- package/dist/parser/shadow.d.ts.map +1 -0
- package/dist/parser/shadow.js +1053 -0
- package/dist/parser/shadow.js.map +1 -0
- package/dist/parser/traits.d.ts +72 -0
- package/dist/parser/traits.d.ts.map +1 -0
- package/dist/parser/traits.js +120 -0
- package/dist/parser/traits.js.map +1 -0
- package/dist/parser/validate.d.ts +89 -0
- package/dist/parser/validate.d.ts.map +1 -0
- package/dist/parser/validate.js +817 -0
- package/dist/parser/validate.js.map +1 -0
- package/dist/parser/yaml.d.ts +326 -0
- package/dist/parser/yaml.d.ts.map +1 -0
- package/dist/parser/yaml.js +1383 -0
- package/dist/parser/yaml.js.map +1 -0
- package/dist/ralph/cli-renderer.d.ts +20 -0
- package/dist/ralph/cli-renderer.d.ts.map +1 -0
- package/dist/ralph/cli-renderer.js +179 -0
- package/dist/ralph/cli-renderer.js.map +1 -0
- package/dist/ralph/events.d.ts +65 -0
- package/dist/ralph/events.d.ts.map +1 -0
- package/dist/ralph/events.js +397 -0
- package/dist/ralph/events.js.map +1 -0
- package/dist/ralph/index.d.ts +8 -0
- package/dist/ralph/index.d.ts.map +1 -0
- package/dist/ralph/index.js +10 -0
- package/dist/ralph/index.js.map +1 -0
- package/dist/schema/common.d.ts +46 -0
- package/dist/schema/common.d.ts.map +1 -0
- package/dist/schema/common.js +71 -0
- package/dist/schema/common.js.map +1 -0
- package/dist/schema/inbox.d.ts +90 -0
- package/dist/schema/inbox.d.ts.map +1 -0
- package/dist/schema/inbox.js +30 -0
- package/dist/schema/inbox.js.map +1 -0
- package/dist/schema/index.d.ts +6 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/meta.d.ts +762 -0
- package/dist/schema/meta.d.ts.map +1 -0
- package/dist/schema/meta.js +144 -0
- package/dist/schema/meta.js.map +1 -0
- package/dist/schema/spec.d.ts +912 -0
- package/dist/schema/spec.d.ts.map +1 -0
- package/dist/schema/spec.js +104 -0
- package/dist/schema/spec.js.map +1 -0
- package/dist/schema/task.d.ts +664 -0
- package/dist/schema/task.d.ts.map +1 -0
- package/dist/schema/task.js +130 -0
- package/dist/schema/task.js.map +1 -0
- package/dist/sessions/index.d.ts +11 -0
- package/dist/sessions/index.d.ts.map +1 -0
- package/dist/sessions/index.js +13 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/sessions/store.d.ts +144 -0
- package/dist/sessions/store.d.ts.map +1 -0
- package/dist/sessions/store.js +325 -0
- package/dist/sessions/store.js.map +1 -0
- package/dist/sessions/types.d.ts +157 -0
- package/dist/sessions/types.d.ts.map +1 -0
- package/dist/sessions/types.js +90 -0
- package/dist/sessions/types.js.map +1 -0
- package/dist/strings/errors.d.ts +420 -0
- package/dist/strings/errors.d.ts.map +1 -0
- package/dist/strings/errors.js +282 -0
- package/dist/strings/errors.js.map +1 -0
- package/dist/strings/guidance.d.ts +65 -0
- package/dist/strings/guidance.d.ts.map +1 -0
- package/dist/strings/guidance.js +66 -0
- package/dist/strings/guidance.js.map +1 -0
- package/dist/strings/index.d.ts +12 -0
- package/dist/strings/index.d.ts.map +1 -0
- package/dist/strings/index.js +12 -0
- package/dist/strings/index.js.map +1 -0
- package/dist/strings/labels.d.ts +74 -0
- package/dist/strings/labels.d.ts.map +1 -0
- package/dist/strings/labels.js +75 -0
- package/dist/strings/labels.js.map +1 -0
- package/dist/strings/validation.d.ts +126 -0
- package/dist/strings/validation.d.ts.map +1 -0
- package/dist/strings/validation.js +135 -0
- package/dist/strings/validation.js.map +1 -0
- package/dist/utils/commit.d.ts +23 -0
- package/dist/utils/commit.d.ts.map +1 -0
- package/dist/utils/commit.js +67 -0
- package/dist/utils/commit.js.map +1 -0
- package/dist/utils/git.d.ts +57 -0
- package/dist/utils/git.d.ts.map +1 -0
- package/dist/utils/git.js +192 -0
- package/dist/utils/git.js.map +1 -0
- package/dist/utils/grep.d.ts +28 -0
- package/dist/utils/grep.d.ts.map +1 -0
- package/dist/utils/grep.js +86 -0
- package/dist/utils/grep.js.map +1 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +6 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/time.d.ts +18 -0
- package/dist/utils/time.d.ts.map +1 -0
- package/dist/utils/time.js +61 -0
- package/dist/utils/time.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow branch utilities for transparent spec/task state tracking.
|
|
3
|
+
*
|
|
4
|
+
* Shadow branch concept:
|
|
5
|
+
* - Orphan branch (kspec-meta) stores kspec state
|
|
6
|
+
* - .kspec/ directory is a git worktree pointing to shadow branch
|
|
7
|
+
* - Main branch gitignores .kspec/
|
|
8
|
+
* - All kspec read/write operations target .kspec/
|
|
9
|
+
* - Changes auto-commit to shadow branch
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs/promises';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { execSync, exec } from 'node:child_process';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
const execAsync = promisify(exec);
|
|
16
|
+
// Import getVerboseMode for checking CLI --debug-shadow flag
|
|
17
|
+
// We use a getter function to avoid issues with circular dependencies
|
|
18
|
+
let getVerboseModeFunc = null;
|
|
19
|
+
export function setVerboseModeGetter(getter) {
|
|
20
|
+
getVerboseModeFunc = getter;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Error types for shadow branch issues
|
|
24
|
+
*/
|
|
25
|
+
export class ShadowError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
suggestion;
|
|
28
|
+
constructor(message, code, suggestion) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.suggestion = suggestion;
|
|
32
|
+
this.name = 'ShadowError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Default shadow branch name
|
|
37
|
+
*/
|
|
38
|
+
export const SHADOW_BRANCH_NAME = 'kspec-meta';
|
|
39
|
+
/**
|
|
40
|
+
* Default shadow worktree directory
|
|
41
|
+
*/
|
|
42
|
+
export const SHADOW_WORKTREE_DIR = '.kspec';
|
|
43
|
+
/**
|
|
44
|
+
* Check if debug mode is enabled.
|
|
45
|
+
* Debug mode can be enabled via:
|
|
46
|
+
* - KSPEC_DEBUG=1 environment variable
|
|
47
|
+
* - Verbose flag (passed from CLI --debug-shadow option)
|
|
48
|
+
*
|
|
49
|
+
* When enabled, shadow branch operations output detailed information.
|
|
50
|
+
*/
|
|
51
|
+
export function isDebugMode(verboseFlag) {
|
|
52
|
+
if (process.env.KSPEC_DEBUG === '1') {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (verboseFlag === true) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
// Check CLI --debug-shadow flag via getter
|
|
59
|
+
if (getVerboseModeFunc?.()) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check if we're in a git repository
|
|
66
|
+
*/
|
|
67
|
+
export async function isGitRepo(dir) {
|
|
68
|
+
try {
|
|
69
|
+
execSync('git rev-parse --git-dir', {
|
|
70
|
+
cwd: dir,
|
|
71
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
72
|
+
encoding: 'utf-8',
|
|
73
|
+
});
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Get the git root directory
|
|
82
|
+
*/
|
|
83
|
+
export function getGitRoot(dir) {
|
|
84
|
+
try {
|
|
85
|
+
const result = execSync('git rev-parse --show-toplevel', {
|
|
86
|
+
cwd: dir,
|
|
87
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
88
|
+
encoding: 'utf-8',
|
|
89
|
+
}).trim();
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Check if a branch exists
|
|
98
|
+
*/
|
|
99
|
+
export async function branchExists(dir, branchName) {
|
|
100
|
+
try {
|
|
101
|
+
execSync(`git show-ref --verify --quiet refs/heads/${branchName}`, {
|
|
102
|
+
cwd: dir,
|
|
103
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
104
|
+
});
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Check if a directory is a valid git worktree
|
|
113
|
+
*/
|
|
114
|
+
export async function isValidWorktree(worktreeDir) {
|
|
115
|
+
try {
|
|
116
|
+
// Check if .git file exists (worktrees have a .git file, not directory)
|
|
117
|
+
const gitPath = path.join(worktreeDir, '.git');
|
|
118
|
+
const stat = await fs.stat(gitPath);
|
|
119
|
+
if (stat.isFile()) {
|
|
120
|
+
// Read the .git file to verify it points to a worktree
|
|
121
|
+
const content = await fs.readFile(gitPath, 'utf-8');
|
|
122
|
+
return content.trim().startsWith('gitdir:');
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Detect if running from inside the shadow worktree directory.
|
|
132
|
+
* Returns the main project root if detected, null otherwise.
|
|
133
|
+
*
|
|
134
|
+
* Detection logic:
|
|
135
|
+
* 1. Check if .git is a file (worktrees have .git files, not directories)
|
|
136
|
+
* 2. Read the gitdir reference from the .git file
|
|
137
|
+
* 3. Check if it points to a worktree for .kspec (pattern: <project>/.git/worktrees/-kspec)
|
|
138
|
+
*/
|
|
139
|
+
export async function detectRunningFromShadowWorktree(cwd) {
|
|
140
|
+
try {
|
|
141
|
+
const gitPath = path.join(cwd, '.git');
|
|
142
|
+
const stat = await fs.stat(gitPath);
|
|
143
|
+
// Worktrees have a .git file, not directory
|
|
144
|
+
if (!stat.isFile()) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const content = await fs.readFile(gitPath, 'utf-8');
|
|
148
|
+
const match = content.trim().match(/^gitdir:\s*(.+)$/);
|
|
149
|
+
if (!match) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const gitdir = match[1];
|
|
153
|
+
// Check if this is a shadow worktree (pattern: <project>/.git/worktrees/-kspec)
|
|
154
|
+
if (gitdir.includes('.git/worktrees/')) {
|
|
155
|
+
const worktreesMatch = gitdir.match(/^(.+)\/\.git\/worktrees\//);
|
|
156
|
+
if (worktreesMatch) {
|
|
157
|
+
const mainProjectRoot = worktreesMatch[1];
|
|
158
|
+
const cwdBase = path.basename(cwd);
|
|
159
|
+
const worktreeName = path.basename(gitdir);
|
|
160
|
+
if (cwdBase === SHADOW_WORKTREE_DIR || worktreeName.includes('kspec')) {
|
|
161
|
+
return mainProjectRoot;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Detect shadow branch configuration from a directory.
|
|
173
|
+
* Returns shadow config if .kspec/ exists and is valid.
|
|
174
|
+
*/
|
|
175
|
+
export async function detectShadow(startDir) {
|
|
176
|
+
const gitRoot = getGitRoot(startDir);
|
|
177
|
+
if (!gitRoot) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const worktreeDir = path.join(gitRoot, SHADOW_WORKTREE_DIR);
|
|
181
|
+
try {
|
|
182
|
+
await fs.access(worktreeDir);
|
|
183
|
+
// Verify it's a valid worktree
|
|
184
|
+
if (await isValidWorktree(worktreeDir)) {
|
|
185
|
+
return {
|
|
186
|
+
enabled: true,
|
|
187
|
+
worktreeDir,
|
|
188
|
+
branchName: SHADOW_BRANCH_NAME,
|
|
189
|
+
projectRoot: gitRoot,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
// Directory exists but not a valid worktree
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// .kspec/ doesn't exist
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Get detailed shadow branch status
|
|
202
|
+
*/
|
|
203
|
+
export async function getShadowStatus(projectRoot) {
|
|
204
|
+
const worktreeDir = path.join(projectRoot, SHADOW_WORKTREE_DIR);
|
|
205
|
+
const status = {
|
|
206
|
+
exists: false,
|
|
207
|
+
healthy: false,
|
|
208
|
+
branchExists: false,
|
|
209
|
+
worktreeExists: false,
|
|
210
|
+
worktreeLinked: false,
|
|
211
|
+
};
|
|
212
|
+
// Check if we're in a git repo
|
|
213
|
+
if (!(await isGitRepo(projectRoot))) {
|
|
214
|
+
status.error = 'Not a git repository';
|
|
215
|
+
return status;
|
|
216
|
+
}
|
|
217
|
+
// Check if branch exists
|
|
218
|
+
status.branchExists = await branchExists(projectRoot, SHADOW_BRANCH_NAME);
|
|
219
|
+
// Check if worktree directory exists
|
|
220
|
+
try {
|
|
221
|
+
await fs.access(worktreeDir);
|
|
222
|
+
status.worktreeExists = true;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
status.worktreeExists = false;
|
|
226
|
+
}
|
|
227
|
+
// Check if worktree is properly linked
|
|
228
|
+
if (status.worktreeExists) {
|
|
229
|
+
status.worktreeLinked = await isValidWorktree(worktreeDir);
|
|
230
|
+
}
|
|
231
|
+
// Determine overall status
|
|
232
|
+
status.exists = status.branchExists || status.worktreeExists;
|
|
233
|
+
status.healthy = status.branchExists && status.worktreeExists && status.worktreeLinked;
|
|
234
|
+
if (!status.healthy && status.exists) {
|
|
235
|
+
if (!status.branchExists) {
|
|
236
|
+
status.error = 'Shadow branch missing but worktree exists';
|
|
237
|
+
}
|
|
238
|
+
else if (!status.worktreeExists) {
|
|
239
|
+
status.error = 'Shadow branch exists but worktree missing';
|
|
240
|
+
}
|
|
241
|
+
else if (!status.worktreeLinked) {
|
|
242
|
+
status.error = 'Worktree exists but not properly linked';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return status;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Create an appropriate ShadowError based on status
|
|
249
|
+
*/
|
|
250
|
+
export function createShadowError(status) {
|
|
251
|
+
if (!status.branchExists && !status.worktreeExists) {
|
|
252
|
+
return new ShadowError('Shadow branch not initialized', 'NOT_INITIALIZED', 'Run `kspec init` to create shadow branch and worktree.');
|
|
253
|
+
}
|
|
254
|
+
if (status.branchExists && !status.worktreeExists) {
|
|
255
|
+
return new ShadowError('.kspec/ directory missing', 'DIRECTORY_MISSING', 'Run `kspec shadow repair` to recreate the worktree.');
|
|
256
|
+
}
|
|
257
|
+
if (status.worktreeExists && !status.worktreeLinked) {
|
|
258
|
+
return new ShadowError('Worktree disconnected from git', 'WORKTREE_DISCONNECTED', 'Run `kspec shadow repair` to fix the worktree link.');
|
|
259
|
+
}
|
|
260
|
+
return new ShadowError(status.error || 'Unknown shadow branch error', 'GIT_ERROR', 'Check git status and try `kspec shadow repair`.');
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Auto-commit changes to shadow branch.
|
|
264
|
+
* Called after write operations when shadow is enabled.
|
|
265
|
+
*
|
|
266
|
+
* @param worktreeDir Path to .kspec/ directory
|
|
267
|
+
* @param message Commit message
|
|
268
|
+
* @param verbose Enable debug output (defaults to KSPEC_DEBUG env var)
|
|
269
|
+
* @returns true if commit succeeded, false if nothing to commit
|
|
270
|
+
*/
|
|
271
|
+
export async function shadowAutoCommit(worktreeDir, message, verbose) {
|
|
272
|
+
const debug = isDebugMode(verbose);
|
|
273
|
+
try {
|
|
274
|
+
if (debug) {
|
|
275
|
+
console.error(`[DEBUG] Shadow auto-commit: git add -A (cwd: ${worktreeDir})`);
|
|
276
|
+
}
|
|
277
|
+
// Stage all changes
|
|
278
|
+
execSync('git add -A', {
|
|
279
|
+
cwd: worktreeDir,
|
|
280
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
281
|
+
});
|
|
282
|
+
// Check if there are staged changes
|
|
283
|
+
try {
|
|
284
|
+
if (debug) {
|
|
285
|
+
console.error(`[DEBUG] Shadow auto-commit: git diff --cached --quiet`);
|
|
286
|
+
}
|
|
287
|
+
execSync('git diff --cached --quiet', {
|
|
288
|
+
cwd: worktreeDir,
|
|
289
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
290
|
+
});
|
|
291
|
+
// No error = no changes
|
|
292
|
+
if (debug) {
|
|
293
|
+
console.error(`[DEBUG] Shadow auto-commit: No changes to commit`);
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// Error = there are changes, proceed with commit
|
|
299
|
+
}
|
|
300
|
+
if (debug) {
|
|
301
|
+
console.error(`[DEBUG] Shadow auto-commit: git commit -m "${message}"`);
|
|
302
|
+
}
|
|
303
|
+
// Commit with message
|
|
304
|
+
// Set KSPEC_SHADOW_COMMIT=1 to signal authorized commit to git hooks
|
|
305
|
+
execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
|
306
|
+
cwd: worktreeDir,
|
|
307
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
308
|
+
env: { ...process.env, KSPEC_SHADOW_COMMIT: '1' },
|
|
309
|
+
});
|
|
310
|
+
if (debug) {
|
|
311
|
+
console.error(`[DEBUG] Shadow auto-commit: Success`);
|
|
312
|
+
}
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
// AC: Only log error if debug mode enabled
|
|
317
|
+
if (debug) {
|
|
318
|
+
console.error('Shadow auto-commit failed:', error);
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Generate commit message for a kspec operation.
|
|
325
|
+
*/
|
|
326
|
+
export function generateCommitMessage(operation, ref, detail) {
|
|
327
|
+
const parts = [];
|
|
328
|
+
switch (operation) {
|
|
329
|
+
case 'task-start':
|
|
330
|
+
parts.push(`Start @${ref}`);
|
|
331
|
+
break;
|
|
332
|
+
case 'task-complete':
|
|
333
|
+
parts.push(`Complete @${ref}`);
|
|
334
|
+
if (detail)
|
|
335
|
+
parts.push(`: ${detail}`);
|
|
336
|
+
break;
|
|
337
|
+
case 'task-note':
|
|
338
|
+
parts.push(`Note on @${ref}`);
|
|
339
|
+
break;
|
|
340
|
+
case 'task-add':
|
|
341
|
+
parts.push(`Add task: ${detail || ref}`);
|
|
342
|
+
break;
|
|
343
|
+
case 'inbox-add':
|
|
344
|
+
parts.push(`Inbox: ${detail?.slice(0, 50)}${(detail?.length || 0) > 50 ? '...' : ''}`);
|
|
345
|
+
break;
|
|
346
|
+
case 'inbox-promote':
|
|
347
|
+
parts.push(`Promote to @${ref}`);
|
|
348
|
+
break;
|
|
349
|
+
case 'item-add':
|
|
350
|
+
parts.push(`Add @${ref}`);
|
|
351
|
+
break;
|
|
352
|
+
case 'item-set':
|
|
353
|
+
parts.push(`Update @${ref}`);
|
|
354
|
+
break;
|
|
355
|
+
case 'item-delete':
|
|
356
|
+
parts.push(`Delete @${ref}`);
|
|
357
|
+
break;
|
|
358
|
+
case 'derive':
|
|
359
|
+
parts.push(`Derive from @${ref}`);
|
|
360
|
+
break;
|
|
361
|
+
default:
|
|
362
|
+
parts.push(operation);
|
|
363
|
+
if (ref)
|
|
364
|
+
parts.push(` @${ref}`);
|
|
365
|
+
}
|
|
366
|
+
return parts.join('');
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Resolve a path relative to shadow worktree if enabled.
|
|
370
|
+
* Falls back to original path if shadow is not enabled.
|
|
371
|
+
*/
|
|
372
|
+
export function resolveShadowPath(originalPath, shadowConfig, projectRoot) {
|
|
373
|
+
if (!shadowConfig?.enabled) {
|
|
374
|
+
return originalPath;
|
|
375
|
+
}
|
|
376
|
+
// If the path is within the project root, rewrite to shadow worktree
|
|
377
|
+
const relativePath = path.relative(projectRoot, originalPath);
|
|
378
|
+
// Skip if path is outside project or already in .kspec
|
|
379
|
+
if (relativePath.startsWith('..') || relativePath.startsWith(SHADOW_WORKTREE_DIR)) {
|
|
380
|
+
return originalPath;
|
|
381
|
+
}
|
|
382
|
+
// Handle spec/ -> .kspec/ mapping
|
|
383
|
+
if (relativePath.startsWith('spec/') || relativePath.startsWith('spec\\')) {
|
|
384
|
+
const specRelative = relativePath.slice(5); // Remove 'spec/'
|
|
385
|
+
return path.join(shadowConfig.worktreeDir, specRelative);
|
|
386
|
+
}
|
|
387
|
+
// For task/inbox files at root, move to .kspec
|
|
388
|
+
if (relativePath.endsWith('.tasks.yaml') || relativePath.endsWith('.inbox.yaml')) {
|
|
389
|
+
return path.join(shadowConfig.worktreeDir, relativePath);
|
|
390
|
+
}
|
|
391
|
+
return originalPath;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Commit changes to shadow branch if enabled.
|
|
395
|
+
* This is the primary interface for CLI commands to trigger auto-commit.
|
|
396
|
+
*
|
|
397
|
+
* @param shadowConfig Shadow configuration (from KspecContext.shadow)
|
|
398
|
+
* @param operation Operation type (e.g., 'task-start', 'task-complete')
|
|
399
|
+
* @param ref Reference slug or ULID (optional)
|
|
400
|
+
* @param detail Additional detail for commit message (optional)
|
|
401
|
+
* @param verbose Enable debug output (defaults to KSPEC_DEBUG env var)
|
|
402
|
+
* @returns true if committed, false if shadow not enabled or nothing to commit
|
|
403
|
+
*/
|
|
404
|
+
export async function commitIfShadow(shadowConfig, operation, ref, detail, verbose) {
|
|
405
|
+
if (!shadowConfig?.enabled) {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
const message = generateCommitMessage(operation, ref, detail);
|
|
409
|
+
const committed = await shadowAutoCommit(shadowConfig.worktreeDir, message, verbose);
|
|
410
|
+
// AC-1: Fire-and-forget push after each commit
|
|
411
|
+
if (committed) {
|
|
412
|
+
shadowPushAsync(shadowConfig.worktreeDir, verbose);
|
|
413
|
+
}
|
|
414
|
+
return committed;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Check if shadow is required but not available, and throw appropriate error.
|
|
418
|
+
* Use this at the start of commands that require shadow mode.
|
|
419
|
+
*
|
|
420
|
+
* @param shadowConfig Shadow configuration from context
|
|
421
|
+
* @param projectRoot Project root for status check
|
|
422
|
+
* @throws ShadowError if shadow is not properly configured
|
|
423
|
+
*/
|
|
424
|
+
export async function requireShadow(shadowConfig, projectRoot) {
|
|
425
|
+
if (shadowConfig?.enabled) {
|
|
426
|
+
return; // Shadow is available
|
|
427
|
+
}
|
|
428
|
+
const status = await getShadowStatus(projectRoot);
|
|
429
|
+
throw createShadowError(status);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Format a ShadowError for display in CLI.
|
|
433
|
+
* Returns a user-friendly message with suggestion.
|
|
434
|
+
*/
|
|
435
|
+
export function formatShadowError(error) {
|
|
436
|
+
return `${error.message}\n\nSuggestion: ${error.suggestion}`;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Check if a remote exists (default: origin)
|
|
440
|
+
*/
|
|
441
|
+
export async function hasRemote(projectRoot, remoteName = 'origin') {
|
|
442
|
+
try {
|
|
443
|
+
const { stdout } = await execAsync(`git remote get-url ${remoteName}`, {
|
|
444
|
+
cwd: projectRoot,
|
|
445
|
+
});
|
|
446
|
+
return stdout.trim().length > 0;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Check if a branch exists on a remote
|
|
454
|
+
*/
|
|
455
|
+
export async function remoteBranchExists(projectRoot, branchName, remoteName = 'origin') {
|
|
456
|
+
try {
|
|
457
|
+
execSync(`git show-ref --verify --quiet refs/remotes/${remoteName}/${branchName}`, {
|
|
458
|
+
cwd: projectRoot,
|
|
459
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
460
|
+
});
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Fetch from remote to ensure refs are up to date.
|
|
469
|
+
* Returns true if fetch succeeded, false otherwise.
|
|
470
|
+
*/
|
|
471
|
+
export async function fetchRemote(projectRoot, remoteName = 'origin') {
|
|
472
|
+
try {
|
|
473
|
+
await execAsync(`git fetch ${remoteName}`, {
|
|
474
|
+
cwd: projectRoot,
|
|
475
|
+
});
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Push shadow branch to remote with tracking.
|
|
484
|
+
* Returns true if push succeeded, false otherwise.
|
|
485
|
+
*/
|
|
486
|
+
export async function pushShadowBranch(worktreeDir, remoteName = 'origin') {
|
|
487
|
+
try {
|
|
488
|
+
await execAsync(`git push -u ${remoteName} ${SHADOW_BRANCH_NAME}`, {
|
|
489
|
+
cwd: worktreeDir,
|
|
490
|
+
});
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Check if shadow branch has remote tracking configured.
|
|
499
|
+
* AC-4: Used to determine whether sync should be attempted.
|
|
500
|
+
*/
|
|
501
|
+
export async function hasRemoteTracking(worktreeDir) {
|
|
502
|
+
try {
|
|
503
|
+
const { stdout } = await execAsync(`git config branch.${SHADOW_BRANCH_NAME}.remote`, { cwd: worktreeDir });
|
|
504
|
+
return stdout.trim().length > 0;
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Ensure shadow branch has remote tracking configured.
|
|
512
|
+
* AC-8: If shadow has no tracking but main branch has origin remote,
|
|
513
|
+
* automatically configure tracking to origin/kspec-meta.
|
|
514
|
+
*
|
|
515
|
+
* @param worktreeDir Path to .kspec/ worktree
|
|
516
|
+
* @param projectRoot Git repository root
|
|
517
|
+
* @returns true if tracking is now configured (was already or just set up)
|
|
518
|
+
*/
|
|
519
|
+
export async function ensureRemoteTracking(worktreeDir, projectRoot) {
|
|
520
|
+
// Check if already has tracking
|
|
521
|
+
if (await hasRemoteTracking(worktreeDir)) {
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
// Check if main branch has origin remote
|
|
525
|
+
if (!(await hasRemote(projectRoot))) {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
// Set up tracking for shadow branch to origin/kspec-meta
|
|
529
|
+
try {
|
|
530
|
+
await execAsync(`git config branch.${SHADOW_BRANCH_NAME}.remote origin`, { cwd: worktreeDir });
|
|
531
|
+
await execAsync(`git config branch.${SHADOW_BRANCH_NAME}.merge refs/heads/${SHADOW_BRANCH_NAME}`, { cwd: worktreeDir });
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Fire-and-forget push to remote.
|
|
540
|
+
* AC-1: Called after each auto-commit when tracking is configured.
|
|
541
|
+
* AC-8: Automatically sets up tracking if main branch has remote.
|
|
542
|
+
* Silently ignores errors - the local commit succeeded regardless.
|
|
543
|
+
*
|
|
544
|
+
* @param worktreeDir Path to .kspec/ worktree
|
|
545
|
+
* @param verbose Enable debug output
|
|
546
|
+
*/
|
|
547
|
+
export async function shadowPushAsync(worktreeDir, verbose) {
|
|
548
|
+
const debug = isDebugMode(verbose);
|
|
549
|
+
// AC-8: Auto-configure tracking if main has remote but shadow doesn't
|
|
550
|
+
const projectRoot = path.dirname(worktreeDir);
|
|
551
|
+
await ensureRemoteTracking(worktreeDir, projectRoot);
|
|
552
|
+
// Check if tracking is configured before attempting push
|
|
553
|
+
if (!(await hasRemoteTracking(worktreeDir))) {
|
|
554
|
+
if (debug) {
|
|
555
|
+
console.error('[DEBUG] Shadow push: No remote tracking configured, skipping');
|
|
556
|
+
}
|
|
557
|
+
return; // AC-4: silently skip if no tracking
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
if (debug) {
|
|
561
|
+
console.error(`[DEBUG] Shadow push: git push (cwd: ${worktreeDir})`);
|
|
562
|
+
}
|
|
563
|
+
// Don't await - fire and forget
|
|
564
|
+
execAsync('git push', { cwd: worktreeDir }).catch((err) => {
|
|
565
|
+
if (debug) {
|
|
566
|
+
console.error('[DEBUG] Shadow push failed:', err);
|
|
567
|
+
}
|
|
568
|
+
// Silently ignore push failures - local state is correct
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
if (debug) {
|
|
573
|
+
console.error('[DEBUG] Shadow push error:', err);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Pull remote changes to shadow branch.
|
|
579
|
+
* AC-2: Called at session start to sync before operations.
|
|
580
|
+
* AC-6: Uses --ff-only first, falls back to --rebase.
|
|
581
|
+
* AC-3: On conflict, returns failure with suggestion.
|
|
582
|
+
* AC-8: Automatically sets up tracking if main branch has remote.
|
|
583
|
+
*/
|
|
584
|
+
export async function shadowPull(worktreeDir) {
|
|
585
|
+
const result = {
|
|
586
|
+
success: false,
|
|
587
|
+
pulled: false,
|
|
588
|
+
pushed: false,
|
|
589
|
+
hadConflict: false,
|
|
590
|
+
};
|
|
591
|
+
// AC-8: Auto-configure tracking if main has remote but shadow doesn't
|
|
592
|
+
const projectRoot = path.dirname(worktreeDir);
|
|
593
|
+
await ensureRemoteTracking(worktreeDir, projectRoot);
|
|
594
|
+
// AC-4: Skip if no remote tracking
|
|
595
|
+
if (!(await hasRemoteTracking(worktreeDir))) {
|
|
596
|
+
result.success = true;
|
|
597
|
+
return result;
|
|
598
|
+
}
|
|
599
|
+
// Check if remote branch exists before attempting pull
|
|
600
|
+
// Fetch first to ensure refs are up to date
|
|
601
|
+
await fetchRemote(projectRoot);
|
|
602
|
+
const remoteHasBranch = await remoteBranchExists(projectRoot, SHADOW_BRANCH_NAME);
|
|
603
|
+
if (!remoteHasBranch) {
|
|
604
|
+
// Remote branch doesn't exist yet - nothing to pull, but success
|
|
605
|
+
result.success = true;
|
|
606
|
+
return result;
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
// Try fast-forward only first (cleanest)
|
|
610
|
+
await execAsync('git pull --ff-only', { cwd: worktreeDir });
|
|
611
|
+
result.success = true;
|
|
612
|
+
result.pulled = true;
|
|
613
|
+
return result;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
// Fast-forward failed, try rebase
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
// AC-6: Fall back to rebase
|
|
620
|
+
await execAsync('git pull --rebase', { cwd: worktreeDir });
|
|
621
|
+
result.success = true;
|
|
622
|
+
result.pulled = true;
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
// Rebase failed - likely conflict
|
|
627
|
+
}
|
|
628
|
+
// AC-3: Conflict detected - abort rebase and report
|
|
629
|
+
try {
|
|
630
|
+
await execAsync('git rebase --abort', { cwd: worktreeDir });
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// May not be in rebase state, ignore
|
|
634
|
+
}
|
|
635
|
+
result.hadConflict = true;
|
|
636
|
+
result.error = 'Sync conflict detected. Run `kspec shadow resolve` to fix.';
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Full sync operation: pull then push.
|
|
641
|
+
* Used by session start and explicit sync commands.
|
|
642
|
+
*/
|
|
643
|
+
export async function shadowSync(worktreeDir) {
|
|
644
|
+
// First pull
|
|
645
|
+
const pullResult = await shadowPull(worktreeDir);
|
|
646
|
+
if (!pullResult.success) {
|
|
647
|
+
return pullResult;
|
|
648
|
+
}
|
|
649
|
+
// Then push (only if tracking configured, checked inside)
|
|
650
|
+
if (await hasRemoteTracking(worktreeDir)) {
|
|
651
|
+
try {
|
|
652
|
+
await execAsync('git push', { cwd: worktreeDir });
|
|
653
|
+
pullResult.pushed = true;
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
// Push failed - not a critical error, local state is correct
|
|
657
|
+
// Could be permissions, network, etc.
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return pullResult;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Check if .gitignore has uncommitted changes
|
|
664
|
+
*/
|
|
665
|
+
async function hasUncommittedGitignore(projectRoot) {
|
|
666
|
+
try {
|
|
667
|
+
// Check both staged and unstaged changes to .gitignore
|
|
668
|
+
const { stdout } = await execAsync('git status --porcelain .gitignore', {
|
|
669
|
+
cwd: projectRoot,
|
|
670
|
+
});
|
|
671
|
+
return stdout.trim().length > 0;
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Commit only .gitignore with a message
|
|
679
|
+
*/
|
|
680
|
+
async function commitGitignore(projectRoot) {
|
|
681
|
+
await execAsync('git add .gitignore', { cwd: projectRoot });
|
|
682
|
+
await execAsync('git commit -m "chore: add .kspec/ to .gitignore for shadow branch"', {
|
|
683
|
+
cwd: projectRoot,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Add .kspec/ to .gitignore if not already present.
|
|
688
|
+
* Fails if .gitignore has uncommitted changes.
|
|
689
|
+
* Commits the change after adding.
|
|
690
|
+
*/
|
|
691
|
+
async function ensureGitignore(projectRoot) {
|
|
692
|
+
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
693
|
+
const entry = `${SHADOW_WORKTREE_DIR}/`;
|
|
694
|
+
// Fail fast if .gitignore has uncommitted changes
|
|
695
|
+
if (await hasUncommittedGitignore(projectRoot)) {
|
|
696
|
+
throw new ShadowError('.gitignore has uncommitted changes', 'GIT_ERROR', 'Commit or stash your .gitignore changes before running kspec init.');
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
let content = '';
|
|
700
|
+
try {
|
|
701
|
+
content = await fs.readFile(gitignorePath, 'utf-8');
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
// File doesn't exist, will create
|
|
705
|
+
}
|
|
706
|
+
// Check if already present (handle various formats)
|
|
707
|
+
const lines = content.split('\n');
|
|
708
|
+
const patterns = [
|
|
709
|
+
SHADOW_WORKTREE_DIR,
|
|
710
|
+
`${SHADOW_WORKTREE_DIR}/`,
|
|
711
|
+
`/${SHADOW_WORKTREE_DIR}`,
|
|
712
|
+
`/${SHADOW_WORKTREE_DIR}/`,
|
|
713
|
+
];
|
|
714
|
+
for (const line of lines) {
|
|
715
|
+
const trimmed = line.trim();
|
|
716
|
+
if (patterns.includes(trimmed)) {
|
|
717
|
+
return false; // Already present
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// Add to gitignore
|
|
721
|
+
const newContent = content.endsWith('\n') || content === ''
|
|
722
|
+
? `${content}${entry}\n`
|
|
723
|
+
: `${content}\n${entry}\n`;
|
|
724
|
+
await fs.writeFile(gitignorePath, newContent, 'utf-8');
|
|
725
|
+
// Commit the change
|
|
726
|
+
await commitGitignore(projectRoot);
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
if (error instanceof ShadowError) {
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
throw new ShadowError(`Failed to update .gitignore: ${error}`, 'GIT_ERROR', 'Check file permissions for .gitignore');
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Generate initial manifest content for shadow branch
|
|
738
|
+
*/
|
|
739
|
+
function generateShadowManifest(projectName) {
|
|
740
|
+
return `# ${projectName} - Kynetic Spec
|
|
741
|
+
# Generated by kspec init
|
|
742
|
+
|
|
743
|
+
kynetic: "1.0"
|
|
744
|
+
|
|
745
|
+
project:
|
|
746
|
+
name: "${projectName}"
|
|
747
|
+
version: "0.1.0"
|
|
748
|
+
status: draft
|
|
749
|
+
description: |
|
|
750
|
+
Add your project description here.
|
|
751
|
+
|
|
752
|
+
# Module includes
|
|
753
|
+
includes:
|
|
754
|
+
- modules/main.yaml
|
|
755
|
+
|
|
756
|
+
# Configuration
|
|
757
|
+
config:
|
|
758
|
+
validation:
|
|
759
|
+
strict_refs: true
|
|
760
|
+
require_acceptance: false
|
|
761
|
+
`;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Generate initial module content
|
|
765
|
+
*/
|
|
766
|
+
function generateShadowModule(projectName) {
|
|
767
|
+
return `# ${projectName} - Main Module
|
|
768
|
+
# Add your spec items here
|
|
769
|
+
|
|
770
|
+
items: []
|
|
771
|
+
`;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Generate initial tasks file
|
|
775
|
+
*/
|
|
776
|
+
function generateShadowTasks(projectName) {
|
|
777
|
+
return `# ${projectName} - Tasks
|
|
778
|
+
# Track implementation work here
|
|
779
|
+
|
|
780
|
+
tasks: []
|
|
781
|
+
`;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Generate initial inbox file
|
|
785
|
+
*/
|
|
786
|
+
function generateShadowInbox() {
|
|
787
|
+
return `# Inbox - Quick Capture
|
|
788
|
+
# Ideas and notes that haven't been triaged yet
|
|
789
|
+
|
|
790
|
+
items: []
|
|
791
|
+
`;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Install pre-commit hook to protect kspec-meta branch.
|
|
795
|
+
* Hook prevents direct commits to shadow branch unless KSPEC_SHADOW_COMMIT=1.
|
|
796
|
+
*
|
|
797
|
+
* Note: Git worktrees use hooks from the main .git/hooks directory (via commondir),
|
|
798
|
+
* not from .git/worktrees/-kspec/hooks. So we install to main hooks directory.
|
|
799
|
+
*
|
|
800
|
+
* @param projectRoot Git repository root
|
|
801
|
+
* @returns true if hook was installed, false if already exists
|
|
802
|
+
*/
|
|
803
|
+
async function installShadowHook(projectRoot) {
|
|
804
|
+
const hooksDir = path.join(projectRoot, '.git', 'hooks');
|
|
805
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
806
|
+
const sourceHookPath = path.join(projectRoot, 'hooks', 'pre-commit');
|
|
807
|
+
try {
|
|
808
|
+
// Check if source hook exists
|
|
809
|
+
try {
|
|
810
|
+
await fs.access(sourceHookPath);
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
// Source hook doesn't exist - skip installation
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
// Check if hook already exists
|
|
817
|
+
try {
|
|
818
|
+
await fs.access(hookPath);
|
|
819
|
+
// Hook exists - don't overwrite (user may have custom hooks)
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
// Hook doesn't exist - install it
|
|
824
|
+
}
|
|
825
|
+
// Copy hook from source
|
|
826
|
+
const hookContent = await fs.readFile(sourceHookPath, 'utf-8');
|
|
827
|
+
await fs.writeFile(hookPath, hookContent, { mode: 0o755 });
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
catch (error) {
|
|
831
|
+
// Silently fail - hook installation is optional
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Convert project name to slug (kebab-case)
|
|
837
|
+
*/
|
|
838
|
+
function toSlug(projectName) {
|
|
839
|
+
return projectName
|
|
840
|
+
.toLowerCase()
|
|
841
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
842
|
+
.replace(/^-|-$/g, '');
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Initialize shadow branch and worktree.
|
|
846
|
+
* Creates orphan branch, worktree, updates gitignore, and creates initial structure.
|
|
847
|
+
*
|
|
848
|
+
* @param projectRoot Git repository root
|
|
849
|
+
* @param options Initialization options
|
|
850
|
+
* @returns Result indicating what was created
|
|
851
|
+
*/
|
|
852
|
+
export async function initializeShadow(projectRoot, options = {}) {
|
|
853
|
+
const result = {
|
|
854
|
+
success: false,
|
|
855
|
+
branchCreated: false,
|
|
856
|
+
worktreeCreated: false,
|
|
857
|
+
gitignoreUpdated: false,
|
|
858
|
+
initialCommit: false,
|
|
859
|
+
alreadyExists: false,
|
|
860
|
+
createdFromRemote: false,
|
|
861
|
+
pushedToRemote: false,
|
|
862
|
+
};
|
|
863
|
+
// Check if we're in a git repo
|
|
864
|
+
if (!(await isGitRepo(projectRoot))) {
|
|
865
|
+
result.error = 'Not a git repository';
|
|
866
|
+
return result;
|
|
867
|
+
}
|
|
868
|
+
const worktreeDir = path.join(projectRoot, SHADOW_WORKTREE_DIR);
|
|
869
|
+
// Check current status
|
|
870
|
+
const status = await getShadowStatus(projectRoot);
|
|
871
|
+
// Handle existing shadow branch
|
|
872
|
+
if (status.healthy && !options.force) {
|
|
873
|
+
result.alreadyExists = true;
|
|
874
|
+
result.success = true;
|
|
875
|
+
return result;
|
|
876
|
+
}
|
|
877
|
+
// Derive project name if not provided
|
|
878
|
+
const projectName = options.projectName || path.basename(projectRoot)
|
|
879
|
+
.replace(/[-_]/g, ' ')
|
|
880
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
881
|
+
const slug = toSlug(projectName);
|
|
882
|
+
// Check for remote shadow branch (AC-4: fetch to ensure refs are up to date)
|
|
883
|
+
const remoteExists = await hasRemote(projectRoot);
|
|
884
|
+
let remoteHasShadow = false;
|
|
885
|
+
if (remoteExists) {
|
|
886
|
+
await fetchRemote(projectRoot); // Best effort, ignore failures
|
|
887
|
+
remoteHasShadow = await remoteBranchExists(projectRoot, SHADOW_BRANCH_NAME);
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
// Step 1: Update .gitignore first (before creating .kspec/)
|
|
891
|
+
result.gitignoreUpdated = await ensureGitignore(projectRoot);
|
|
892
|
+
// Step 2: Create worktree with orphan branch (or attach to existing branch)
|
|
893
|
+
if (!status.worktreeExists || !status.worktreeLinked) {
|
|
894
|
+
// Remove existing directory if present but not linked
|
|
895
|
+
if (status.worktreeExists && !status.worktreeLinked) {
|
|
896
|
+
await fs.rm(worktreeDir, { recursive: true, force: true });
|
|
897
|
+
}
|
|
898
|
+
// Remove stale worktree reference if any
|
|
899
|
+
try {
|
|
900
|
+
await execAsync(`git worktree remove ${SHADOW_WORKTREE_DIR} --force`, {
|
|
901
|
+
cwd: projectRoot,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
// Ignore - worktree may not exist in git's list
|
|
906
|
+
}
|
|
907
|
+
if (remoteHasShadow) {
|
|
908
|
+
// AC-1: Remote has shadow branch - create worktree from it with tracking
|
|
909
|
+
await execAsync(`git worktree add ${SHADOW_WORKTREE_DIR} ${SHADOW_BRANCH_NAME}`, { cwd: projectRoot });
|
|
910
|
+
// Set up tracking for the branch
|
|
911
|
+
await execAsync(`git branch --set-upstream-to=origin/${SHADOW_BRANCH_NAME} ${SHADOW_BRANCH_NAME}`, { cwd: projectRoot });
|
|
912
|
+
result.createdFromRemote = true;
|
|
913
|
+
}
|
|
914
|
+
else if (!status.branchExists) {
|
|
915
|
+
// AC-2/AC-3: No remote branch or no remote - create orphan branch
|
|
916
|
+
await execAsync(`git worktree add --orphan -b ${SHADOW_BRANCH_NAME} ${SHADOW_WORKTREE_DIR}`, { cwd: projectRoot });
|
|
917
|
+
result.branchCreated = true;
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
// Attach to existing local branch
|
|
921
|
+
await execAsync(`git worktree add ${SHADOW_WORKTREE_DIR} ${SHADOW_BRANCH_NAME}`, { cwd: projectRoot });
|
|
922
|
+
}
|
|
923
|
+
result.worktreeCreated = true;
|
|
924
|
+
}
|
|
925
|
+
// Step 3: Create initial structure if empty (only for new branches, not remote)
|
|
926
|
+
const manifestPath = path.join(worktreeDir, `${slug}.yaml`);
|
|
927
|
+
const modulesDir = path.join(worktreeDir, 'modules');
|
|
928
|
+
const moduleFilePath = path.join(modulesDir, 'main.yaml');
|
|
929
|
+
const tasksPath = path.join(worktreeDir, `${slug}.tasks.yaml`);
|
|
930
|
+
const inboxPath = path.join(worktreeDir, `${slug}.inbox.yaml`);
|
|
931
|
+
let filesCreated = false;
|
|
932
|
+
// Only create files if manifest doesn't exist (remote branches will have files)
|
|
933
|
+
try {
|
|
934
|
+
// Look for any .yaml manifest file (project name may differ)
|
|
935
|
+
const files = await fs.readdir(worktreeDir);
|
|
936
|
+
const hasManifest = files.some(f => f.endsWith('.yaml') && !f.includes('.tasks.') && !f.includes('.inbox.'));
|
|
937
|
+
if (!hasManifest) {
|
|
938
|
+
throw new Error('No manifest found');
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
// Manifest doesn't exist, create initial structure
|
|
943
|
+
await fs.mkdir(modulesDir, { recursive: true });
|
|
944
|
+
await fs.writeFile(manifestPath, generateShadowManifest(projectName), 'utf-8');
|
|
945
|
+
await fs.writeFile(moduleFilePath, generateShadowModule(projectName), 'utf-8');
|
|
946
|
+
await fs.writeFile(tasksPath, generateShadowTasks(projectName), 'utf-8');
|
|
947
|
+
await fs.writeFile(inboxPath, generateShadowInbox(), 'utf-8');
|
|
948
|
+
filesCreated = true;
|
|
949
|
+
}
|
|
950
|
+
// Step 4: Initial commit if files were created
|
|
951
|
+
if (filesCreated) {
|
|
952
|
+
result.initialCommit = await shadowAutoCommit(worktreeDir, `Initialize ${projectName} spec`);
|
|
953
|
+
}
|
|
954
|
+
// Step 5: AC-2: Push new branch to remote to establish tracking
|
|
955
|
+
if (result.branchCreated && remoteExists && !remoteHasShadow) {
|
|
956
|
+
result.pushedToRemote = await pushShadowBranch(worktreeDir);
|
|
957
|
+
}
|
|
958
|
+
// Step 6: Install pre-commit hook to protect shadow branch
|
|
959
|
+
await installShadowHook(projectRoot);
|
|
960
|
+
result.success = true;
|
|
961
|
+
return result;
|
|
962
|
+
}
|
|
963
|
+
catch (error) {
|
|
964
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
965
|
+
return result;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Repair a broken shadow branch setup.
|
|
970
|
+
* Handles cases where worktree is disconnected or directory is missing.
|
|
971
|
+
*
|
|
972
|
+
* @param projectRoot Git repository root
|
|
973
|
+
* @returns Result indicating what was repaired
|
|
974
|
+
*/
|
|
975
|
+
export async function repairShadow(projectRoot) {
|
|
976
|
+
const status = await getShadowStatus(projectRoot);
|
|
977
|
+
if (status.healthy) {
|
|
978
|
+
return {
|
|
979
|
+
success: true,
|
|
980
|
+
branchCreated: false,
|
|
981
|
+
worktreeCreated: false,
|
|
982
|
+
gitignoreUpdated: false,
|
|
983
|
+
initialCommit: false,
|
|
984
|
+
alreadyExists: true,
|
|
985
|
+
createdFromRemote: false,
|
|
986
|
+
pushedToRemote: false,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
if (!status.branchExists) {
|
|
990
|
+
// Can't repair without a branch - need full init
|
|
991
|
+
return {
|
|
992
|
+
success: false,
|
|
993
|
+
branchCreated: false,
|
|
994
|
+
worktreeCreated: false,
|
|
995
|
+
gitignoreUpdated: false,
|
|
996
|
+
initialCommit: false,
|
|
997
|
+
alreadyExists: false,
|
|
998
|
+
createdFromRemote: false,
|
|
999
|
+
pushedToRemote: false,
|
|
1000
|
+
error: 'Shadow branch does not exist. Run `kspec init` instead.',
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
// Branch exists but worktree is broken - repair it
|
|
1004
|
+
const worktreeDir = path.join(projectRoot, SHADOW_WORKTREE_DIR);
|
|
1005
|
+
try {
|
|
1006
|
+
// Remove stale worktree reference
|
|
1007
|
+
try {
|
|
1008
|
+
await execAsync(`git worktree remove ${SHADOW_WORKTREE_DIR} --force`, {
|
|
1009
|
+
cwd: projectRoot,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
// Ignore - worktree may not be in git's list
|
|
1014
|
+
}
|
|
1015
|
+
// Remove directory if exists (handles corrupted .git file case)
|
|
1016
|
+
await fs.rm(worktreeDir, { recursive: true, force: true });
|
|
1017
|
+
// Prune stale worktree references (cleans up orphaned entries)
|
|
1018
|
+
try {
|
|
1019
|
+
await execAsync('git worktree prune', { cwd: projectRoot });
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
// Ignore - prune is best-effort
|
|
1023
|
+
}
|
|
1024
|
+
// Recreate worktree
|
|
1025
|
+
await execAsync(`git worktree add ${SHADOW_WORKTREE_DIR} ${SHADOW_BRANCH_NAME}`, { cwd: projectRoot });
|
|
1026
|
+
// Install pre-commit hook
|
|
1027
|
+
await installShadowHook(projectRoot);
|
|
1028
|
+
return {
|
|
1029
|
+
success: true,
|
|
1030
|
+
branchCreated: false,
|
|
1031
|
+
worktreeCreated: true,
|
|
1032
|
+
gitignoreUpdated: false,
|
|
1033
|
+
initialCommit: false,
|
|
1034
|
+
alreadyExists: false,
|
|
1035
|
+
createdFromRemote: false,
|
|
1036
|
+
pushedToRemote: false,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
return {
|
|
1041
|
+
success: false,
|
|
1042
|
+
branchCreated: false,
|
|
1043
|
+
worktreeCreated: false,
|
|
1044
|
+
gitignoreUpdated: false,
|
|
1045
|
+
initialCommit: false,
|
|
1046
|
+
alreadyExists: false,
|
|
1047
|
+
createdFromRemote: false,
|
|
1048
|
+
pushedToRemote: false,
|
|
1049
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
//# sourceMappingURL=shadow.js.map
|