@hanhnd/agent-kit 1.0.33 → 1.0.34
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/dist/core/handoffs/index.d.ts +8 -7
- package/dist/core/handoffs/index.js +12 -4
- package/dist/core/handoffs/index.test.d.ts +1 -0
- package/dist/core/handoffs/index.test.js +88 -0
- package/dist/mcp/agent.d.ts +1 -1
- package/dist/mcp/agent.js +3 -3
- package/dist/mcp/core.d.ts +7 -0
- package/dist/mcp/core.js +39 -25
- package/dist/mcp/core.test.d.ts +1 -0
- package/dist/mcp/core.test.js +98 -0
- package/dist/server.js +49 -28
- package/package.json +1 -1
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
export declare const HANDOFF_TYPES: readonly ["brainstorm", "clarification", "plan", "ticket", "research", "scenario", "investigation"];
|
|
1
|
+
export declare const HANDOFF_TYPES: readonly ["brainstorm", "clarification", "plan", "ticket", "research", "scenario", "investigation", "code"];
|
|
2
2
|
type HandoffType = (typeof HANDOFF_TYPES)[number];
|
|
3
|
-
type CanonicalHandoffType = 'brainstorm' | 'clarification' | 'plan' | 'ticket' | 'research' | 'scenario' | 'investigation';
|
|
4
|
-
interface
|
|
3
|
+
type CanonicalHandoffType = 'brainstorm' | 'clarification' | 'plan' | 'ticket' | 'research' | 'scenario' | 'investigation' | 'code';
|
|
4
|
+
export interface SavedHandoffFolderLocation {
|
|
5
5
|
featureSlug: string;
|
|
6
6
|
canonicalType: CanonicalHandoffType;
|
|
7
|
-
|
|
7
|
+
folderPath: string;
|
|
8
8
|
relativePath: string;
|
|
9
9
|
}
|
|
10
|
-
export declare function
|
|
10
|
+
export declare function resolveHandoffFolder(input: {
|
|
11
11
|
workspaceRoot: string;
|
|
12
12
|
type: HandoffType;
|
|
13
13
|
slug: string;
|
|
14
|
-
|
|
15
|
-
}):
|
|
14
|
+
primaryContent: string;
|
|
15
|
+
}): SavedHandoffFolderLocation;
|
|
16
|
+
export declare function validateHandoffFilename(name: string): string;
|
|
16
17
|
export {};
|
|
@@ -7,6 +7,7 @@ export const HANDOFF_TYPES = [
|
|
|
7
7
|
'research',
|
|
8
8
|
'scenario',
|
|
9
9
|
'investigation',
|
|
10
|
+
'code',
|
|
10
11
|
];
|
|
11
12
|
const TICKET_ID_PATTERN = /\b[A-Z][A-Z0-9]+-\d+\b/;
|
|
12
13
|
function findTicketId(value) {
|
|
@@ -44,18 +45,25 @@ function deriveFeatureSlug(input) {
|
|
|
44
45
|
return contentTicketSlug;
|
|
45
46
|
return sanitizeFeatureSlug(contentSlugCandidate(input.content)) || 'untitled-handoff';
|
|
46
47
|
}
|
|
47
|
-
export function
|
|
48
|
+
export function resolveHandoffFolder(input) {
|
|
48
49
|
const canonicalType = normalizeHandoffType(input.type);
|
|
49
50
|
const featureSlug = deriveFeatureSlug({
|
|
50
51
|
requestedSlug: input.slug,
|
|
51
|
-
content: input.
|
|
52
|
+
content: input.primaryContent,
|
|
52
53
|
type: canonicalType,
|
|
53
54
|
});
|
|
54
|
-
const relativePath = path.join('.agent-kit', 'handoffs', featureSlug,
|
|
55
|
+
const relativePath = path.join('.agent-kit', 'handoffs', featureSlug, canonicalType);
|
|
55
56
|
return {
|
|
56
57
|
featureSlug,
|
|
57
58
|
canonicalType,
|
|
58
|
-
|
|
59
|
+
folderPath: path.join(input.workspaceRoot, relativePath),
|
|
59
60
|
relativePath,
|
|
60
61
|
};
|
|
61
62
|
}
|
|
63
|
+
export function validateHandoffFilename(name) {
|
|
64
|
+
const normalized = name.trim().replace(/^["']|["']$/g, '');
|
|
65
|
+
if (!/^[A-Za-z0-9_-]+\.md$/.test(normalized)) {
|
|
66
|
+
throw new Error(`Unsafe handoff filename: ${name}`);
|
|
67
|
+
}
|
|
68
|
+
return normalized;
|
|
69
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as assert from 'node:assert/strict';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { describe, test } from 'node:test';
|
|
4
|
+
import { resolveHandoffFolder, validateHandoffFilename } from './index.js';
|
|
5
|
+
const WORKSPACE = '/tmp/test-workspace';
|
|
6
|
+
describe('resolveHandoffFolder', () => {
|
|
7
|
+
test('relativePath has no .md suffix', () => {
|
|
8
|
+
const result = resolveHandoffFolder({
|
|
9
|
+
workspaceRoot: WORKSPACE,
|
|
10
|
+
type: 'plan',
|
|
11
|
+
slug: 'my-feature',
|
|
12
|
+
primaryContent: '',
|
|
13
|
+
});
|
|
14
|
+
assert.ok(!result.relativePath.endsWith('.md'), `relativePath must not end with .md, got: ${result.relativePath}`);
|
|
15
|
+
assert.equal(result.relativePath, path.join('.agent-kit', 'handoffs', 'my-feature', 'plan'));
|
|
16
|
+
});
|
|
17
|
+
test('folderPath is absolute under workspaceRoot', () => {
|
|
18
|
+
const result = resolveHandoffFolder({
|
|
19
|
+
workspaceRoot: WORKSPACE,
|
|
20
|
+
type: 'brainstorm',
|
|
21
|
+
slug: 'auth',
|
|
22
|
+
primaryContent: '',
|
|
23
|
+
});
|
|
24
|
+
assert.equal(result.folderPath, path.join(WORKSPACE, '.agent-kit', 'handoffs', 'auth', 'brainstorm'));
|
|
25
|
+
});
|
|
26
|
+
test('ticket-ID extracted from primaryContent when slug is empty', () => {
|
|
27
|
+
const result = resolveHandoffFolder({
|
|
28
|
+
workspaceRoot: WORKSPACE,
|
|
29
|
+
type: 'plan',
|
|
30
|
+
slug: '',
|
|
31
|
+
primaryContent: '# Fix PROJ-456\n\nSome content.',
|
|
32
|
+
});
|
|
33
|
+
assert.equal(result.featureSlug, 'proj-456');
|
|
34
|
+
});
|
|
35
|
+
test('first-heading fallback when no ticket ID in slug or content', () => {
|
|
36
|
+
const result = resolveHandoffFolder({
|
|
37
|
+
workspaceRoot: WORKSPACE,
|
|
38
|
+
type: 'research',
|
|
39
|
+
slug: '',
|
|
40
|
+
primaryContent: '# My Feature\n\nDetail.',
|
|
41
|
+
});
|
|
42
|
+
assert.equal(result.featureSlug, 'my-feature');
|
|
43
|
+
});
|
|
44
|
+
test('"untitled-handoff" fallback when both slug and content are empty', () => {
|
|
45
|
+
const result = resolveHandoffFolder({ workspaceRoot: WORKSPACE, type: 'ticket', slug: '', primaryContent: '' });
|
|
46
|
+
assert.equal(result.featureSlug, 'untitled-handoff');
|
|
47
|
+
});
|
|
48
|
+
test('code handoff resolves under code folder', () => {
|
|
49
|
+
const result = resolveHandoffFolder({
|
|
50
|
+
workspaceRoot: WORKSPACE,
|
|
51
|
+
type: 'code',
|
|
52
|
+
slug: 'execution-decision-ledger',
|
|
53
|
+
primaryContent: '# Execution Report',
|
|
54
|
+
});
|
|
55
|
+
assert.equal(result.canonicalType, 'code');
|
|
56
|
+
assert.equal(result.relativePath, path.join('.agent-kit', 'handoffs', 'execution-decision-ledger', 'code'));
|
|
57
|
+
assert.equal(result.folderPath, path.join(WORKSPACE, '.agent-kit', 'handoffs', 'execution-decision-ledger', 'code'));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('validateHandoffFilename', () => {
|
|
61
|
+
test('"README.md" returns unchanged', () => {
|
|
62
|
+
assert.equal(validateHandoffFilename('README.md'), 'README.md');
|
|
63
|
+
});
|
|
64
|
+
test('"DETAIL.md" returns unchanged', () => {
|
|
65
|
+
assert.equal(validateHandoffFilename('DETAIL.md'), 'DETAIL.md');
|
|
66
|
+
});
|
|
67
|
+
test('"index.md" returns unchanged', () => {
|
|
68
|
+
assert.equal(validateHandoffFilename('index.md'), 'index.md');
|
|
69
|
+
});
|
|
70
|
+
test('"../etc/passwd" throws', () => {
|
|
71
|
+
assert.throws(() => validateHandoffFilename('../etc/passwd'), /Unsafe handoff filename/);
|
|
72
|
+
});
|
|
73
|
+
test('"sub/dir.md" throws', () => {
|
|
74
|
+
assert.throws(() => validateHandoffFilename('sub/dir.md'), /Unsafe handoff filename/);
|
|
75
|
+
});
|
|
76
|
+
test('"README" throws — missing .md extension', () => {
|
|
77
|
+
assert.throws(() => validateHandoffFilename('README'), /Unsafe handoff filename/);
|
|
78
|
+
});
|
|
79
|
+
test('empty string throws', () => {
|
|
80
|
+
assert.throws(() => validateHandoffFilename(''), /Unsafe handoff filename/);
|
|
81
|
+
});
|
|
82
|
+
test('"README.txt" throws — wrong extension', () => {
|
|
83
|
+
assert.throws(() => validateHandoffFilename('README.txt'), /Unsafe handoff filename/);
|
|
84
|
+
});
|
|
85
|
+
test('".hidden.md" throws — leading dot', () => {
|
|
86
|
+
assert.throws(() => validateHandoffFilename('.hidden.md'), /Unsafe handoff filename/);
|
|
87
|
+
});
|
|
88
|
+
});
|
package/dist/mcp/agent.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent Delegation Tools - Invoke external agent CLIs (Gemini, Claude, Codex)
|
|
3
|
-
* Supports handoff
|
|
3
|
+
* Supports handoff folder references and automatic fallback
|
|
4
4
|
*/
|
|
5
5
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
export declare function registerAgentTools(server: McpServer): void;
|
package/dist/mcp/agent.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent Delegation Tools - Invoke external agent CLIs (Gemini, Claude, Codex)
|
|
3
|
-
* Supports handoff
|
|
3
|
+
* Supports handoff folder references and automatic fallback
|
|
4
4
|
*/
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
6
|
import { randomUUID } from 'crypto';
|
|
@@ -49,11 +49,11 @@ function initAgentLog(workspaceRoot, slug, agent) {
|
|
|
49
49
|
return { logPath: path.relative(workspaceRoot, logFile), logStream };
|
|
50
50
|
}
|
|
51
51
|
export function registerAgentTools(server) {
|
|
52
|
-
server.tool('kit_trigger_agent', '!Important: Trigger only when user explicitly asks to delegate a task to an external agent CLI (gemini, claude, or codex). The task can be a direct message or a path to a handoff
|
|
52
|
+
server.tool('kit_trigger_agent', '!Important: Trigger only when user explicitly asks to delegate a task to an external agent CLI (gemini, claude, or codex). The task can be a direct message or a path to a handoff folder (.agent-kit/handoffs/feature-slug/plan).', {
|
|
53
53
|
agent: z.enum(['gemini', 'claude', 'codex']).describe('The agent CLI to invoke: "gemini", "claude", or "codex"'),
|
|
54
54
|
task: z
|
|
55
55
|
.string()
|
|
56
|
-
.describe('Task message or path to a handoff
|
|
56
|
+
.describe('Task message or path to a handoff folder (e.g., ".agent-kit/handoffs/feature-slug/plan")'),
|
|
57
57
|
}, async ({ agent, task }, extra) => {
|
|
58
58
|
const workspaceRoot = getWorkspaceRoot();
|
|
59
59
|
let job;
|
package/dist/mcp/core.d.ts
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
* Core Tools - Extension info and handoff persistence
|
|
3
3
|
*/
|
|
4
4
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { mcpText } from '../utils/utils.js';
|
|
5
6
|
import { DEFAULT_EXTENSIONS } from '../core/config/index.js';
|
|
7
|
+
import { HANDOFF_TYPES } from '../core/handoffs/index.js';
|
|
8
|
+
export declare function handleSaveHandoff(args: {
|
|
9
|
+
type: (typeof HANDOFF_TYPES)[number];
|
|
10
|
+
slug: string;
|
|
11
|
+
files: Record<string, string>;
|
|
12
|
+
}, workspaceRoot: string): ReturnType<typeof mcpText>;
|
|
6
13
|
/**
|
|
7
14
|
* Register core tools with MCP server
|
|
8
15
|
*/
|
package/dist/mcp/core.js
CHANGED
|
@@ -6,41 +6,55 @@ import * as path from 'path';
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { getWorkspaceRoot, mcpText } from '../utils/utils.js';
|
|
8
8
|
import { DEFAULT_EXTENSIONS } from '../core/config/index.js';
|
|
9
|
-
import { HANDOFF_TYPES,
|
|
9
|
+
import { HANDOFF_TYPES, resolveHandoffFolder, validateHandoffFilename } from '../core/handoffs/index.js';
|
|
10
|
+
export function handleSaveHandoff(args, workspaceRoot) {
|
|
11
|
+
try {
|
|
12
|
+
if (Object.keys(args.files).length === 0) {
|
|
13
|
+
return mcpText('Error saving handoff: files cannot be empty');
|
|
14
|
+
}
|
|
15
|
+
for (const name of Object.keys(args.files)) {
|
|
16
|
+
try {
|
|
17
|
+
validateHandoffFilename(name);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
return mcpText(`Error saving handoff: ${err instanceof Error ? err.message : err}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const primaryContent = args.files['README.md'] ?? Object.values(args.files)[0];
|
|
24
|
+
const location = resolveHandoffFolder({
|
|
25
|
+
workspaceRoot,
|
|
26
|
+
type: args.type,
|
|
27
|
+
slug: args.slug,
|
|
28
|
+
primaryContent,
|
|
29
|
+
});
|
|
30
|
+
fs.mkdirSync(location.folderPath, { recursive: true });
|
|
31
|
+
for (const [name, content] of Object.entries(args.files)) {
|
|
32
|
+
fs.writeFileSync(path.join(location.folderPath, name), content, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
return mcpText(`✅ Saved to: ${location.folderPath}`);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
return mcpText(`Error saving handoff: ${error instanceof Error ? error.message : error}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
10
40
|
/**
|
|
11
41
|
* Register core tools with MCP server
|
|
12
42
|
*/
|
|
13
43
|
export function registerCoreTools(server) {
|
|
14
44
|
// ═══════════════════════════════════════════════════════════════
|
|
15
45
|
// TOOL: SAVE HANDOFF
|
|
16
|
-
// Writes handoff artifacts to the workspace
|
|
17
|
-
// Returns the saved
|
|
46
|
+
// Writes handoff artifacts to the workspace as a folder of files
|
|
47
|
+
// Returns the saved folder path to use in next-step instructions
|
|
18
48
|
// ═══════════════════════════════════════════════════════════════
|
|
19
|
-
server.tool('kit_save_handoff', `Save a handoff artifact to .agent-kit/handoffs/. Returns the saved
|
|
49
|
+
server.tool('kit_save_handoff', `Save a handoff artifact to .agent-kit/handoffs/. Returns the saved folder path to use in next-step instructions. Do NOT append version numbers (v2, v3, etc.) to the slug.`, {
|
|
20
50
|
type: z.enum(HANDOFF_TYPES).describe('Handoff type'),
|
|
21
|
-
|
|
51
|
+
files: z
|
|
52
|
+
.record(z.string(), z.string())
|
|
53
|
+
.describe('Markdown files to write into the handoff folder. May include one or many files per call; repeated calls with the same type and slug add or overwrite the provided files. Filenames must match /^[A-Za-z0-9_-]+\\.md$/.'),
|
|
22
54
|
slug: z
|
|
23
55
|
.string()
|
|
24
|
-
.describe('Short identifier for the
|
|
25
|
-
}, async (
|
|
26
|
-
try {
|
|
27
|
-
const location = resolveHandoffPath({
|
|
28
|
-
workspaceRoot: getWorkspaceRoot(),
|
|
29
|
-
type,
|
|
30
|
-
slug,
|
|
31
|
-
content,
|
|
32
|
-
});
|
|
33
|
-
const handoffDir = path.dirname(location.filePath);
|
|
34
|
-
if (!fs.existsSync(handoffDir)) {
|
|
35
|
-
fs.mkdirSync(handoffDir, { recursive: true });
|
|
36
|
-
}
|
|
37
|
-
fs.writeFileSync(location.filePath, content, 'utf8');
|
|
38
|
-
return mcpText(`✅ Saved to: ${location.filePath}`);
|
|
39
|
-
}
|
|
40
|
-
catch (error) {
|
|
41
|
-
return mcpText(`Error saving handoff: ${error}`);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
56
|
+
.describe('Short identifier for the folder, e.g. "user-auth" or "PROJ-123". Do NOT append version numbers (v2, v3, etc.).'),
|
|
57
|
+
}, async (args) => handleSaveHandoff(args, getWorkspaceRoot()));
|
|
44
58
|
}
|
|
45
59
|
// Export DEFAULT_EXTENSIONS for backward compatibility
|
|
46
60
|
export { DEFAULT_EXTENSIONS };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as assert from 'node:assert/strict';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { afterEach, describe, test } from 'node:test';
|
|
6
|
+
import { handleSaveHandoff } from './core.js';
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
function makeTempWorkspace() {
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'core-test-'));
|
|
10
|
+
tempDirs.push(dir);
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
while (tempDirs.length > 0) {
|
|
15
|
+
const dir = tempDirs.pop();
|
|
16
|
+
if (dir)
|
|
17
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
function resultText(result) {
|
|
21
|
+
return result.content[0].text;
|
|
22
|
+
}
|
|
23
|
+
describe('handleSaveHandoff', () => {
|
|
24
|
+
test('C1: README.md + DETAIL.md written under folder; returns folder path without .md', () => {
|
|
25
|
+
const workspace = makeTempWorkspace();
|
|
26
|
+
const result = handleSaveHandoff({ type: 'brainstorm', slug: 'auth', files: { 'README.md': '# Readme', 'DETAIL.md': '# Detail' } }, workspace);
|
|
27
|
+
const text = resultText(result);
|
|
28
|
+
assert.ok(text.startsWith('✅ Saved to:'), `expected success, got: ${text}`);
|
|
29
|
+
assert.ok(!text.endsWith('.md'), 'folder path must not end with .md');
|
|
30
|
+
const folderPath = text.replace('✅ Saved to: ', '');
|
|
31
|
+
assert.ok(fs.existsSync(path.join(folderPath, 'README.md')), 'README.md must exist');
|
|
32
|
+
assert.ok(fs.existsSync(path.join(folderPath, 'DETAIL.md')), 'DETAIL.md must exist');
|
|
33
|
+
assert.equal(fs.readFileSync(path.join(folderPath, 'README.md'), 'utf8'), '# Readme');
|
|
34
|
+
assert.equal(fs.readFileSync(path.join(folderPath, 'DETAIL.md'), 'utf8'), '# Detail');
|
|
35
|
+
});
|
|
36
|
+
test('C2: single README.md written under folder; returns folder path', () => {
|
|
37
|
+
const workspace = makeTempWorkspace();
|
|
38
|
+
const result = handleSaveHandoff({ type: 'ticket', slug: 'my-ticket', files: { 'README.md': '# Ticket content' } }, workspace);
|
|
39
|
+
const text = resultText(result);
|
|
40
|
+
assert.ok(text.startsWith('✅ Saved to:'), `expected success, got: ${text}`);
|
|
41
|
+
const folderPath = text.replace('✅ Saved to: ', '');
|
|
42
|
+
assert.ok(!text.endsWith('.md'), 'folder path must not end with .md');
|
|
43
|
+
assert.ok(fs.existsSync(path.join(folderPath, 'README.md')), 'README.md must exist');
|
|
44
|
+
assert.equal(fs.readFileSync(path.join(folderPath, 'README.md'), 'utf8'), '# Ticket content');
|
|
45
|
+
});
|
|
46
|
+
test('C3: empty files returns error containing "files cannot be empty"', () => {
|
|
47
|
+
const workspace = makeTempWorkspace();
|
|
48
|
+
const result = handleSaveHandoff({ type: 'plan', slug: 'x', files: {} }, workspace);
|
|
49
|
+
assert.match(resultText(result), /files cannot be empty/);
|
|
50
|
+
});
|
|
51
|
+
test('C4: path-traversal filename returns error containing "Unsafe handoff filename"', () => {
|
|
52
|
+
const workspace = makeTempWorkspace();
|
|
53
|
+
const result = handleSaveHandoff({ type: 'plan', slug: 'x', files: { '../escape.md': 'bad' } }, workspace);
|
|
54
|
+
assert.match(resultText(result), /Unsafe handoff filename/);
|
|
55
|
+
});
|
|
56
|
+
test('C5: filename without .md extension returns error containing "Unsafe handoff filename"', () => {
|
|
57
|
+
const workspace = makeTempWorkspace();
|
|
58
|
+
const result = handleSaveHandoff({ type: 'plan', slug: 'x', files: { README: 'bad' } }, workspace);
|
|
59
|
+
assert.match(resultText(result), /Unsafe handoff filename/);
|
|
60
|
+
});
|
|
61
|
+
test('C6: slug derived from README.md content ticket-ID when slug is empty', () => {
|
|
62
|
+
const workspace = makeTempWorkspace();
|
|
63
|
+
const result = handleSaveHandoff({ type: 'brainstorm', slug: '', files: { 'README.md': '# Fix PROJ-789\n\nDetails.' } }, workspace);
|
|
64
|
+
const text = resultText(result);
|
|
65
|
+
assert.ok(text.includes('proj-789'), `expected proj-789 in path, got: ${text}`);
|
|
66
|
+
});
|
|
67
|
+
test('C7: second call with same slug overwrites files without error (idempotent)', () => {
|
|
68
|
+
const workspace = makeTempWorkspace();
|
|
69
|
+
const args = { type: 'plan', slug: 'idem', files: { 'README.md': 'v1' } };
|
|
70
|
+
const r1 = handleSaveHandoff(args, workspace);
|
|
71
|
+
assert.ok(resultText(r1).startsWith('✅'), `first call failed: ${resultText(r1)}`);
|
|
72
|
+
const argsV2 = { type: 'plan', slug: 'idem', files: { 'README.md': 'v2' } };
|
|
73
|
+
const r2 = handleSaveHandoff(argsV2, workspace);
|
|
74
|
+
assert.ok(resultText(r2).startsWith('✅'), `second call failed: ${resultText(r2)}`);
|
|
75
|
+
const folderPath = resultText(r2).replace('✅ Saved to: ', '');
|
|
76
|
+
assert.equal(fs.readFileSync(path.join(folderPath, 'README.md'), 'utf8'), 'v2');
|
|
77
|
+
});
|
|
78
|
+
test('C8: code handoff writes REPORT.md and DECISIONS.md without README.md', () => {
|
|
79
|
+
const workspace = makeTempWorkspace();
|
|
80
|
+
const result = handleSaveHandoff({
|
|
81
|
+
type: 'code',
|
|
82
|
+
slug: 'execution-decision-ledger',
|
|
83
|
+
files: {
|
|
84
|
+
'REPORT.md': '# Code Execution Report',
|
|
85
|
+
'DECISIONS.md': '# Execution Decision Records',
|
|
86
|
+
},
|
|
87
|
+
}, workspace);
|
|
88
|
+
const text = resultText(result);
|
|
89
|
+
assert.ok(text.startsWith('✅ Saved to:'), `expected success, got: ${text}`);
|
|
90
|
+
const folderPath = text.replace('✅ Saved to: ', '');
|
|
91
|
+
assert.equal(folderPath, path.join(workspace, '.agent-kit', 'handoffs', 'execution-decision-ledger', 'code'));
|
|
92
|
+
assert.ok(fs.existsSync(path.join(folderPath, 'REPORT.md')), 'REPORT.md must exist');
|
|
93
|
+
assert.ok(fs.existsSync(path.join(folderPath, 'DECISIONS.md')), 'DECISIONS.md must exist');
|
|
94
|
+
assert.equal(fs.existsSync(path.join(folderPath, 'README.md')), false);
|
|
95
|
+
assert.equal(fs.readFileSync(path.join(folderPath, 'REPORT.md'), 'utf8'), '# Code Execution Report');
|
|
96
|
+
assert.equal(fs.readFileSync(path.join(folderPath, 'DECISIONS.md'), 'utf8'), '# Execution Decision Records');
|
|
97
|
+
});
|
|
98
|
+
});
|
package/dist/server.js
CHANGED
|
@@ -36062,7 +36062,8 @@ var HANDOFF_TYPES = [
|
|
|
36062
36062
|
"ticket",
|
|
36063
36063
|
"research",
|
|
36064
36064
|
"scenario",
|
|
36065
|
-
"investigation"
|
|
36065
|
+
"investigation",
|
|
36066
|
+
"code"
|
|
36066
36067
|
];
|
|
36067
36068
|
var TICKET_ID_PATTERN = /\b[A-Z][A-Z0-9]+-\d+\b/;
|
|
36068
36069
|
function findTicketId(value) {
|
|
@@ -36090,52 +36091,72 @@ function deriveFeatureSlug(input) {
|
|
|
36090
36091
|
if (contentTicketSlug) return contentTicketSlug;
|
|
36091
36092
|
return sanitizeFeatureSlug(contentSlugCandidate(input.content)) || "untitled-handoff";
|
|
36092
36093
|
}
|
|
36093
|
-
function
|
|
36094
|
+
function resolveHandoffFolder(input) {
|
|
36094
36095
|
const canonicalType = normalizeHandoffType(input.type);
|
|
36095
36096
|
const featureSlug = deriveFeatureSlug({
|
|
36096
36097
|
requestedSlug: input.slug,
|
|
36097
|
-
content: input.
|
|
36098
|
+
content: input.primaryContent,
|
|
36098
36099
|
type: canonicalType
|
|
36099
36100
|
});
|
|
36100
|
-
const relativePath = path3.join(".agent-kit", "handoffs", featureSlug,
|
|
36101
|
+
const relativePath = path3.join(".agent-kit", "handoffs", featureSlug, canonicalType);
|
|
36101
36102
|
return {
|
|
36102
36103
|
featureSlug,
|
|
36103
36104
|
canonicalType,
|
|
36104
|
-
|
|
36105
|
+
folderPath: path3.join(input.workspaceRoot, relativePath),
|
|
36105
36106
|
relativePath
|
|
36106
36107
|
};
|
|
36107
36108
|
}
|
|
36109
|
+
function validateHandoffFilename(name) {
|
|
36110
|
+
const normalized = name.trim().replace(/^["']|["']$/g, "");
|
|
36111
|
+
if (!/^[A-Za-z0-9_-]+\.md$/.test(normalized)) {
|
|
36112
|
+
throw new Error(`Unsafe handoff filename: ${name}`);
|
|
36113
|
+
}
|
|
36114
|
+
return normalized;
|
|
36115
|
+
}
|
|
36108
36116
|
|
|
36109
36117
|
// src/mcp/core.ts
|
|
36118
|
+
function handleSaveHandoff(args, workspaceRoot2) {
|
|
36119
|
+
try {
|
|
36120
|
+
if (Object.keys(args.files).length === 0) {
|
|
36121
|
+
return mcpText("Error saving handoff: files cannot be empty");
|
|
36122
|
+
}
|
|
36123
|
+
for (const name of Object.keys(args.files)) {
|
|
36124
|
+
try {
|
|
36125
|
+
validateHandoffFilename(name);
|
|
36126
|
+
} catch (err) {
|
|
36127
|
+
return mcpText(`Error saving handoff: ${err instanceof Error ? err.message : err}`);
|
|
36128
|
+
}
|
|
36129
|
+
}
|
|
36130
|
+
const primaryContent = args.files["README.md"] ?? Object.values(args.files)[0];
|
|
36131
|
+
const location = resolveHandoffFolder({
|
|
36132
|
+
workspaceRoot: workspaceRoot2,
|
|
36133
|
+
type: args.type,
|
|
36134
|
+
slug: args.slug,
|
|
36135
|
+
primaryContent
|
|
36136
|
+
});
|
|
36137
|
+
fs3.mkdirSync(location.folderPath, { recursive: true });
|
|
36138
|
+
for (const [name, content] of Object.entries(args.files)) {
|
|
36139
|
+
fs3.writeFileSync(path4.join(location.folderPath, name), content, "utf8");
|
|
36140
|
+
}
|
|
36141
|
+
return mcpText(`\u2705 Saved to: ${location.folderPath}`);
|
|
36142
|
+
} catch (error2) {
|
|
36143
|
+
return mcpText(`Error saving handoff: ${error2 instanceof Error ? error2.message : error2}`);
|
|
36144
|
+
}
|
|
36145
|
+
}
|
|
36110
36146
|
function registerCoreTools(server2) {
|
|
36111
36147
|
server2.tool(
|
|
36112
36148
|
"kit_save_handoff",
|
|
36113
|
-
`Save a handoff artifact to .agent-kit/handoffs/. Returns the saved
|
|
36149
|
+
`Save a handoff artifact to .agent-kit/handoffs/. Returns the saved folder path to use in next-step instructions. Do NOT append version numbers (v2, v3, etc.) to the slug.`,
|
|
36114
36150
|
{
|
|
36115
36151
|
type: external_exports.enum(HANDOFF_TYPES).describe("Handoff type"),
|
|
36116
|
-
|
|
36152
|
+
files: external_exports.record(external_exports.string(), external_exports.string()).describe(
|
|
36153
|
+
"Markdown files to write into the handoff folder. May include one or many files per call; repeated calls with the same type and slug add or overwrite the provided files. Filenames must match /^[A-Za-z0-9_-]+\\.md$/."
|
|
36154
|
+
),
|
|
36117
36155
|
slug: external_exports.string().describe(
|
|
36118
|
-
'Short identifier for the
|
|
36156
|
+
'Short identifier for the folder, e.g. "user-auth" or "PROJ-123". Do NOT append version numbers (v2, v3, etc.).'
|
|
36119
36157
|
)
|
|
36120
36158
|
},
|
|
36121
|
-
async (
|
|
36122
|
-
try {
|
|
36123
|
-
const location = resolveHandoffPath({
|
|
36124
|
-
workspaceRoot: getWorkspaceRoot(),
|
|
36125
|
-
type,
|
|
36126
|
-
slug,
|
|
36127
|
-
content
|
|
36128
|
-
});
|
|
36129
|
-
const handoffDir = path4.dirname(location.filePath);
|
|
36130
|
-
if (!fs3.existsSync(handoffDir)) {
|
|
36131
|
-
fs3.mkdirSync(handoffDir, { recursive: true });
|
|
36132
|
-
}
|
|
36133
|
-
fs3.writeFileSync(location.filePath, content, "utf8");
|
|
36134
|
-
return mcpText(`\u2705 Saved to: ${location.filePath}`);
|
|
36135
|
-
} catch (error2) {
|
|
36136
|
-
return mcpText(`Error saving handoff: ${error2}`);
|
|
36137
|
-
}
|
|
36138
|
-
}
|
|
36159
|
+
async (args) => handleSaveHandoff(args, getWorkspaceRoot())
|
|
36139
36160
|
);
|
|
36140
36161
|
}
|
|
36141
36162
|
|
|
@@ -36245,10 +36266,10 @@ ${"\u2500".repeat(60)}
|
|
|
36245
36266
|
function registerAgentTools(server2) {
|
|
36246
36267
|
server2.tool(
|
|
36247
36268
|
"kit_trigger_agent",
|
|
36248
|
-
"!Important: Trigger only when user explicitly asks to delegate a task to an external agent CLI (gemini, claude, or codex). The task can be a direct message or a path to a handoff
|
|
36269
|
+
"!Important: Trigger only when user explicitly asks to delegate a task to an external agent CLI (gemini, claude, or codex). The task can be a direct message or a path to a handoff folder (.agent-kit/handoffs/feature-slug/plan).",
|
|
36249
36270
|
{
|
|
36250
36271
|
agent: external_exports.enum(["gemini", "claude", "codex"]).describe('The agent CLI to invoke: "gemini", "claude", or "codex"'),
|
|
36251
|
-
task: external_exports.string().describe('Task message or path to a handoff
|
|
36272
|
+
task: external_exports.string().describe('Task message or path to a handoff folder (e.g., ".agent-kit/handoffs/feature-slug/plan")')
|
|
36252
36273
|
},
|
|
36253
36274
|
async ({ agent, task }, extra) => {
|
|
36254
36275
|
const workspaceRoot2 = getWorkspaceRoot();
|