workflow-toolkit 1.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 (142) hide show
  1. package/.agents/skills/ponytail/SKILL.md +111 -0
  2. package/.agents/skills/ponytail-audit/SKILL.md +37 -0
  3. package/.agents/skills/ponytail-debt/SKILL.md +41 -0
  4. package/.agents/skills/ponytail-gain/SKILL.md +47 -0
  5. package/.agents/skills/ponytail-help/SKILL.md +70 -0
  6. package/.agents/skills/ponytail-review/SKILL.md +52 -0
  7. package/.agents/skills/prompt-review/SKILL.md +60 -0
  8. package/.agents/skills/wtk/SKILL.md +64 -0
  9. package/.agents/skills/wtk/references/artifacts.md +82 -0
  10. package/.agents/skills/wtk/references/evidence.md +68 -0
  11. package/.agents/skills/wtk/references/git.md +75 -0
  12. package/.agents/skills/wtk/references/test-contract.md +76 -0
  13. package/.agents/skills/wtk/references/validation.md +127 -0
  14. package/.agents/skills/wtk-config/SKILL.md +102 -0
  15. package/.agents/skills/wtk-config/assets/agents/claude/deep-reviewer.md +40 -0
  16. package/.agents/skills/wtk-config/assets/agents/claude/designer.md +57 -0
  17. package/.agents/skills/wtk-config/assets/agents/claude/explorer.md +41 -0
  18. package/.agents/skills/wtk-config/assets/agents/claude/implementer.md +57 -0
  19. package/.agents/skills/wtk-config/assets/agents/claude/planner.md +68 -0
  20. package/.agents/skills/wtk-config/assets/agents/claude/verifier.md +80 -0
  21. package/.agents/skills/wtk-config/assets/agents/codex/deep-reviewer.toml +41 -0
  22. package/.agents/skills/wtk-config/assets/agents/codex/designer.toml +55 -0
  23. package/.agents/skills/wtk-config/assets/agents/codex/explorer.toml +35 -0
  24. package/.agents/skills/wtk-config/assets/agents/codex/implementer.toml +52 -0
  25. package/.agents/skills/wtk-config/assets/agents/codex/planner.toml +66 -0
  26. package/.agents/skills/wtk-config/assets/agents/codex/verifier.toml +78 -0
  27. package/.agents/skills/wtk-config/assets/agents/cursor/deep-reviewer.md +38 -0
  28. package/.agents/skills/wtk-config/assets/agents/cursor/designer.md +55 -0
  29. package/.agents/skills/wtk-config/assets/agents/cursor/explorer.md +39 -0
  30. package/.agents/skills/wtk-config/assets/agents/cursor/implementer.md +55 -0
  31. package/.agents/skills/wtk-config/assets/agents/cursor/planner.md +66 -0
  32. package/.agents/skills/wtk-config/assets/agents/cursor/verifier.md +79 -0
  33. package/.agents/skills/wtk-config/scripts/ad-index.py +123 -0
  34. package/.agents/skills/wtk-config/scripts/repository_intelligence.py +671 -0
  35. package/.agents/skills/wtk-config/scripts/workflow_config.py +972 -0
  36. package/.agents/skills/wtk-deep-review/SKILL.md +164 -0
  37. package/.agents/skills/wtk-deep-review/assets/PROMPT.md +56 -0
  38. package/.agents/skills/wtk-deep-review/assets/REVIEW_UI.html +1340 -0
  39. package/.agents/skills/wtk-deep-review/assets/findings.schema.json +113 -0
  40. package/.agents/skills/wtk-deep-review/references/context-pack.md +88 -0
  41. package/.agents/skills/wtk-deep-review/references/orchestration.md +164 -0
  42. package/.agents/skills/wtk-deep-review/references/output-contracts.md +149 -0
  43. package/.agents/skills/wtk-deep-review/references/publish-github.md +83 -0
  44. package/.agents/skills/wtk-deep-review/references/state-and-learnings.md +64 -0
  45. package/.agents/skills/wtk-deep-review/references/subagent-runtimes.md +33 -0
  46. package/.agents/skills/wtk-deep-review/references/taxonomy.md +70 -0
  47. package/.agents/skills/wtk-deep-review/scripts/_common.py +371 -0
  48. package/.agents/skills/wtk-deep-review/scripts/build_jobs.py +561 -0
  49. package/.agents/skills/wtk-deep-review/scripts/build_knowledge.py +349 -0
  50. package/.agents/skills/wtk-deep-review/scripts/build_manifest.py +479 -0
  51. package/.agents/skills/wtk-deep-review/scripts/graft_context.py +160 -0
  52. package/.agents/skills/wtk-deep-review/scripts/graphify_context.py +75 -0
  53. package/.agents/skills/wtk-deep-review/scripts/merge_findings.py +313 -0
  54. package/.agents/skills/wtk-deep-review/scripts/render_html.py +240 -0
  55. package/.agents/skills/wtk-deep-review/scripts/render_review.py +282 -0
  56. package/.agents/skills/wtk-deep-review/scripts/run_jobs.py +436 -0
  57. package/.agents/skills/wtk-deep-review/scripts/token_metrics.py +369 -0
  58. package/.agents/skills/wtk-discover/SKILL.md +270 -0
  59. package/.agents/skills/wtk-discover/references/document-format.md +149 -0
  60. package/.agents/skills/wtk-implement/SKILL.md +95 -0
  61. package/.agents/skills/wtk-implement/references/checklist-format.md +116 -0
  62. package/.agents/skills/wtk-implement/references/screens.md +129 -0
  63. package/.agents/skills/wtk-implement/references/test-policy.md +152 -0
  64. package/.agents/skills/wtk-implement/references/verify.md +297 -0
  65. package/.agents/skills/wtk-knowledge-check/SKILL.md +16 -0
  66. package/.agents/skills/wtk-knowledge-check/scripts/check.ts +502 -0
  67. package/.agents/skills/wtk-knowledge-check/scripts/cli.ts +32 -0
  68. package/.agents/skills/wtk-knowledge-check/scripts/frontmatter.ts +44 -0
  69. package/.agents/skills/wtk-lean/.skill-meta.json +6 -0
  70. package/.agents/skills/wtk-lean/NOTICE.md +18 -0
  71. package/.agents/skills/wtk-lean/SKILL.md +137 -0
  72. package/.agents/skills/wtk-lean/references/build.md +168 -0
  73. package/.agents/skills/wtk-lean/references/checks.md +253 -0
  74. package/.agents/skills/wtk-lean/references/memory.md +156 -0
  75. package/.agents/skills/wtk-lean/references/plan.md +514 -0
  76. package/.agents/skills/wtk-lean/references/verify.md +336 -0
  77. package/.agents/skills/wtk-lean/scripts/check_commit.py +121 -0
  78. package/.agents/skills/wtk-lean/scripts/fixtures/checks.md +98 -0
  79. package/.agents/skills/wtk-lean/scripts/fixtures/plan.md +119 -0
  80. package/.agents/skills/wtk-lean/scripts/fixtures/verification.md +41 -0
  81. package/.agents/skills/wtk-lean/scripts/lessons.py +412 -0
  82. package/.agents/skills/wtk-lean/scripts/selftest.py +401 -0
  83. package/.agents/skills/wtk-lean/scripts/validate_checks.py +417 -0
  84. package/.agents/skills/wtk-lean/scripts/validate_plan.py +580 -0
  85. package/.agents/skills/wtk-lean/scripts/validate_verification.py +357 -0
  86. package/.agents/skills/wtk-plan/SKILL.md +102 -0
  87. package/.agents/skills/wtk-plan/references/document-format.md +222 -0
  88. package/.agents/skills/wtk-qa/SKILL.md +14 -0
  89. package/.agents/skills/wtk-qa-execute/SKILL.md +115 -0
  90. package/.agents/skills/wtk-qa-execute/references/fix-loop.md +23 -0
  91. package/.agents/skills/wtk-qa-execute/references/session-protocol.md +25 -0
  92. package/.agents/skills/wtk-qa-plan/SKILL.md +111 -0
  93. package/.agents/skills/wtk-qa-plan/references/profile.md +35 -0
  94. package/.agents/skills/wtk-ship/SKILL.md +49 -0
  95. package/.agents/skills/wtk-ship/remediation.py +170 -0
  96. package/.agents/skills/wtk-ship/scripts/close_feature.py +83 -0
  97. package/.agents/skills/wtk-ship/scripts/review_convergence.py +373 -0
  98. package/.wtk.toml.example +93 -0
  99. package/AGENTS.md +129 -0
  100. package/NOTICE.md +12 -0
  101. package/README.md +440 -0
  102. package/bin/wtk.js +25 -0
  103. package/docs/toolkit/README.md +56 -0
  104. package/docs/toolkit/decisions.md +61 -0
  105. package/docs/toolkit/guidelines/CONTEXT-BUDGET.md +64 -0
  106. package/docs/toolkit/guidelines/DX.md +67 -0
  107. package/docs/toolkit/guidelines/FRONTEND.md +73 -0
  108. package/docs/toolkit/guidelines/KNOWLEDGE-WIKI.md +128 -0
  109. package/docs/toolkit/guidelines/MODELING.md +134 -0
  110. package/docs/toolkit/guidelines/QA-EXECUTION.md +46 -0
  111. package/docs/toolkit/guidelines/QA-SCENARIOS.md +165 -0
  112. package/docs/toolkit/guidelines/REVIEW-ROUNDS.md +160 -0
  113. package/docs/toolkit/guidelines/SECURITY.md +160 -0
  114. package/docs/toolkit/guidelines/UI-UX.md +117 -0
  115. package/docs/toolkit/guidelines/WORKFLOW-MEMORY.md +73 -0
  116. package/docs/toolkit/guidelines.md +50 -0
  117. package/docs/toolkit/loop.md +77 -0
  118. package/docs/toolkit/purpose.md +51 -0
  119. package/docs/toolkit/repository-intelligence.md +53 -0
  120. package/docs/toolkit/reviews.md +77 -0
  121. package/knowledge/AGENTS.md +316 -0
  122. package/knowledge/raw/README.md +17 -0
  123. package/package.json +74 -0
  124. package/scripts/install_security_skills.py +816 -0
  125. package/scripts/installer/engine.js +254 -0
  126. package/scripts/installer/knowledge.js +28 -0
  127. package/scripts/installer/packets.js +77 -0
  128. package/scripts/installer/terminal.js +98 -0
  129. package/scripts/installer/transaction.js +75 -0
  130. package/skills-lock.json +92 -0
  131. package/templates/adoption/agents/core.md +23 -0
  132. package/templates/adoption/agents/quality.md +3 -0
  133. package/templates/adoption/knowledge/wiki/architecture/index.md +3 -0
  134. package/templates/adoption/knowledge/wiki/decisions/index.md +3 -0
  135. package/templates/adoption/knowledge/wiki/design/index.md +3 -0
  136. package/templates/adoption/knowledge/wiki/domain/index.md +3 -0
  137. package/templates/adoption/knowledge/wiki/index.md +3 -0
  138. package/templates/adoption/knowledge/wiki/log.md +3 -0
  139. package/templates/adoption/knowledge/wiki/open-questions/index.md +3 -0
  140. package/templates/adoption/knowledge/wiki/product/index.md +3 -0
  141. package/templates/adoption/knowledge/wiki/research/index.md +3 -0
  142. package/templates/adoption/product/AGENT-CONTEXT.md +25 -0
