specline 1.4.0 → 2.0.1

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 (75) hide show
  1. package/README.md +132 -125
  2. package/adapters/claude/deploy.json +12 -0
  3. package/adapters/claude/hooks/hooks.json +12 -0
  4. package/adapters/claude/hooks.json +12 -0
  5. package/adapters/claude/orchestration.md +17 -0
  6. package/adapters/codex/agent.toml.hbs +7 -0
  7. package/adapters/codex/deploy.json +12 -0
  8. package/adapters/codex/hooks.json +12 -0
  9. package/adapters/codex/orchestration.md +18 -0
  10. package/adapters/cursor/deploy.json +12 -0
  11. package/adapters/cursor/hooks.json +9 -0
  12. package/adapters/cursor/orchestration.md +17 -0
  13. package/adapters/opencode/deploy.json +12 -0
  14. package/adapters/opencode/orchestration.md +18 -0
  15. package/adapters/opencode/plugin.js +10 -0
  16. package/cli.mjs +161 -558
  17. package/core/agents/specline-backend-dev.yaml +45 -0
  18. package/core/agents/specline-code-reviewer.yaml +67 -0
  19. package/core/agents/specline-config-dev.yaml +50 -0
  20. package/core/agents/specline-config-reviewer.yaml +70 -0
  21. package/core/agents/specline-explore-assistant.yaml +79 -0
  22. package/core/agents/specline-frontend-dev.yaml +45 -0
  23. package/core/agents/specline-spec-creator.yaml +58 -0
  24. package/core/agents/specline-spec-reviewer.yaml +58 -0
  25. package/core/agents/specline-test-runner.yaml +62 -0
  26. package/core/agents/specline-test-writer.yaml +67 -0
  27. package/core/bootstrap/using-specline.md +14 -0
  28. package/core/gates/pipeline-gate-checks/a1-covers-ref.sh +125 -0
  29. package/core/gates/pipeline-gate-checks/a2-a3-reverse.sh +171 -0
  30. package/core/gates/pipeline-gate-checks/c1-exception.sh +71 -0
  31. package/core/gates/pipeline-gate-checks/c2-vague.sh +60 -0
  32. package/core/gates/pipeline-gate-checks/common.sh +68 -0
  33. package/core/gates/pipeline-gate-checks/d1-cycle.sh +149 -0
  34. package/core/gates/pipeline-gate-checks/d3-type-file.sh +260 -0
  35. package/core/gates/pipeline-gate.sh +1456 -0
  36. package/core/hooks/session-start.sh +259 -0
  37. package/core/skills/specline-apply-change/SKILL.md +197 -0
  38. package/core/skills/specline-archive-change/SKILL.md +173 -0
  39. package/core/skills/specline-explore/SKILL.md +504 -0
  40. package/core/skills/specline-knowledge/SKILL.md +539 -0
  41. package/core/skills/specline-pipeline/SKILL.md +604 -0
  42. package/core/skills/specline-pipeline/references/error-recovery-details.md +49 -0
  43. package/core/skills/specline-pipeline/references/event-log-spec.md +59 -0
  44. package/core/skills/specline-pipeline/references/pipeline-state-schema.md +87 -0
  45. package/core/skills/specline-pipeline/templates/subagent-prompts.md +397 -0
  46. package/core/skills/specline-propose/SKILL.md +186 -0
  47. package/core/skills/specline-quickfix/SKILL.md +289 -0
  48. package/core/templates/AGENTS.md.hbs +5 -0
  49. package/core/templates/specline/config.yaml +15 -0
  50. package/lib/deploy-claude.mjs +80 -0
  51. package/lib/deploy-codex.mjs +77 -0
  52. package/lib/deploy-opencode.mjs +93 -0
  53. package/lib/deploy.mjs +668 -0
  54. package/lib/gate.mjs +103 -0
  55. package/lib/hash.mjs +13 -0
  56. package/lib/hook.mjs +105 -0
  57. package/lib/init.mjs +122 -0
  58. package/lib/lock.mjs +99 -0
  59. package/lib/merge.mjs +188 -0
  60. package/lib/paths.mjs +40 -0
  61. package/lib/platforms.mjs +74 -0
  62. package/lib/render-agents.mjs +88 -0
  63. package/lib/render.mjs +126 -0
  64. package/lib/sync.mjs +253 -0
  65. package/lib/tty-select.mjs +89 -0
  66. package/package.json +4 -1
  67. package/templates/.cursor/README.md +18 -0
  68. package/templates/.cursor/agents/specline-code-reviewer.md +18 -2
  69. package/templates/.cursor/agents/specline-spec-creator.md +51 -2
  70. package/templates/.cursor/agents/specline-test-runner.md +10 -1
  71. package/templates/.cursor/agents/specline-test-writer.md +58 -7
  72. package/templates/.cursor/hooks/specline-pipeline-gate-checks/a2-a3-reverse.sh +1 -1
  73. package/templates/.cursor/hooks/specline-pipeline-gate.sh +118 -0
  74. package/templates/.cursor/skills/specline-pipeline/SKILL.md +10 -4
  75. package/templates/.cursor/skills/specline-propose/SKILL.md +3 -3
