@aicoffe/ai-native-sdlc-mcp 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.
Files changed (58) hide show
  1. package/README.md +40 -0
  2. package/dist/main.d.ts +2 -0
  3. package/dist/main.js +25 -0
  4. package/dist/server.d.ts +16 -0
  5. package/dist/server.js +21 -0
  6. package/dist/tools/workflow-tools.d.ts +14 -0
  7. package/dist/tools/workflow-tools.js +171 -0
  8. package/dist/workflow/definition.d.ts +2 -0
  9. package/dist/workflow/definition.js +252 -0
  10. package/dist/workflow/event-bus.d.ts +16 -0
  11. package/dist/workflow/event-bus.js +46 -0
  12. package/dist/workflow/events.d.ts +12 -0
  13. package/dist/workflow/events.js +50 -0
  14. package/dist/workflow/handlers/close-intent.d.ts +2 -0
  15. package/dist/workflow/handlers/close-intent.js +21 -0
  16. package/dist/workflow/handlers/contain.d.ts +13 -0
  17. package/dist/workflow/handlers/contain.js +42 -0
  18. package/dist/workflow/handlers/create-feature-branch.d.ts +2 -0
  19. package/dist/workflow/handlers/create-feature-branch.js +25 -0
  20. package/dist/workflow/handlers/create-pr.d.ts +2 -0
  21. package/dist/workflow/handlers/create-pr.js +45 -0
  22. package/dist/workflow/handlers/create-spec-dir.d.ts +2 -0
  23. package/dist/workflow/handlers/create-spec-dir.js +50 -0
  24. package/dist/workflow/handlers/exec.d.ts +5 -0
  25. package/dist/workflow/handlers/exec.js +13 -0
  26. package/dist/workflow/handlers/fs-utils.d.ts +1 -0
  27. package/dist/workflow/handlers/fs-utils.js +11 -0
  28. package/dist/workflow/handlers/gh.d.ts +42 -0
  29. package/dist/workflow/handlers/gh.js +57 -0
  30. package/dist/workflow/handlers/git.d.ts +12 -0
  31. package/dist/workflow/handlers/git.js +59 -0
  32. package/dist/workflow/handlers/index.d.ts +12 -0
  33. package/dist/workflow/handlers/index.js +20 -0
  34. package/dist/workflow/handlers/merge-pr.d.ts +2 -0
  35. package/dist/workflow/handlers/merge-pr.js +13 -0
  36. package/dist/workflow/handlers/noop.d.ts +2 -0
  37. package/dist/workflow/handlers/noop.js +1 -0
  38. package/dist/workflow/handlers/notify-blocked.d.ts +2 -0
  39. package/dist/workflow/handlers/notify-blocked.js +12 -0
  40. package/dist/workflow/handlers/update-current-index.d.ts +2 -0
  41. package/dist/workflow/handlers/update-current-index.js +73 -0
  42. package/dist/workflow/ids.d.ts +27 -0
  43. package/dist/workflow/ids.js +63 -0
  44. package/dist/workflow/mutex.d.ts +12 -0
  45. package/dist/workflow/mutex.js +24 -0
  46. package/dist/workflow/paths.d.ts +6 -0
  47. package/dist/workflow/paths.js +15 -0
  48. package/dist/workflow/retry.d.ts +14 -0
  49. package/dist/workflow/retry.js +52 -0
  50. package/dist/workflow/state-machine.d.ts +46 -0
  51. package/dist/workflow/state-machine.js +313 -0
  52. package/dist/workflow/store.d.ts +30 -0
  53. package/dist/workflow/store.js +95 -0
  54. package/dist/workflow/template.d.ts +4 -0
  55. package/dist/workflow/template.js +18 -0
  56. package/dist/workflow/types.d.ts +102 -0
  57. package/dist/workflow/types.js +3 -0
  58. package/package.json +45 -0