@@ -0,0 +1,254 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { TextDecoder } from 'node:util';
6
+
7
+ export const WORKFLOW_VERSION = '1.0.1';
8
+ export const LAYERS = ['core', 'quality', 'extras'];
9
+ export const DEPENDENCIES = { core: [], quality: ['core'], extras: ['core'] };
10
+ export const BLOCK_LAYERS = ['core', 'quality'];
11
+ export const WORKFLOW_GITIGNORE_ENTRIES = ['.wtk.toml', '.claude/agents/', '.codex/agents/', '.cursor/agents/', '!.wtk-deep-review/', '.wtk-deep-review/*', '!.wtk-deep-review/learnings.md', 'graft/', 'graphify-out/', '.repository-intelligence/'];
12
+ export const LEGACY_WORKFLOW_GITIGNORE_ENTRIES = ['.specs/features/', '!.deep-review/', '.deep-review/*', '!.deep-review/learnings.md'];
13
+ export const WORKFLOW_SEARCHIGNORE_ENTRIES = ['!graft/', 'graft/.cache/', 'graft/.graph/', 'graphify-out/', '.repository-intelligence/'];
14
+ export const RUNTIME_PATHS = ['claude', 'codex', 'cursor'].flatMap((provider) => ['planner', 'implementer', 'verifier', 'explorer', 'deep-reviewer', 'designer'].map((role) => `.${provider}/agents/${role}.${provider === 'codex' ? 'toml' : 'md'}`));
15
+ export const KNOWLEDGE_DESTINATIONS = {
16
+ 'AGENTS.md': { destination: 'docs/product/AGENT-CONTEXT.md', reason: 'Consumer-authored instructions require manual review.' },
17
+ 'CLAUDE.md': { destination: 'docs/product/AGENT-CONTEXT.md', reason: 'Consumer-authored instructions require manual review.' },
18
+ 'knowledge/AGENTS.md': { destination: 'knowledge/wiki/index.md', reason: 'Consumer-authored knowledge requires manual review.' },
19
+ 'knowledge/raw/README.md': { destination: 'knowledge/wiki/research/index.md', reason: 'Consumer-authored knowledge requires manual review.' },
20
+ };
21
+
22
+ export const LAYER_PATHS = {
23
+ core: ['docs/toolkit/guidelines', 'docs/toolkit/README.md', 'docs/toolkit/repository-intelligence.md', 'docs/toolkit/decisions.md', 'docs/toolkit/guidelines.md', 'docs/toolkit/loop.md', 'docs/toolkit/purpose.md', 'docs/toolkit/reviews.md', 'knowledge/AGENTS.md', 'knowledge/raw/README.md', '.agents/skills/wtk', '.agents/skills/wtk-lean', '.agents/skills/wtk-discover', '.agents/skills/wtk-plan', '.agents/skills/wtk-implement', '.agents/skills/wtk-config', '.agents/skills/wtk-knowledge-check', '.agents/skills/wtk-ship', '.agents/skills/ponytail'],
24
+ quality: ['.agents/skills/wtk-deep-review', '.agents/skills/wtk-qa', '.agents/skills/wtk-qa-plan', '.agents/skills/wtk-qa-execute'],
25
+ extras: ['.agents/skills/ponytail-audit', '.agents/skills/ponytail-debt', '.agents/skills/ponytail-gain', '.agents/skills/ponytail-help', '.agents/skills/ponytail-review', '.agents/skills/prompt-review'],
26
+ };
27
+ export const CLAUDE_SKILL_LINKS = {
28
+ core: ['wtk', 'wtk-lean', 'wtk-discover', 'wtk-plan', 'wtk-implement', 'wtk-config', 'wtk-knowledge-check', 'wtk-ship', 'ponytail'],
29
+ quality: ['wtk-deep-review', 'wtk-qa', 'wtk-qa-plan', 'wtk-qa-execute'],
30
+ extras: ['ponytail-audit', 'ponytail-debt', 'ponytail-gain', 'ponytail-help', 'ponytail-review', 'prompt-review'],
31
+ };
32
+ export const LAYER_MISSING_PATHS = { core: ['.wtk.toml.example'], quality: [], extras: [] };
33
+ export const CONSUMER_MISSING_SOURCES = {
34
+ 'docs/product/AGENT-CONTEXT.md': 'templates/adoption/product/AGENT-CONTEXT.md',
35
+ 'knowledge/wiki/index.md': 'templates/adoption/knowledge/wiki/index.md',
36
+ 'knowledge/wiki/log.md': 'templates/adoption/knowledge/wiki/log.md',
37
+ ...Object.fromEntries(['domain', 'product', 'architecture', 'design', 'decisions', 'research', 'open-questions'].map((group) => [`knowledge/wiki/${group}/index.md`, `templates/adoption/knowledge/wiki/${group}/index.md`])),
38
+ };
39
+ export const RETIRABLE_WORKFLOW_DIRS = ['.agents/skills/workflow-spec-driven/', '.agents/skills/workflow-config/', '.agents/skills/wspecify/', '.agents/skills/wdesign/', '.agents/skills/wtasks/', '.agents/skills/wimplement/', '.agents/skills/wverify/', '.agents/skills/wreview/', '.agents/skills/wqa/', '.agents/skills/autonomous/', '.agents/skills/deep-review/', '.agents/skills/qa-plan/', '.agents/skills/qa-execute/', '.agents/skills/ponytail-audit/', '.agents/skills/ponytail-debt/', '.agents/skills/ponytail-gain/', '.agents/skills/ponytail-help/', '.agents/skills/ponytail-review/', '.agents/skills/wtk-verify/', '.agents/skills/wtk-review/', 'docs/guidelines/', 'docs/workflow/', 'templates/agents/', 'templates/adoption/agents/', 'tools/knowledge/src/', 'tools/shared/src/'];
40
+ export const RETIRABLE_WORKFLOW_FILES = ['tools/ad-index.py', 'tools/orca_assisted_probe.py', 'tools/qa_parallel_pilot.py', 'tools/resource_lock.py', 'docs/toolkit/guidelines/GATES.md', 'docs/toolkit/guidelines/VERIFICATION-EVIDENCE.md', 'docs/toolkit/guidelines/TEST-CONTRACT.md', 'docs/toolkit/guidelines/BRANCHING.md', 'docs/toolkit/guidelines/ARTIFACT-LIFECYCLE.md'];
41
+
42
+ export class InstallerError extends Error {}
43
+ const fail = (message) => { throw new InstallerError(message); };
44
+ const asRoot = (root) => path.resolve(root);
45
+ const posix = (value) => value.split(path.sep).join('/');
46
+ const decodeUtf8 = (value, label) => { try { return new TextDecoder('utf-8', { fatal: true }).decode(value); } catch (error) { fail(`${label} is not valid UTF-8: ${error.message}`); } };
47
+ const semver = (value) => value.split('.').map(Number);
48
+ export const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
49
+
50
+ export function validateRelative(relative) {
51
+ if (typeof relative !== 'string' || !relative || path.posix.isAbsolute(relative) || relative.split('/').some((part) => !part || part === '.' || part === '..') || posix(relative) !== relative) fail(`manifest path is unsafe: ${relative}`);
52
+ return relative;
53
+ }
54
+
55
+ export function safePath(root, relative, label = 'path') {
56
+ validateRelative(relative);
57
+ const base = asRoot(root);
58
+ const target = path.join(base, ...relative.split('/'));
59
+ let current = base;
60
+ for (const part of relative.split('/')) {
61
+ current = path.join(current, part);
62
+ if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) fail(`${label} ${relative} uses symlink ${posix(path.relative(base, current))}`);
63
+ if (current !== target && fs.existsSync(current) && !fs.lstatSync(current).isDirectory()) fail(`${label} parent ${posix(path.relative(base, current))} must be a directory`);
64
+ }
65
+ if (fs.existsSync(target) && !fs.lstatSync(target).isFile()) fail(`${label} ${relative} must be a file`);
66
+ return target;
67
+ }
68
+
69
+ function sourceFiles(root, relative) {
70
+ const source = path.join(root, ...relative.split('/'));
71
+ if (!fs.existsSync(source)) fail(`workflow source is missing: ${relative}`);
72
+ const stat = fs.lstatSync(source);
73
+ if (stat.isSymbolicLink()) fail(`workflow source is a symlink: ${relative}`);
74
+ if (stat.isFile()) return [relative];
75
+ const result = [];
76
+ const walk = (directory) => {
77
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
78
+ if (entry.name === '__pycache__' || entry.name.endsWith('.pyc')) continue;
79
+ const full = path.join(directory, entry.name);
80
+ const rel = posix(path.relative(root, full));
81
+ if (entry.isSymbolicLink()) fail(`workflow source is a symlink: ${rel}`);
82
+ if (entry.isDirectory()) walk(full); else if (entry.isFile()) result.push(rel);
83
+ }
84
+ };
85
+ walk(source);
86
+ return result;
87
+ }
88
+
89
+ export function resolveModules(values) {
90
+ const raw = Array.isArray(values) ? values : String(values).split(',');
91
+ const selected = new Set(raw.map((item) => String(item).trim()).filter(Boolean));
92
+ if (selected.has('full')) { selected.delete('full'); LAYERS.forEach((item) => selected.add(item)); }
93
+ const unknown = [...selected].filter((item) => !LAYERS.includes(item));
94
+ if (unknown.length) fail(`unknown module(s): ${unknown.sort().join(', ')}`);
95
+ if (!selected.size) fail('select at least one module');
96
+ let changed = true;
97
+ while (changed) { changed = false; for (const module of [...selected]) for (const dependency of DEPENDENCIES[module]) if (!selected.has(dependency)) { selected.add(dependency); changed = true; } }
98
+ return LAYERS.filter((module) => selected.has(module));
99
+ }
100
+
101
+ export function requestedModules(values) {
102
+ const raw = Array.isArray(values) ? values : String(values).split(',');
103
+ const selected = new Set(raw.map((item) => String(item).trim()).filter(Boolean));
104
+ if (!selected.size || [...selected].some((item) => item !== 'full' && !LAYERS.includes(item)) || (selected.has('full') && selected.size > 1)) fail('modules must be core, quality, extras, or full');
105
+ return selected.has('full') ? [...LAYERS] : LAYERS.filter((module) => selected.has(module));
106
+ }
107
+
108
+ function catalog(root, modules) {
109
+ const entries = new Map();
110
+ for (const module of modules) for (const item of [...LAYER_PATHS[module], ...LAYER_MISSING_PATHS[module]]) for (const relative of sourceFiles(root, item)) {
111
+ const prior = entries.get(relative); if (prior && !prior.includes(module)) prior.push(module); else if (!prior) entries.set(relative, [module]);
112
+ }
113
+ if (modules.includes('core')) for (const [destination, source] of Object.entries(CONSUMER_MISSING_SOURCES)) { sourceFiles(root, source); entries.set(destination, ['core']); }
114
+ return entries;
115
+ }
116
+
117
+ function adoptedBytes(relative, source) { return relative === 'docs/toolkit/README.md' ? Buffer.from(source.toString().split(/(?<=\n)/).filter((line) => !line.includes('(pack.md)')).join('')) : source; }
118
+
119
+ function emptyManifest() { return { schema: 1, workflow_version: WORKFLOW_VERSION, layers: [], files: {}, blocks: {} }; }
120
+ function validHash(value, label, allowNull = false) { if (allowNull && value === null) return; if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) fail(`manifest ${label} must be a lowercase SHA-256 hash`); }
121
+ export function loadManifest(root) {
122
+ const target = safePath(root, '.my-workflow/adoption.json', 'manifest');
123
+ if (!fs.existsSync(target)) return emptyManifest();
124
+ let data; try { data = JSON.parse(decodeUtf8(fs.readFileSync(target), 'adoption manifest')); } catch (error) { fail(`invalid adoption manifest: ${error.message}`); }
125
+ if (!data || typeof data !== 'object' || Array.isArray(data) || Object.keys(data).sort().join() !== 'blocks,files,layers,schema,workflow_version') fail('adoption manifest has an unsupported schema');
126
+ if (data.schema !== 1 || typeof data.workflow_version !== 'string' || !/^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/.test(data.workflow_version) || semver(data.workflow_version)[0] > semver(WORKFLOW_VERSION)[0] || semver(data.workflow_version)[0] === semver(WORKFLOW_VERSION)[0] && (semver(data.workflow_version)[1] > semver(WORKFLOW_VERSION)[1] || semver(data.workflow_version)[1] === semver(WORKFLOW_VERSION)[1] && semver(data.workflow_version)[2] > semver(WORKFLOW_VERSION)[2])) fail('adoption manifest schema must be version 1; workflow version is newer than the installer');
127
+ if (!Array.isArray(data.layers) || data.layers.some((module) => !LAYERS.includes(module)) || data.layers.join() !== [...new Set(data.layers)].sort((a, b) => LAYERS.indexOf(a) - LAYERS.indexOf(b)).join()) fail('manifest layers must be unique and catalog-ordered');
128
+ if (data.layers.length && JSON.stringify(resolveModules(data.layers)) !== JSON.stringify(data.layers)) fail('manifest layers must include every fixed dependency');
129
+ if (!data.files || typeof data.files !== 'object' || Array.isArray(data.files) || !data.blocks || typeof data.blocks !== 'object' || Array.isArray(data.blocks)) fail('manifest files and blocks must be objects');
130
+ for (const [relative, record] of Object.entries(data.files)) {
131
+ validateRelative(relative); if (!record || typeof record !== 'object' || Object.keys(record).sort().join() !== 'installed_sha256,layer,ownership,source_sha256') fail(`manifest file record is invalid: ${relative}`);
132
+ if (!LAYERS.includes(record.layer) || !['managed', 'consumer'].includes(record.ownership)) fail(`manifest file record has invalid ownership/layer: ${relative}`);
133
+ validHash(record.source_sha256, `source_sha256 for ${relative}`); if (record.ownership === 'consumer' && record.installed_sha256 !== null) fail(`manifest installed_sha256 for ${relative} must be null for consumer ownership`); validHash(record.installed_sha256, `installed_sha256 for ${relative}`, record.ownership === 'consumer');
134
+ }
135
+ for (const [key, record] of Object.entries(data.blocks)) { const [relative, module] = key.split(/:(?=[^:]+$)/); validateRelative(relative); if (!['AGENTS.md', 'CLAUDE.md'].includes(relative) || !BLOCK_LAYERS.includes(module) || !data.layers.includes(module) || (relative === 'CLAUDE.md' && module !== 'core') || !record || Object.keys(record).join() !== 'sha256') fail(`manifest block record is invalid: ${key}`); validHash(record.sha256, `block ${key}`); }
136
+ return data;
137
+ }
138
+
139
+ function record(module, ownership, source, installed) { return { layer: module, ownership, source_sha256: sha256(source), installed_sha256: installed === null ? null : sha256(installed) }; }
140
+ function isProvider(relative) { return relative.startsWith('.agents/skills/wtk-config/assets/agents/'); }
141
+ function isRetirable(relative) { return RETIRABLE_WORKFLOW_FILES.includes(relative) || RETIRABLE_WORKFLOW_DIRS.some((root) => relative.startsWith(root)); }
142
+
143
+ function blockSpan(text, module) {
144
+ const start = `<!-- my-workflow:${module}:start -->`, end = `<!-- my-workflow:${module}:end -->`;
145
+ const valid = /^<!-- my-workflow:(?:core|quality):(?:start|end) -->$/;
146
+ if (text.split(/\r?\n/).some((line) => line.includes('my-workflow:') && !valid.test(line.trim()))) fail(`managed ${module} block is duplicated or altered`);
147
+ const starts = [...text.matchAll(new RegExp(start.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))]; const ends = [...text.matchAll(new RegExp(end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))];
148
+ if (!starts.length && !ends.length) return null; if (starts.length !== 1 || ends.length !== 1 || starts[0].index > ends[0].index) fail(`managed ${module} block is incomplete or nested`);
149
+ return [starts[0].index, ends[0].index + end.length];
150
+ }
151
+ function blockContent(sourceRoot, module, filename) {
152
+ const body = filename === 'CLAUDE.md' ? '@AGENTS.md' : fs.readFileSync(path.join(sourceRoot, 'templates/adoption/agents', `${module}.md`), 'utf8').trimEnd();
153
+ return `<!-- my-workflow:${module}:start -->\n${body}\n<!-- my-workflow:${module}:end -->`;
154
+ }
155
+ export function composeBlocks(sourceRoot, root, modules, manifest) {
156
+ const outputs = {}, blocks = { ...manifest.blocks }, conflicts = [];
157
+ for (const filename of ['AGENTS.md', 'CLAUDE.md']) {
158
+ const target = safePath(root, filename, 'managed instruction'); const exists = fs.existsSync(target); let rendered = exists ? decodeUtf8(fs.readFileSync(target), `${filename} instruction`) : filename === 'AGENTS.md' ? decodeUtf8(fs.readFileSync(path.join(sourceRoot, filename)), `${filename} package source`) : '';
159
+ for (const module of (filename === 'AGENTS.md' ? BLOCK_LAYERS : ['core'])) if (modules.includes(module)) {
160
+ let span; try { span = blockSpan(rendered, module); } catch { conflicts.push(`${filename}:${module}`); continue; }
161
+ const block = blockContent(sourceRoot, module, filename); const key = `${filename}:${module}`;
162
+ if (span) { if (manifest.blocks[key] && sha256(Buffer.from(rendered.slice(span[0], span[1]))) !== manifest.blocks[key].sha256) { conflicts.push(key); continue; } rendered = rendered.slice(0, span[0]) + block + rendered.slice(span[1]); }
163
+ else { if (rendered && !rendered.endsWith('\n')) rendered += '\n'; if (rendered) rendered += '\n'; rendered += block + '\n'; }
164
+ blocks[key] = { sha256: sha256(Buffer.from(block)) };
165
+ }
166
+ if (Buffer.from(rendered).compare(exists ? fs.readFileSync(target) : Buffer.alloc(0)) !== 0) outputs[filename] = Buffer.from(rendered);
167
+ }
168
+ return { outputs, blocks, conflicts };
169
+ }
170
+
171
+ function mergeIgnore(existing, entries, remove = []) { const lines = (existing ? existing.toString() : '').split(/\r?\n/).filter((line) => line && !remove.includes(line) && !entries.includes(line)); return Buffer.from([...lines, ...entries, ''].join('\n')); }
172
+ function sourceBytes(root, relative) { return fs.readFileSync(path.join(root, (CONSUMER_MISSING_SOURCES[relative] || relative).split('/').join(path.sep))); }
173
+
174
+ function linkParent(root, relative) {
175
+ const parent = path.posix.dirname(relative);
176
+ if (parent === '.') return;
177
+ let current = root;
178
+ for (const part of parent.split('/')) {
179
+ current = path.join(current, part);
180
+ if (!fs.existsSync(current)) continue;
181
+ const stat = fs.lstatSync(current);
182
+ if (stat.isSymbolicLink()) fail(`Claude skill link ${relative} uses symlink ${posix(path.relative(root, current))}`);
183
+ if (!stat.isDirectory()) fail(`Claude skill link ${relative} parent ${posix(path.relative(root, current))} must be a directory`);
184
+ }
185
+ }
186
+
187
+ function claudeSkillLink(root, skill) {
188
+ const relative = `.claude/skills/${skill}`;
189
+ const target = `../../.agents/skills/${skill}`;
190
+ linkParent(root, relative);
191
+ const destination = path.join(root, ...relative.split('/'));
192
+ const current = fs.lstatSync(destination, { throwIfNoEntry: false });
193
+ if (!current) return { relative, target, kind: 'add' };
194
+ if (!current.isSymbolicLink() || fs.readlinkSync(destination) !== target) fail(`Claude skill link ${relative} is an unsafe collision`);
195
+ const resolved = path.resolve(path.dirname(destination), target);
196
+ const targetRoot = path.resolve(root, `.agents/skills/${skill}`);
197
+ if (resolved !== targetRoot || !resolved.startsWith(`${path.resolve(root)}${path.sep}`)) fail(`Claude skill link ${relative} escapes target`);
198
+ return { relative, target, kind: 'no-change' };
199
+ }
200
+
201
+ export function buildPlan({ sourceRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../..'), targetRoot = process.cwd(), selectedModules, modules } = {}) {
202
+ const source = asRoot(sourceRoot), target = asRoot(targetRoot); const requested = selectedModules || modules; const requestedResolved = requestedModules(requested); const installedManifest = loadManifest(target); const installed = installedManifest.layers || []; const effective = resolveModules(requestedResolved); const manifestLayers = resolveModules([...requestedResolved, ...installed]);
203
+ const entries = catalog(source, effective); const actions = [], conflicts = [], retired = []; const records = Object.fromEntries(Object.entries(installedManifest.files || {}).filter(([, record]) => !effective.includes(record.layer)));
204
+ const missing = new Set(effective.flatMap((module) => LAYER_MISSING_PATHS[module].flatMap((item) => sourceFiles(source, item)))); if (effective.includes('core')) Object.keys(CONSUMER_MISSING_SOURCES).forEach((item) => missing.add(item));
205
+ for (const [relative, owners] of [...entries.entries()].sort(([a], [b]) => a.localeCompare(b))) {
206
+ const module = owners[0];
207
+ const packageBytes = sourceBytes(source, relative), installedBytes = adoptedBytes(relative, packageBytes), destination = safePath(target, relative, 'managed destination'); const previous = installedManifest.files[relative]; const exists = fs.existsSync(destination);
208
+ if (missing.has(relative)) { actions.push({ path: relative, kind: exists ? 'preserve' : 'add', modules: owners, reason: exists ? 'consumer-owned file unchanged' : 'new neutral scaffolding' }); records[relative] = record(module, 'consumer', packageBytes, null); continue; }
209
+ if (previous?.ownership === 'consumer' && !isProvider(relative)) { actions.push({ path: relative, kind: 'preserve', modules: owners, reason: 'consumer-owned file unchanged' }); records[relative] = previous; continue; }
210
+ const current = exists ? fs.readFileSync(destination) : null; let kind;
211
+ if (previous) { if (!current) { kind = 'conflict'; conflicts.push(relative); } else if (sha256(current) !== previous.installed_sha256) { kind = 'modified'; conflicts.push(relative); } else kind = current.compare(installedBytes) === 0 ? 'no-change' : 'update'; }
212
+ else if (!exists) kind = 'add'; else if (current.compare(installedBytes) === 0) kind = 'claim'; else { kind = 'conflict'; conflicts.push(relative); }
213
+ actions.push({ path: relative, kind, modules: owners, sourceSha256: sha256(packageBytes), installedSha256: exists ? sha256(installedBytes) : undefined, reason: ['conflict', 'modified'].includes(kind) ? 'consumer content requires a decision' : kind === 'claim' ? 'file unchanged; ownership record added' : undefined }); records[relative] = record(module, 'managed', packageBytes, installedBytes);
214
+ }
215
+ for (const [relative, previous] of Object.entries(installedManifest.files || {}).sort(([a], [b]) => a.localeCompare(b))) {
216
+ if (records[relative] || previous.ownership === 'consumer' || relative.startsWith('knowledge/wiki/')) continue;
217
+ const destination = safePath(target, relative, 'retired destination'); if (!isRetirable(relative)) continue;
218
+ if (!fs.existsSync(destination)) { retired.push(relative); actions.push({ path: relative, kind: 'remove', modules: [previous.layer], reason: 'retired workflow path' }); continue; }
219
+ const expected = previous.ownership === 'consumer' ? previous.source_sha256 : previous.installed_sha256; if (sha256(fs.readFileSync(destination)) !== expected) { conflicts.push(relative); actions.push({ path: relative, kind: 'conflict', modules: [previous.layer], reason: 'retired file was modified' }); } else { retired.push(relative); actions.push({ path: relative, kind: 'remove', modules: [previous.layer], reason: 'retired workflow path' }); }
220
+ }
221
+ const blocks = composeBlocks(source, target, effective, installedManifest); conflicts.push(...blocks.conflicts); for (const conflict of blocks.conflicts) { const [filename, owner] = conflict.split(':'); actions.push({ path: conflict, kind: 'conflict', modules: [owner], reason: 'consumer content requires a decision' }); }
222
+ const gitignore = safePath(target, '.gitignore', 'ignore file'); const searchignore = safePath(target, '.ignore', 'ignore file'); const ignorePlans = [[gitignore, '.gitignore', mergeIgnore(fs.existsSync(gitignore) ? fs.readFileSync(gitignore) : null, WORKFLOW_GITIGNORE_ENTRIES, LEGACY_WORKFLOW_GITIGNORE_ENTRIES)], [searchignore, '.ignore', mergeIgnore(fs.existsSync(searchignore) ? fs.readFileSync(searchignore) : null, WORKFLOW_SEARCHIGNORE_ENTRIES)]]; for (const [file, relative, bytes] of ignorePlans) { const exists = fs.existsSync(file); const kind = !exists ? 'add' : Buffer.compare(fs.readFileSync(file), bytes) === 0 ? 'no-change' : 'update'; actions.push({ path: relative, kind, modules: [...effective], reason: 'workflow ignore rules' }); }
223
+ const links = {};
224
+ const linkedSkills = new Set(effective.flatMap((module) => CLAUDE_SKILL_LINKS[module]));
225
+ for (const skill of linkedSkills) {
226
+ const link = claudeSkillLink(target, skill);
227
+ actions.push({ path: link.relative, kind: link.kind, modules: effective.filter((module) => CLAUDE_SKILL_LINKS[module].includes(skill)), reason: 'Claude public skill alias' });
228
+ if (link.kind === 'add') links[link.relative] = link.target;
229
+ }
230
+ const staged = { '.gitignore': ignorePlans[0][2], '.ignore': ignorePlans[1][2], ...blocks.outputs };
231
+ for (const action of actions) if (['add', 'update', 'claim'].includes(action.kind) && !['.gitignore', '.ignore'].includes(action.path) && !links[action.path]) staged[action.path] = adoptedBytes(action.path, sourceBytes(source, action.path));
232
+ const newManifest = { schema: 1, workflow_version: WORKFLOW_VERSION, layers: manifestLayers, files: records, blocks: blocks.blocks }; staged['.my-workflow/adoption.json'] = Buffer.from(`${JSON.stringify(newManifest, null, 2)}\n`);
233
+ const selectedSet = new Set(effective); const assessments = effective.map((id) => { const own = actions.filter((action) => action.modules.includes(id)); const kinds = new Set(own.map((action) => action.kind)); const status = kinds.has('conflict') ? 'conflict' : kinds.has('modified') ? 'modified' : kinds.has('update') ? 'outdated' : kinds.has('add') || kinds.has('claim') ? 'not installed' : 'up to date'; return { id, status, requiredBy: LAYERS.filter((candidate) => candidate !== id && DEPENDENCIES[candidate].includes(id) && selectedSet.has(candidate)), actions: own }; });
234
+ const noChange = actions.length > 0 && actions.every((action) => ['preserve', 'no-change'].includes(action.kind)) && !conflicts.length;
235
+ const finalActions = noChange ? [] : actions.map((action) => ({ ...action, modules: [...new Set(action.modules)] })).sort((a, b) => a.path.localeCompare(b.path));
236
+ return { plan: { target, packageVersion: WORKFLOW_VERSION, selectedModules: effective, assessments, actions: finalActions, unresolved: [...new Set(conflicts)].sort(), retired, status: conflicts.length ? 'conflict' : noChange ? 'no-change' : 'ready', message: noChange ? 'Selected modules are up to date. No files will change.' : undefined }, staged, links, manifest: newManifest };
237
+ }
238
+
239
+ export function gitProof(root, runner = execFileSync) {
240
+ const target = asRoot(root);
241
+ try {
242
+ const run = (args) => String(runner('git', args, { cwd: target, shell: false, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })).trim();
243
+ if (run(['rev-parse', '--is-inside-work-tree']) !== 'true') fail('Git work tree required');
244
+ if (path.resolve(run(['rev-parse', '--show-toplevel'])) !== target) fail('target must be the root of its Git work tree');
245
+ if (!run(['rev-parse', '--verify', 'HEAD'])) fail('Git repository requires HEAD');
246
+ if (run(['status', '--porcelain', '--untracked-files=all'])) fail('Git target must be clean');
247
+ return true;
248
+ } catch (error) { if (error instanceof InstallerError) throw error; fail(`Git proof failed: ${error.message}`); }
249
+ }
250
+
251
+ export function assessModules(input) { return buildPlan(input).plan.assessments; }
252
+ export function recalculatePlan(planInput, decisions = {}) { const result = buildPlan({ ...planInput, selectedModules: decisions.selectedModules || planInput.selectedModules }); if (decisions.replacements) for (const action of result.plan.actions) if (action.kind === 'conflict' && decisions.replacements.includes(action.path)) { action.kind = 'replace'; result.plan.unresolved = result.plan.unresolved.filter((item) => item !== action.path); } return result; }
253
+
254
+ export default { buildPlan, assessModules, recalculatePlan, resolveModules, requestedModules, loadManifest, validateRelative, safePath, sha256, gitProof, LAYERS };
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { KNOWLEDGE_DESTINATIONS } from './engine.js';
4
+
5
+ export function knowledgeTransfers(plan, backup) {
6
+ const backupRoot = typeof backup === 'string' ? backup : backup?.backup;
7
+ if (!backupRoot) return [];
8
+ return (plan?.actions || []).filter((action) => ['replace', 'remove'].includes(action.kind || action.action) && KNOWLEDGE_DESTINATIONS[action.path?.split(':')[0]]).map((action) => {
9
+ const source = path.posix.join(backupRoot.split(path.sep).join('/'), 'files', action.path.split(':')[0]);
10
+ const destination = KNOWLEDGE_DESTINATIONS[action.path.split(':')[0]];
11
+ return { source, destination: destination.destination, reason: destination.reason, status: 'Pending human transfer' };
12
+ });
13
+ }
14
+
15
+ export function writeKnowledgeChecklist(backupRoot, items) {
16
+ if (!items?.length) return null;
17
+ const target = path.join(backupRoot, 'knowledge-transfer.md');
18
+ const lines = ['# Knowledge transfer', '', 'The installer preserved these consumer-owned sources. Review and transfer content manually.', ''];
19
+ for (const item of items) lines.push(`- Source: ${item.source}`, ` Destination: ${item.destination}`, ` Reason: ${item.reason}`, ` Status: ${item.status}`, '');
20
+ fs.writeFileSync(target, `${lines.join('\n')}\n`);
21
+ return target;
22
+ }
23
+
24
+ export function prepareKnowledge(plan, prepared) {
25
+ const items = knowledgeTransfers(plan, prepared); if (items.length && prepared?.backup) writeKnowledgeChecklist(path.resolve(prepared.targetRoot, prepared.backup), items); return items;
26
+ }
27
+
28
+ export default { knowledgeTransfers, writeKnowledgeChecklist, prepareKnowledge };
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse } from 'smol-toml';
4
+ import { safePath } from './engine.js';
5
+
6
+ export const PROVIDERS = ['claude', 'codex', 'cursor'];
7
+ export const ROLES = ['planner', 'implementer', 'verifier', 'explorer', 'deep_reviewer', 'designer'];
8
+ export const AGENT_NAMES = { deep_reviewer: 'deep-reviewer' };
9
+ const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
10
+ const modelRe = /^[^\\\s[\]"\x00-\x1f\x7f]+$/;
11
+ const runtimePath = (provider, role) => `.${provider}/agents/${AGENT_NAMES[role] || role}.${provider === 'codex' ? 'toml' : 'md'}`;
12
+ const templatePath = (root, provider, role) => path.join(root, '.agents', 'skills', 'wtk-config', 'assets', 'agents', provider, `${AGENT_NAMES[role] || role}.${provider === 'codex' ? 'toml' : 'md'}`);
13
+ const error = (message) => { throw new Error(`wtk-config: ${message}`); };
14
+
15
+ export function validateWorkflowConfig(config) {
16
+ if (!config || typeof config !== 'object' || Array.isArray(config)) error('configuration must contain a table');
17
+ if (config.version !== 3) error('version must be integer 3; refresh the project configuration');
18
+ const allowed = new Set(['version', 'deep_review', 'parallelization', 'profiles', 'models', 'remediation']);
19
+ const unknown = Object.keys(config).find((key) => !allowed.has(key)); if (unknown) error(`contains unknown top-level key '${unknown}'`);
20
+ if (!config.models || typeof config.models !== 'object') error('models must be a table containing every provider');
21
+ for (const provider of PROVIDERS) {
22
+ const values = config.models[provider]; if (!values || typeof values !== 'object') error(`models.${provider} must be a table`);
23
+ for (const role of ROLES) {
24
+ const setting = values[role]; const name = `models.${provider}.${role}`;
25
+ if (!setting || typeof setting !== 'object') error(`${name} must be a table`);
26
+ if (typeof setting.model !== 'string' || !setting.model.trim() || !modelRe.test(setting.model)) error(`${name}.model must be a valid native model identifier`);
27
+ if (typeof setting.effort !== 'string' || !EFFORTS.includes(setting.effort)) error(`${name}.effort must be one of: ${EFFORTS.join(', ')}`);
28
+ if (provider === 'claude' && setting.effort === 'ultra') error(`${name}.effort 'ultra' is not supported by claude`);
29
+ }
30
+ }
31
+ return config;
32
+ }
33
+
34
+ export function readWorkflowConfig(root) {
35
+ const local = safePath(root, '.wtk.toml', 'workflow config'); const example = safePath(root, '.wtk.toml.example', 'workflow config'); const configPath = fs.existsSync(local) ? local : example;
36
+ if (!fs.existsSync(configPath)) error('.wtk.toml is missing');
37
+ let config; try { config = parse(fs.readFileSync(configPath, 'utf8')); } catch (cause) { error(`invalid .wtk.toml: ${cause.message}`); }
38
+ return validateWorkflowConfig(config);
39
+ }
40
+
41
+ function one(regex, content, label) { const matches = [...content.matchAll(regex)]; if (matches.length !== 1) error(`packet must contain exactly one ${label} metadata field`); return matches[0]; }
42
+ export function packetSetting(provider, content) {
43
+ const text = Buffer.isBuffer(content) ? content.toString('utf8') : content;
44
+ let model, effort;
45
+ if (provider === 'claude') { const header = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text)?.[1]; if (header === undefined) error('packet must contain a native YAML frontmatter header'); model = one(/^model:\s*([^\r\n]+)$/gm, header, 'model')[1].trim(); effort = one(/^effort:\s*([^\r\n]+)$/gm, header, 'effort')[1].trim(); }
46
+ else if (provider === 'cursor') { const header = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text)?.[1]; if (header === undefined) error('packet must contain a native YAML frontmatter header'); const match = one(/^model:\s*([^\[\r\n]+)\[effort=([^\]\r\n]+)\]$/gm, header, 'model/effort'); model = match[1].trim(); effort = match[2].trim(); }
47
+ else { const modelMatch = one(/^model\s*=\s*"((?:\\.|[^"\\])*)"/gm, text, 'model'); const effortMatch = one(/^model_reasoning_effort\s*=\s*"((?:\\.|[^"\\])*)"/gm, text, 'model_reasoning_effort'); model = JSON.parse(`"${modelMatch[1]}"`); effort = JSON.parse(`"${effortMatch[1]}"`); }
48
+ if (!modelRe.test(model)) error('packet contains an invalid model identifier'); return { model, effort };
49
+ }
50
+
51
+ export function renderAgentPacket(provider, content, setting) {
52
+ const text = Buffer.isBuffer(content) ? content.toString('utf8') : content; let rendered;
53
+ if (provider === 'claude') {
54
+ rendered = text.replace(/^(model:)\s*[^\r\n]+$/m, `$1 ${setting.model}`).replace(/^(effort:)\s*[^\r\n]+$/m, `$1 ${setting.effort}`);
55
+ } else if (provider === 'cursor') {
56
+ rendered = text.replace(/^(model:)\s*[^\[\r\n]+\[effort=[^\]\r\n]+\]/m, `$1 ${setting.model}[effort=${setting.effort}]`);
57
+ } else {
58
+ const quote = JSON.stringify(setting.model).slice(1, -1); const effort = JSON.stringify(setting.effort).slice(1, -1);
59
+ rendered = text.replace(/^(model\s*=\s*")[^"\\]*(?:\\.[^"\\]*)*(")/m, `$1${quote}$2`).replace(/^(model_reasoning_effort\s*=\s*")[^"\\]*(?:\\.[^"\\]*)*(")/m, `$1${effort}$2`);
60
+ }
61
+ packetSetting(provider, rendered); return Buffer.isBuffer(content) ? Buffer.from(rendered) : rendered;
62
+ }
63
+
64
+ export function stageAgentPackets(stageRoot, targetRoot = stageRoot) {
65
+ const root = path.resolve(stageRoot); const target = path.resolve(targetRoot); let config;
66
+ if (fs.existsSync(path.join(target, '.wtk.toml')) || fs.existsSync(path.join(target, '.wtk.toml.example'))) config = readWorkflowConfig(target);
67
+ else config = validateWorkflowConfig(parse(fs.readFileSync(path.join(root, '.wtk.toml.example'), 'utf8')));
68
+ const generated = {};
69
+ for (const provider of PROVIDERS) for (const role of ROLES) {
70
+ const template = templatePath(root, provider, role); if (!fs.existsSync(template) || !fs.lstatSync(template).isFile()) error(`missing agent template ${path.relative(root, template)}`);
71
+ const content = fs.readFileSync(template); packetSetting(provider, content); generated[runtimePath(provider, role)] = renderAgentPacket(provider, content, config.models[provider][role]);
72
+ }
73
+ if (!fs.existsSync(path.join(target, '.wtk.toml')) && fs.existsSync(path.join(root, '.wtk.toml.example'))) generated['.wtk.toml'] = fs.readFileSync(path.join(root, '.wtk.toml.example'));
74
+ return generated;
75
+ }
76
+
77
+ export default { stageAgentPackets, readWorkflowConfig, validateWorkflowConfig, packetSetting, renderAgentPacket, PROVIDERS, ROLES };
@@ -0,0 +1,98 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { buildPlan, composeBlocks, gitProof, LAYERS, loadManifest, resolveModules, safePath } from './engine.js';
5
+ import { applyTransaction, hasInterruptedTransaction, restoreInterrupted } from './transaction.js';
6
+ import { knowledgeTransfers } from './knowledge.js';
7
+ import { stageAgentPackets } from './packets.js';
8
+
9
+ const descriptions = {
10
+ compact: { core: 'Workflow Toolkit, Lean workflow, and shared tooling', quality: 'Deep review and QA skills', extras: 'Optional Ponytail utilities and prompt-review' },
11
+ wide: { core: 'Workflow Toolkit, Lean workflow, configuration, and shared tooling', quality: 'Deep review, verification, and QA skills', extras: 'Optional Ponytail utilities and prompt-review' },
12
+ };
13
+ const statusBox = (value, width = 15) => { const label = statusLabel(value); return `[${label}]${' '.repeat(Math.max(1, width - label.length - 2))}`; };
14
+ const labels = { add: 'ADD', update: 'UPDATE', claim: 'ADOPT', replace: 'REPLACE', remove: 'REMOVE', preserve: 'PRESERVE', 'no-change': 'NO CHANGE', conflict: 'CONFLICT', modified: 'MODIFIED' };
15
+ const statusLabel = (value) => value.toUpperCase();
16
+ const line = (value, width) => { if (value.length <= width) return [value]; const result = []; for (let start = 0; start < value.length; start += width) result.push(value.slice(start, start + width)); return result; };
17
+
18
+ export function parseModuleSelection(input) {
19
+ if (typeof input !== 'string' || !input.trim()) return null;
20
+ const values = input.split(',').map((item) => item.trim()); if (!values.every((item) => /^[1-3]$/.test(item))) return null;
21
+ const indexes = [...new Set(values.map(Number))]; return LAYERS.filter((_, index) => indexes.includes(index + 1));
22
+ }
23
+
24
+ export function renderPlan(plan, width = 80) {
25
+ const lines = [`Plan for ${plan.target}`, ''];
26
+ if (width >= 100) lines.push(' ACTION MODULE PATH');
27
+ for (const action of plan.actions || []) {
28
+ const modules = (action.modules || []).join(', ');
29
+ const label = labels[action.kind] || action.kind;
30
+ if (width >= 100) {
31
+ lines.push(...line(` ${label.padEnd(10)} ${modules.padEnd(10)} ${action.path}${action.reason ? ` (${action.reason})` : ''}`, width));
32
+ } else {
33
+ lines.push(...line(` ${label.padEnd(9)} ${action.path}`, width));
34
+ lines.push(...line(` ${action.reason ? `module: ${modules}; ${action.reason}` : `module: ${modules}`}`, width));
35
+ }
36
+ }
37
+ if (plan.unresolved?.length) lines.push('', `Unresolved conflicts: ${plan.unresolved.length}`, 'Resolve every conflict before final confirmation.');
38
+ return lines.join('\n');
39
+ }
40
+
41
+ const outputOf = (write) => (value) => { if (write) write(value); };
42
+ async function ask(input, write, prompt) { outputOf(write)(prompt); const answer = await input(prompt); return answer == null ? null : String(answer); }
43
+ const yes = (answer) => answer !== null && /^y(?:es)?$/i.test(answer.trim());
44
+ const shellQuote = (value) => `'${String(value).replaceAll("'", "'\"'\"'")}'`;
45
+ export const securityInstallCommand = (packageRoot, targetRoot) => `python3 ${shellQuote(path.join(packageRoot, 'scripts/install_security_skills.py'))} ${shellQuote(targetRoot)} --yes`;
46
+ export const repositoryIntelligenceInstallCommands = [
47
+ 'npm install --save-dev --save-exact @nanonets/graft@0.10.1',
48
+ 'uv tool install graphifyy==0.9.14',
49
+ ];
50
+ const printRepositoryIntelligenceSetup = (print) => {
51
+ print('\nRepository intelligence setup (manual; not executed):');
52
+ repositoryIntelligenceInstallCommands.forEach((command) => print(` ${command}`));
53
+ };
54
+ function addPacketActions(built, packageRoot, targetRoot) { const packets = stageAgentPackets(packageRoot, targetRoot); Object.assign(built.staged, packets); for (const [relative, bytes] of Object.entries(packets)) { const target = safePath(targetRoot, relative, 'runtime packet'); const exists = fs.existsSync(target); const kind = !exists ? 'add' : fs.readFileSync(target).compare(bytes) === 0 ? 'no-change' : 'update'; if (!built.plan.actions.some((action) => action.path === relative)) built.plan.actions.push({ path: relative, kind, modules: [...built.plan.selectedModules], reason: 'generated provider packet' }); } built.plan.actions.sort((a, b) => a.path.localeCompare(b.path)); if (built.plan.message && built.plan.actions.some((action) => !['preserve', 'no-change'].includes(action.kind))) { built.plan.message = undefined; built.plan.status = 'ready'; } else if (built.plan.message) built.plan.actions = []; return built; }
55
+
56
+ export async function runInstallWizard({ targetRoot = process.cwd(), sourceRoot, input = async () => null, write = console.log, width = Number(process.stdout.columns) || 80, color = !process.env.NO_COLOR, planner = buildPlan, transaction = applyTransaction, requireGit = false } = {}) {
57
+ const root = path.resolve(targetRoot); const print = outputOf(write);
58
+ const packageRoot = sourceRoot ? path.resolve(sourceRoot) : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
59
+ if (requireGit) { try { gitProof(root); } catch (error) { print(`[ERROR] ${error.message}\nNo files changed.`); return { code: 1, error }; } }
60
+ if (hasInterruptedTransaction(root)) {
61
+ const answer = await ask(input, write, 'A previous installation was interrupted. Restore its recorded backup before continuing. Restore now? (y/N): ');
62
+ if (!yes(answer)) { print('Installation cancelled. No files changed.'); return { cancelled: true, code: 0 }; }
63
+ try { restoreInterrupted({ targetRoot: root }); } catch (error) { print(`[ERROR] ${error.message}\nNo files changed.`); return { code: 1, error }; }
64
+ }
65
+ print('\nWorkflow Toolkit Installer'); print(`Target: ${root}\n`); print('Select modules to install or update:');
66
+ const wide = width >= 100;
67
+ const assessment = planner({ sourceRoot, targetRoot: root, selectedModules: LAYERS }).plan.assessments;
68
+ assessment.forEach((module, index) => print(` ${index + 1}. [ ] ${module.id.padEnd(9)} ${statusBox(module.status, wide ? 17 : 16)}${descriptions[wide ? 'wide' : 'compact'][module.id]}`));
69
+ let selected; while (!selected) { const answer = await ask(input, write, '\nModules [1-3, comma-separated]: '); if (answer === null) { print('Installation cancelled. No files changed.'); return { cancelled: true, code: 0 }; } selected = parseModuleSelection(answer); if (!selected) print('Choose one or more modules using numbers 1-3.'); }
70
+ const plannedModules = resolveModules(selected); print('\nSelected modules:'); for (const module of plannedModules) { const source = assessment.find((item) => item.id === module); const required = plannedModules.filter((item) => item !== module && (module === 'core' && ['quality', 'extras'].includes(item))).join(', '); print(` [x] ${module.padEnd(9)} ${statusBox(source?.status || 'not installed', wide ? 17 : 16)}${required ? `required by ${required}` : ''}`); }
71
+ const preview = await ask(input, write, '\nContinue to preview? (y/N): '); if (!yes(preview)) { print('Installation cancelled. No files changed.'); return { cancelled: true, code: 0 }; }
72
+ let built = addPacketActions(planner({ sourceRoot, targetRoot: root, selectedModules: selected }), packageRoot, root); if (built.plan.message) { print(built.plan.message); print('No backup required.'); printRepositoryIntelligenceSetup(print); print('\nExternal security skills remain separately authorized.'); print('\nIf external security skills are not installed, the security gate remains uncovered.'); print(securityInstallCommand(packageRoot, root)); return { plan: built.plan, code: 0 }; }
73
+ print(`\n${renderPlan(built.plan, width)}`);
74
+ const replacements = []; const excluded = new Set();
75
+ while (built.plan.unresolved?.length) {
76
+ const conflict = built.plan.unresolved[0]; const action = built.plan.actions.find((item) => item.path === conflict); const dependents = (action?.modules || []).flatMap((module) => plannedModules.filter((candidate) => candidate !== module && DEPENDENCIES[candidate]?.includes(module))).filter((item, index, list) => list.indexOf(item) === index);
77
+ print(`\nConflict 1 of ${built.plan.unresolved.length}`); print(`This file contains content not owned by the installer: ${conflict}`); print(`Module: ${(action?.modules || []).join(', ')}`);
78
+ if (wide) print(`\nChoose an action:\n 1. Back up and replace Adds a pending transfer from the backup to docs/product/AGENT-CONTEXT.md.\n 2. Exclude module ${dependents.length ? `Excluding ${(action?.modules || []).join(', ')} also excludes: ${dependents.join(', ')}.` : 'Exclude the affected module.'}`);
79
+ else { print('\nChoose an action:\n 1. Back up and replace\n Adds a pending transfer from the backup to\n docs/product/AGENT-CONTEXT.md.\n 2. Exclude module'); if (dependents.length) print(` Excluding ${(action?.modules || []).join(', ')} also excludes: ${dependents.join(', ')}.`); }
80
+ print(' 3. Cancel installation');
81
+ const choice = await ask(input, write, '\nDecision [1-3]: ');
82
+ if (choice === null || choice.trim() === '3' || !['1', '2'].includes(choice.trim())) { print('Installation cancelled. No files changed.'); return { cancelled: true, code: 0 }; }
83
+ if (choice.trim() === '1') { replacements.push(conflict); print(wide ? 'AGENTS.md will be backed up and replaced. Pending knowledge transfers: 1' : 'AGENTS.md will be backed up and replaced.'); if (knowledgeDestination(conflict) && !wide) print('Pending knowledge transfers: 1'); }
84
+ else { for (const module of action?.modules || []) excluded.add(module); if (excluded.has('core')) ['quality', 'extras'].forEach((module) => excluded.add(module)); const cascade = [...excluded].filter((module) => module !== (action?.modules || [])[0]); print(`Excluding ${[...excluded].join(', ')}${cascade.length ? `; also excludes: ${cascade.join(', ')}` : ''}; recalculating plan.`); }
85
+ const modulesAfter = plannedModules.filter((module) => !excluded.has(module)); if (!modulesAfter.length) { print('Installation cancelled. No files changed.'); return { cancelled: true, code: 0 }; }
86
+ built = addPacketActions(planner({ sourceRoot, targetRoot: root, selectedModules: modulesAfter }), packageRoot, root); for (const item of built.plan.actions) if (replacements.includes(item.path)) item.kind = 'replace'; for (const replacement of replacements.filter((item) => item.includes(':'))) { const [filename, module] = replacement.split(':'); const manifest = loadManifest(root); delete manifest.blocks[replacement]; const composed = composeBlocks(packageRoot, root, built.plan.selectedModules, manifest); if (composed.outputs[filename]) built.staged[filename] = composed.outputs[filename]; built.manifest.blocks = { ...built.manifest.blocks, ...composed.blocks }; built.staged['.my-workflow/adoption.json'] = fs.readFileSync(path.join(root, '.my-workflow/adoption.json')); built.staged['.my-workflow/adoption.json'] = Buffer.from(`${JSON.stringify(built.manifest, null, 2)}\n`); } built.plan.unresolved = built.plan.unresolved.filter((item) => !replacements.includes(item));
87
+ }
88
+ const actions = built.plan.actions.filter((item) => !['preserve', 'no-change'].includes(item.kind)); const counts = Object.fromEntries(Object.keys(labels).map((key) => [key, built.plan.actions.filter((item) => item.kind === key).length])); const summary = Object.entries(counts).filter(([, count]) => count).map(([key, count]) => `${count} ${labels[key].toLowerCase()}`).join(', ') || 'none'; const knowledgeCount = built.plan.actions.filter((item) => ['replace', 'remove'].includes(item.kind) && knowledgeDestination(item.path)).length; const backupPreview = actions.some((item) => ['replace', 'remove', 'update'].includes(item.kind)) ? '.my-workflow/backups/<UTC timestamp>/' : 'No backup required.'; print('\nReady to install'); print(`Selected modules: ${built.plan.selectedModules.join(', ')}`); print(`Actions: ${summary}`); print(`Backup: ${backupPreview}`); print(`Knowledge transfers: ${knowledgeCount} pending`);
89
+ const confirmation = await ask(input, write, '\nApply this plan? (y/N): '); if (!yes(confirmation)) { print('Installation cancelled. No files changed.'); return { cancelled: true, code: 0 }; }
90
+ const knowledgeActions = built.plan.actions.filter((item) => ['replace', 'remove'].includes(item.kind) && item.path.split(':')[0] in { 'AGENTS.md': 1, 'CLAUDE.md': 1, 'knowledge/AGENTS.md': 1, 'knowledge/raw/README.md': 1 });
91
+ const prepared = { targetRoot: root, plan: built.plan, staged: built.staged, links: built.links, knowledgeActions }; for (const action of built.plan.actions.filter((item) => item.kind === 'replace')) { const relative = action.path.split(':')[0]; if (!prepared.staged[relative]) prepared.staged[relative] = fs.readFileSync(path.join(packageRoot, relative)); }
92
+ if (!wide) print('\nApplying... files'); let result; try { result = transaction(prepared); } catch (error) { const publication = /publication failed; previous repository state was restored/.test(error.message); if (publication) print('[ERROR] Installation failed. The previous repository state was restored.'); else if (/backup failed:/.test(error.message)) print(`[ERROR] Backup failed: ${error.message.slice(error.message.indexOf(':') + 1).trim()}`); else print(`[ERROR] ${error.message}`); if (!publication) print('No target files or adoption state changed.'); return { code: 1, error }; } if (!wide) print('Applying... adoption state'); const items = knowledgeTransfers(built.plan, result); print('\nInstallation complete.'); print(`Modules: ${built.plan.selectedModules.join(', ')}`); print(`Actions: ${summary}`); const backup = result.backup ? `${result.backup}/` : 'No backup required.'; print(`Backup: ${backup}`); if (items.length) { print('\nKnowledge transfer required:'); items.forEach((item) => { if (wide) print(` Source: ${item.source}\n Destination: ${item.destination}\n Reason: ${item.reason}\n Status: ${item.status}`); else print(` Source:\n ${item.source}\n Destination: ${item.destination}\n Reason: ${item.reason}\n Status: ${item.status}`); }); if (wide) print(`Checklist: ${result.backup}/knowledge-transfer.md`); else print(`Checklist:\n ${result.backup}/\n knowledge-transfer.md`); } printRepositoryIntelligenceSetup(print); print('\nExternal security skills remain separately authorized.'); print('\nIf external security skills are not installed, the security gate remains uncovered.'); print(securityInstallCommand(packageRoot, root)); return { plan: built.plan, result, knowledge: items, code: 0 };
93
+ }
94
+
95
+ const DEPENDENCIES = { core: [], quality: ['core'], extras: ['core'] };
96
+ const knowledgeDestination = (relative) => ['AGENTS.md', 'CLAUDE.md', 'knowledge/AGENTS.md', 'knowledge/raw/README.md'].includes(relative?.split(':')[0]);
97
+
98
+ export default { runInstallWizard, renderPlan, parseModuleSelection };
@@ -0,0 +1,75 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { KNOWLEDGE_DESTINATIONS, safePath, sha256, validateRelative, InstallerError } from './engine.js';
5
+
6
+ const fail = (message) => { throw new InstallerError(message); };
7
+ const atomicWrite = (file, content) => { fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`); try { const fd = fs.openSync(temporary, 'w', 0o600); fs.writeFileSync(fd, content); fs.fsyncSync(fd); fs.closeSync(fd); fs.renameSync(temporary, file); } finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); } };
8
+ const targetOf = (input) => path.resolve(input?.targetRoot || input?.target || process.cwd());
9
+ const timestamp = (clock) => new Date(typeof clock === 'function' ? clock() : clock || Date.now()).toISOString().replace(/:/g, '-');
10
+ const journalPath = (root) => path.join(root, '.my-workflow', 'transaction.json');
11
+ function ensureDirectory(root, relative) { let current = root; for (const part of relative.split('/')) { current = path.join(current, part); if (fs.existsSync(current) && (!fs.lstatSync(current).isDirectory() || fs.lstatSync(current).isSymbolicLink())) fail(`backup parent ${relative} uses symlink or non-directory: ${part}`); } }
12
+
13
+ function actionPath(action) { return action.path.includes(':') && !action.path.startsWith('.my-workflow/') ? action.path.split(':')[0] : action.path; }
14
+ function destructive(action) { return ['update', 'replace', 'remove'].includes(action.kind || action.action); }
15
+ function linkDestination(root, relative, target) {
16
+ validateRelative(relative);
17
+ if (typeof target !== 'string' || !target || path.posix.isAbsolute(target) || path.posix.normalize(target) !== target) fail(`Claude skill link target is unsafe: ${relative}`);
18
+ ensureDirectory(root, path.posix.dirname(relative));
19
+ const destination = path.join(root, ...relative.split('/'));
20
+ const resolved = path.resolve(path.dirname(destination), target);
21
+ const base = path.resolve(root);
22
+ if (resolved !== path.resolve(root, `.agents/skills/${relative.split('/').at(-1)}`) || !resolved.startsWith(`${base}${path.sep}`)) fail(`Claude skill link target escapes target: ${relative}`);
23
+ const current = fs.lstatSync(destination, { throwIfNoEntry: false });
24
+ if (current && (!current.isSymbolicLink() || fs.readlinkSync(destination) !== target)) fail(`Claude skill link ${relative} is an unsafe collision`);
25
+ return destination;
26
+ }
27
+
28
+ function publishLink(root, relative, target) {
29
+ const destination = linkDestination(root, relative, target);
30
+ if (fs.existsSync(destination) || fs.lstatSync(destination, { throwIfNoEntry: false })) return;
31
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
32
+ linkDestination(root, relative, target);
33
+ fs.symlinkSync(target, destination);
34
+ }
35
+
36
+ function removeLink(root, relative) {
37
+ validateRelative(relative);
38
+ ensureDirectory(root, path.posix.dirname(relative));
39
+ const destination = path.join(root, ...relative.split('/'));
40
+ const current = fs.lstatSync(destination, { throwIfNoEntry: false });
41
+ if (!current) return;
42
+ if (!current.isSymbolicLink()) fail(`restore cleanup ${relative} is not a symlink`);
43
+ fs.unlinkSync(destination);
44
+ }
45
+ function entry(root, action, backupRoot) {
46
+ const relative = actionPath(action); const file = safePath(root, relative, 'backup source'); if (!fs.existsSync(file)) return null; const stat = fs.lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) fail(`backup failed: ${relative} is not a regular file`); const bytes = fs.readFileSync(file); const backup = path.join(backupRoot, 'files', ...relative.split('/')); fs.mkdirSync(path.dirname(backup), { recursive: true }); fs.writeFileSync(backup, bytes, { mode: stat.mode & 0o7777 }); const copied = fs.readFileSync(backup); if (sha256(bytes) !== sha256(copied) || (fs.statSync(backup).mode & 0o7777) !== (stat.mode & 0o7777)) fail(`backup failed: ${relative} could not be verified`); return { path: relative, action: action.kind || action.action, sha256: sha256(bytes), mode: stat.mode & 0o7777, backup: posix(path.relative(backupRoot, backup)) }; }
47
+ const posix = (value) => value.split(path.sep).join('/');
48
+
49
+ export function prepareBackup(input, maybeTarget) {
50
+ const value = input?.plan ? input : { plan: input, targetRoot: maybeTarget }; const root = targetOf(value); const plan = value.plan || {}; const staged = value.staged || {}; const links = value.links || {}; const actions = (plan.actions || []).filter(destructive); for (const [relative, target] of Object.entries(links)) linkDestination(root, relative, target); if (!actions.length && !Object.keys(staged).length && !Object.keys(links).length) return { targetRoot: root, plan, staged, links, backup: null, journal: null, entries: [] };
51
+ const removeLinks = Object.keys(links).filter((relative) => !fs.lstatSync(path.join(root, ...relative.split('/')), { throwIfNoEntry: false }));
52
+ if (!actions.length) { ensureDirectory(root, '.my-workflow'); const adoption = path.join(root, '.my-workflow', 'adoption.json'); const journal = { schema: 1, state: 'prepared', backup: null, restore: [], removeOnRestore: Object.keys(staged).filter((relative) => !fs.existsSync(path.join(root, relative))), removeLinksOnRestore: removeLinks, previousAdoptionState: fs.existsSync(adoption) ? fs.readFileSync(adoption, 'utf8') : null }; atomicWrite(journalPath(root), Buffer.from(`${JSON.stringify(journal, null, 2)}\n`)); return { targetRoot: root, plan, staged, links, backup: null, journal, entries: [] }; }
53
+ const backupRelative = `.my-workflow/backups/${timestamp(value.clock)}`; ensureDirectory(root, '.my-workflow'); ensureDirectory(root, '.my-workflow/backups'); const backupRoot = path.join(root, ...backupRelative.split('/')); if (fs.existsSync(backupRoot)) fail(`backup already exists: ${backupRelative}`); fs.mkdirSync(path.join(backupRoot, 'files'), { recursive: true });
54
+ const entries = []; const seen = new Set(); try { for (const action of actions) { const relative = actionPath(action); if (seen.has(relative)) continue; seen.add(relative); if (value.failBackup === relative) fail(`backup failed: ${relative} could not be verified`); const backupEntry = entry(root, action, backupRoot); if (backupEntry) entries.push(backupEntry); } const manifest = { created_at: new Date(typeof value.clock === 'function' ? value.clock() : value.clock || Date.now()).toISOString(), package: 'workflow-toolkit', version: plan.packageVersion || '1.0.1', target: '.', files: entries }; atomicWrite(path.join(backupRoot, 'manifest.json'), Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`)); const knowledge = (value.knowledgeActions || []).filter((action) => KNOWLEDGE_DESTINATIONS[actionPath(action)]).map((action) => { const relative = actionPath(action); const destination = KNOWLEDGE_DESTINATIONS[relative]; return { source: `${backupRelative}/files/${relative}`, destination: destination.destination, reason: destination.reason, status: 'Pending human transfer' }; }); if (knowledge.length) { if (value.failChecklist) fail('checklist write failed'); const lines = ['# Knowledge transfer', '', 'Review these consumer-owned sources and transfer content manually.', '']; for (const item of knowledge) lines.push(`- Source: ${item.source}`, ` Destination: ${item.destination}`, ` Reason: ${item.reason}`, ` Status: ${item.status}`, ''); atomicWrite(path.join(backupRoot, 'knowledge-transfer.md'), Buffer.from(`${lines.join('\n')}\n`)); } const adoption = safePath(root, '.my-workflow/adoption.json', 'adoption state'); const journal = { schema: 1, state: 'prepared', backup: backupRelative, restore: entries, removeOnRestore: Object.keys(staged).filter((relative) => !fs.existsSync(path.join(root, relative))), removeLinksOnRestore: removeLinks, previousAdoptionState: fs.existsSync(adoption) ? fs.readFileSync(adoption, 'utf8') : null }; atomicWrite(journalPath(root), Buffer.from(`${JSON.stringify(journal, null, 2)}\n`)); return { targetRoot: root, plan, staged, links, backup: backupRelative, journal, entries }; } catch (error) { removePath(backupRoot); const parent = path.dirname(backupRoot); if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) fs.rmdirSync(parent); throw error; } }
55
+
56
+ function removePath(target) { if (!fs.existsSync(target) && !fs.lstatSync(target, { throwIfNoEntry: false })) return; const stat = fs.lstatSync(target); if (stat.isDirectory() && !stat.isSymbolicLink()) for (const child of fs.readdirSync(target)) removePath(path.join(target, child)); fs.rmSync(target, { recursive: true, force: true }); }
57
+ function publishBytes(root, relative, bytes) { const destination = safePath(root, relative, 'publication destination'); atomicWrite(destination, bytes); }
58
+
59
+ export function publish(prepared, options = {}) {
60
+ const root = targetOf(prepared); const plan = prepared.plan || {}; const staged = prepared.staged || {}; const links = prepared.links || {}; const entries = prepared.entries || []; const journal = prepared.journal || null; const order = (relative) => relative === '.my-workflow/adoption.json' ? 2 : relative === '.wtk.toml' || relative.startsWith('.claude/agents/') || relative.startsWith('.codex/agents/') || relative.startsWith('.cursor/agents/') ? 1 : 0;
61
+ try { for (const [relative, bytes] of Object.entries(staged).sort(([a], [b]) => order(a) - order(b) || a.localeCompare(b))) { if (relative === '.my-workflow/adoption.json') continue; if (options.failAfter && options.failAfter === relative) fail(`injected publication failure: ${relative}`); publishBytes(root, relative, bytes); options.onPublish?.(relative); } for (const [relative, target] of Object.entries(links).sort(([a], [b]) => a.localeCompare(b))) { if (options.failAfter && options.failAfter === relative) fail(`injected publication failure: ${relative}`); publishLink(root, relative, target); options.onPublish?.(relative); } for (const action of plan.actions || []) if ((action.kind || action.action) === 'remove') { const relative = actionPath(action); const destination = safePath(root, relative, 'retired destination'); if (fs.existsSync(destination)) { if (!fs.lstatSync(destination).isFile()) fail(`retired destination is no longer a file: ${relative}`); fs.unlinkSync(destination); options.onPublish?.(relative); } } if (staged['.my-workflow/adoption.json']) { publishBytes(root, '.my-workflow/adoption.json', staged['.my-workflow/adoption.json']); options.onPublish?.('.my-workflow/adoption.json'); } if (journal && fs.existsSync(journalPath(root))) fs.unlinkSync(journalPath(root)); return { ok: true, backup: prepared.backup }; } catch (error) { if (journal) { try { restoreInterrupted({ targetRoot: root, journal }); } catch (restoreError) { fail(`publication failed and rollback failed: ${restoreError.message}`); } } throw new InstallerError(`publication failed; previous repository state was restored: ${error.message}`); }
62
+ }
63
+
64
+ export function applyTransaction(input, options = {}) { const prepared = prepareBackup(input); return publish(prepared, options); }
65
+
66
+ export function restoreInterrupted(input) {
67
+ const root = targetOf(input); const journal = input?.journal || (() => { const file = journalPath(root); if (!fs.existsSync(file)) return null; safePath(root, '.my-workflow/transaction.json', 'transaction journal'); try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { fail(`invalid transaction journal: ${error.message}`); } })(); if (!journal) return { restored: false, reason: 'none' }; if (journal.schema !== 1 || journal.state !== 'prepared' || (journal.backup !== null && typeof journal.backup !== 'string')) fail('invalid transaction journal'); if (journal.backup !== null) { validateRelative(journal.backup); if (!journal.backup.startsWith('.my-workflow/backups/')) fail('transaction backup must stay inside .my-workflow/backups'); const backupRoot = path.resolve(root, ...journal.backup.split('/')); if (backupRoot !== root && !backupRoot.startsWith(`${root}${path.sep}`)) fail('transaction backup must stay inside target'); ensureDirectory(root, '.my-workflow'); ensureDirectory(root, '.my-workflow/backups'); ensureDirectory(root, journal.backup); }
68
+ for (const record of journal.restore || []) { const target = safePath(root, record.path, 'restore target'); const backup = safePath(path.join(root, ...journal.backup.split('/')), record.backup, 'restore backup'); if (!fs.existsSync(backup) || sha256(fs.readFileSync(backup)) !== record.sha256) fail(`backup verification failed: ${record.path}`); publishBytes(root, record.path, fs.readFileSync(backup)); fs.chmodSync(target, record.mode); }
69
+ for (const relative of journal.removeOnRestore || []) { const target = safePath(root, relative, 'restore cleanup'); if (fs.existsSync(target)) removePath(target); }
70
+ for (const relative of journal.removeLinksOnRestore || []) removeLink(root, relative);
71
+ ensureDirectory(root, '.my-workflow'); const adoption = safePath(root, '.my-workflow/adoption.json', 'adoption state'); if (journal.previousAdoptionState === null) { if (fs.existsSync(adoption)) fs.unlinkSync(adoption); } else atomicWrite(adoption, Buffer.from(journal.previousAdoptionState)); if (fs.existsSync(journalPath(root))) fs.unlinkSync(journalPath(root)); return { restored: true, backup: journal.backup };
72
+ }
73
+
74
+ export function hasInterruptedTransaction(root = process.cwd()) { const target = path.resolve(root); const file = journalPath(target); if (!fs.existsSync(file)) return false; safePath(target, '.my-workflow/transaction.json', 'transaction journal'); return true; }
75
+ export default { prepareBackup, publish, applyTransaction, restoreInterrupted, hasInterruptedTransaction };