specline 1.4.0 → 2.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 +132 -125
- package/adapters/claude/deploy.json +12 -0
- package/adapters/claude/hooks/hooks.json +12 -0
- package/adapters/claude/hooks.json +12 -0
- package/adapters/claude/orchestration.md +17 -0
- package/adapters/codex/agent.toml.hbs +7 -0
- package/adapters/codex/deploy.json +12 -0
- package/adapters/codex/hooks.json +12 -0
- package/adapters/codex/orchestration.md +18 -0
- package/adapters/cursor/deploy.json +12 -0
- package/adapters/cursor/hooks.json +9 -0
- package/adapters/cursor/orchestration.md +17 -0
- package/adapters/opencode/deploy.json +12 -0
- package/adapters/opencode/orchestration.md +18 -0
- package/adapters/opencode/plugin.js +10 -0
- package/cli.mjs +161 -558
- package/core/agents/specline-backend-dev.yaml +45 -0
- package/core/agents/specline-code-reviewer.yaml +67 -0
- package/core/agents/specline-config-dev.yaml +50 -0
- package/core/agents/specline-config-reviewer.yaml +70 -0
- package/core/agents/specline-explore-assistant.yaml +79 -0
- package/core/agents/specline-frontend-dev.yaml +45 -0
- package/core/agents/specline-spec-creator.yaml +58 -0
- package/core/agents/specline-spec-reviewer.yaml +58 -0
- package/core/agents/specline-test-runner.yaml +62 -0
- package/core/agents/specline-test-writer.yaml +67 -0
- package/core/bootstrap/using-specline.md +14 -0
- package/core/gates/pipeline-gate-checks/a1-covers-ref.sh +125 -0
- package/core/gates/pipeline-gate-checks/a2-a3-reverse.sh +171 -0
- package/core/gates/pipeline-gate-checks/c1-exception.sh +71 -0
- package/core/gates/pipeline-gate-checks/c2-vague.sh +60 -0
- package/core/gates/pipeline-gate-checks/common.sh +68 -0
- package/core/gates/pipeline-gate-checks/d1-cycle.sh +149 -0
- package/core/gates/pipeline-gate-checks/d3-type-file.sh +260 -0
- package/core/gates/pipeline-gate.sh +1456 -0
- package/core/hooks/session-start.sh +259 -0
- package/core/skills/specline-apply-change/SKILL.md +197 -0
- package/core/skills/specline-archive-change/SKILL.md +173 -0
- package/core/skills/specline-explore/SKILL.md +504 -0
- package/core/skills/specline-knowledge/SKILL.md +539 -0
- package/core/skills/specline-pipeline/SKILL.md +604 -0
- package/core/skills/specline-pipeline/references/error-recovery-details.md +49 -0
- package/core/skills/specline-pipeline/references/event-log-spec.md +59 -0
- package/core/skills/specline-pipeline/references/pipeline-state-schema.md +87 -0
- package/core/skills/specline-pipeline/templates/subagent-prompts.md +397 -0
- package/core/skills/specline-propose/SKILL.md +186 -0
- package/core/skills/specline-quickfix/SKILL.md +289 -0
- package/core/templates/AGENTS.md.hbs +5 -0
- package/core/templates/specline/config.yaml +15 -0
- package/lib/deploy-claude.mjs +80 -0
- package/lib/deploy-codex.mjs +77 -0
- package/lib/deploy-opencode.mjs +93 -0
- package/lib/deploy.mjs +668 -0
- package/lib/gate.mjs +103 -0
- package/lib/hash.mjs +13 -0
- package/lib/hook.mjs +105 -0
- package/lib/init.mjs +122 -0
- package/lib/lock.mjs +99 -0
- package/lib/merge.mjs +184 -0
- package/lib/paths.mjs +40 -0
- package/lib/platforms.mjs +74 -0
- package/lib/render-agents.mjs +88 -0
- package/lib/render.mjs +126 -0
- package/lib/sync.mjs +253 -0
- package/lib/tty-select.mjs +89 -0
- package/package.json +4 -1
- package/templates/.cursor/README.md +18 -0
- package/templates/.cursor/agents/specline-code-reviewer.md +18 -2
- package/templates/.cursor/agents/specline-spec-creator.md +51 -2
- package/templates/.cursor/agents/specline-test-runner.md +10 -1
- package/templates/.cursor/agents/specline-test-writer.md +58 -7
- package/templates/.cursor/hooks/specline-pipeline-gate-checks/a2-a3-reverse.sh +1 -1
- package/templates/.cursor/hooks/specline-pipeline-gate.sh +118 -0
- package/templates/.cursor/skills/specline-pipeline/SKILL.md +10 -4
- 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": "
|
|
3
|
+
"version": "2.0.0",
|
|
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.
|