@refactco/refact-os 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/bin/refact-os.js +154 -0
- package/lib/adapters.js +302 -0
- package/lib/company.js +76 -0
- package/lib/frontmatter.js +30 -0
- package/lib/migrate.js +116 -0
- package/lib/project-utils.js +179 -0
- package/lib/refact-config.js +324 -0
- package/lib/scaffold.js +329 -0
- package/lib/validate.js +145 -0
- package/package.json +46 -0
- package/templates/base/AGENTS.md +9 -0
- package/templates/base/CLAUDE.md +3 -0
- package/templates/base/README.md +54 -0
- package/templates/base/agent/AGENTS.md +60 -0
- package/templates/base/agent/CLAUDE.md +7 -0
- package/templates/base/agent/claude-hooks.json +32 -0
- package/templates/base/agent/hooks/claude-sync-transcript.py +236 -0
- package/templates/base/agent/hooks/preflight-metadata.mjs +202 -0
- package/templates/base/agent/hooks/send-transcript-to-remote-server.py +238 -0
- package/templates/base/agent/hooks/sync-chat-transcript.py +188 -0
- package/templates/base/agent/hooks.json +29 -0
- package/templates/base/agent/scripts/import-project-chat-history.py +196 -0
- package/templates/base/agent/scripts/sync-asana.mjs +408 -0
- package/templates/base/agent/skills/adopt/SKILL.md +46 -0
- package/templates/base/agent/skills/close-ticket/SKILL.md +31 -0
- package/templates/base/agent/skills/extract-learnings/SKILL.md +90 -0
- package/templates/base/agent/skills/git-it/SKILL.md +138 -0
- package/templates/base/agent/skills/import-chat-history/SKILL.md +85 -0
- package/templates/base/agent/skills/ingest-input/SKILL.md +43 -0
- package/templates/base/agent/skills/open-ticket/SKILL.md +36 -0
- package/templates/base/agent/skills/process-docs/SKILL.md +69 -0
- package/templates/base/agent/skills/project-status/SKILL.md +35 -0
- package/templates/base/agent/skills/project-status/scripts/scan-status.mjs +153 -0
- package/templates/base/agent/skills/refact/SKILL.md +139 -0
- package/templates/base/agent/skills/setup-project/SKILL.md +140 -0
- package/templates/base/agent/skills/sync-asana/SKILL.md +106 -0
- package/templates/base/agent/skills/update-canonical-record/SKILL.md +28 -0
- package/templates/base/agent/skills/update-package/SKILL.md +51 -0
- package/templates/base/docs/context/project.md +30 -0
- package/templates/base/docs/decisions.md +22 -0
- package/templates/base/docs/index.md +31 -0
- package/templates/base/docs/sources/raw/.gitkeep +0 -0
- package/templates/base/docs/task/.gitkeep +0 -0
- package/templates/base/env.example +14 -0
- package/templates/base/gitignore +34 -0
- package/templates/overlays/client/agent/skills/create-deliverable/SKILL.md +29 -0
- package/templates/overlays/client/docs/deliverables/.gitkeep +0 -0
- package/templates/overlays/code/agent/skills/add-codebase/SKILL.md +239 -0
- package/templates/overlays/code/agent/skills/code-development/SKILL.md +58 -0
- package/templates/overlays/code/agent/skills/code-development/references/gitflow.md +144 -0
- package/templates/overlays/nextjs/agent/skills/nextjs-dev/SKILL.md +93 -0
- package/templates/overlays/nextjs/agent/skills/setup-netlify-deploy/SKILL.md +143 -0
- package/templates/overlays/nextjs/agent/skills/setup-nextjs-app/SKILL.md +118 -0
- package/templates/overlays/nextjs/agent/skills/setup-vercel-deploy/SKILL.md +116 -0
- package/templates/overlays/wordpress/agent/skills/install-wp-skills/SKILL.md +130 -0
- package/templates/overlays/wordpress/agent/skills/setup-kinsta-deploy/SKILL.md +201 -0
- package/templates/overlays/wordpress/agent/skills/wp-env/SKILL.md +478 -0
- package/templates/overlays/wordpress/wp-cli.yml.example +46 -0
package/lib/adapters.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { parseFrontmatter } = require("./frontmatter");
|
|
4
|
+
|
|
5
|
+
// Above this many skills, walking every SKILL.md's frontmatter each session is
|
|
6
|
+
// wasteful — generate a derived index instead. Below it, walking the directory
|
|
7
|
+
// is fine. The index is a build artifact (regenerated on sync, drift-checked by
|
|
8
|
+
// validate), so it cannot silently drift.
|
|
9
|
+
const INDEX_THRESHOLD = 25;
|
|
10
|
+
const INDEX_FILE = path.join("agent", "skills", ".index.json");
|
|
11
|
+
|
|
12
|
+
// Generate tool-native adapter folders (.cursor/, .claude/) from the canonical
|
|
13
|
+
// agent/ directory. Adapters are disposable, regenerated output — never edited
|
|
14
|
+
// by hand. The canonical source of truth is agent/.
|
|
15
|
+
|
|
16
|
+
const GENERATED_NOTE =
|
|
17
|
+
"This folder is GENERATED from agent/ by refact-os. Do not edit by hand.\n" +
|
|
18
|
+
"Change the canonical files under agent/ and run `npm run refact:sync`.\n";
|
|
19
|
+
|
|
20
|
+
function copyDir(src, dst) {
|
|
21
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
22
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
23
|
+
const s = path.join(src, entry.name);
|
|
24
|
+
const d = path.join(dst, entry.name);
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
copyDir(s, d);
|
|
27
|
+
} else {
|
|
28
|
+
fs.copyFileSync(s, d);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function replaceDir(src, dst) {
|
|
34
|
+
if (!fs.existsSync(src)) return false;
|
|
35
|
+
fs.rmSync(dst, { recursive: true, force: true });
|
|
36
|
+
copyDir(src, dst);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Records, inside a generated skills dir, which skill folders refact-os owns
|
|
41
|
+
// (i.e. generated from agent/skills/). Anything in the dir NOT listed here is a
|
|
42
|
+
// foreign skill placed by another tool — preserved across syncs, never deleted.
|
|
43
|
+
const OWNED_SKILLS_MANIFEST = ".refact-os-owned.json";
|
|
44
|
+
|
|
45
|
+
function listSkillDirs(dir) {
|
|
46
|
+
if (!fs.existsSync(dir)) return [];
|
|
47
|
+
return fs
|
|
48
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
49
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")))
|
|
50
|
+
.map((e) => e.name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Reconcile a generated skills dir with agent/skills/ WITHOUT clobbering foreign
|
|
54
|
+
// skills. Unlike replaceDir (wipe + copy), this mirrors each agent skill, prunes
|
|
55
|
+
// only skills refact-os previously generated but agent/ no longer has, and leaves
|
|
56
|
+
// any skill we never generated (e.g. a third-party Cursor skill pack) untouched.
|
|
57
|
+
function syncSkillsDir(src, dst) {
|
|
58
|
+
if (!fs.existsSync(src)) return false;
|
|
59
|
+
const agentSkills = listSkillDirs(src);
|
|
60
|
+
const agentSet = new Set(agentSkills);
|
|
61
|
+
|
|
62
|
+
const manifestPath = path.join(dst, OWNED_SKILLS_MANIFEST);
|
|
63
|
+
let prevOwned = [];
|
|
64
|
+
if (fs.existsSync(manifestPath)) {
|
|
65
|
+
try {
|
|
66
|
+
const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
67
|
+
if (Array.isArray(m.skills)) prevOwned = m.skills;
|
|
68
|
+
} catch (_err) {
|
|
69
|
+
prevOwned = [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
74
|
+
// Prune skills we generated last time that agent/ has since dropped (rename or
|
|
75
|
+
// package prune). Never touch a dir we don't have on record as ours.
|
|
76
|
+
for (const name of prevOwned) {
|
|
77
|
+
if (!agentSet.has(name)) fs.rmSync(path.join(dst, name), { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
// (Re)write every current agent skill, replacing any prior copy.
|
|
80
|
+
for (const name of agentSkills) {
|
|
81
|
+
const d = path.join(dst, name);
|
|
82
|
+
fs.rmSync(d, { recursive: true, force: true });
|
|
83
|
+
copyDir(path.join(src, name), d);
|
|
84
|
+
}
|
|
85
|
+
// Mirror any top-level files (e.g. the generated .index.json) the same way the
|
|
86
|
+
// old wipe-and-copy did, so resolver artifacts still land in the adapter dir.
|
|
87
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
88
|
+
if (entry.isFile()) fs.copyFileSync(path.join(src, entry.name), path.join(dst, entry.name));
|
|
89
|
+
}
|
|
90
|
+
fs.writeFileSync(
|
|
91
|
+
manifestPath,
|
|
92
|
+
`${JSON.stringify(
|
|
93
|
+
{
|
|
94
|
+
generatedBy: "refact-os",
|
|
95
|
+
note: "Skill folders refact-os generated from agent/skills/ and will prune/regenerate. Skills NOT listed here are foreign (placed by another tool) and are preserved on sync. Do not edit by hand.",
|
|
96
|
+
skills: [...agentSkills].sort(),
|
|
97
|
+
},
|
|
98
|
+
null,
|
|
99
|
+
2,
|
|
100
|
+
)}\n`,
|
|
101
|
+
"utf8",
|
|
102
|
+
);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Merge refact-os's canonical Claude settings (hook groups + a permission
|
|
107
|
+
// allowlist) into an existing settings object.
|
|
108
|
+
// - Hooks: we own (and replace on every sync) only the groups whose command
|
|
109
|
+
// references our hook script — any other event keys, and any user-authored
|
|
110
|
+
// hooks on the same events, are preserved.
|
|
111
|
+
// - Permissions: the managed `allow` entries are unioned into the existing
|
|
112
|
+
// allow list (so re-syncing is idempotent and never drops a user-added rule),
|
|
113
|
+
// and any other permission keys the user set (deny, ask) are left untouched.
|
|
114
|
+
// Keys prefixed with "_" in the spec are treated as comments and skipped.
|
|
115
|
+
function mergeClaudeSettings(existing, managed) {
|
|
116
|
+
const settings = existing && typeof existing === "object" ? { ...existing } : {};
|
|
117
|
+
const hooks = settings.hooks && typeof settings.hooks === "object" ? { ...settings.hooks } : {};
|
|
118
|
+
for (const [key, value] of Object.entries(managed)) {
|
|
119
|
+
if (key.startsWith("_") || key === "permissions") continue;
|
|
120
|
+
const prior = Array.isArray(hooks[key]) ? hooks[key] : [];
|
|
121
|
+
const userGroups = prior.filter((group) => {
|
|
122
|
+
const cmds = (group && Array.isArray(group.hooks) ? group.hooks : []).map((h) => (h && h.command) || "");
|
|
123
|
+
return !cmds.some((c) => String(c).includes(CLAUDE_HOOK_MARKER));
|
|
124
|
+
});
|
|
125
|
+
hooks[key] = [...userGroups, ...(Array.isArray(value) ? value : [])];
|
|
126
|
+
}
|
|
127
|
+
settings.hooks = hooks;
|
|
128
|
+
if (managed.permissions && Array.isArray(managed.permissions.allow)) {
|
|
129
|
+
const perms = settings.permissions && typeof settings.permissions === "object" ? { ...settings.permissions } : {};
|
|
130
|
+
const curAllow = Array.isArray(perms.allow) ? perms.allow : [];
|
|
131
|
+
perms.allow = [...new Set([...curAllow, ...managed.permissions.allow])];
|
|
132
|
+
settings.permissions = perms;
|
|
133
|
+
}
|
|
134
|
+
return settings;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Generate/merge the Claude-format hooks block into .claude/settings.json.
|
|
138
|
+
// Returns the relative filename written, or null when there is no spec to apply.
|
|
139
|
+
function writeClaudeSettings(agentDir, toolRoot) {
|
|
140
|
+
const specPath = path.join(agentDir, CLAUDE_HOOKS_FILE);
|
|
141
|
+
if (!fs.existsSync(specPath)) return null;
|
|
142
|
+
let managed;
|
|
143
|
+
try {
|
|
144
|
+
managed = JSON.parse(fs.readFileSync(specPath, "utf8"));
|
|
145
|
+
} catch (_err) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const settingsPath = path.join(toolRoot, "settings.json");
|
|
149
|
+
let existing = {};
|
|
150
|
+
if (fs.existsSync(settingsPath)) {
|
|
151
|
+
try {
|
|
152
|
+
existing = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
153
|
+
} catch (_err) {
|
|
154
|
+
existing = {};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
fs.mkdirSync(toolRoot, { recursive: true });
|
|
158
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(mergeClaudeSettings(existing, managed), null, 2)}\n`, "utf8");
|
|
159
|
+
return "settings.json";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Which agent/ subtrees each tool surface receives.
|
|
163
|
+
const SURFACES = {
|
|
164
|
+
cursor: {
|
|
165
|
+
dir: ".cursor",
|
|
166
|
+
subdirs: ["skills", "references", "hooks", "scripts"],
|
|
167
|
+
files: ["hooks.json"],
|
|
168
|
+
},
|
|
169
|
+
claude: {
|
|
170
|
+
// Claude gets hooks/ + scripts/ too (for the transcript-capture hook and
|
|
171
|
+
// the chat-import backfill). Claude ignores hooks.json and reads hooks from
|
|
172
|
+
// settings.json instead, so that block is generated separately below.
|
|
173
|
+
dir: ".claude",
|
|
174
|
+
subdirs: ["skills", "hooks", "scripts"],
|
|
175
|
+
files: [],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Canonical Claude hooks spec (event -> hook groups) and the marker that
|
|
180
|
+
// identifies the groups refact-os owns inside .claude/settings.json.
|
|
181
|
+
const CLAUDE_HOOKS_FILE = "claude-hooks.json";
|
|
182
|
+
const CLAUDE_HOOK_MARKER = "claude-sync-transcript.py";
|
|
183
|
+
|
|
184
|
+
function generateAdapters(targetRoot, options = {}) {
|
|
185
|
+
const agentDir = path.join(targetRoot, "agent");
|
|
186
|
+
if (!fs.existsSync(agentDir)) {
|
|
187
|
+
throw new Error(`No agent/ directory at ${targetRoot} — nothing to generate adapters from.`);
|
|
188
|
+
}
|
|
189
|
+
const tools = options.tools || ["cursor", "claude"];
|
|
190
|
+
const result = { tools: [], surfaces: {} };
|
|
191
|
+
|
|
192
|
+
for (const tool of tools) {
|
|
193
|
+
const surface = SURFACES[tool];
|
|
194
|
+
if (!surface) continue;
|
|
195
|
+
const toolRoot = path.join(targetRoot, surface.dir);
|
|
196
|
+
const written = [];
|
|
197
|
+
|
|
198
|
+
for (const sub of surface.subdirs) {
|
|
199
|
+
const src = path.join(agentDir, sub);
|
|
200
|
+
const dst = path.join(toolRoot, sub);
|
|
201
|
+
// skills/ may hold foreign (non-refact-os) skill packs — reconcile instead
|
|
202
|
+
// of wiping. Other subdirs are wholly refact-os-owned, so a clean replace
|
|
203
|
+
// is correct (and drops files removed upstream).
|
|
204
|
+
const ok = sub === "skills" ? syncSkillsDir(src, dst) : replaceDir(src, dst);
|
|
205
|
+
if (ok) written.push(sub);
|
|
206
|
+
}
|
|
207
|
+
for (const file of surface.files) {
|
|
208
|
+
const src = path.join(agentDir, file);
|
|
209
|
+
if (fs.existsSync(src)) {
|
|
210
|
+
fs.mkdirSync(toolRoot, { recursive: true });
|
|
211
|
+
fs.copyFileSync(src, path.join(toolRoot, file));
|
|
212
|
+
written.push(file);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (tool === "claude") {
|
|
216
|
+
const settingsFile = writeClaudeSettings(agentDir, toolRoot);
|
|
217
|
+
if (settingsFile) written.push(settingsFile);
|
|
218
|
+
}
|
|
219
|
+
if (written.length > 0) {
|
|
220
|
+
fs.writeFileSync(path.join(toolRoot, "GENERATED.md"), `# ${surface.dir} (generated)\n\n${GENERATED_NOTE}`, "utf8");
|
|
221
|
+
result.tools.push(tool);
|
|
222
|
+
result.surfaces[tool] = written;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
writeSurfaceMap(targetRoot, result);
|
|
227
|
+
result.index = generateSkillIndex(targetRoot);
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The "resolver slice": the frontmatter fields the agent reads at selection
|
|
232
|
+
// time. Each field changes a routing decision — when_to_use ("might fit"),
|
|
233
|
+
// when_not_to_use ("but not when…", prevents misfires), next_skills ("leads
|
|
234
|
+
// to…", enables chaining), requires_approval ("proceed or stop?"). Keep this in
|
|
235
|
+
// sync with the spec's "How Skills Are Selected".
|
|
236
|
+
function skillFrontmatterSlice(targetRoot, name) {
|
|
237
|
+
const p = path.join(targetRoot, "agent", "skills", name, "SKILL.md");
|
|
238
|
+
const fm = parseFrontmatter(fs.readFileSync(p, "utf8")) || {};
|
|
239
|
+
return {
|
|
240
|
+
name,
|
|
241
|
+
pattern: fm.pattern || null,
|
|
242
|
+
description: fm.description || null,
|
|
243
|
+
when_to_use: fm.when_to_use || null,
|
|
244
|
+
when_not_to_use: fm.when_not_to_use || null,
|
|
245
|
+
next_skills: Array.isArray(fm.next_skills) ? fm.next_skills : [],
|
|
246
|
+
requires_approval: String(fm.requires_approval) === "true",
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Build (or remove) the derived skill index, threshold-gated.
|
|
251
|
+
function generateSkillIndex(targetRoot, options = {}) {
|
|
252
|
+
const threshold = options.threshold || INDEX_THRESHOLD;
|
|
253
|
+
const skills = listSkills(targetRoot);
|
|
254
|
+
const indexPath = path.join(targetRoot, INDEX_FILE);
|
|
255
|
+
if (skills.length < threshold) {
|
|
256
|
+
if (fs.existsSync(indexPath)) fs.rmSync(indexPath);
|
|
257
|
+
return { generated: false, count: skills.length, threshold };
|
|
258
|
+
}
|
|
259
|
+
const payload = {
|
|
260
|
+
generatedBy: "refact-os",
|
|
261
|
+
note: "Derived index of agent/skills frontmatter. Regenerated by `npm run refact:sync`; do not edit.",
|
|
262
|
+
threshold,
|
|
263
|
+
skills: skills.map((n) => skillFrontmatterSlice(targetRoot, n)),
|
|
264
|
+
};
|
|
265
|
+
fs.writeFileSync(indexPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
266
|
+
return { generated: true, count: skills.length, threshold };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function readSkillIndex(targetRoot) {
|
|
270
|
+
const indexPath = path.join(targetRoot, INDEX_FILE);
|
|
271
|
+
if (!fs.existsSync(indexPath)) return null;
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(fs.readFileSync(indexPath, "utf8"));
|
|
274
|
+
} catch (_err) {
|
|
275
|
+
return { _invalid: true };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function writeSurfaceMap(targetRoot, result) {
|
|
280
|
+
const mapPath = path.join(targetRoot, "agent", "agent-surface-map.json");
|
|
281
|
+
const map = {
|
|
282
|
+
generatedBy: "refact-os",
|
|
283
|
+
canonical: "agent/",
|
|
284
|
+
surfaces: Object.fromEntries(
|
|
285
|
+
result.tools.map((t) => [SURFACES[t].dir, { from: "agent/", contains: result.surfaces[t] }]),
|
|
286
|
+
),
|
|
287
|
+
};
|
|
288
|
+
fs.writeFileSync(mapPath, `${JSON.stringify(map, null, 2)}\n`, "utf8");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Returns the list of canonical skill names (folders under agent/skills with a SKILL.md).
|
|
292
|
+
function listSkills(targetRoot) {
|
|
293
|
+
const skillsDir = path.join(targetRoot, "agent", "skills");
|
|
294
|
+
if (!fs.existsSync(skillsDir)) return [];
|
|
295
|
+
return fs
|
|
296
|
+
.readdirSync(skillsDir, { withFileTypes: true })
|
|
297
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(skillsDir, e.name, "SKILL.md")))
|
|
298
|
+
.map((e) => e.name)
|
|
299
|
+
.sort();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = { generateAdapters, listSkills, generateSkillIndex, readSkillIndex, skillFrontmatterSlice, INDEX_THRESHOLD };
|
package/lib/company.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { execFileSync } = require("child_process");
|
|
5
|
+
const { loadConfig, saveConfig } = require("./refact-config");
|
|
6
|
+
|
|
7
|
+
// Company context lives canonically in the refact-operation repo. `sync company`
|
|
8
|
+
// pulls a folder from it into the project's docs/context/company/ — local and
|
|
9
|
+
// fast for the agent to read, but re-syncable so it never permanently drifts.
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SOURCE = "github:refactco/refact-operation";
|
|
12
|
+
const DEFAULT_SOURCE_PATH = "docs/company";
|
|
13
|
+
const DEST = path.join("docs", "context", "company");
|
|
14
|
+
|
|
15
|
+
const GENERATED_NOTE =
|
|
16
|
+
"This folder is SYNCED from the company source repo by `npx refact-os sync company`.\n" +
|
|
17
|
+
"Do not edit here — change it in the source and re-sync. Local edits are overwritten.\n";
|
|
18
|
+
|
|
19
|
+
function toGitUrl(source) {
|
|
20
|
+
if (/^github:/.test(source)) {
|
|
21
|
+
return `git@github.com:${source.slice("github:".length)}.git`;
|
|
22
|
+
}
|
|
23
|
+
return source; // assume a full git URL (https or ssh)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function copyDir(src, dst) {
|
|
27
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
28
|
+
let count = 0;
|
|
29
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
30
|
+
const s = path.join(src, entry.name);
|
|
31
|
+
const d = path.join(dst, entry.name);
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
count += copyDir(s, d);
|
|
34
|
+
} else {
|
|
35
|
+
fs.copyFileSync(s, d);
|
|
36
|
+
count += 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return count;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function syncCompany(targetRoot, options = {}) {
|
|
43
|
+
const config = loadConfig(targetRoot);
|
|
44
|
+
const companyCfg = (config.company && typeof config.company === "object") ? config.company : {};
|
|
45
|
+
const source = options.source || companyCfg.source || DEFAULT_SOURCE;
|
|
46
|
+
const sourcePath = options.sourcePath || companyCfg.sourcePath || DEFAULT_SOURCE_PATH;
|
|
47
|
+
const gitUrl = toGitUrl(source);
|
|
48
|
+
|
|
49
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "refact-company-"));
|
|
50
|
+
let sha;
|
|
51
|
+
try {
|
|
52
|
+
execFileSync("git", ["clone", "--depth", "1", gitUrl, tmp], { stdio: ["ignore", "ignore", "pipe"] });
|
|
53
|
+
sha = execFileSync("git", ["-C", tmp, "rev-parse", "HEAD"]).toString().trim();
|
|
54
|
+
const srcDir = path.join(tmp, sourcePath);
|
|
55
|
+
if (!fs.existsSync(srcDir)) {
|
|
56
|
+
throw new Error(`Source path "${sourcePath}" not found in ${source}.`);
|
|
57
|
+
}
|
|
58
|
+
const destDir = path.join(targetRoot, DEST);
|
|
59
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
60
|
+
const fileCount = copyDir(srcDir, destDir);
|
|
61
|
+
fs.writeFileSync(path.join(destDir, "GENERATED.md"), `# Company context (synced)\n\n${GENERATED_NOTE}\nSource: ${source} (${sourcePath}) @ ${sha}\n`, "utf8");
|
|
62
|
+
|
|
63
|
+
config.company = { source, sourcePath, syncedSha: sha, syncedAt: new Date().toISOString().slice(0, 10) };
|
|
64
|
+
saveConfig(targetRoot, config);
|
|
65
|
+
return { dest: DEST, source, sourcePath, sha, fileCount };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err && err.stderr) {
|
|
68
|
+
throw new Error(`git clone failed for ${gitUrl}: ${err.stderr.toString().trim()}`);
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
} finally {
|
|
72
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { syncCompany, DEFAULT_SOURCE, DEFAULT_SOURCE_PATH };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Minimal YAML frontmatter reader for SKILL.md files. Returns top-level scalar
|
|
2
|
+
// keys plus list keys (next_skills, sub_agents, inputs, outputs). Shared by the
|
|
3
|
+
// validator and the adapter/index generator.
|
|
4
|
+
function parseFrontmatter(content) {
|
|
5
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
6
|
+
if (!match) return null;
|
|
7
|
+
const lines = match[1].split("\n");
|
|
8
|
+
const data = {};
|
|
9
|
+
let currentList = null;
|
|
10
|
+
for (const line of lines) {
|
|
11
|
+
if (/^\s*-\s+/.test(line) && currentList) {
|
|
12
|
+
data[currentList].push(line.replace(/^\s*-\s+/, "").replace(/\s+#.*$/, "").trim());
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
16
|
+
if (!kv) continue;
|
|
17
|
+
const key = kv[1];
|
|
18
|
+
const value = kv[2].trim();
|
|
19
|
+
if (value === "" || value === "[]") {
|
|
20
|
+
data[key] = [];
|
|
21
|
+
currentList = value === "[]" ? null : key;
|
|
22
|
+
} else {
|
|
23
|
+
data[key] = value;
|
|
24
|
+
currentList = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { parseFrontmatter };
|
package/lib/migrate.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
// Migrate an older-model refact-os repo to the v2.3 substrate. Non-destructive:
|
|
5
|
+
// a file move never overwrites an existing destination file. Directory moves
|
|
6
|
+
// MERGE into the destination — the seed pre-creates the role dirs (each with a
|
|
7
|
+
// .gitkeep), so a wholesale rename would always be blocked and old chats would
|
|
8
|
+
// be stranded in the legacy folder. Instead we move the contents file-by-file,
|
|
9
|
+
// leaving anything already at the destination untouched. Returns the moves
|
|
10
|
+
// performed so the caller can report them.
|
|
11
|
+
|
|
12
|
+
// Old working-memory + raw-input layout -> v2.3 roles.
|
|
13
|
+
const FILE_MOVES = [
|
|
14
|
+
["context/decisions.md", "docs/decisions.md"],
|
|
15
|
+
["context/roles.md", "docs/context/people.md"],
|
|
16
|
+
["context/open-decisions.md", "docs/context/open-decisions.md"],
|
|
17
|
+
["context/learnings.md", "docs/context/learnings.md"],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const DIR_MOVES = [
|
|
21
|
+
["docs/emails", "docs/sources/raw/email"],
|
|
22
|
+
["docs/call-transcripts", "docs/sources/raw/call-transcripts"],
|
|
23
|
+
["docs/agent-transcripts", "docs/sources/raw/agent-transcripts"],
|
|
24
|
+
["docs/asana/closed", "docs/task/closed"],
|
|
25
|
+
["docs/asana", "docs/task/open"],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function pruneEmptyDir(dir) {
|
|
29
|
+
try {
|
|
30
|
+
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0) {
|
|
31
|
+
fs.rmdirSync(dir);
|
|
32
|
+
}
|
|
33
|
+
} catch (_err) {
|
|
34
|
+
// leave non-empty / undeletable dirs for the user
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Move a single file, but never clobber a file already at the destination.
|
|
39
|
+
function moveFileIfPresent(targetRoot, from, to) {
|
|
40
|
+
const src = path.join(targetRoot, from);
|
|
41
|
+
const dst = path.join(targetRoot, to);
|
|
42
|
+
if (!fs.existsSync(src)) return null;
|
|
43
|
+
if (fs.existsSync(dst)) return { from, to, skipped: "destination exists" };
|
|
44
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
45
|
+
fs.renameSync(src, dst);
|
|
46
|
+
return { from, to };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Recursively move files from src into dst without overwriting anything already
|
|
50
|
+
// there. Returns how many files were moved vs. left in place.
|
|
51
|
+
function mergeInto(src, dst) {
|
|
52
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
53
|
+
let moved = 0;
|
|
54
|
+
let kept = 0;
|
|
55
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
56
|
+
const s = path.join(src, entry.name);
|
|
57
|
+
const d = path.join(dst, entry.name);
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
const sub = mergeInto(s, d);
|
|
60
|
+
moved += sub.moved;
|
|
61
|
+
kept += sub.kept;
|
|
62
|
+
pruneEmptyDir(s);
|
|
63
|
+
} else if (fs.existsSync(d)) {
|
|
64
|
+
kept += 1; // non-destructive: keep the existing copy
|
|
65
|
+
} else {
|
|
66
|
+
fs.renameSync(s, d);
|
|
67
|
+
moved += 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { moved, kept };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Move a whole directory. Fast path is a plain rename when the destination is
|
|
74
|
+
// absent; otherwise merge file-by-file so old content lands in a seed-created
|
|
75
|
+
// role dir instead of being skipped.
|
|
76
|
+
function moveDirIfPresent(targetRoot, from, to) {
|
|
77
|
+
const src = path.join(targetRoot, from);
|
|
78
|
+
const dst = path.join(targetRoot, to);
|
|
79
|
+
if (!fs.existsSync(src)) return null;
|
|
80
|
+
if (!fs.existsSync(dst)) {
|
|
81
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
82
|
+
fs.renameSync(src, dst);
|
|
83
|
+
return { from, to };
|
|
84
|
+
}
|
|
85
|
+
const { moved, kept } = mergeInto(src, dst);
|
|
86
|
+
pruneEmptyDir(src);
|
|
87
|
+
if (moved === 0) {
|
|
88
|
+
return { from, to, skipped: kept > 0 ? "destination already has these files" : "destination exists, source empty" };
|
|
89
|
+
}
|
|
90
|
+
return { from, to, merged: moved };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function migrateRepo(targetRoot) {
|
|
94
|
+
const moved = [];
|
|
95
|
+
const skipped = [];
|
|
96
|
+
|
|
97
|
+
for (const [from, to] of DIR_MOVES) {
|
|
98
|
+
const r = moveDirIfPresent(targetRoot, from, to);
|
|
99
|
+
if (r && r.skipped) skipped.push(r);
|
|
100
|
+
else if (r) moved.push(r);
|
|
101
|
+
}
|
|
102
|
+
for (const [from, to] of FILE_MOVES) {
|
|
103
|
+
const r = moveFileIfPresent(targetRoot, from, to);
|
|
104
|
+
if (r && r.skipped) skipped.push(r);
|
|
105
|
+
else if (r) moved.push(r);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Clean up now-empty legacy roots.
|
|
109
|
+
for (const dir of ["context", "docs/asana"]) {
|
|
110
|
+
pruneEmptyDir(path.join(targetRoot, dir));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { moved, skipped };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { migrateRepo };
|