@@ -0,0 +1,74 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Parse a simple YAML list from platforms.yaml.
6
+ * Handles both inline `[cursor, claude]` and block `- cursor` formats.
7
+ * @param {string} content
8
+ * @returns {string[]}
9
+ */
10
+ export function parsePlatformsYaml(content) {
11
+ const platforms = [];
12
+ const lines = content.split('\n');
13
+
14
+ for (const line of lines) {
15
+ const trimmed = line.trim();
16
+ if (trimmed === '' || trimmed.startsWith('#')) continue;
17
+
18
+ if (trimmed.startsWith('- ')) {
19
+ platforms.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ''));
20
+ continue;
21
+ }
22
+
23
+ const inlineMatch = trimmed.match(/^platforms:\s*\[(.+)\]/);
24
+ if (inlineMatch) {
25
+ return inlineMatch[1]
26
+ .split(',')
27
+ .map((p) => p.trim().replace(/^["']|["']$/g, ''))
28
+ .filter(Boolean);
29
+ }
30
+
31
+ if (trimmed.startsWith('platforms:')) continue;
32
+ }
33
+
34
+ return platforms;
35
+ }
36
+
37
+ /**
38
+ * Read the platforms list from specline/platforms.yaml
39
+ * @param {string} projectDir
40
+ * @returns {string[]}
41
+ */
42
+ export function readPlatforms(projectDir) {
43
+ const platformsPath = join(projectDir, 'specline', 'platforms.yaml');
44
+ if (!existsSync(platformsPath)) return [];
45
+ const content = readFileSync(platformsPath, 'utf-8');
46
+ return parsePlatformsYaml(content);
47
+ }
48
+
49
+ /**
50
+ * CLI entry point for `specline platforms`
51
+ * @param {string} [cwd]
52
+ * @returns {number} exit code
53
+ */
54
+ export function cliPlatforms(cwd) {
55
+ const projectDir = cwd || process.cwd();
56
+ const platformsPath = join(projectDir, 'specline', 'platforms.yaml');
57
+
58
+ if (!existsSync(platformsPath)) {
59
+ process.stderr.write('No specline/platforms.yaml found. Run `specline init` first.\n');
60
+ return 1;
61
+ }
62
+
63
+ const platforms = readPlatforms(projectDir);
64
+ if (platforms.length === 0) {
65
+ process.stdout.write('No platforms configured.\n');
66
+ return 0;
67
+ }
68
+
69
+ process.stdout.write('Deployed platforms:\n');
70
+ for (const p of platforms) {
71
+ process.stdout.write(` - ${p}\n`);
72
+ }
73
+ return 0;
74
+ }
@@ -0,0 +1,88 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { PACKAGE_ROOT } from './paths.mjs';
4
+
5
+ /**
6
+ * @param {string} yamlContent
7
+ * @returns {{ name: string, description: string, instructions: string }}
8
+ */
9
+ export function parseAgentYaml(yamlContent) {
10
+ const lines = yamlContent.split('\n');
11
+ const fields = { name: '', description: '', instructions: '' };
12
+ let current = '';
13
+ const block = [];
14
+
15
+ for (const line of lines) {
16
+ if (line.match(/^name:\s/)) {
17
+ current = 'name';
18
+ fields.name = line.replace(/^name:\s*"?/, '').replace(/"$/, '').trim();
19
+ } else if (line.match(/^description:\s/)) {
20
+ current = 'description';
21
+ const remainder = line.replace(/^description:\s*"?/, '').replace(/"$/, '').trim();
22
+ if (/^[>|][-+]?$/.test(remainder)) {
23
+ // Multi-line block scalar (e.g. >-, |, >+, |-)
24
+ block.length = 0;
25
+ fields.description = '';
26
+ } else {
27
+ fields.description = remainder;
28
+ }
29
+ } else if (line.match(/^instructions:\s*\|\s*$/)) {
30
+ // Save collected description block before switching to instructions
31
+ if (current === 'description' && block.length > 0) {
32
+ fields.description = block.join(' ').trim();
33
+ block.length = 0;
34
+ }
35
+ current = 'instructions';
36
+ } else if (current === 'description') {
37
+ const trimmed = line.trim();
38
+ if (trimmed.length > 0) block.push(trimmed);
39
+ } else if (current === 'instructions') {
40
+ block.push(line.replace(/^ /, ''));
41
+ } else if (line.match(/^(output|constraints|phases):/)) {
42
+ current = '';
43
+ }
44
+ }
45
+
46
+ // Handle description at end-of-file
47
+ if (current === 'description' && block.length > 0) {
48
+ fields.description = block.join(' ').trim();
49
+ }
50
+
51
+ fields.instructions = block.join('\n').trim();
52
+ return fields;
53
+ }
54
+
55
+ /** @param {string} yamlContent */
56
+ export function renderCursorAgent(yamlContent) {
57
+ const { name, description, instructions } = parseAgentYaml(yamlContent);
58
+ return `---\nname: ${name}\ndescription: ${description}\n---\n\n${instructions}\n`;
59
+ }
60
+
61
+ /** @param {string} yamlContent */
62
+ export function renderClaudeAgent(yamlContent) {
63
+ return renderCursorAgent(yamlContent);
64
+ }
65
+
66
+ /** @param {string} yamlContent @param {string} [tomlTemplate] */
67
+ export function renderCodexAgent(yamlContent, tomlTemplate) {
68
+ const { name, description, instructions } = parseAgentYaml(yamlContent);
69
+ if (tomlTemplate) {
70
+ return tomlTemplate
71
+ .replace(/\{\{name\}\}/g, name)
72
+ .replace(/\{\{description\}\}/g, description)
73
+ .replace(/\{\{instructions\}\}/g, instructions.replace(/\\/g, '\\\\').replace(/"/g, '\\"'));
74
+ }
75
+ const escapedInstructions = instructions.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""');
76
+ return `name = "${name}"\ndescription = "${description}"\n\n[instructions]\ndeveloper_instructions = """\n${escapedInstructions}\n"""\n`;
77
+ }
78
+
79
+ /** @param {string} yamlPath */
80
+ export function getAgentInstructionsFromFile(yamlPath) {
81
+ return parseAgentYaml(readFileSync(yamlPath, 'utf-8')).instructions;
82
+ }
83
+
84
+ /** @param {string} role e.g. specline-backend-dev */
85
+ export function getAgentInstructions(role, packageRoot = PACKAGE_ROOT) {
86
+ const yamlPath = join(packageRoot, 'core', 'agents', `${role}.yaml`);
87
+ return getAgentInstructionsFromFile(yamlPath);
88
+ }
package/lib/render.mjs ADDED
@@ -0,0 +1,126 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { PACKAGE_ROOT, adapterDir } from './paths.mjs';
4
+ import { parseAgentYaml } from './render-agents.mjs';
5
+
6
+ const DEFAULT_VARS = {
7
+ cursor: {
8
+ DISPATCH: '使用 Task 工具,subagent_type="{{ROLE}}"',
9
+ CONFIRM: '使用 AskUserQuestion 工具',
10
+ LINT: '使用 ReadLints 工具',
11
+ },
12
+ claude: {
13
+ DISPATCH: '使用 dispatch_agent 工具,agent_name="{{ROLE}}"',
14
+ CONFIRM: '直接向用户提问',
15
+ LINT: '运行 bash lint 命令检查',
16
+ },
17
+ codex: {
18
+ DISPATCH: '使用 dispatch_agent 工具,agent_name="{{ROLE}}"',
19
+ CONFIRM: '直接向用户提问',
20
+ LINT: '运行 bash lint 命令检查',
21
+ },
22
+ opencode: {
23
+ DISPATCH: '使用 subagent 工具调度子 Agent',
24
+ CONFIRM: '直接向用户提问',
25
+ LINT: '运行 bash lint 命令检查',
26
+ },
27
+ };
28
+
29
+ /**
30
+ * Load vars from adapter deploy.json, fallback to DEFAULT_VARS.
31
+ * @param {string} platform
32
+ * @returns {{ DISPATCH: string, CONFIRM: string, LINT: string }}
33
+ */
34
+ export function loadPlatformVars(platform) {
35
+ const deployPath = join(adapterDir(platform), 'deploy.json');
36
+ if (existsSync(deployPath)) {
37
+ try {
38
+ const manifest = JSON.parse(readFileSync(deployPath, 'utf-8'));
39
+ if (manifest.vars) return manifest.vars;
40
+ } catch { /* fallback */ }
41
+ }
42
+ return DEFAULT_VARS[platform] || DEFAULT_VARS.cursor;
43
+ }
44
+
45
+ /**
46
+ * Replace {{DISPATCH}}, {{CONFIRM}}, {{LINT}} template variables.
47
+ * Unknown {{...}} (including {{ROLE}}) are preserved as-is.
48
+ * @param {string} content - skill source content
49
+ * @param {{ DISPATCH?: string, CONFIRM?: string, LINT?: string }} vars
50
+ * @returns {string}
51
+ */
52
+ export function renderSkill(content, vars) {
53
+ let result = content;
54
+ if (vars.DISPATCH != null) result = result.replace(/\{\{DISPATCH\}\}/g, vars.DISPATCH);
55
+ if (vars.CONFIRM != null) result = result.replace(/\{\{CONFIRM\}\}/g, vars.CONFIRM);
56
+ if (vars.LINT != null) result = result.replace(/\{\{LINT\}\}/g, vars.LINT);
57
+ return result;
58
+ }
59
+
60
+ /**
61
+ * Strip platform-conditional sections.
62
+ * Keeps sections matching targetPlatform, removes others.
63
+ *
64
+ * Format:
65
+ * <!-- platform:cursor -->
66
+ * ... cursor-specific content ...
67
+ * <!-- /platform:cursor -->
68
+ *
69
+ * <!-- platform:claude,codex,opencode -->
70
+ * ... non-cursor content ...
71
+ * <!-- /platform:claude,codex,opencode -->
72
+ *
73
+ * @param {string} content
74
+ * @param {string} targetPlatform
75
+ * @returns {string}
76
+ */
77
+ export function stripPlatformSections(content, targetPlatform) {
78
+ return content.replace(
79
+ /<!-- platform:([\w,]+) -->\n([\s\S]*?)<!-- \/platform:\1 -->\n?/g,
80
+ (match, platforms, body) => {
81
+ const platformList = platforms.split(',').map((p) => p.trim());
82
+ if (platformList.includes(targetPlatform)) {
83
+ return body;
84
+ }
85
+ return '';
86
+ }
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Full skill rendering pipeline: variable replacement + platform section stripping.
92
+ * @param {string} content
93
+ * @param {string} platform
94
+ * @returns {string}
95
+ */
96
+ export function renderSkillForPlatform(content, platform) {
97
+ const vars = loadPlatformVars(platform);
98
+ let result = renderSkill(content, vars);
99
+ result = stripPlatformSections(result, platform);
100
+ return result;
101
+ }
102
+
103
+ /**
104
+ * Render agent YAML canonical to platform-specific format.
105
+ * @param {string} yamlContent - raw YAML from core/agents/*.yaml
106
+ * @param {string} platform
107
+ * @returns {string|null} rendered content, or null if platform doesn't render agents
108
+ */
109
+ export function renderAgent(yamlContent, platform) {
110
+ if (platform === 'opencode') return null;
111
+
112
+ const { name, description, instructions } = parseAgentYaml(yamlContent);
113
+
114
+ if (platform === 'cursor' || platform === 'claude') {
115
+ return `---\nname: ${name}\ndescription: ${description}\n---\n\n${instructions}\n`;
116
+ }
117
+
118
+ if (platform === 'codex') {
119
+ const escapedName = (name || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
120
+ const escapedDesc = (description || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
121
+ const escapedInstructions = instructions.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""');
122
+ return `name = "${escapedName}"\ndescription = "${escapedDesc}"\n\n[instructions]\ndeveloper_instructions = """\n${escapedInstructions}\n"""\n`;
123
+ }
124
+
125
+ return null;
126
+ }
package/lib/sync.mjs ADDED
@@ -0,0 +1,253 @@
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ unlinkSync,
6
+ mkdirSync,
7
+ } from 'fs';
8
+ import { join, dirname } from 'path';
9
+ import { computeFileHash, sha256 } from './hash.mjs';
10
+ import { readLockFile, writeLockFile, isV1Lock, migrateV1ToV2 } from './lock.mjs';
11
+ import {
12
+ getCombinedUpstreamManifest,
13
+ writeManifestToProject,
14
+ readProjectPlatforms,
15
+ filterManifestByPlatforms,
16
+ hashManifestEntry,
17
+ readUpstreamContent,
18
+ } from './deploy.mjs';
19
+ import { PACKAGE_ROOT } from './paths.mjs';
20
+ import { mergeHooksJson, mergeConfigYaml, backupBeforeOverwrite, mergeClaudeSettings } from './merge.mjs';
21
+
22
+ const PROTECTED_PREFIXES = [
23
+ 'specline/changes/',
24
+ 'specline/specs/',
25
+ 'specline/.pipeline-sessions.json',
26
+ ];
27
+
28
+ const LEGACY_HOOK_SCRIPTS = [
29
+ '.cursor/hooks/specline-phase-guard.sh',
30
+ '.cursor/hooks/specline-agent-guard.sh',
31
+ '.cursor/hooks/specline-reminder.sh',
32
+ '.cursor/hooks/specline-auto-format.sh',
33
+ ];
34
+
35
+ /** @param {string} relPath */
36
+ function isProtectedPath(relPath) {
37
+ return PROTECTED_PREFIXES.some((prefix) => relPath === prefix || relPath.startsWith(prefix));
38
+ }
39
+
40
+ /** @param {string} relPath */
41
+ function isHooksJson(relPath) {
42
+ return relPath === '.cursor/hooks.json' || relPath === '.codex/hooks.json';
43
+ }
44
+
45
+ /** @param {string} relPath */
46
+ function isClaudeSettings(relPath) {
47
+ return relPath === '.claude/settings.json';
48
+ }
49
+
50
+ /** @param {string} relPath */
51
+ function isConfigYaml(relPath) {
52
+ return relPath === 'specline/config.yaml';
53
+ }
54
+
55
+ /**
56
+ * Three-way hash comparison to classify sync action.
57
+ * @param {{ upstreamHash: string|null, lockHash: string|null, localHash: string|null, localExists: boolean }} info
58
+ * @returns {'NEW'|'WILL_UPDATE'|'UNCHANGED'|'MODIFIED_ONLY'|'CONFLICT'|'UPSTREAM_REMOVED'}
59
+ */
60
+ function classifyFile({ upstreamHash, lockHash, localHash, localExists }) {
61
+ if (!upstreamHash && lockHash) return 'UPSTREAM_REMOVED';
62
+ if (!upstreamHash) return 'UNCHANGED';
63
+ if (!localExists) return 'NEW';
64
+
65
+ if (!lockHash) {
66
+ return localHash === upstreamHash ? 'UNCHANGED' : 'CONFLICT';
67
+ }
68
+
69
+ if (localHash === lockHash) {
70
+ return upstreamHash === lockHash ? 'UNCHANGED' : 'WILL_UPDATE';
71
+ }
72
+
73
+ return upstreamHash === lockHash ? 'MODIFIED_ONLY' : 'CONFLICT';
74
+ }
75
+
76
+ /** Read package version from package.json */
77
+ function getPackageVersion(packageRoot) {
78
+ try {
79
+ return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8')).version || '2.0.0';
80
+ } catch {
81
+ return '2.0.0';
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @typedef {Object} SyncPlan
87
+ * @property {string} path
88
+ * @property {'NEW'|'WILL_UPDATE'|'UNCHANGED'|'MODIFIED_ONLY'|'CONFLICT'|'UPSTREAM_REMOVED'} type
89
+ */
90
+
91
+ /**
92
+ * @typedef {Object} SyncResult
93
+ * @property {SyncPlan[]} plan
94
+ * @property {{ newCount: number, updated: number, conflicted: number, skippedModified: number, unchanged: number, upstreamRemoved: number }} stats
95
+ * @property {boolean} migrated
96
+ */
97
+
98
+ /**
99
+ * Main sync logic for multi-platform projects.
100
+ *
101
+ * @param {string} projectDir
102
+ * @param {{ platforms?: string[], dryRun?: boolean, packageRoot?: string }} [options]
103
+ * @returns {SyncResult}
104
+ */
105
+ export function runSync(projectDir, options = {}) {
106
+ const { dryRun = false, packageRoot = PACKAGE_ROOT } = options;
107
+
108
+ const lockData = readLockFile(projectDir);
109
+ if (!lockData) {
110
+ throw new Error('No lock file found. Run `specline init` first.');
111
+ }
112
+
113
+ const migrated = isV1Lock(lockData);
114
+ if (migrated) {
115
+ const inferredPlatforms = readProjectPlatforms(projectDir);
116
+ migrateV1ToV2(lockData, lockData.version || '2.0.0', inferredPlatforms);
117
+ }
118
+
119
+ let syncPlatforms;
120
+ if (options.platforms?.length) {
121
+ syncPlatforms = options.platforms;
122
+ } else if (lockData.platforms?.length) {
123
+ syncPlatforms = lockData.platforms;
124
+ } else {
125
+ syncPlatforms = readProjectPlatforms(projectDir);
126
+ }
127
+
128
+ const manifest = getCombinedUpstreamManifest(syncPlatforms, packageRoot);
129
+
130
+ const allPaths = new Set();
131
+ for (const p of manifest.keys()) allPaths.add(p);
132
+ for (const p of lockData.files.keys()) {
133
+ if (!isProtectedPath(p)) allPaths.add(p);
134
+ }
135
+ for (const legacy of LEGACY_HOOK_SCRIPTS) {
136
+ if (lockData.files.has(legacy) || existsSync(join(projectDir, legacy))) {
137
+ allPaths.add(legacy);
138
+ }
139
+ }
140
+
141
+ /** @type {SyncPlan[]} */
142
+ const plan = [];
143
+
144
+ for (const relPath of allPaths) {
145
+ if (isProtectedPath(relPath)) continue;
146
+
147
+ const entry = manifest.get(relPath);
148
+ const upstreamHash = entry ? hashManifestEntry(entry) : null;
149
+ const lockHash = lockData.files.get(relPath) || null;
150
+ const localPath = join(projectDir, relPath);
151
+ const localExists = existsSync(localPath);
152
+ const localHash = localExists ? computeFileHash(localPath) : null;
153
+
154
+ const type = classifyFile({ upstreamHash, lockHash, localHash, localExists });
155
+ plan.push({ path: relPath, type });
156
+ }
157
+
158
+ const stats = { newCount: 0, updated: 0, conflicted: 0, skippedModified: 0, unchanged: 0, upstreamRemoved: 0 };
159
+ for (const item of plan) {
160
+ switch (item.type) {
161
+ case 'NEW': stats.newCount++; break;
162
+ case 'WILL_UPDATE': stats.updated++; break;
163
+ case 'CONFLICT': stats.conflicted++; break;
164
+ case 'MODIFIED_ONLY': stats.skippedModified++; break;
165
+ case 'UNCHANGED': stats.unchanged++; break;
166
+ case 'UPSTREAM_REMOVED': stats.upstreamRemoved++; break;
167
+ }
168
+ }
169
+
170
+ if (dryRun) {
171
+ return { plan, stats, migrated };
172
+ }
173
+
174
+ const newFiles = new Map();
175
+
176
+ for (const item of plan) {
177
+ const destPath = join(projectDir, item.path);
178
+
179
+ switch (item.type) {
180
+ case 'UNCHANGED':
181
+ case 'MODIFIED_ONLY': {
182
+ if (existsSync(destPath)) {
183
+ newFiles.set(item.path, computeFileHash(destPath));
184
+ }
185
+ break;
186
+ }
187
+
188
+ case 'UPSTREAM_REMOVED': {
189
+ if (existsSync(destPath)) {
190
+ try { unlinkSync(destPath); } catch { /* ignore */ }
191
+ }
192
+ break;
193
+ }
194
+
195
+ case 'NEW':
196
+ case 'WILL_UPDATE':
197
+ case 'CONFLICT': {
198
+ const entry = manifest.get(item.path);
199
+ if (!entry) break;
200
+
201
+ const destDir = dirname(destPath);
202
+ if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
203
+
204
+ if (isClaudeSettings(item.path)) {
205
+ const existingContent = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '{}';
206
+ const upstreamContent = readUpstreamContent(item.path, manifest);
207
+ if (upstreamContent) {
208
+ const merged = mergeClaudeSettings(existingContent, upstreamContent);
209
+ writeFileSync(destPath, merged, 'utf-8');
210
+ newFiles.set(item.path, sha256(merged));
211
+ }
212
+ } else if (isHooksJson(item.path)) {
213
+ const existingContent = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '{}';
214
+ const upstreamContent = readUpstreamContent(item.path, manifest);
215
+ if (upstreamContent) {
216
+ const merged = mergeHooksJson(existingContent, upstreamContent);
217
+ writeFileSync(destPath, merged, 'utf-8');
218
+ newFiles.set(item.path, sha256(merged));
219
+ }
220
+ } else if (isConfigYaml(item.path)) {
221
+ const existingContent = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '';
222
+ const upstreamContent = readUpstreamContent(item.path, manifest);
223
+ if (upstreamContent) {
224
+ const merged = mergeConfigYaml(existingContent, upstreamContent);
225
+ writeFileSync(destPath, merged, 'utf-8');
226
+ newFiles.set(item.path, sha256(merged));
227
+ }
228
+ } else {
229
+ if (item.type === 'CONFLICT' && existsSync(destPath)) {
230
+ backupBeforeOverwrite(destPath);
231
+ }
232
+ const subManifest = new Map([[item.path, entry]]);
233
+ writeManifestToProject(projectDir, subManifest);
234
+ newFiles.set(item.path, computeFileHash(destPath));
235
+ }
236
+ break;
237
+ }
238
+ }
239
+ }
240
+
241
+ const version = getPackageVersion(packageRoot);
242
+ writeLockFile(projectDir, {
243
+ version,
244
+ synced_at: new Date().toISOString(),
245
+ schema: 2,
246
+ platforms: [...syncPlatforms],
247
+ files: newFiles,
248
+ });
249
+
250
+ return { plan, stats, migrated };
251
+ }
252
+
253
+
@@ -0,0 +1,89 @@
1
+ import { PLATFORMS } from './paths.mjs';
2
+
3
+ const PLATFORM_LABELS = {
4
+ cursor: 'Cursor',
5
+ claude: 'Claude Code',
6
+ codex: 'Codex',
7
+ opencode: 'OpenCode',
8
+ };
9
+
10
+ /**
11
+ * Interactive multi-select for platforms using raw mode ANSI escape.
12
+ * Falls back to ['cursor'] in non-TTY environments.
13
+ * @returns {Promise<string[]>}
14
+ */
15
+ export async function selectPlatforms() {
16
+ if (!process.stdin.isTTY) return ['cursor'];
17
+
18
+ const items = PLATFORMS.map((name, i) => ({
19
+ name,
20
+ label: PLATFORM_LABELS[name] || name,
21
+ selected: i === 0,
22
+ }));
23
+
24
+ let cursor = 0;
25
+
26
+ const render = () => {
27
+ const lines = items.map((item, i) => {
28
+ const icon = item.selected ? '\x1b[36m◉\x1b[0m' : '◯';
29
+ const pointer = i === cursor ? '\x1b[1m>\x1b[0m ' : ' ';
30
+ return `${pointer}${icon} ${item.label}`;
31
+ });
32
+ return lines.join('\n');
33
+ };
34
+
35
+ const clearLines = (count) => {
36
+ for (let i = 0; i < count; i++) {
37
+ process.stdout.write('\x1b[1A\x1b[2K');
38
+ }
39
+ };
40
+
41
+ return new Promise((resolve) => {
42
+ process.stdout.write('选择目标平台(空格切换,回车确认):\n');
43
+ process.stdout.write(render() + '\n');
44
+
45
+ const stdin = process.stdin;
46
+ stdin.setRawMode(true);
47
+ stdin.resume();
48
+ stdin.setEncoding('utf-8');
49
+
50
+ const onData = (key) => {
51
+ if (key === '\u0003') {
52
+ // Ctrl+C
53
+ stdin.setRawMode(false);
54
+ stdin.removeListener('data', onData);
55
+ stdin.pause();
56
+ process.stdout.write('\n');
57
+ process.exit(130);
58
+ }
59
+
60
+ if (key === '\r' || key === '\n') {
61
+ stdin.setRawMode(false);
62
+ stdin.removeListener('data', onData);
63
+ stdin.pause();
64
+ process.stdout.write('\n');
65
+ const selected = items.filter((it) => it.selected).map((it) => it.name);
66
+ resolve(selected.length > 0 ? selected : ['cursor']);
67
+ return;
68
+ }
69
+
70
+ if (key === ' ') {
71
+ items[cursor].selected = !items[cursor].selected;
72
+ } else if (key === '\x1b[A' || key === 'k') {
73
+ cursor = (cursor - 1 + items.length) % items.length;
74
+ } else if (key === '\x1b[B' || key === 'j') {
75
+ cursor = (cursor + 1) % items.length;
76
+ } else if (key === 'a') {
77
+ const allSelected = items.every((it) => it.selected);
78
+ items.forEach((it) => { it.selected = !allSelected; });
79
+ } else {
80
+ return;
81
+ }
82
+
83
+ clearLines(items.length);
84
+ process.stdout.write(render() + '\n');
85
+ };
86
+
87
+ stdin.on('data', onData);
88
+ });
89
+ }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "specline",
3
- "version": "1.4.0",
3
+ "version": "2.0.1",
4
4
  "description": "Spec-driven AI coding pipeline with deterministic quality gates for Cursor IDE",
5
5
  "bin": {
6
6
  "specline": "./cli.mjs"
7
7
  },
8
8
  "files": [
9
9
  "cli.mjs",
10
+ "lib/",
11
+ "core/",
12
+ "adapters/",
10
13
  "templates/"
11
14
  ],
12
15
  "keywords": [
@@ -0,0 +1,18 @@
1
+ # ⚠️ DEPRECATED
2
+
3
+ This directory is a legacy compatibility layer. **Do not edit files here directly.**
4
+
5
+ ## Source of Truth
6
+
7
+ - Skills: `core/skills/`
8
+ - Agents: `core/agents/`
9
+ - Gates: `core/gates/`
10
+ - Hooks: `core/hooks/`
11
+ - Adapters: `adapters/{cursor,claude,codex,opencode}/`
12
+
13
+ ## Migration
14
+
15
+ Files in this directory are no longer the primary source. They will be maintained
16
+ for backward compatibility during the v1→v2 transition period.
17
+
18
+ Use `specline sync` to update your project from the new `core/` + `adapters/` structure.