ctx-cc 3.5.0 → 4.0.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 +34 -289
- package/agents/ctx-arch-mapper.md +5 -3
- package/agents/ctx-auditor.md +5 -3
- package/agents/ctx-concerns-mapper.md +5 -3
- package/agents/ctx-criteria-suggester.md +6 -4
- package/agents/ctx-debugger.md +5 -3
- package/agents/ctx-designer.md +488 -114
- package/agents/ctx-discusser.md +5 -3
- package/agents/ctx-executor.md +5 -3
- package/agents/ctx-handoff.md +6 -4
- package/agents/ctx-learner.md +5 -3
- package/agents/ctx-mapper.md +4 -3
- package/agents/ctx-ml-analyst.md +600 -0
- package/agents/ctx-ml-engineer.md +933 -0
- package/agents/ctx-ml-reviewer.md +485 -0
- package/agents/ctx-ml-scientist.md +626 -0
- package/agents/ctx-parallelizer.md +4 -3
- package/agents/ctx-planner.md +5 -3
- package/agents/ctx-predictor.md +4 -3
- package/agents/ctx-qa.md +5 -3
- package/agents/ctx-quality-mapper.md +5 -3
- package/agents/ctx-researcher.md +5 -3
- package/agents/ctx-reviewer.md +6 -4
- package/agents/ctx-team-coordinator.md +5 -3
- package/agents/ctx-tech-mapper.md +5 -3
- package/agents/ctx-verifier.md +5 -3
- package/bin/ctx.js +168 -27
- package/commands/brand.md +309 -0
- package/commands/design.md +304 -0
- package/commands/experiment.md +251 -0
- package/commands/help.md +57 -7
- package/commands/metrics.md +1 -1
- package/commands/milestone.md +1 -1
- package/commands/ml-status.md +197 -0
- package/commands/monitor.md +1 -1
- package/commands/train.md +266 -0
- package/commands/visual-qa.md +559 -0
- package/commands/voice.md +1 -1
- package/hooks/post-tool-use.js +39 -0
- package/hooks/pre-tool-use.js +93 -0
- package/hooks/subagent-stop.js +32 -0
- package/package.json +9 -3
- package/plugin.json +45 -0
- package/skills/ctx-design-system/SKILL.md +572 -0
- package/skills/ctx-ml-experiment/SKILL.md +334 -0
- package/skills/ctx-ml-pipeline/SKILL.md +437 -0
- package/skills/ctx-orchestrator/SKILL.md +91 -0
- package/skills/ctx-review-gate/SKILL.md +111 -0
- package/skills/ctx-state/SKILL.md +100 -0
- package/skills/ctx-visual-qa/SKILL.md +587 -0
- package/src/agents.js +109 -0
- package/src/auto.js +287 -0
- package/src/capabilities.js +171 -0
- package/src/commits.js +94 -0
- package/src/config.js +112 -0
- package/src/context.js +241 -0
- package/src/handoff.js +156 -0
- package/src/hooks.js +218 -0
- package/src/install.js +119 -51
- package/src/lifecycle.js +194 -0
- package/src/metrics.js +198 -0
- package/src/pipeline.js +269 -0
- package/src/review-gate.js +244 -0
- package/src/runner.js +120 -0
- package/src/skills.js +143 -0
- package/src/state.js +267 -0
- package/src/worktree.js +244 -0
- package/templates/PRD.json +1 -1
- package/templates/config.json +1 -237
- package/workflows/ctx-router.md +0 -485
- package/workflows/map-codebase.md +0 -329
package/src/config.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG_PATH = path.join(packageRoot, 'templates', 'config.json');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Load config from a path, falling back to defaults.
|
|
13
|
+
*/
|
|
14
|
+
export function loadConfig(configPath) {
|
|
15
|
+
const defaults = safeReadJson(DEFAULT_CONFIG_PATH) || {};
|
|
16
|
+
const user = (configPath ? safeReadJson(configPath) : null) || {};
|
|
17
|
+
return deepMerge(defaults, user);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get a nested value by dot-notation key.
|
|
22
|
+
* getByPath({ a: { b: 1 } }, 'a.b') → 1
|
|
23
|
+
*/
|
|
24
|
+
export function getByPath(obj, key) {
|
|
25
|
+
return key.split('.').reduce((o, k) => (o != null ? o[k] : undefined), obj);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Set a nested value by dot-notation key.
|
|
30
|
+
* setByPath({}, 'a.b', 1) → { a: { b: 1 } }
|
|
31
|
+
*/
|
|
32
|
+
export function setByPath(obj, key, value) {
|
|
33
|
+
const keys = key.split('.');
|
|
34
|
+
let current = obj;
|
|
35
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
36
|
+
if (current[keys[i]] == null || typeof current[keys[i]] !== 'object') {
|
|
37
|
+
current[keys[i]] = {};
|
|
38
|
+
}
|
|
39
|
+
current = current[keys[i]];
|
|
40
|
+
}
|
|
41
|
+
current[keys[keys.length - 1]] = coerceValue(value);
|
|
42
|
+
return obj;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* List all config keys as flat dot-notation paths.
|
|
47
|
+
*/
|
|
48
|
+
export function flattenKeys(obj, prefix = '') {
|
|
49
|
+
const keys = [];
|
|
50
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
51
|
+
const fullKey = prefix ? `${prefix}.${k}` : k;
|
|
52
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
53
|
+
keys.push(...flattenKeys(v, fullKey));
|
|
54
|
+
} else {
|
|
55
|
+
keys.push(fullKey);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return keys;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Save config to a JSON file.
|
|
63
|
+
*/
|
|
64
|
+
export function saveConfig(configPath, config) {
|
|
65
|
+
const dir = path.dirname(configPath);
|
|
66
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
67
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Format config as a readable table.
|
|
72
|
+
*/
|
|
73
|
+
export function formatConfigTable(config) {
|
|
74
|
+
const keys = flattenKeys(config);
|
|
75
|
+
const maxKey = Math.max(20, ...keys.map(k => k.length));
|
|
76
|
+
const lines = keys.map(k => {
|
|
77
|
+
const val = getByPath(config, k);
|
|
78
|
+
return ` ${k.padEnd(maxKey)} ${JSON.stringify(val)}`;
|
|
79
|
+
});
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- internal ---
|
|
84
|
+
|
|
85
|
+
function safeReadJson(filepath) {
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(fs.readFileSync(filepath, 'utf-8'));
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function deepMerge(target, source) {
|
|
94
|
+
const result = { ...target };
|
|
95
|
+
for (const [key, val] of Object.entries(source)) {
|
|
96
|
+
if (val && typeof val === 'object' && !Array.isArray(val) && target[key] && typeof target[key] === 'object') {
|
|
97
|
+
result[key] = deepMerge(target[key], val);
|
|
98
|
+
} else {
|
|
99
|
+
result[key] = val;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function coerceValue(str) {
|
|
106
|
+
if (str === 'true') return true;
|
|
107
|
+
if (str === 'false') return false;
|
|
108
|
+
if (str === 'null') return null;
|
|
109
|
+
if (/^\d+$/.test(str)) return parseInt(str, 10);
|
|
110
|
+
if (/^\d+\.\d+$/.test(str)) return parseFloat(str);
|
|
111
|
+
return str;
|
|
112
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default context profiles per agent category.
|
|
7
|
+
* Each profile lists which context sources the agent receives.
|
|
8
|
+
*
|
|
9
|
+
* Sources:
|
|
10
|
+
* state — .ctx/STATE.json
|
|
11
|
+
* prd — .ctx/PRD.json (active story only)
|
|
12
|
+
* plan — .ctx/phases/<story>/PLAN.md
|
|
13
|
+
* repoMap — .ctx/REPO-MAP.md
|
|
14
|
+
* gitDiff — git diff (staged + unstaged)
|
|
15
|
+
* gitLog — recent commits
|
|
16
|
+
* fileTree — directory listing
|
|
17
|
+
* readme — README.md first 100 lines
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_PROFILES = {
|
|
20
|
+
// Planning agents — need requirements and architecture, not code diffs
|
|
21
|
+
plan: ['state', 'prd', 'repoMap', 'readme'],
|
|
22
|
+
predict: ['state', 'prd', 'repoMap', 'fileTree'],
|
|
23
|
+
criteria: ['state', 'prd'],
|
|
24
|
+
discuss: ['state', 'prd', 'repoMap'],
|
|
25
|
+
parallelize: ['state', 'plan'],
|
|
26
|
+
|
|
27
|
+
// Execution agents — need the plan and relevant source files
|
|
28
|
+
execute: ['state', 'plan', 'repoMap'],
|
|
29
|
+
debug: ['state', 'gitDiff', 'gitLog', 'repoMap'],
|
|
30
|
+
|
|
31
|
+
// Review agents — need the diff, not the full tree
|
|
32
|
+
review: ['state', 'gitDiff', 'prd'],
|
|
33
|
+
audit: ['state', 'gitDiff'],
|
|
34
|
+
verify: ['state', 'prd', 'gitDiff'],
|
|
35
|
+
|
|
36
|
+
// Mapper agents — need filesystem overview
|
|
37
|
+
map: ['fileTree', 'readme'],
|
|
38
|
+
'arch-map': ['fileTree', 'readme'],
|
|
39
|
+
'tech-map': ['fileTree', 'readme'],
|
|
40
|
+
'quality-map': ['fileTree'],
|
|
41
|
+
'concerns-map': ['fileTree', 'gitLog'],
|
|
42
|
+
|
|
43
|
+
// Knowledge agents
|
|
44
|
+
research: ['state', 'prd', 'repoMap'],
|
|
45
|
+
learn: ['state', 'repoMap'],
|
|
46
|
+
design: ['state', 'prd', 'readme'],
|
|
47
|
+
|
|
48
|
+
// Coordination agents
|
|
49
|
+
handoff: ['state'],
|
|
50
|
+
team: ['state'],
|
|
51
|
+
qa: ['state', 'prd', 'readme'],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Estimated tokens per source (conservative)
|
|
55
|
+
const SOURCE_TOKEN_ESTIMATES = {
|
|
56
|
+
state: 500,
|
|
57
|
+
prd: 1000,
|
|
58
|
+
plan: 1500,
|
|
59
|
+
repoMap: 2000,
|
|
60
|
+
gitDiff: 3000,
|
|
61
|
+
gitLog: 500,
|
|
62
|
+
fileTree: 1000,
|
|
63
|
+
readme: 800,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load context manifest from file, or return defaults.
|
|
68
|
+
*/
|
|
69
|
+
export function loadContextManifest(ctxDir) {
|
|
70
|
+
const manifestPath = path.join(ctxDir, 'context-manifest.json');
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
73
|
+
} catch {
|
|
74
|
+
return DEFAULT_PROFILES;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the context profile for an agent command.
|
|
80
|
+
* Checks: agent frontmatter 'context-profile' → manifest → defaults.
|
|
81
|
+
*/
|
|
82
|
+
export function getContextProfile(agentCommand, manifest = DEFAULT_PROFILES) {
|
|
83
|
+
return manifest[agentCommand] || ['state', 'repoMap']; // fallback: minimal
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build context string for an agent based on its profile.
|
|
88
|
+
* Returns { context: string, estimatedTokens: number, warnings: string[] }.
|
|
89
|
+
*/
|
|
90
|
+
export function buildContext(agentCommand, projectDir, ctxDir, options = {}) {
|
|
91
|
+
const manifest = loadContextManifest(ctxDir);
|
|
92
|
+
const sources = getContextProfile(agentCommand, manifest);
|
|
93
|
+
const tokenLimit = options.tokenLimit || 100_000;
|
|
94
|
+
|
|
95
|
+
const sections = [];
|
|
96
|
+
let estimatedTokens = 0;
|
|
97
|
+
const warnings = [];
|
|
98
|
+
|
|
99
|
+
for (const source of sources) {
|
|
100
|
+
const { content, tokens } = loadSource(source, projectDir, ctxDir);
|
|
101
|
+
if (content) {
|
|
102
|
+
sections.push(content);
|
|
103
|
+
estimatedTokens += tokens;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (estimatedTokens > tokenLimit * 0.8) {
|
|
108
|
+
warnings.push(`Context payload ~${estimatedTokens} tokens (${Math.round(estimatedTokens / tokenLimit * 100)}% of limit). Consider reducing context sources.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
context: sections.join('\n\n---\n\n'),
|
|
113
|
+
estimatedTokens,
|
|
114
|
+
warnings,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Estimate token count from a string (chars / 4 approximation).
|
|
120
|
+
*/
|
|
121
|
+
export function estimateTokens(text) {
|
|
122
|
+
return Math.ceil((text || '').length / 4);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Source loaders ---
|
|
126
|
+
|
|
127
|
+
function loadSource(source, projectDir, ctxDir) {
|
|
128
|
+
const loaders = {
|
|
129
|
+
state: () => safeRead(path.join(ctxDir, 'STATE.json'), '## Project State\n```json\n', '\n```'),
|
|
130
|
+
prd: () => loadPrdStory(ctxDir),
|
|
131
|
+
plan: () => loadCurrentPlan(ctxDir),
|
|
132
|
+
repoMap: () => safeRead(path.join(ctxDir, 'REPO-MAP.md'), '## Repository Map\n'),
|
|
133
|
+
gitDiff: () => loadGitDiff(projectDir),
|
|
134
|
+
gitLog: () => loadGitLog(projectDir),
|
|
135
|
+
fileTree: () => loadFileTree(projectDir),
|
|
136
|
+
readme: () => loadReadme(projectDir),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const loader = loaders[source];
|
|
140
|
+
if (!loader) return { content: null, tokens: 0 };
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const content = loader();
|
|
144
|
+
return { content, tokens: estimateTokens(content) };
|
|
145
|
+
} catch {
|
|
146
|
+
return { content: null, tokens: 0 };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function safeRead(filePath, prefix = '', suffix = '') {
|
|
151
|
+
try {
|
|
152
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
153
|
+
return `${prefix}${content}${suffix}`;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function loadPrdStory(ctxDir) {
|
|
160
|
+
try {
|
|
161
|
+
const prd = JSON.parse(fs.readFileSync(path.join(ctxDir, 'PRD.json'), 'utf-8'));
|
|
162
|
+
const current = prd.metadata?.currentStory;
|
|
163
|
+
if (!current) return `## PRD\nProject: ${prd.project?.name || 'unknown'}\nNo active story.`;
|
|
164
|
+
|
|
165
|
+
const story = (prd.stories || []).find(s => s.id === current);
|
|
166
|
+
if (!story) return null;
|
|
167
|
+
|
|
168
|
+
return [
|
|
169
|
+
`## Active Story: ${story.id} — ${story.title}`,
|
|
170
|
+
story.description,
|
|
171
|
+
'',
|
|
172
|
+
'### Acceptance Criteria',
|
|
173
|
+
...(story.acceptanceCriteria || []).map((c, i) => `${i + 1}. ${c}`),
|
|
174
|
+
].join('\n');
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function loadCurrentPlan(ctxDir) {
|
|
181
|
+
try {
|
|
182
|
+
const state = JSON.parse(fs.readFileSync(path.join(ctxDir, 'STATE.json'), 'utf-8'));
|
|
183
|
+
const storyId = state.activeStory;
|
|
184
|
+
if (!storyId) return null;
|
|
185
|
+
|
|
186
|
+
const planPath = path.join(ctxDir, 'phases', storyId, 'PLAN.md');
|
|
187
|
+
return safeRead(planPath, '## Execution Plan\n');
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function loadGitDiff(projectDir) {
|
|
194
|
+
try {
|
|
195
|
+
const diff = execSync('git diff HEAD --stat --no-color 2>/dev/null && echo "---" && git diff HEAD --no-color 2>/dev/null', {
|
|
196
|
+
cwd: projectDir, encoding: 'utf-8', timeout: 10000, maxBuffer: 1024 * 1024,
|
|
197
|
+
}).trim();
|
|
198
|
+
if (!diff || diff === '---') return null;
|
|
199
|
+
// Truncate if too large
|
|
200
|
+
const maxChars = 12000; // ~3000 tokens
|
|
201
|
+
const truncated = diff.length > maxChars ? diff.slice(0, maxChars) + '\n\n... (diff truncated)' : diff;
|
|
202
|
+
return `## Git Diff\n\`\`\`diff\n${truncated}\n\`\`\``;
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function loadGitLog(projectDir) {
|
|
209
|
+
try {
|
|
210
|
+
const log = execSync('git log --oneline -10 --no-color 2>/dev/null', {
|
|
211
|
+
cwd: projectDir, encoding: 'utf-8', timeout: 5000,
|
|
212
|
+
}).trim();
|
|
213
|
+
return log ? `## Recent Commits\n\`\`\`\n${log}\n\`\`\`` : null;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function loadFileTree(projectDir) {
|
|
220
|
+
try {
|
|
221
|
+
const tree = execSync(
|
|
222
|
+
'find . -type f -not -path "./.git/*" -not -path "./node_modules/*" -not -path "./.ctx/*" -not -path "./.next/*" -not -path "./dist/*" -not -path "./.cache/*" | head -200 | sort',
|
|
223
|
+
{ cwd: projectDir, encoding: 'utf-8', timeout: 5000, maxBuffer: 512 * 1024 }
|
|
224
|
+
).trim();
|
|
225
|
+
return tree ? `## File Tree\n\`\`\`\n${tree}\n\`\`\`` : null;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function loadReadme(projectDir) {
|
|
232
|
+
try {
|
|
233
|
+
const readme = fs.readFileSync(path.join(projectDir, 'README.md'), 'utf-8');
|
|
234
|
+
const lines = readme.split('\n').slice(0, 100);
|
|
235
|
+
return `## README (first 100 lines)\n${lines.join('\n')}`;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export { DEFAULT_PROFILES };
|
package/src/handoff.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { readState } from './state.js';
|
|
4
|
+
|
|
5
|
+
const HANDOFF_FILE = 'HANDOFF.json';
|
|
6
|
+
const HANDOFFS_DIR = 'handoffs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a handoff file from current state.
|
|
10
|
+
* Serializes state + generates a human-readable context summary.
|
|
11
|
+
*/
|
|
12
|
+
export function createHandoff(ctxDir) {
|
|
13
|
+
const state = readState(ctxDir);
|
|
14
|
+
if (!state) {
|
|
15
|
+
throw new Error('No STATE.json found. Nothing to pause.');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const handoff = {
|
|
19
|
+
version: '4.0',
|
|
20
|
+
createdAt: new Date().toISOString(),
|
|
21
|
+
state: { ...state },
|
|
22
|
+
summary: generateSummary(state),
|
|
23
|
+
nextAction: getNextAction(state),
|
|
24
|
+
decisions: extractDecisions(ctxDir),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Archive existing handoff if present
|
|
28
|
+
archiveHandoff(ctxDir);
|
|
29
|
+
|
|
30
|
+
// Write new handoff
|
|
31
|
+
const handoffPath = path.join(ctxDir, HANDOFF_FILE);
|
|
32
|
+
fs.writeFileSync(handoffPath, JSON.stringify(handoff, null, 2) + '\n');
|
|
33
|
+
|
|
34
|
+
return handoff;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read a handoff file. Returns null if not found.
|
|
39
|
+
*/
|
|
40
|
+
export function readHandoff(ctxDir) {
|
|
41
|
+
const handoffPath = path.join(ctxDir, HANDOFF_FILE);
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(fs.readFileSync(handoffPath, 'utf-8'));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resume from a handoff. Restores state and returns context for continuation.
|
|
51
|
+
*/
|
|
52
|
+
export function resumeFromHandoff(ctxDir) {
|
|
53
|
+
const handoff = readHandoff(ctxDir);
|
|
54
|
+
if (!handoff) {
|
|
55
|
+
throw new Error('No paused session found. Run /ctx:pause first to create a checkpoint.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Clean up handoff file after resume
|
|
59
|
+
const handoffPath = path.join(ctxDir, HANDOFF_FILE);
|
|
60
|
+
archiveHandoff(ctxDir);
|
|
61
|
+
|
|
62
|
+
return handoff;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Format handoff for human-readable display.
|
|
67
|
+
*/
|
|
68
|
+
export function formatHandoff(handoff) {
|
|
69
|
+
if (!handoff) return ' No paused session found.';
|
|
70
|
+
|
|
71
|
+
const lines = [
|
|
72
|
+
` Paused at: ${handoff.createdAt}`,
|
|
73
|
+
` Phase: ${handoff.state?.phase || 'unknown'}`,
|
|
74
|
+
` Story: ${handoff.state?.activeStory || 'none'} ${handoff.state?.storyTitle ? `— ${handoff.state.storyTitle}` : ''}`,
|
|
75
|
+
` Tasks done: ${(handoff.state?.completedTasks || []).length}`,
|
|
76
|
+
'',
|
|
77
|
+
' Summary:',
|
|
78
|
+
` ${handoff.summary}`,
|
|
79
|
+
'',
|
|
80
|
+
` Next action: ${handoff.nextAction}`,
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
if (handoff.decisions && handoff.decisions.length > 0) {
|
|
84
|
+
lines.push('', ' Key decisions:');
|
|
85
|
+
for (const d of handoff.decisions) {
|
|
86
|
+
lines.push(` - ${d}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- internal ---
|
|
94
|
+
|
|
95
|
+
function generateSummary(state) {
|
|
96
|
+
const parts = [`Phase: ${state.phase}`];
|
|
97
|
+
|
|
98
|
+
if (state.activeStory) {
|
|
99
|
+
parts.push(`Working on: ${state.activeStory} ${state.storyTitle ? `(${state.storyTitle})` : ''}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const taskCount = (state.completedTasks || []).length;
|
|
103
|
+
if (taskCount > 0) {
|
|
104
|
+
parts.push(`Completed ${taskCount} task(s)`);
|
|
105
|
+
const lastTask = state.completedTasks[taskCount - 1];
|
|
106
|
+
parts.push(`Last completed: ${lastTask.title}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const agentCount = (state.agentHistory || []).length;
|
|
110
|
+
if (agentCount > 0) {
|
|
111
|
+
const lastAgent = state.agentHistory[agentCount - 1];
|
|
112
|
+
parts.push(`Last agent: ${lastAgent.agent} (${lastAgent.taskSummary})`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return parts.join('. ');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getNextAction(state) {
|
|
119
|
+
const actions = {
|
|
120
|
+
init: 'Run /ctx:plan to create an execution plan',
|
|
121
|
+
plan: 'Run /ctx:execute to start building',
|
|
122
|
+
execute: 'Continue execution or run /ctx:verify',
|
|
123
|
+
verify: 'Check verification results and advance',
|
|
124
|
+
complete: 'Pick next story from PRD',
|
|
125
|
+
};
|
|
126
|
+
return actions[state.phase] || 'Run /ctx:status to check state';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function extractDecisions(ctxDir) {
|
|
130
|
+
// Try to read CONTEXT.md for decisions
|
|
131
|
+
try {
|
|
132
|
+
const contextPath = path.join(ctxDir, 'CONTEXT.md');
|
|
133
|
+
const content = fs.readFileSync(contextPath, 'utf-8');
|
|
134
|
+
const decisions = [];
|
|
135
|
+
for (const line of content.split('\n')) {
|
|
136
|
+
if (line.startsWith('- ') && (line.includes('decision') || line.includes('chose') || line.includes('decided'))) {
|
|
137
|
+
decisions.push(line.slice(2));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return decisions;
|
|
141
|
+
} catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function archiveHandoff(ctxDir) {
|
|
147
|
+
const handoffPath = path.join(ctxDir, HANDOFF_FILE);
|
|
148
|
+
if (!fs.existsSync(handoffPath)) return;
|
|
149
|
+
|
|
150
|
+
const archiveDir = path.join(ctxDir, HANDOFFS_DIR);
|
|
151
|
+
if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
|
|
152
|
+
|
|
153
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
154
|
+
const archivePath = path.join(archiveDir, `HANDOFF-${timestamp}.json`);
|
|
155
|
+
fs.renameSync(handoffPath, archivePath);
|
|
156
|
+
}
|