@@ -0,0 +1,50 @@
1
+ export function workflowStartedEvent(workflow_id, title) {
2
+ return {
3
+ type: 'workflow.started',
4
+ workflow_id,
5
+ title,
6
+ timestamp: new Date().toISOString(),
7
+ };
8
+ }
9
+ export function stageCompletedEvent(workflow_id, stage_id, output) {
10
+ return {
11
+ type: 'stage.completed',
12
+ workflow_id,
13
+ stage_id,
14
+ output,
15
+ timestamp: new Date().toISOString(),
16
+ };
17
+ }
18
+ export function gateDecisionEvent(args) {
19
+ const event = {
20
+ type: 'gate.decision',
21
+ workflow_id: args.workflow_id,
22
+ stage_id: args.stage_id,
23
+ decision: args.decision,
24
+ approved_by: args.approved_by,
25
+ timestamp: new Date().toISOString(),
26
+ };
27
+ if (args.changes !== undefined) {
28
+ event.changes = args.changes;
29
+ }
30
+ return event;
31
+ }
32
+ export function actionCompletedEvent(workflow_id, stage_id, result) {
33
+ return {
34
+ type: 'action.completed',
35
+ workflow_id,
36
+ stage_id,
37
+ result,
38
+ timestamp: new Date().toISOString(),
39
+ };
40
+ }
41
+ export function actionFailedEvent(workflow_id, stage_id, error, retryable) {
42
+ return {
43
+ type: 'action.failed',
44
+ workflow_id,
45
+ stage_id,
46
+ error,
47
+ retryable,
48
+ timestamp: new Date().toISOString(),
49
+ };
50
+ }
@@ -0,0 +1,2 @@
1
+ import type { ActionHandler, HandlerDeps } from './index.js';
2
+ export declare function closeIntentHandler(deps: HandlerDeps): ActionHandler;
@@ -0,0 +1,21 @@
1
+ // mcp-server/src/workflow/handlers/close-intent.ts
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { resolveUnder, validateContextVars } from './contain.js';
4
+ import { pathExists } from './fs-utils.js';
5
+ export function closeIntentHandler(deps) {
6
+ return async (params, state) => {
7
+ validateContextVars(state.context);
8
+ const status = String(params.update_status ?? 'implemented');
9
+ const relPath = `intent/${String(state.context.date)}-${String(state.context.slug)}.md`;
10
+ const absPath = resolveUnder(deps.repoRoot, relPath);
11
+ if (!(await pathExists(absPath))) {
12
+ throw new Error(`Intent file not found: ${relPath} (expected at ${absPath})`);
13
+ }
14
+ const content = await readFile(absPath, 'utf8');
15
+ const updated = content.replace(/^Status:.*$/m, `Status: ${status}`);
16
+ if (updated !== content) {
17
+ await writeFile(absPath, updated, 'utf8');
18
+ }
19
+ return { context_updates: { intent_status: status }, intent_file: relPath };
20
+ };
21
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Resolves relPath under rootAbs with strict containment: absolute paths and
3
+ * any '..' segment are rejected (non-retryable), the resolved result must stay
4
+ * under rootAbs. Guards against poisoned .workflow state writing outside
5
+ * REPO_ROOT (e.g. context.date/slug/domain containing traversal segments).
6
+ */
7
+ export declare function resolveUnder(rootAbs: string, relPath: string): string;
8
+ /**
9
+ * Validates the context vars that feed handler-built paths. Called by
10
+ * recoverWorkflows before executing any action so a hostile state file cannot
11
+ * drive filesystem writes at startup.
12
+ */
13
+ export declare function validateContextVars(context: Record<string, unknown>): void;
@@ -0,0 +1,42 @@
1
+ // mcp-server/src/workflow/handlers/contain.ts
2
+ import path from 'node:path';
3
+ /**
4
+ * Resolves relPath under rootAbs with strict containment: absolute paths and
5
+ * any '..' segment are rejected (non-retryable), the resolved result must stay
6
+ * under rootAbs. Guards against poisoned .workflow state writing outside
7
+ * REPO_ROOT (e.g. context.date/slug/domain containing traversal segments).
8
+ */
9
+ export function resolveUnder(rootAbs, relPath) {
10
+ if (path.isAbsolute(relPath)) {
11
+ throw new Error(`Path escapes repository root (absolute not allowed): ${relPath}`);
12
+ }
13
+ const segments = relPath.split(/[\\/]/);
14
+ if (segments.includes('..')) {
15
+ throw new Error(`Path escapes repository root: ${relPath}`);
16
+ }
17
+ const base = path.resolve(rootAbs);
18
+ const resolved = path.resolve(base, relPath);
19
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
20
+ throw new Error(`Path escapes repository root: ${relPath}`);
21
+ }
22
+ return resolved;
23
+ }
24
+ /**
25
+ * Validates the context vars that feed handler-built paths. Called by
26
+ * recoverWorkflows before executing any action so a hostile state file cannot
27
+ * drive filesystem writes at startup.
28
+ */
29
+ export function validateContextVars(context) {
30
+ const date = String(context.date ?? '');
31
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
32
+ throw new Error(`Invalid context.date: "${date}"`);
33
+ }
34
+ const slug = String(context.slug ?? '');
35
+ if (!/^[a-z0-9-]{1,64}$/.test(slug)) {
36
+ throw new Error(`Invalid context.slug: "${slug}"`);
37
+ }
38
+ const domain = context.domain === undefined ? 'default' : String(context.domain);
39
+ if (!/^[a-z0-9-]{0,64}$/.test(domain)) {
40
+ throw new Error(`Invalid context.domain: "${domain}"`);
41
+ }
42
+ }
@@ -0,0 +1,2 @@
1
+ import type { ActionHandler, HandlerDeps } from './index.js';
2
+ export declare function createFeatureBranchHandler(deps: HandlerDeps): ActionHandler;
@@ -0,0 +1,25 @@
1
+ // mcp-server/src/workflow/handlers/create-feature-branch.ts
2
+ import { renderTemplate } from '../template.js';
3
+ import { branchExists, currentBranch, git, hasCommits, workingTreeDirty } from './git.js';
4
+ export function createFeatureBranchHandler(deps) {
5
+ return async (params, state) => {
6
+ const pattern = String(params.branch_pattern ?? 'feature/{date}-{slug}');
7
+ const target = renderTemplate(pattern, state.context);
8
+ const repoRoot = deps.repoRoot;
9
+ if ((await currentBranch(repoRoot)) === target) {
10
+ return { context_updates: { branch: target }, branch: target, reused: true };
11
+ }
12
+ if (!(await hasCommits(repoRoot))) {
13
+ throw new Error(`Cannot create branch "${target}": HEAD is unborn (no commits yet); make an initial commit first.`);
14
+ }
15
+ if (await branchExists(repoRoot, target)) {
16
+ if (await workingTreeDirty(repoRoot)) {
17
+ throw new Error(`Working tree is dirty; commit or stash your changes before checking out "${target}".`);
18
+ }
19
+ await git(repoRoot, 'checkout', target);
20
+ return { context_updates: { branch: target }, branch: target, reused: true };
21
+ }
22
+ await git(repoRoot, 'checkout', '-b', target);
23
+ return { context_updates: { branch: target }, branch: target, reused: false };
24
+ };
25
+ }
@@ -0,0 +1,2 @@
1
+ import type { ActionHandler, HandlerDeps } from './index.js';
2
+ export declare function createPrHandler(deps: HandlerDeps): ActionHandler;
@@ -0,0 +1,45 @@
1
+ // mcp-server/src/workflow/handlers/create-pr.ts
2
+ import { renderTemplate } from '../template.js';
3
+ export function createPrHandler(deps) {
4
+ return async (params, state) => {
5
+ const head = renderTemplate('feature/{date}-{slug}', state.context);
6
+ const base = String(params.base_branch ?? 'main');
7
+ const titlePattern = String(params.title_pattern ?? 'feat: {title}');
8
+ const existing = await deps.gh.findPrByBranch(head);
9
+ if (existing) {
10
+ return {
11
+ context_updates: { pr_number: existing.number, pr_url: existing.url },
12
+ pr_number: existing.number,
13
+ pr_url: existing.url,
14
+ reused: true,
15
+ };
16
+ }
17
+ const pr = await deps.gh.createPr({
18
+ head,
19
+ base,
20
+ title: renderTemplate(titlePattern, state.context),
21
+ body: generatePrBody(state),
22
+ });
23
+ if (params.trigger_ai_review === true) {
24
+ await deps.gh.addComment(pr.number, '@claude review');
25
+ }
26
+ return {
27
+ context_updates: { pr_number: pr.number, pr_url: pr.url },
28
+ pr_number: pr.number,
29
+ pr_url: pr.url,
30
+ reused: false,
31
+ };
32
+ };
33
+ }
34
+ function generatePrBody(state) {
35
+ return [
36
+ `# ${state.title}`,
37
+ '',
38
+ `- Workflow: ${state.workflow_id}`,
39
+ `- Intent: ${state.intent_id}`,
40
+ `- Spec: specs/${String(state.context.date)}-${String(state.context.slug)}/`,
41
+ '',
42
+ '此 PR 由 AI-native SDLC 工作流自动创建。',
43
+ '',
44
+ ].join('\n');
45
+ }
@@ -0,0 +1,2 @@
1
+ import type { ActionHandler, HandlerDeps } from './index.js';
2
+ export declare function createSpecDirHandler(deps: HandlerDeps): ActionHandler;
@@ -0,0 +1,50 @@
1
+ // mcp-server/src/workflow/handlers/create-spec-dir.ts
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { commitStagedIfAny, git } from './git.js';
5
+ import { resolveUnder, validateContextVars } from './contain.js';
6
+ import { pathExists } from './fs-utils.js';
7
+ export function createSpecDirHandler(deps) {
8
+ return async (params, state) => {
9
+ validateContextVars(state.context);
10
+ const createSubdirs = params.create_subdirs ?? [];
11
+ const writeFiles = params.write_files ?? [];
12
+ const gitCommit = params.git_commit === true;
13
+ const specDirRel = `specs/${String(state.context.date)}-${String(state.context.slug)}`;
14
+ const specDirAbs = resolveUnder(deps.repoRoot, specDirRel);
15
+ await mkdir(specDirAbs, { recursive: true });
16
+ for (const subdir of createSubdirs) {
17
+ await mkdir(resolveUnder(deps.repoRoot, `${specDirRel}/${subdir}`), { recursive: true });
18
+ }
19
+ for (const file of writeFiles) {
20
+ const filePath = resolveUnder(deps.repoRoot, `${specDirRel}/${file}`);
21
+ if (await pathExists(filePath))
22
+ continue;
23
+ await mkdir(path.dirname(filePath), { recursive: true });
24
+ await writeFile(filePath, generateFileContent(file, state), 'utf8');
25
+ }
26
+ if (gitCommit) {
27
+ const status = await git(deps.repoRoot, 'status', '--porcelain');
28
+ if (status.trim()) {
29
+ await git(deps.repoRoot, 'add', '--', specDirRel);
30
+ await commitStagedIfAny(deps.repoRoot, `chore: create spec dir for ${state.intent_id}`, [specDirRel]);
31
+ }
32
+ }
33
+ return { context_updates: { spec_dir: specDirRel }, spec_dir: specDirRel };
34
+ };
35
+ }
36
+ function generateFileContent(file, state) {
37
+ if (file === 'reviews/intent-review.md') {
38
+ const reviewer = state.context.approved_by !== undefined ? String(state.context.approved_by) : 'auto';
39
+ return [
40
+ '# Intent Review',
41
+ '',
42
+ `**Intent**: ${state.intent_id}`,
43
+ `**Reviewer**: ${reviewer}`,
44
+ '**Decision**: Accepted',
45
+ `**Date**: ${new Date().toISOString()}`,
46
+ '',
47
+ ].join('\n');
48
+ }
49
+ return '';
50
+ }
@@ -0,0 +1,5 @@
1
+ export interface ExecResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ }
5
+ export declare function execFileAsync(command: string, args: string[], cwd: string): Promise<ExecResult>;
@@ -0,0 +1,13 @@
1
+ // mcp-server/src/workflow/handlers/exec.ts
2
+ import { execFile as execFileCb } from 'node:child_process';
3
+ export function execFileAsync(command, args, cwd) {
4
+ return new Promise((resolve, reject) => {
5
+ execFileCb(command, args, { cwd, encoding: 'utf8' }, (err, stdout, stderr) => {
6
+ if (err) {
7
+ reject(err);
8
+ return;
9
+ }
10
+ resolve({ stdout: String(stdout), stderr: String(stderr) });
11
+ });
12
+ });
13
+ }
@@ -0,0 +1 @@
1
+ export declare function pathExists(target: string): Promise<boolean>;
@@ -0,0 +1,11 @@
1
+ // mcp-server/src/workflow/handlers/fs-utils.ts
2
+ import { access } from 'node:fs/promises';
3
+ export async function pathExists(target) {
4
+ try {
5
+ await access(target);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Thrown when the GitHub CLI binary is missing (spawn ENOENT etc.).
3
+ * Non-retryable: no amount of retrying installs gh.
4
+ */
5
+ export declare class GhUnavailableError extends Error {
6
+ constructor(detail?: string);
7
+ }
8
+ export interface GhPrRef {
9
+ number: number;
10
+ url: string;
11
+ }
12
+ export interface GhAdapter {
13
+ findPrByBranch(branch: string): Promise<GhPrRef | null>;
14
+ createPr(args: {
15
+ head: string;
16
+ base: string;
17
+ title: string;
18
+ body: string;
19
+ }): Promise<GhPrRef>;
20
+ mergePr(number: number, opts: {
21
+ deleteBranch: boolean;
22
+ }): Promise<void>;
23
+ addComment(number: number, body: string): Promise<void>;
24
+ }
25
+ /** Real adapter: shells out to `gh` with array args, cwd pinned to the repo root. */
26
+ export declare class ExecFileGhAdapter implements GhAdapter {
27
+ private readonly repoRoot;
28
+ private readonly executable;
29
+ constructor(repoRoot: string, executable?: string);
30
+ private gh;
31
+ findPrByBranch(branch: string): Promise<GhPrRef | null>;
32
+ createPr(args: {
33
+ head: string;
34
+ base: string;
35
+ title: string;
36
+ body: string;
37
+ }): Promise<GhPrRef>;
38
+ mergePr(number: number, opts: {
39
+ deleteBranch: boolean;
40
+ }): Promise<void>;
41
+ addComment(number: number, body: string): Promise<void>;
42
+ }
@@ -0,0 +1,57 @@
1
+ // mcp-server/src/workflow/handlers/gh.ts
2
+ import { execFileAsync } from './exec.js';
3
+ /**
4
+ * Thrown when the GitHub CLI binary is missing (spawn ENOENT etc.).
5
+ * Non-retryable: no amount of retrying installs gh.
6
+ */
7
+ export class GhUnavailableError extends Error {
8
+ constructor(detail) {
9
+ super(`GitHub CLI (gh) is unavailable${detail ? `: ${detail}` : ''}. ` +
10
+ 'Please install gh (https://cli.github.com/) and authenticate with `gh auth login`.');
11
+ this.name = 'GhUnavailableError';
12
+ }
13
+ }
14
+ /** Real adapter: shells out to `gh` with array args, cwd pinned to the repo root. */
15
+ export class ExecFileGhAdapter {
16
+ repoRoot;
17
+ executable;
18
+ constructor(repoRoot, executable = 'gh') {
19
+ this.repoRoot = repoRoot;
20
+ this.executable = executable;
21
+ }
22
+ async gh(...args) {
23
+ try {
24
+ return await execFileAsync(this.executable, args, this.repoRoot);
25
+ }
26
+ catch (err) {
27
+ const error = err;
28
+ if (error.code === 'ENOENT') {
29
+ throw new GhUnavailableError(error.message);
30
+ }
31
+ const stderr = error.stderr ?? error.message;
32
+ throw new Error(`gh ${args.join(' ')} failed: ${stderr}`);
33
+ }
34
+ }
35
+ async findPrByBranch(branch) {
36
+ const { stdout } = await this.gh('pr', 'list', '--head', branch, '--state', 'open', '--json', 'number,url');
37
+ const parsed = JSON.parse(stdout);
38
+ return parsed.length > 0 ? parsed[0] : null;
39
+ }
40
+ async createPr(args) {
41
+ await this.gh('pr', 'create', '--head', args.head, '--base', args.base, '--title', args.title, '--body', args.body);
42
+ const ref = await this.findPrByBranch(args.head);
43
+ if (!ref) {
44
+ throw new Error(`gh pr create did not produce a findable PR for ${args.head}`);
45
+ }
46
+ return ref;
47
+ }
48
+ async mergePr(number, opts) {
49
+ const args = ['pr', 'merge', String(number), '--merge'];
50
+ if (opts.deleteBranch)
51
+ args.push('--delete-branch');
52
+ await this.gh(...args);
53
+ }
54
+ async addComment(number, body) {
55
+ await this.gh('pr', 'comment', String(number), '--body', body);
56
+ }
57
+ }
@@ -0,0 +1,12 @@
1
+ export declare function git(repoRoot: string, ...args: string[]): Promise<string>;
2
+ export declare function gitOk(repoRoot: string, ...args: string[]): Promise<boolean>;
3
+ export declare function currentBranch(repoRoot: string): Promise<string>;
4
+ export declare function branchExists(repoRoot: string, branch: string): Promise<boolean>;
5
+ export declare function hasCommits(repoRoot: string): Promise<boolean>;
6
+ export declare function workingTreeDirty(repoRoot: string): Promise<boolean>;
7
+ /**
8
+ * Pathspec commit: commits ONLY expectedRelPaths (and paths under them),
9
+ * never touching anything else the user may have staged. Returns false when
10
+ * none of expectedRelPaths intersect the staged index (no_changes semantics).
11
+ */
12
+ export declare function commitStagedIfAny(repoRoot: string, message: string, expectedRelPaths: string[]): Promise<boolean>;
@@ -0,0 +1,59 @@
1
+ // mcp-server/src/workflow/handlers/git.ts
2
+ //
3
+ // All git usage goes through execFile with ARRAY args and cwd pinned to the
4
+ // repo root — no shell strings. Git handlers never push, never --force,
5
+ // never touch .git/hooks, never --no-verify.
6
+ import { execFileAsync } from './exec.js';
7
+ export async function git(repoRoot, ...args) {
8
+ try {
9
+ const { stdout } = await execFileAsync('git', args, repoRoot);
10
+ return stdout;
11
+ }
12
+ catch (err) {
13
+ const error = err;
14
+ const detail = (error.stderr ?? error.message ?? '').toString().trim();
15
+ throw new Error(`git ${args.join(' ')} failed: ${detail}`);
16
+ }
17
+ }
18
+ export async function gitOk(repoRoot, ...args) {
19
+ try {
20
+ await execFileAsync('git', args, repoRoot);
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export async function currentBranch(repoRoot) {
28
+ try {
29
+ const { stdout } = await execFileAsync('git', ['symbolic-ref', '--short', 'HEAD'], repoRoot);
30
+ return stdout.trim();
31
+ }
32
+ catch {
33
+ return '';
34
+ }
35
+ }
36
+ export async function branchExists(repoRoot, branch) {
37
+ return gitOk(repoRoot, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`);
38
+ }
39
+ export async function hasCommits(repoRoot) {
40
+ return gitOk(repoRoot, 'rev-parse', '--verify', '--quiet', 'HEAD');
41
+ }
42
+ export async function workingTreeDirty(repoRoot) {
43
+ const { stdout } = await execFileAsync('git', ['status', '--porcelain'], repoRoot);
44
+ return stdout.trim().length > 0;
45
+ }
46
+ /**
47
+ * Pathspec commit: commits ONLY expectedRelPaths (and paths under them),
48
+ * never touching anything else the user may have staged. Returns false when
49
+ * none of expectedRelPaths intersect the staged index (no_changes semantics).
50
+ */
51
+ export async function commitStagedIfAny(repoRoot, message, expectedRelPaths) {
52
+ const { stdout } = await execFileAsync('git', ['diff', '--cached', '--name-only'], repoRoot);
53
+ const staged = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
54
+ const intersects = staged.some((p) => expectedRelPaths.some((exp) => p === exp || p.startsWith(`${exp}/`)));
55
+ if (!intersects)
56
+ return false;
57
+ await git(repoRoot, 'commit', '-m', message, '--', ...expectedRelPaths);
58
+ return true;
59
+ }
@@ -0,0 +1,12 @@
1
+ import type { WorkflowState } from '../types.js';
2
+ import type { GhAdapter } from './gh.js';
3
+ export interface ActionResult {
4
+ context_updates?: Record<string, unknown>;
5
+ [key: string]: unknown;
6
+ }
7
+ export type ActionHandler = (params: Record<string, unknown>, state: WorkflowState) => Promise<ActionResult>;
8
+ export interface HandlerDeps {
9
+ repoRoot: string;
10
+ gh: GhAdapter;
11
+ }
12
+ export declare function buildActionHandlers(deps: HandlerDeps): Record<string, ActionHandler>;
@@ -0,0 +1,20 @@
1
+ import { createSpecDirHandler } from './create-spec-dir.js';
2
+ import { createFeatureBranchHandler } from './create-feature-branch.js';
3
+ import { createPrHandler } from './create-pr.js';
4
+ import { mergePrHandler } from './merge-pr.js';
5
+ import { updateCurrentIndexHandler } from './update-current-index.js';
6
+ import { closeIntentHandler } from './close-intent.js';
7
+ import { notifyBlockedHandler } from './notify-blocked.js';
8
+ import { noopHandler } from './noop.js';
9
+ export function buildActionHandlers(deps) {
10
+ return {
11
+ 'create-spec-dir': createSpecDirHandler(deps),
12
+ 'create-feature-branch': createFeatureBranchHandler(deps),
13
+ 'create-pr': createPrHandler(deps),
14
+ 'merge-pr': mergePrHandler(deps),
15
+ 'update-current-index': updateCurrentIndexHandler(deps),
16
+ 'close-intent': closeIntentHandler(deps),
17
+ 'notify-blocked': notifyBlockedHandler(deps),
18
+ noop: noopHandler,
19
+ };
20
+ }
@@ -0,0 +1,2 @@
1
+ import type { ActionHandler, HandlerDeps } from './index.js';
2
+ export declare function mergePrHandler(deps: HandlerDeps): ActionHandler;
@@ -0,0 +1,13 @@
1
+ export function mergePrHandler(deps) {
2
+ return async (params, state) => {
3
+ const raw = state.context.pr_number;
4
+ if (raw === undefined || raw === null || raw === '') {
5
+ throw new Error('pr_number not found in workflow context; run create-pr before merge-pr ' +
6
+ `(workflow ${state.workflow_id}).`);
7
+ }
8
+ const prNumber = Number(raw);
9
+ // require_approvals is enforced by the platform (branch protection), not by us.
10
+ await deps.gh.mergePr(prNumber, { deleteBranch: params.delete_branch === true });
11
+ return { merged: true, pr_number: prNumber };
12
+ };
13
+ }
@@ -0,0 +1,2 @@
1
+ import type { ActionHandler } from './index.js';
2
+ export declare const noopHandler: ActionHandler;
@@ -0,0 +1 @@
1
+ export const noopHandler = async () => ({});
@@ -0,0 +1,2 @@
1
+ import type { ActionHandler, HandlerDeps } from './index.js';
2
+ export declare function notifyBlockedHandler(_deps: HandlerDeps): ActionHandler;
@@ -0,0 +1,12 @@
1
+ export function notifyBlockedHandler(_deps) {
2
+ return async (params, state) => {
3
+ const notify = params.notify ?? [];
4
+ console.error(JSON.stringify({
5
+ type: 'workflow.blocked',
6
+ workflow_id: state.workflow_id,
7
+ stage: state.current_stage,
8
+ notify,
9
+ }));
10
+ return { notified: notify };
11
+ };
12
+ }
@@ -0,0 +1,2 @@
1
+ import type { ActionHandler, HandlerDeps } from './index.js';
2
+ export declare function updateCurrentIndexHandler(deps: HandlerDeps): ActionHandler;
@@ -0,0 +1,73 @@
1
+ // mcp-server/src/workflow/handlers/update-current-index.ts
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { commitStagedIfAny, git } from './git.js';
5
+ import { resolveUnder, validateContextVars } from './contain.js';
6
+ import { pathExists } from './fs-utils.js';
7
+ const PREAMBLE = '# Spec Index\n\n> 活规格索引:每个工作流一个章节,由工作流状态机(update-current-index)自动维护。\n';
8
+ export function updateCurrentIndexHandler(deps) {
9
+ return async (params, state) => {
10
+ validateContextVars(state.context);
11
+ const domain = String(state.context.domain ?? 'default');
12
+ const relPath = path.posix.join('specs', '_current', `${domain}.md`);
13
+ const absPath = resolveUnder(deps.repoRoot, relPath);
14
+ await mkdir(path.dirname(absPath), { recursive: true });
15
+ const existing = (await pathExists(absPath)) ? await readFile(absPath, 'utf8') : null;
16
+ const section = renderSection(state);
17
+ const updated = existing === null
18
+ ? PREAMBLE + '\n' + section
19
+ : replaceSection(existing, state.workflow_id, section);
20
+ if (updated !== existing) {
21
+ await writeFile(absPath, updated, 'utf8');
22
+ }
23
+ if (params.git_commit === true) {
24
+ const status = await git(deps.repoRoot, 'status', '--porcelain');
25
+ if (status.trim()) {
26
+ await git(deps.repoRoot, 'add', '--', relPath);
27
+ await commitStagedIfAny(deps.repoRoot, `chore: update current index for ${state.workflow_id}`, [relPath]);
28
+ }
29
+ }
30
+ return { context_updates: { current_index: relPath }, target: relPath };
31
+ };
32
+ }
33
+ /**
34
+ * Section content is derived ONLY from the persisted state (no Date.now()) so
35
+ * re-running with the same state rewrites byte-identical content.
36
+ */
37
+ function renderSection(state) {
38
+ const date = String(state.context.date ?? '');
39
+ const slug = String(state.context.slug ?? '');
40
+ const lines = [
41
+ `## [${state.workflow_id}] ${state.title}`,
42
+ '',
43
+ `- Status: ${state.status}`,
44
+ `- Current Stage: ${state.current_stage}`,
45
+ `- Spec: specs/${date}-${slug}/`,
46
+ `- Intent: intent/${date}-${slug}.md`,
47
+ `- Last Active: ${state.last_active}`,
48
+ '',
49
+ ];
50
+ return lines.join('\n');
51
+ }
52
+ /** Replaces (or appends) the `## [{workflow_id}] ...` section, preserving all
53
+ * other sections and their order byte-for-byte. */
54
+ function replaceSection(content, workflowId, section) {
55
+ const lines = content.split('\n');
56
+ const headerRe = new RegExp(`^## \\[${escapeRegExp(workflowId)}\\]`);
57
+ const start = lines.findIndex((line) => headerRe.test(line));
58
+ if (start === -1) {
59
+ const base = content.endsWith('\n') ? content : `${content}\n`;
60
+ return base + section;
61
+ }
62
+ let end = lines.length;
63
+ for (let i = start + 1; i < lines.length; i++) {
64
+ if (/^## \[/.test(lines[i])) {
65
+ end = i;
66
+ break;
67
+ }
68
+ }
69
+ return [...lines.slice(0, start), ...section.split('\n'), ...lines.slice(end)].join('\n');
70
+ }
71
+ function escapeRegExp(text) {
72
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
73
+ }