thoth-agents 0.3.5 → 0.3.6

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.
@@ -3,10 +3,10 @@
3
3
  import {
4
4
  cpSync,
5
5
  existsSync,
6
+ lstatSync,
6
7
  mkdirSync,
7
8
  readdirSync,
8
9
  readFileSync,
9
- statSync,
10
10
  writeFileSync,
11
11
  } from 'node:fs';
12
12
  import { dirname, join, resolve } from 'node:path';
@@ -15,12 +15,13 @@ import { fileURLToPath } from 'node:url';
15
15
  const SCRIPT_PATH = fileURLToPath(import.meta.url);
16
16
  const SKILL_ROOT = dirname(dirname(SCRIPT_PATH));
17
17
  const BUNDLE_ROOT = dirname(SKILL_ROOT);
18
- const OWNED_SKILL_NAMES = [
19
- 'thoth-init',
20
- 'thoth-sdd',
21
- 'thoth-constitution',
22
- 'thoth-archive',
23
- 'plan-reviewer',
18
+ const OPEN_SPEC_DIRECTORIES = [
19
+ 'openspec',
20
+ join('openspec', 'changes'),
21
+ join('openspec', 'changes', 'archive'),
22
+ join('openspec', 'specs'),
23
+ join('openspec', 'memory'),
24
+ join('openspec', 'templates'),
24
25
  ];
25
26
 
26
27
  function parseArgs(argv) {
@@ -29,110 +30,193 @@ function parseArgs(argv) {
29
30
  const argument = argv[index];
30
31
  if (argument === '--json') options.json = true;
31
32
  else if (argument === '--project') options.project = argv[++index];
32
- else if (argument === '--harness') options.harness = argv[++index];
33
33
  else throw new Error(`Unknown argument: ${argument}`);
34
34
  }
35
35
  if (!options.project) throw new Error('--project is required');
36
- if (!['opencode', 'codex', 'claude'].includes(options.harness)) {
37
- throw new Error('--harness must be opencode, codex, or claude');
38
- }
39
36
  return options;
40
37
  }
41
38
 
42
- function copyMissing(source, target, report) {
43
- if (!existsSync(source)) return;
44
- const sourceStat = statSync(source);
45
- if (sourceStat.isDirectory()) {
46
- mkdirSync(target, { recursive: true });
47
- for (const entry of readdirSync(source)) {
48
- copyMissing(join(source, entry), join(target, entry), report);
39
+ function assertDirectory(path, label) {
40
+ if (!existsSync(path)) return;
41
+ const stat = lstatSync(path);
42
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
43
+ throw new Error(`${label} must be a directory: ${path}`);
44
+ }
45
+ }
46
+
47
+ function assertRegularFile(path, label) {
48
+ if (!existsSync(path)) return;
49
+ const stat = lstatSync(path);
50
+ if (stat.isSymbolicLink() || !stat.isFile()) {
51
+ throw new Error(`${label} must be a regular file: ${path}`);
52
+ }
53
+ }
54
+
55
+ function collectFiles(sourceRoot, targetRoot) {
56
+ if (!existsSync(sourceRoot)) {
57
+ throw new Error(`Bundled SDD template directory is missing: ${sourceRoot}`);
58
+ }
59
+ assertDirectory(sourceRoot, 'Bundled template directory');
60
+ const entries = [];
61
+
62
+ function visit(source, target) {
63
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
64
+ const sourcePath = join(source, entry.name);
65
+ const targetPath = join(target, entry.name);
66
+ if (entry.isSymbolicLink()) {
67
+ throw new Error(
68
+ `Bundled templates cannot contain symlinks: ${sourcePath}`,
69
+ );
70
+ }
71
+ if (entry.isDirectory()) {
72
+ entries.push({ kind: 'directory', target: targetPath });
73
+ visit(sourcePath, targetPath);
74
+ } else if (entry.isFile()) {
75
+ entries.push({ kind: 'file', source: sourcePath, target: targetPath });
76
+ } else {
77
+ throw new Error(`Unsupported bundled template entry: ${sourcePath}`);
78
+ }
49
79
  }
50
- return;
51
80
  }
81
+
82
+ visit(sourceRoot, targetRoot);
83
+ return entries;
84
+ }
85
+
86
+ function preflight(project) {
87
+ assertDirectory(project, 'Project root');
88
+ if (!existsSync(project)) {
89
+ throw new Error(
90
+ `--project must reference an existing project directory: ${project}`,
91
+ );
92
+ }
93
+
94
+ const constitutionSource = join(
95
+ BUNDLE_ROOT,
96
+ 'thoth-constitution',
97
+ 'templates',
98
+ 'constitution.md',
99
+ );
100
+ assertRegularFile(constitutionSource, 'Bundled constitution template');
101
+ if (!existsSync(constitutionSource)) {
102
+ throw new Error(
103
+ `Bundled constitution template is missing: ${constitutionSource}`,
104
+ );
105
+ }
106
+
107
+ const templates = collectFiles(
108
+ join(BUNDLE_ROOT, 'thoth-sdd', 'templates'),
109
+ join(project, 'openspec', 'templates'),
110
+ );
111
+ for (const directory of OPEN_SPEC_DIRECTORIES) {
112
+ assertDirectory(join(project, directory), 'OpenSpec path');
113
+ }
114
+ for (const entry of templates) {
115
+ if (entry.kind === 'directory') {
116
+ assertDirectory(entry.target, 'OpenSpec template path');
117
+ } else {
118
+ assertRegularFile(entry.target, 'OpenSpec template path');
119
+ }
120
+ }
121
+
122
+ const constitutionTarget = join(
123
+ project,
124
+ 'openspec',
125
+ 'memory',
126
+ 'constitution.md',
127
+ );
128
+ const manifestTarget = join(project, 'openspec', '.thoth-agents.json');
129
+ assertRegularFile(constitutionTarget, 'OpenSpec constitution path');
130
+ assertRegularFile(manifestTarget, 'OpenSpec manifest path');
131
+
132
+ return {
133
+ constitutionSource,
134
+ constitutionTarget,
135
+ manifestTarget,
136
+ templates,
137
+ };
138
+ }
139
+
140
+ function createDirectory(target, report) {
141
+ if (existsSync(target)) return;
142
+ mkdirSync(target);
143
+ report.created.push(target);
144
+ }
145
+
146
+ function copyPreservingExisting(source, target, report) {
52
147
  if (existsSync(target)) {
53
- report.skipped.push(target);
148
+ report.preserved.push(target);
54
149
  return;
55
150
  }
56
- mkdirSync(dirname(target), { recursive: true });
57
151
  cpSync(source, target, { errorOnExist: true });
58
152
  report.created.push(target);
59
153
  }
60
154
 
61
- function writeMissing(target, content, report) {
155
+ function writePreservingExisting(target, content, report) {
62
156
  if (existsSync(target)) {
63
- report.skipped.push(target);
157
+ report.preserved.push(target);
64
158
  return;
65
159
  }
66
- mkdirSync(dirname(target), { recursive: true });
67
160
  writeFileSync(target, content);
68
161
  report.created.push(target);
69
162
  }
70
163
 
71
- function installConstitution(project, report) {
72
- const target = join(project, 'openspec', 'memory', 'constitution.md');
73
- const template = join(
74
- BUNDLE_ROOT,
75
- 'thoth-constitution',
76
- 'templates',
77
- 'constitution.md',
78
- );
79
- if (!existsSync(template)) return;
80
- const today = new Date().toISOString().slice(0, 10);
81
- writeMissing(
82
- target,
83
- readFileSync(template, 'utf8').replaceAll('YYYY-MM-DD', today),
84
- report,
85
- );
164
+ function synchronizeManagedFile(target, content, report) {
165
+ if (!existsSync(target)) {
166
+ writeFileSync(target, content);
167
+ report.created.push(target);
168
+ return;
169
+ }
170
+ if (readFileSync(target, 'utf8') === content) {
171
+ report.preserved.push(target);
172
+ return;
173
+ }
174
+ writeFileSync(target, content);
175
+ report.managed.push(target);
86
176
  }
87
177
 
88
- function installGovernance(project, report) {
89
- installConstitution(project, report);
90
- const templatesRoot = join(BUNDLE_ROOT, 'thoth-sdd', 'templates');
91
- if (existsSync(templatesRoot)) {
92
- for (const entry of readdirSync(templatesRoot)) {
93
- copyMissing(
94
- join(templatesRoot, entry),
95
- join(project, 'openspec', 'templates', entry),
96
- report,
97
- );
98
- }
178
+ function synchronizeOpenSpec(project, assets, report) {
179
+ for (const directory of OPEN_SPEC_DIRECTORIES) {
180
+ createDirectory(join(project, directory), report);
99
181
  }
100
- writeMissing(
101
- join(project, 'openspec', '.thoth-agents.json'),
182
+ for (const entry of assets.templates) {
183
+ if (entry.kind === 'directory') createDirectory(entry.target, report);
184
+ else copyPreservingExisting(entry.source, entry.target, report);
185
+ }
186
+
187
+ const today = new Date().toISOString().slice(0, 10);
188
+ writePreservingExisting(
189
+ assets.constitutionTarget,
190
+ readFileSync(assets.constitutionSource, 'utf8').replaceAll(
191
+ 'YYYY-MM-DD',
192
+ today,
193
+ ),
194
+ report,
195
+ );
196
+ synchronizeManagedFile(
197
+ assets.manifestTarget,
102
198
  `${JSON.stringify({ version: 1, initializedBy: 'thoth-agents' }, null, 2)}\n`,
103
199
  report,
104
200
  );
105
201
  }
106
202
 
107
- function installOpenCode(project, report) {
108
- for (const skillName of OWNED_SKILL_NAMES) {
109
- copyMissing(
110
- join(BUNDLE_ROOT, skillName),
111
- join(project, '.agents', 'skills', skillName),
112
- report,
113
- );
114
- }
115
- }
116
-
117
203
  try {
118
204
  const options = parseArgs(process.argv.slice(2));
119
205
  const project = resolve(options.project);
120
- mkdirSync(project, { recursive: true });
206
+ const assets = preflight(project);
121
207
  const report = {
122
208
  status: 'ready',
123
- harness: options.harness,
124
209
  project,
125
210
  created: [],
126
211
  managed: [],
127
- skipped: [],
212
+ preserved: [],
128
213
  };
129
214
 
130
- installGovernance(project, report);
131
- if (options.harness === 'opencode') installOpenCode(project, report);
215
+ synchronizeOpenSpec(project, assets, report);
132
216
 
133
217
  const output = options.json
134
218
  ? JSON.stringify(report)
135
- : `thoth-agents initialized ${project} for ${options.harness}`;
219
+ : `thoth-agents synchronized OpenSpec governance in ${project}`;
136
220
  process.stdout.write(`${output}\n`);
137
221
  } catch (error) {
138
222
  process.stderr.write(