dream-wf 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.
@@ -0,0 +1,96 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { pathExists, readTextIfExists } from '../lib/files.js';
4
+ import { commandExists } from '../lib/trellis.js';
5
+
6
+ export async function checkDependencies(rootDir, platform) {
7
+ const checks = [];
8
+
9
+ checks.push(binaryCheck('node', 'Node.js >= 18 is required.'));
10
+ checks.push(binaryCheck('python3', 'Python >= 3.9 is required by Trellis.'));
11
+ checks.push(binaryCheck('trellis', 'Install with: npm install -g @mindfoldhq/trellis@latest'));
12
+ checks.push(binaryCheck('uvx', 'Required for grok-search-mcp. Install uv: https://docs.astral.sh/uv/'));
13
+
14
+ checks.push(await fileCheck(path.join(rootDir, '.trellis'), 'Trellis project directory'));
15
+ checks.push(await fileCheck(path.join(rootDir, '.trellis', 'workflow.md'), 'Trellis workflow'));
16
+
17
+ if (platform === 'cursor') {
18
+ checks.push(await fileCheck(path.join(rootDir, '.cursor', 'rules', 'dream-wf.mdc'), 'Cursor dream-wf always-on rule'));
19
+ checks.push(await fileCheck(path.join(rootDir, '.cursor', 'skills', 'dream-wf-grill-prd', 'SKILL.md'), 'Cursor dream-wf grill PRD skill'));
20
+ checks.push(await fileCheck(path.join(rootDir, '.cursor', 'skills', 'dream-wf-mcp-policy', 'SKILL.md'), 'Cursor dream-wf MCP policy skill'));
21
+ }
22
+
23
+ if (platform === 'claude') {
24
+ checks.push(await contentCheck(path.join(rootDir, 'CLAUDE.md'), '<!-- DREAM-WF:START -->', 'Claude Code dream-wf entry block'));
25
+ checks.push(await fileCheck(path.join(rootDir, '.claude', 'skills', 'dream-wf-grill-prd', 'SKILL.md'), 'Claude Code dream-wf grill PRD skill'));
26
+ checks.push(await fileCheck(path.join(rootDir, '.claude', 'skills', 'dream-wf-mcp-policy', 'SKILL.md'), 'Claude Code dream-wf MCP policy skill'));
27
+ }
28
+
29
+ if (platform === 'opencode') {
30
+ checks.push(await contentCheck(path.join(rootDir, 'AGENTS.md'), '<!-- DREAM-WF:START -->', 'OpenCode dream-wf entry block'));
31
+ checks.push(await fileCheck(path.join(rootDir, '.opencode', 'skills', 'dream-wf-grill-prd', 'SKILL.md'), 'OpenCode dream-wf grill PRD skill'));
32
+ checks.push(await fileCheck(path.join(rootDir, '.opencode', 'skills', 'dream-wf-mcp-policy', 'SKILL.md'), 'OpenCode dream-wf MCP policy skill'));
33
+ }
34
+
35
+ checks.push(await secretScan(rootDir));
36
+
37
+ return checks;
38
+ }
39
+
40
+ function binaryCheck(command, hint) {
41
+ return {
42
+ name: command,
43
+ ok: commandExists(command),
44
+ hint
45
+ };
46
+ }
47
+
48
+ async function fileCheck(filePath, label) {
49
+ return {
50
+ name: label,
51
+ ok: await pathExists(filePath),
52
+ hint: `Missing ${filePath}`
53
+ };
54
+ }
55
+
56
+ async function contentCheck(filePath, needle, label) {
57
+ const text = await readTextIfExists(filePath);
58
+ return {
59
+ name: label,
60
+ ok: Boolean(text?.includes(needle)),
61
+ hint: `Missing ${needle} in ${filePath}`
62
+ };
63
+ }
64
+
65
+ async function secretScan(rootDir) {
66
+ const boardPath = path.join(rootDir, 'board.md');
67
+ const text = await readTextIfExists(boardPath);
68
+ if (!text) {
69
+ return { name: 'secret scan', ok: true, hint: 'No obvious project secret sample file found.' };
70
+ }
71
+
72
+ const suspicious = [
73
+ /GROK_API_KEY\s*[:=]/,
74
+ /TAVILY_API_KEY\s*[:=]/,
75
+ /WINDSURF_API_KEY\s*[:=]/,
76
+ /devin-session-/,
77
+ /tvly-[A-Za-z0-9_-]+/
78
+ ];
79
+
80
+ const hasSuspiciousContent = suspicious.some((pattern) => pattern.test(text));
81
+ return {
82
+ name: 'secret scan',
83
+ ok: !hasSuspiciousContent,
84
+ hint: hasSuspiciousContent ? 'Potential MCP secrets found in board.md. Do not commit real API keys.' : 'No obvious MCP secrets detected.'
85
+ };
86
+ }
87
+
88
+ export function installTrellisIfRequested() {
89
+ const result = spawnSync('npm', ['install', '-g', '@mindfoldhq/trellis@latest'], {
90
+ stdio: 'inherit'
91
+ });
92
+
93
+ if (result.status !== 0) {
94
+ throw new Error(`Failed to install Trellis with npm, exit code ${result.status ?? 'unknown'}.`);
95
+ }
96
+ }
@@ -0,0 +1,16 @@
1
+ import { checkDependencies } from '../deps/index.js';
2
+
3
+ export async function runDoctor(rootDir, platform) {
4
+ const checks = await checkDependencies(rootDir, platform);
5
+ const ok = checks.every((check) => check.ok);
6
+ return { ok, checks };
7
+ }
8
+
9
+ export function formatDoctorReport(report) {
10
+ const lines = [];
11
+ lines.push(`dream-wf doctor: ${report.ok ? 'ok' : 'issues found'}`);
12
+ for (const check of report.checks) {
13
+ lines.push(`${check.ok ? '✓' : '✗'} ${check.name}${check.ok ? '' : ` — ${check.hint}`}`);
14
+ }
15
+ return lines.join('\n');
16
+ }
@@ -0,0 +1,64 @@
1
+ import { mkdir, readFile, writeFile, access, copyFile } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export async function pathExists(filePath) {
6
+ try {
7
+ await access(filePath, constants.F_OK);
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ export async function readTextIfExists(filePath) {
15
+ if (!(await pathExists(filePath))) {
16
+ return undefined;
17
+ }
18
+ return readFile(filePath, 'utf8');
19
+ }
20
+
21
+ export async function writeTextFile(filePath, contents) {
22
+ await mkdir(path.dirname(filePath), { recursive: true });
23
+ await writeFile(filePath, contents, 'utf8');
24
+ }
25
+
26
+ export async function writeIfChanged(filePath, contents) {
27
+ const current = await readTextIfExists(filePath);
28
+ if (current === contents) {
29
+ return { changed: false, action: 'unchanged', path: filePath };
30
+ }
31
+
32
+ await writeTextFile(filePath, contents);
33
+ return { changed: true, action: current === undefined ? 'created' : 'updated', path: filePath };
34
+ }
35
+
36
+ export async function backupIfExists(filePath) {
37
+ if (!(await pathExists(filePath))) {
38
+ return undefined;
39
+ }
40
+
41
+ const backupPath = `${filePath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
42
+ await copyFile(filePath, backupPath);
43
+ return backupPath;
44
+ }
45
+
46
+ export async function appendBlockOnce(filePath, marker, block, options = {}) {
47
+ const existing = await readTextIfExists(filePath);
48
+ if (existing?.includes(marker)) {
49
+ return { changed: false, action: 'unchanged', path: filePath };
50
+ }
51
+
52
+ const prefix = existing ? ensureTrailingNewline(existing) : '';
53
+ const next = `${prefix}${options.heading ? `\n${options.heading}\n` : '\n'}${block.trim()}\n`;
54
+ await writeTextFile(filePath, next);
55
+ return { changed: true, action: existing === undefined ? 'created' : 'updated', path: filePath };
56
+ }
57
+
58
+ export function ensureTrailingNewline(value) {
59
+ return value.endsWith('\n') ? value : `${value}\n`;
60
+ }
61
+
62
+ export function formatRelative(rootDir, filePath) {
63
+ return path.relative(rootDir, filePath) || '.';
64
+ }
@@ -0,0 +1,44 @@
1
+ import { readTextIfExists, writeTextFile } from './files.js';
2
+
3
+ export async function readJsonObject(filePath, fallback = {}) {
4
+ const text = await readTextIfExists(filePath);
5
+ if (!text) {
6
+ return fallback;
7
+ }
8
+
9
+ try {
10
+ const parsed = JSON.parse(text);
11
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback;
12
+ } catch (error) {
13
+ throw new Error(`Failed to parse JSON at ${filePath}: ${error.message}`);
14
+ }
15
+ }
16
+
17
+ export async function writeJsonObject(filePath, value) {
18
+ await writeTextFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
19
+ }
20
+
21
+ export function pushUniqueByCommand(items, candidate) {
22
+ const command = extractCommand(candidate);
23
+ if (command && items.some((item) => extractCommand(item) === command)) {
24
+ return false;
25
+ }
26
+ items.push(candidate);
27
+ return true;
28
+ }
29
+
30
+ function extractCommand(item) {
31
+ if (!item || typeof item !== 'object') {
32
+ return undefined;
33
+ }
34
+
35
+ if (typeof item.command === 'string') {
36
+ return item.command;
37
+ }
38
+
39
+ if (Array.isArray(item.hooks) && item.hooks.length > 0) {
40
+ return item.hooks.map((hook) => hook?.command).filter(Boolean).join('|');
41
+ }
42
+
43
+ return undefined;
44
+ }
@@ -0,0 +1,32 @@
1
+ export const SUPPORTED_PLATFORMS = new Set(['cursor', 'claude', 'opencode']);
2
+
3
+ export function normalizePlatform(value) {
4
+ if (!value) {
5
+ return undefined;
6
+ }
7
+
8
+ return value.trim().toLowerCase();
9
+ }
10
+
11
+ export function assertSupportedPlatform(platform) {
12
+ if (!platform) {
13
+ throw new Error('Missing required -p <cursor|claude|opencode>.');
14
+ }
15
+
16
+ if (!SUPPORTED_PLATFORMS.has(platform)) {
17
+ throw new Error(`Unsupported platform "${platform}". Use one of: cursor, claude, opencode.`);
18
+ }
19
+ }
20
+
21
+ export function trellisPlatformFlag(platform) {
22
+ if (platform === 'claude') {
23
+ return '--claude';
24
+ }
25
+ if (platform === 'cursor') {
26
+ return '--cursor';
27
+ }
28
+ if (platform === 'opencode') {
29
+ return '--opencode';
30
+ }
31
+ assertSupportedPlatform(platform);
32
+ }
@@ -0,0 +1,118 @@
1
+ import path from 'node:path';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { appendBlockOnce, pathExists, readTextIfExists, writeTextFile } from './files.js';
4
+ import { trellisPlatformFlag } from './platforms.js';
5
+
6
+ const DREAM_WF_MARKER = '<!-- dream-wf:profile:v1 -->';
7
+
8
+ export async function detectTrellis(rootDir) {
9
+ const trellisDir = path.join(rootDir, '.trellis');
10
+ const workflowPath = path.join(trellisDir, 'workflow.md');
11
+ const scriptsDir = path.join(trellisDir, 'scripts');
12
+
13
+ return {
14
+ trellisDir,
15
+ workflowPath,
16
+ exists: await pathExists(trellisDir),
17
+ hasWorkflow: await pathExists(workflowPath),
18
+ hasScripts: await pathExists(scriptsDir),
19
+ cli: commandExists('trellis')
20
+ };
21
+ }
22
+
23
+ export async function ensureTrellisInitialized(rootDir, options) {
24
+ const state = await detectTrellis(rootDir);
25
+ if (state.exists) {
26
+ return { ...state, initialized: true, initCommand: undefined };
27
+ }
28
+
29
+ const platformFlag = trellisPlatformFlag(options.platform);
30
+ const initCommand = `trellis init -u ${options.developer ?? '<your-name>'} ${platformFlag}`;
31
+
32
+ if (!options.installDeps) {
33
+ return { ...state, initialized: false, initCommand };
34
+ }
35
+
36
+ if (!options.developer) {
37
+ throw new Error('Pass --developer <name> when using --install-deps so dream-wf can run trellis init non-interactively.');
38
+ }
39
+
40
+ if (!state.cli) {
41
+ throw new Error('trellis is not installed. Install it with: npm install -g @mindfoldhq/trellis@latest');
42
+ }
43
+
44
+ const result = spawnSync('trellis', ['init', '-u', options.developer, platformFlag], {
45
+ cwd: rootDir,
46
+ stdio: 'inherit'
47
+ });
48
+
49
+ if (result.status !== 0) {
50
+ throw new Error(`trellis init failed with exit code ${result.status ?? 'unknown'}.`);
51
+ }
52
+
53
+ return { ...(await detectTrellis(rootDir)), initialized: true, initCommand };
54
+ }
55
+
56
+ export async function installTrellisProfile(rootDir) {
57
+ const workflowPath = path.join(rootDir, '.trellis', 'workflow.md');
58
+ const existing = await readTextIfExists(workflowPath);
59
+ if (!existing) {
60
+ return {
61
+ changed: false,
62
+ action: 'skipped',
63
+ path: workflowPath,
64
+ reason: '.trellis/workflow.md not found. Run trellis init first.'
65
+ };
66
+ }
67
+
68
+ return appendBlockOnce(workflowPath, DREAM_WF_MARKER, dreamWorkflowBlock());
69
+ }
70
+
71
+ export async function writeSpecPolicy(rootDir, name, contents) {
72
+ return writeTextFile(path.join(rootDir, '.trellis', 'spec', 'guides', name), contents);
73
+ }
74
+
75
+ export function commandExists(command) {
76
+ const result = spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', 'sh', command], {
77
+ stdio: 'ignore'
78
+ });
79
+ return result.status === 0;
80
+ }
81
+
82
+ function dreamWorkflowBlock() {
83
+ return [
84
+ DREAM_WF_MARKER,
85
+ '',
86
+ '## Dream WF Profile',
87
+ '',
88
+ 'This repository uses `dream-wf` as a Trellis custom patch profile. Trellis remains the source of truth for task lifecycle, specs, workflow state, before-dev, check, update-spec, break-loop, sub-agent context injection, and finish-work.',
89
+ '',
90
+ '### Dream WF Planning Override',
91
+ '',
92
+ 'When a request enters Trellis planning, keep the native Trellis task artifacts and lifecycle, but use the `dream-wf-grill-prd` skill as the PRD clarification method before implementation.',
93
+ '',
94
+ 'Rules:',
95
+ '',
96
+ '- Keep Trellis task creation, `prd.md`, `design.md`, `implement.md`, `implement.jsonl`, and `check.jsonl`.',
97
+ '- Do not use Trellis brainstorm as an open-ended interview style when `dream-wf-grill-prd` is available.',
98
+ '- Do not start planning by writing a speculative initial PRD. First inspect available context, then ask the first grill-me question.',
99
+ '- Use grill-me behavior for requirement discovery: ask one question at a time, provide 2-3 options and a recommended answer, and inspect code/docs/config before asking the user.',
100
+ '- Update `prd.md` only after a user answer, confirmed existing fact, or explicit decision is available.',
101
+ '- Treat PRD confirmation as separate from task creation consent.',
102
+ '- Generate initial spec candidates from user answers, PRD decisions, and verified project/code facts; require user review before treating them as stable conventions.',
103
+ '- Continue using `trellis-before-dev`, `trellis-check`, `trellis-update-spec`, and `trellis-break-loop` without replacing them.',
104
+ '',
105
+ '### Dream WF MCP Tool Policy',
106
+ '',
107
+ 'Before searching, classify the need:',
108
+ '',
109
+ '- Codebase semantic understanding: prefer `fast-context-mcp` / `fast_context_search`.',
110
+ '- Exact known symbols or files: use exact search or direct reads.',
111
+ '- External docs, live technical information, and web pages: prefer `grok-search-mcp` / `web_search` or `web_fetch`.',
112
+ '- If the preferred MCP is unavailable, state the fallback reason before using another tool.',
113
+ '',
114
+ 'Read `.trellis/spec/guides/dream-wf-mcp-policy.md` and `.trellis/spec/guides/dream-wf-prd-policy.md` when planning or starting implementation.',
115
+ '',
116
+ '<!-- /dream-wf:profile:v1 -->'
117
+ ].join('\n');
118
+ }
@@ -0,0 +1,54 @@
1
+ import path from 'node:path';
2
+ import { readFile, chmod } from 'node:fs/promises';
3
+ import { readJsonObject, writeJsonObject, pushUniqueByCommand } from '../../lib/json.js';
4
+ import { writeIfChanged } from '../../lib/files.js';
5
+ import { installCommonDreamWfFiles, installManagedBlock, installSkill } from '../shared.js';
6
+
7
+ export async function installClaudeCode(packageRoot, targetRoot, options) {
8
+ const results = [];
9
+
10
+ results.push(await installManagedBlock(packageRoot, targetRoot, 'templates/rules/claude-code/dream-wf-block.md', 'CLAUDE.md', '<!-- DREAM-WF:START -->', '<!-- DREAM-WF:END -->'));
11
+ results.push(await installSkill(packageRoot, targetRoot, '.claude', 'dream-wf-grill-prd'));
12
+ results.push(await installSkill(packageRoot, targetRoot, '.claude', 'dream-wf-mcp-policy'));
13
+ results.push(...await installCommonDreamWfFiles(packageRoot, targetRoot));
14
+
15
+ if (options.mode === 'strict') {
16
+ results.push(await installClaudeHook(packageRoot, targetRoot));
17
+ results.push(await mergeClaudeSettings(targetRoot));
18
+ }
19
+
20
+ return results;
21
+ }
22
+
23
+ async function installClaudeHook(packageRoot, targetRoot) {
24
+ const sourcePath = path.join(packageRoot, 'templates', 'hooks', 'claude-code', 'dream-wf-guard.py');
25
+ const targetPath = path.join(targetRoot, '.claude', 'hooks', 'dream-wf-guard.py');
26
+ const contents = await readFile(sourcePath, 'utf8');
27
+ const result = await writeIfChanged(targetPath, contents);
28
+ await chmod(targetPath, 0o755);
29
+ return result;
30
+ }
31
+
32
+ async function mergeClaudeSettings(rootDir) {
33
+ const settingsPath = path.join(rootDir, '.claude', 'settings.json');
34
+ const settings = await readJsonObject(settingsPath, {});
35
+ settings.hooks = settings.hooks ?? {};
36
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse ?? [];
37
+
38
+ const changed = pushUniqueByCommand(settings.hooks.PreToolUse, {
39
+ matcher: '*',
40
+ hooks: [
41
+ {
42
+ type: 'command',
43
+ command: 'python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/dream-wf-guard.py"',
44
+ timeout: 10
45
+ }
46
+ ]
47
+ });
48
+
49
+ if (changed) {
50
+ await writeJsonObject(settingsPath, settings);
51
+ }
52
+
53
+ return { changed, action: changed ? 'updated' : 'unchanged', path: settingsPath };
54
+ }
@@ -0,0 +1,50 @@
1
+ import path from 'node:path';
2
+ import { readFile, chmod } from 'node:fs/promises';
3
+ import { readJsonObject, writeJsonObject, pushUniqueByCommand } from '../../lib/json.js';
4
+ import { writeIfChanged } from '../../lib/files.js';
5
+ import { installCommonDreamWfFiles, installRuleFile, installSkill } from '../shared.js';
6
+
7
+ export async function installCursor(packageRoot, targetRoot, options) {
8
+ const results = [];
9
+
10
+ results.push(await installRuleFile(packageRoot, targetRoot, 'templates/rules/cursor/dream-wf.mdc', '.cursor/rules/dream-wf.mdc'));
11
+ results.push(await installSkill(packageRoot, targetRoot, '.cursor', 'dream-wf-grill-prd'));
12
+ results.push(await installSkill(packageRoot, targetRoot, '.cursor', 'dream-wf-mcp-policy'));
13
+ results.push(...await installCommonDreamWfFiles(packageRoot, targetRoot));
14
+
15
+ if (options.mode === 'strict') {
16
+ results.push(await installCursorHook(packageRoot, targetRoot));
17
+ results.push(await mergeCursorHooks(targetRoot));
18
+ }
19
+
20
+ return results;
21
+ }
22
+
23
+ async function installCursorHook(packageRoot, targetRoot) {
24
+ const sourcePath = path.join(packageRoot, 'templates', 'hooks', 'cursor', 'dream-wf-guard.py');
25
+ const targetPath = path.join(targetRoot, '.cursor', 'hooks', 'dream-wf-guard.py');
26
+ const contents = await readFile(sourcePath, 'utf8');
27
+ const result = await writeIfChanged(targetPath, contents);
28
+ await chmod(targetPath, 0o755);
29
+ return result;
30
+ }
31
+
32
+ async function mergeCursorHooks(rootDir) {
33
+ const hooksPath = path.join(rootDir, '.cursor', 'hooks.json');
34
+ const hooks = await readJsonObject(hooksPath, { version: 1, hooks: {} });
35
+ hooks.version = hooks.version ?? 1;
36
+ hooks.hooks = hooks.hooks ?? {};
37
+ hooks.hooks.preToolUse = hooks.hooks.preToolUse ?? [];
38
+
39
+ const changed = pushUniqueByCommand(hooks.hooks.preToolUse, {
40
+ command: '.cursor/hooks/dream-wf-guard.py',
41
+ failClosed: true,
42
+ timeout: 10
43
+ });
44
+
45
+ if (changed) {
46
+ await writeJsonObject(hooksPath, hooks);
47
+ }
48
+
49
+ return { changed, action: changed ? 'updated' : 'unchanged', path: hooksPath };
50
+ }
@@ -0,0 +1,26 @@
1
+ import path from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { writeIfChanged } from '../../lib/files.js';
4
+ import { installCommonDreamWfFiles, installManagedBlock, installSkill } from '../shared.js';
5
+
6
+ export async function installOpenCode(packageRoot, targetRoot, options) {
7
+ const results = [];
8
+
9
+ results.push(await installManagedBlock(packageRoot, targetRoot, 'templates/rules/opencode/dream-wf-block.md', 'AGENTS.md', '<!-- DREAM-WF:START -->', '<!-- DREAM-WF:END -->'));
10
+ results.push(await installSkill(packageRoot, targetRoot, '.opencode', 'dream-wf-grill-prd'));
11
+ results.push(await installSkill(packageRoot, targetRoot, '.opencode', 'dream-wf-mcp-policy'));
12
+ results.push(...await installCommonDreamWfFiles(packageRoot, targetRoot));
13
+
14
+ if (options.mode === 'strict') {
15
+ results.push(await installOpenCodePlugin(packageRoot, targetRoot));
16
+ }
17
+
18
+ return results;
19
+ }
20
+
21
+ async function installOpenCodePlugin(packageRoot, targetRoot) {
22
+ const sourcePath = path.join(packageRoot, 'templates', 'hooks', 'opencode', 'dream-wf-guard.js');
23
+ const targetPath = path.join(targetRoot, '.opencode', 'plugins', 'dream-wf-guard.js');
24
+ const contents = await readFile(sourcePath, 'utf8');
25
+ return writeIfChanged(targetPath, contents);
26
+ }
@@ -0,0 +1,58 @@
1
+ import path from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { readTextIfExists, writeIfChanged, writeTextFile } from '../lib/files.js';
4
+
5
+ export async function installSkill(packageRoot, targetRoot, platformDir, skillName) {
6
+ const sourcePath = path.join(packageRoot, 'templates', 'skills', skillName, 'SKILL.md');
7
+ const targetPath = path.join(targetRoot, platformDir, 'skills', skillName, 'SKILL.md');
8
+ const contents = await readFile(sourcePath, 'utf8');
9
+ return writeIfChanged(targetPath, contents);
10
+ }
11
+
12
+ export async function installSpecGuide(packageRoot, targetRoot, fileName) {
13
+ const sourcePath = path.join(packageRoot, 'templates', 'spec', 'guides', fileName);
14
+ const targetPath = path.join(targetRoot, '.trellis', 'spec', 'guides', fileName);
15
+ const contents = await readFile(sourcePath, 'utf8');
16
+ return writeIfChanged(targetPath, contents);
17
+ }
18
+
19
+ export async function installCommonDreamWfFiles(packageRoot, targetRoot) {
20
+ return [
21
+ await installSpecGuide(packageRoot, targetRoot, 'dream-wf-prd-policy.md'),
22
+ await installSpecGuide(packageRoot, targetRoot, 'dream-wf-mcp-policy.md')
23
+ ];
24
+ }
25
+
26
+ export async function installRuleFile(packageRoot, targetRoot, sourceRelativePath, targetRelativePath) {
27
+ const contents = await readFile(path.join(packageRoot, sourceRelativePath), 'utf8');
28
+ return writeIfChanged(path.join(targetRoot, targetRelativePath), contents);
29
+ }
30
+
31
+ export async function installManagedBlock(packageRoot, targetRoot, sourceRelativePath, targetRelativePath, startMarker, endMarker) {
32
+ const block = await readFile(path.join(packageRoot, sourceRelativePath), 'utf8');
33
+ const targetPath = path.join(targetRoot, targetRelativePath);
34
+ const existing = await readTextIfExists(targetPath);
35
+
36
+ if (!existing) {
37
+ await writeTextFile(targetPath, block);
38
+ return { changed: true, action: 'created', path: targetPath };
39
+ }
40
+
41
+ const start = existing.indexOf(startMarker);
42
+ const end = existing.indexOf(endMarker);
43
+ let next;
44
+
45
+ if (start !== -1 && end !== -1 && end > start) {
46
+ const afterEnd = end + endMarker.length;
47
+ next = `${existing.slice(0, start)}${block.trim()}${existing.slice(afterEnd)}`;
48
+ } else {
49
+ next = `${existing.trimEnd()}\n\n${block.trim()}\n`;
50
+ }
51
+
52
+ if (next === existing) {
53
+ return { changed: false, action: 'unchanged', path: targetPath };
54
+ }
55
+
56
+ await writeTextFile(targetPath, next);
57
+ return { changed: true, action: 'updated', path: targetPath };
58
+ }