@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/scaffold.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const {
|
|
5
|
+
detectProjectType,
|
|
6
|
+
detectProjectTypes,
|
|
7
|
+
askProjectTypes,
|
|
8
|
+
normalizeProjectTypes,
|
|
9
|
+
primaryProjectType,
|
|
10
|
+
ensurePackageScripts,
|
|
11
|
+
} = require("./project-utils");
|
|
12
|
+
const {
|
|
13
|
+
loadConfig,
|
|
14
|
+
saveConfig,
|
|
15
|
+
configPath,
|
|
16
|
+
migrateFromAgents,
|
|
17
|
+
ensureStack,
|
|
18
|
+
getProjectTypes,
|
|
19
|
+
setAsanaProjectId,
|
|
20
|
+
askAsanaProjectId,
|
|
21
|
+
findMissingFields,
|
|
22
|
+
} = require("./refact-config");
|
|
23
|
+
const { generateAdapters } = require("./adapters");
|
|
24
|
+
|
|
25
|
+
const TEMPLATE_BASE = path.resolve(__dirname, "..", "templates", "base");
|
|
26
|
+
const TEMPLATE_OVERLAYS = path.resolve(__dirname, "..", "templates", "overlays");
|
|
27
|
+
const PKG_VERSION = require("../package.json").version;
|
|
28
|
+
|
|
29
|
+
// Names of the skills a given template tree (base or an overlay) ships. Used to
|
|
30
|
+
// build the "shipped skills" manifest recorded in .refact-os.json, which is what
|
|
31
|
+
// lets a later update prune skills the package has since removed or renamed —
|
|
32
|
+
// without ever touching skills the user authored (those are never in the list).
|
|
33
|
+
function listTemplateSkills(templateRoot) {
|
|
34
|
+
const dir = path.join(templateRoot, "agent", "skills");
|
|
35
|
+
if (!fs.existsSync(dir)) return [];
|
|
36
|
+
return fs
|
|
37
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
38
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")))
|
|
39
|
+
.map((e) => e.name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Project types that always carry a codebase — they pull in the `code` overlay
|
|
43
|
+
// (code-development, add-codebase) automatically. A plain `blank` repo opts in
|
|
44
|
+
// with `--overlay code` only if it's actually a codebase.
|
|
45
|
+
const CODE_TYPES = ["wordpress", "nextjs"];
|
|
46
|
+
|
|
47
|
+
// Files the user owns: created when missing, never overwritten on upgrade.
|
|
48
|
+
function isSkipIfExists(relDest) {
|
|
49
|
+
if (relDest === "agent/AGENTS.md" || relDest === "agent/CLAUDE.md") return true;
|
|
50
|
+
// Root AGENTS.md / CLAUDE.md: written when absent (greenfield gets the thin
|
|
51
|
+
// pointer), but never overwritten — an existing repo may already have a real
|
|
52
|
+
// root AGENTS.md (its own contract), and force-writing the pointer over it
|
|
53
|
+
// destroys content. Same treatment as agent/AGENTS.md and README.md.
|
|
54
|
+
if (relDest === "AGENTS.md" || relDest === "CLAUDE.md") return true;
|
|
55
|
+
if (relDest === "README.md" || relDest === ".env.example") return true;
|
|
56
|
+
if (relDest === "wp-cli.yml.example") return true;
|
|
57
|
+
if (relDest.startsWith("docs/")) return true;
|
|
58
|
+
if (relDest.endsWith(".gitkeep")) return true;
|
|
59
|
+
return false; // canonical, package-managed (the agent/ payload + adapters)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function destFor(relPath) {
|
|
63
|
+
if (relPath === "env.example") return ".env.example";
|
|
64
|
+
return relPath;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Copy a template tree (base or an overlay) into the target, honoring the
|
|
68
|
+
// user-owned skip rules. Returns the list of destination paths written.
|
|
69
|
+
function walkCopyTree(srcRoot, targetRoot, force, skip) {
|
|
70
|
+
const copied = [];
|
|
71
|
+
function rec(dir) {
|
|
72
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
73
|
+
const abs = path.join(dir, entry.name);
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
rec(abs);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const rel = path.relative(srcRoot, abs).split(path.sep).join("/");
|
|
79
|
+
if (rel === "gitignore") continue; // handled by ensureGitignoreEntries
|
|
80
|
+
if (typeof skip === "function" && skip(rel)) continue;
|
|
81
|
+
const dest = destFor(rel);
|
|
82
|
+
const writeForce = isSkipIfExists(dest) ? false : force;
|
|
83
|
+
const target = path.join(targetRoot, dest);
|
|
84
|
+
if (!writeForce && fs.existsSync(target)) continue;
|
|
85
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
86
|
+
fs.copyFileSync(abs, target);
|
|
87
|
+
if (dest.startsWith("agent/hooks/")) {
|
|
88
|
+
try {
|
|
89
|
+
fs.chmodSync(target, 0o755);
|
|
90
|
+
} catch (_err) {
|
|
91
|
+
// ignore on restricted filesystems
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
copied.push(dest);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
rec(srcRoot);
|
|
98
|
+
return copied;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ensureGitignoreEntries(targetRoot) {
|
|
102
|
+
const gitignorePath = path.join(targetRoot, ".gitignore");
|
|
103
|
+
const templateContent = fs.readFileSync(path.join(TEMPLATE_BASE, "gitignore"), "utf8");
|
|
104
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
105
|
+
fs.writeFileSync(gitignorePath, templateContent, "utf8");
|
|
106
|
+
return "created";
|
|
107
|
+
}
|
|
108
|
+
return "unchanged";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A repo is "brownfield" if it already has its own content beyond bootstrap
|
|
112
|
+
// files (README/LICENSE/package manifests) and dotfiles (.git, .github, …). On a
|
|
113
|
+
// brownfield repo, init adds only the agent layer and defers structure to the
|
|
114
|
+
// `adopt` skill, rather than imposing the docs/ seed and apps/ over existing dirs.
|
|
115
|
+
function repoHasOwnContent(targetRoot) {
|
|
116
|
+
const BOOTSTRAP = new Set([
|
|
117
|
+
"README.md", "README", "readme.md", "Readme.md",
|
|
118
|
+
"LICENSE", "LICENSE.md", "LICENSE.txt", "license",
|
|
119
|
+
"package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
|
|
120
|
+
"node_modules", "agent",
|
|
121
|
+
]);
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = fs.readdirSync(targetRoot, { withFileTypes: true });
|
|
125
|
+
} catch (_err) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return entries.some((e) => !e.name.startsWith(".") && !BOOTSTRAP.has(e.name));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ensureWordPressDirectories(targetRoot) {
|
|
132
|
+
const wpContentRoot = path.join(targetRoot, "apps", "wordpress", "wp-content");
|
|
133
|
+
fs.mkdirSync(wpContentRoot, { recursive: true });
|
|
134
|
+
for (const dir of ["plugins", "themes", "mu-plugins"]) {
|
|
135
|
+
const subdir = path.join(wpContentRoot, dir);
|
|
136
|
+
fs.mkdirSync(subdir, { recursive: true });
|
|
137
|
+
const keepFile = path.join(subdir, ".gitkeep");
|
|
138
|
+
if (!fs.existsSync(keepFile)) fs.writeFileSync(keepFile, "", "utf8");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function computeContractHash() {
|
|
143
|
+
const p = path.join(TEMPLATE_BASE, "agent", "AGENTS.md");
|
|
144
|
+
return crypto.createHash("sha256").update(fs.readFileSync(p)).digest("hex");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// agent/AGENTS.md is skipIfExists, so the user keeps their customized copy. We
|
|
148
|
+
// still flag when the upstream contract template shifts so they can merge by hand.
|
|
149
|
+
function trackContractChange(config) {
|
|
150
|
+
const currentHash = computeContractHash();
|
|
151
|
+
if (!config._scaffold || typeof config._scaffold !== "object") config._scaffold = {};
|
|
152
|
+
if (!config._scaffold.templateHashes || typeof config._scaffold.templateHashes !== "object") {
|
|
153
|
+
config._scaffold.templateHashes = {};
|
|
154
|
+
}
|
|
155
|
+
const storedHash = config._scaffold.templateHashes["agent/AGENTS.md"];
|
|
156
|
+
config._scaffold.templateHashes["agent/AGENTS.md"] = currentHash;
|
|
157
|
+
if (!storedHash) return { firstRun: true, changed: false };
|
|
158
|
+
return { firstRun: false, changed: storedHash !== currentHash };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function scaffoldProject(targetDir, options = {}) {
|
|
162
|
+
const resolvedTarget = path.resolve(targetDir || process.cwd());
|
|
163
|
+
const interactive = Boolean(options.interactive);
|
|
164
|
+
const force = options.force !== false;
|
|
165
|
+
const hadConfig = fs.existsSync(configPath(resolvedTarget));
|
|
166
|
+
|
|
167
|
+
const config = loadConfig(resolvedTarget);
|
|
168
|
+
const agentsMigration = migrateFromAgents(resolvedTarget, config);
|
|
169
|
+
|
|
170
|
+
const optionProjectTypes = Array.isArray(options.projectTypes)
|
|
171
|
+
? options.projectTypes
|
|
172
|
+
: options.projectTypes
|
|
173
|
+
? [options.projectTypes]
|
|
174
|
+
: [];
|
|
175
|
+
const explicitProjectTypes = normalizeProjectTypes([...optionProjectTypes, ...(options.projectType ? [options.projectType] : [])]);
|
|
176
|
+
const configTypes = getProjectTypes(config); // derived from `stack` keys (legacy keys already migrated above)
|
|
177
|
+
|
|
178
|
+
// Resolve the type list. An explicit (`--type`) or interactive choice is
|
|
179
|
+
// AUTHORITATIVE and replaces the stack; detection is only a *suggestion* for
|
|
180
|
+
// the prompt's default, never a silent commitment — so a stray wp-content dir
|
|
181
|
+
// can't lock a repo to wordpress before the user is even asked.
|
|
182
|
+
let projectTypes;
|
|
183
|
+
let userChose = false;
|
|
184
|
+
if (explicitProjectTypes.length > 0) {
|
|
185
|
+
projectTypes = explicitProjectTypes;
|
|
186
|
+
userChose = true;
|
|
187
|
+
} else if (interactive) {
|
|
188
|
+
const suggested = configTypes.length > 0 ? configTypes : detectProjectTypes(resolvedTarget);
|
|
189
|
+
projectTypes = await askProjectTypes(suggested.length > 0 ? suggested : ["blank"]);
|
|
190
|
+
userChose = true;
|
|
191
|
+
} else if (configTypes.length > 0) {
|
|
192
|
+
projectTypes = configTypes; // non-interactive re-run on a configured repo: keep it
|
|
193
|
+
} else {
|
|
194
|
+
projectTypes = ["blank"]; // non-interactive first scaffold (e.g. a CI run): minimal default
|
|
195
|
+
}
|
|
196
|
+
if (projectTypes.length === 0) projectTypes = ["blank"];
|
|
197
|
+
|
|
198
|
+
// `stack` is the single source of truth for the type list. An authoritative
|
|
199
|
+
// choice replaces it (so picking "blank" drops a previously-detected stack);
|
|
200
|
+
// otherwise types are merged, preserving any existing entry detail.
|
|
201
|
+
ensureStack(config, projectTypes, { replace: userChose });
|
|
202
|
+
projectTypes = getProjectTypes(config);
|
|
203
|
+
const projectType = primaryProjectType(projectTypes);
|
|
204
|
+
|
|
205
|
+
const asanaConfigured = "asana" in config && config.asana && "projectId" in config.asana;
|
|
206
|
+
if (!asanaConfigured) {
|
|
207
|
+
if (interactive) {
|
|
208
|
+
const raw = await askAsanaProjectId();
|
|
209
|
+
setAsanaProjectId(config, raw);
|
|
210
|
+
} else {
|
|
211
|
+
// Asana is optional. Record an explicit null so a non-interactive setup
|
|
212
|
+
// never leaves /refact blocked on a missing key; configure it later
|
|
213
|
+
// (interactively, by editing .refact-os.json, or via `/refact sync asana`).
|
|
214
|
+
setAsanaProjectId(config, "");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Brownfield vs greenfield. Decided once (at first init) and recorded, so a
|
|
219
|
+
// later `init`/update never suddenly imposes structure on an adopted repo.
|
|
220
|
+
// `--seed` (options.seed) forces the full scaffold and graduates a brownfield
|
|
221
|
+
// repo to it. On a brownfield repo we add ONLY the agent layer (base skills,
|
|
222
|
+
// root pointers, config, adapters) and defer the docs/ seed, apps/, and type
|
|
223
|
+
// overlays to the `adopt` skill, which reconciles them with existing dirs.
|
|
224
|
+
let brownfield;
|
|
225
|
+
if (options.seed) {
|
|
226
|
+
brownfield = false;
|
|
227
|
+
} else if (!hadConfig) {
|
|
228
|
+
brownfield = repoHasOwnContent(resolvedTarget);
|
|
229
|
+
} else {
|
|
230
|
+
brownfield = Boolean(config._scaffold && config._scaffold.brownfield);
|
|
231
|
+
}
|
|
232
|
+
if (!config._scaffold || typeof config._scaffold !== "object") config._scaffold = {};
|
|
233
|
+
config._scaffold.brownfield = brownfield;
|
|
234
|
+
|
|
235
|
+
// On a brownfield repo, skip the substrate seed (docs/) and convention files
|
|
236
|
+
// (.env.example) — they're not core agent payload, and an existing repo likely
|
|
237
|
+
// has its own env/docs conventions. `adopt` adds what's actually needed.
|
|
238
|
+
const brownfieldSkip = (rel) => rel.startsWith("docs/") || rel === "env.example";
|
|
239
|
+
const copied = walkCopyTree(TEMPLATE_BASE, resolvedTarget, force, brownfield ? brownfieldSkip : null);
|
|
240
|
+
// Track which skills the package ships THIS run (base + any applied overlays).
|
|
241
|
+
// Recorded as the shipped-skills manifest so a later update can prune cleanly.
|
|
242
|
+
const shippedSkills = new Set(listTemplateSkills(TEMPLATE_BASE));
|
|
243
|
+
// Overlays add only what a project genuinely needs. Sources:
|
|
244
|
+
// - one per project type in the stack (wordpress, nextjs) — greenfield only
|
|
245
|
+
// - `code` auto-applied when the stack has a code-bearing type — greenfield only
|
|
246
|
+
// - any explicit `--overlay <name>` (e.g. code, client) — applies ALWAYS, so an
|
|
247
|
+
// adopted (brownfield) repo can opt into a skill-pack on request.
|
|
248
|
+
const overlayNames = new Set();
|
|
249
|
+
if (!brownfield) {
|
|
250
|
+
for (const type of projectTypes) {
|
|
251
|
+
if (type !== "blank") overlayNames.add(type);
|
|
252
|
+
}
|
|
253
|
+
if (projectTypes.some((t) => CODE_TYPES.includes(t))) overlayNames.add("code");
|
|
254
|
+
}
|
|
255
|
+
for (const name of Array.isArray(options.overlays) ? options.overlays : []) {
|
|
256
|
+
const n = String(name || "").trim().toLowerCase();
|
|
257
|
+
if (n) overlayNames.add(n);
|
|
258
|
+
}
|
|
259
|
+
// On a brownfield repo, copy only the overlay's agent/ payload (skills/hooks/
|
|
260
|
+
// scripts) — never its convention/structure files (e.g. wp-cli.yml.example),
|
|
261
|
+
// same as the base seed. Greenfield gets the whole overlay.
|
|
262
|
+
const overlaySkip = brownfield ? (rel) => !rel.startsWith("agent/") : null;
|
|
263
|
+
for (const name of overlayNames) {
|
|
264
|
+
const overlayDir = path.join(TEMPLATE_OVERLAYS, name);
|
|
265
|
+
if (fs.existsSync(overlayDir)) {
|
|
266
|
+
copied.push(...walkCopyTree(overlayDir, resolvedTarget, force, overlaySkip));
|
|
267
|
+
for (const s of listTemplateSkills(overlayDir)) shippedSkills.add(s);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Safe prune: a skill the package shipped on a previous run but no longer ships
|
|
272
|
+
// (removed or renamed upstream) is deleted from the consumer's canonical
|
|
273
|
+
// agent/skills/. Only previously-shipped skills are eligible — user-authored
|
|
274
|
+
// skills are never in the manifest, so they're never touched. (Adapters are
|
|
275
|
+
// regenerated below, so the stale wrapper disappears too.)
|
|
276
|
+
const prevShipped = Array.isArray(config._scaffold && config._scaffold.shippedSkills)
|
|
277
|
+
? config._scaffold.shippedSkills
|
|
278
|
+
: [];
|
|
279
|
+
const prevVersion = config._scaffold && config._scaffold.version ? config._scaffold.version : null;
|
|
280
|
+
const prunedSkills = [];
|
|
281
|
+
for (const name of prevShipped) {
|
|
282
|
+
if (shippedSkills.has(name)) continue;
|
|
283
|
+
const skillDir = path.join(resolvedTarget, "agent", "skills", name);
|
|
284
|
+
if (fs.existsSync(skillDir)) {
|
|
285
|
+
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
286
|
+
prunedSkills.push(name);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
config._scaffold.version = PKG_VERSION;
|
|
290
|
+
config._scaffold.shippedSkills = [...shippedSkills].sort();
|
|
291
|
+
|
|
292
|
+
const contract = trackContractChange(config);
|
|
293
|
+
const adapters = generateAdapters(resolvedTarget, { tools: options.tools });
|
|
294
|
+
|
|
295
|
+
if (!brownfield && projectTypes.includes("wordpress")) {
|
|
296
|
+
ensureWordPressDirectories(resolvedTarget);
|
|
297
|
+
}
|
|
298
|
+
if (!brownfield) {
|
|
299
|
+
fs.mkdirSync(path.join(resolvedTarget, "apps"), { recursive: true });
|
|
300
|
+
}
|
|
301
|
+
const gitignoreStatus = ensureGitignoreEntries(resolvedTarget);
|
|
302
|
+
|
|
303
|
+
const configPathWritten = saveConfig(resolvedTarget, config);
|
|
304
|
+
const missingFields = findMissingFields(config);
|
|
305
|
+
const packageStatus = ensurePackageScripts(resolvedTarget);
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
target: resolvedTarget,
|
|
309
|
+
projectType,
|
|
310
|
+
projectTypes,
|
|
311
|
+
brownfield,
|
|
312
|
+
version: PKG_VERSION,
|
|
313
|
+
prevVersion,
|
|
314
|
+
prunedSkills,
|
|
315
|
+
filesWritten: copied.length,
|
|
316
|
+
adapters,
|
|
317
|
+
configPath: configPathWritten,
|
|
318
|
+
missingFields: missingFields.map((f) => f.path),
|
|
319
|
+
agentsMigration,
|
|
320
|
+
packageStatus,
|
|
321
|
+
gitignoreStatus,
|
|
322
|
+
contractChanged: contract.changed,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports = {
|
|
327
|
+
detectProjectType,
|
|
328
|
+
scaffoldProject,
|
|
329
|
+
};
|
package/lib/validate.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { listSkills, INDEX_THRESHOLD, readSkillIndex, skillFrontmatterSlice } = require("./adapters");
|
|
4
|
+
const { parseFrontmatter } = require("./frontmatter");
|
|
5
|
+
|
|
6
|
+
// The universal base. docs/deliverables/ is intentionally NOT here — it's
|
|
7
|
+
// earned/added by the `client` overlay, since internal/ops/research repos
|
|
8
|
+
// never ship external deliverables.
|
|
9
|
+
const REQUIRED_DIRS = [
|
|
10
|
+
"agent",
|
|
11
|
+
"agent/skills",
|
|
12
|
+
"docs",
|
|
13
|
+
"docs/sources/raw",
|
|
14
|
+
"docs/context",
|
|
15
|
+
"docs/task",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const REQUIRED_FILES = ["agent/AGENTS.md", "docs/index.md", "docs/decisions.md"];
|
|
19
|
+
|
|
20
|
+
const FORBIDDEN_DIRS = ["agent/workflows", "agent/evals"];
|
|
21
|
+
|
|
22
|
+
const REQUIRED_SKILL_FIELDS = ["name", "description", "when_to_use", "pattern"];
|
|
23
|
+
const VALID_PATTERNS = ["procedure", "orchestrator", "review"];
|
|
24
|
+
// Description-length warn bounds. The spec's sweet spot is ~100–200 chars; we
|
|
25
|
+
// only warn on egregious outliers (a near-empty description, or a paragraph
|
|
26
|
+
// that belongs in the body) so the signal stays trustworthy.
|
|
27
|
+
const DESC_MIN = 40;
|
|
28
|
+
const DESC_MAX = 300;
|
|
29
|
+
|
|
30
|
+
function validateRepo(targetRoot) {
|
|
31
|
+
const errors = [];
|
|
32
|
+
const warnings = [];
|
|
33
|
+
|
|
34
|
+
for (const dir of REQUIRED_DIRS) {
|
|
35
|
+
if (!fs.existsSync(path.join(targetRoot, dir))) errors.push(`Missing required directory: ${dir}/`);
|
|
36
|
+
}
|
|
37
|
+
for (const file of REQUIRED_FILES) {
|
|
38
|
+
if (!fs.existsSync(path.join(targetRoot, file))) errors.push(`Missing required file: ${file}`);
|
|
39
|
+
}
|
|
40
|
+
for (const dir of FORBIDDEN_DIRS) {
|
|
41
|
+
if (fs.existsSync(path.join(targetRoot, dir))) {
|
|
42
|
+
errors.push(`Forbidden folder ${dir}/ — move its contents into agent/skills/<name>/SKILL.md (orchestrator/review pattern).`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const skills = listSkills(targetRoot);
|
|
47
|
+
if (skills.length === 0 && fs.existsSync(path.join(targetRoot, "agent", "skills"))) {
|
|
48
|
+
warnings.push("agent/skills/ has no skills with a SKILL.md.");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const skillNames = new Set(skills);
|
|
52
|
+
for (const name of skills) {
|
|
53
|
+
const skillPath = path.join(targetRoot, "agent", "skills", name, "SKILL.md");
|
|
54
|
+
const fm = parseFrontmatter(fs.readFileSync(skillPath, "utf8"));
|
|
55
|
+
if (!fm) {
|
|
56
|
+
errors.push(`${name}: SKILL.md has no YAML frontmatter.`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const field of REQUIRED_SKILL_FIELDS) {
|
|
60
|
+
if (!fm[field] || (Array.isArray(fm[field]) && fm[field].length === 0)) {
|
|
61
|
+
errors.push(`${name}: missing required frontmatter field "${field}".`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (fm.name && fm.name !== name) {
|
|
65
|
+
warnings.push(`${name}: frontmatter name "${fm.name}" does not match folder "${name}".`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Pattern contract (orchestrator | procedure | review).
|
|
69
|
+
if (fm.pattern && !VALID_PATTERNS.includes(fm.pattern)) {
|
|
70
|
+
errors.push(`${name}: invalid pattern "${fm.pattern}" — must be one of ${VALID_PATTERNS.join(", ")}.`);
|
|
71
|
+
}
|
|
72
|
+
const nextSkills = fm.next_skills || [];
|
|
73
|
+
const subAgents = fm.sub_agents || [];
|
|
74
|
+
if (fm.pattern === "orchestrator" && nextSkills.length === 0 && subAgents.length === 0) {
|
|
75
|
+
errors.push(`${name}: orchestrator pattern must reference at least one other skill via next_skills or sub_agents.`);
|
|
76
|
+
}
|
|
77
|
+
if (fm.pattern === "review" && nextSkills.length > 0) {
|
|
78
|
+
warnings.push(`${name}: review pattern usually declares next_skills: [] (it judges rather than chains).`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// requires_approval is optional but must be boolean when present.
|
|
82
|
+
if ("requires_approval" in fm && !["true", "false"].includes(String(fm.requires_approval))) {
|
|
83
|
+
errors.push(`${name}: requires_approval must be true or false (got "${fm.requires_approval}").`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Resolver-slice quality (warn-level). These fields are read at selection
|
|
87
|
+
// time, so keep them present and tight; noise here trains people to ignore
|
|
88
|
+
// warnings, hence the deliberately lenient bounds.
|
|
89
|
+
if (!("next_skills" in fm)) {
|
|
90
|
+
warnings.push(`${name}: no next_skills key — declare next_skills: [] explicitly so a terminal chain reads as intentional, not forgotten.`);
|
|
91
|
+
}
|
|
92
|
+
if (fm.description) {
|
|
93
|
+
const len = String(fm.description).length;
|
|
94
|
+
if (len < DESC_MIN) {
|
|
95
|
+
warnings.push(`${name}: description is very short (${len} chars) — it should convey the trigger, not just restate the name.`);
|
|
96
|
+
} else if (len > DESC_MAX) {
|
|
97
|
+
warnings.push(`${name}: description is ${len} chars — aim for the ~100–200 sweet spot; move detail into when_to_use or the body (it's read at every skill selection).`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const ref of nextSkills) {
|
|
102
|
+
if (ref && !skillNames.has(ref)) {
|
|
103
|
+
errors.push(`${name}: next_skills references "${ref}" which does not exist.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const ref of subAgents) {
|
|
107
|
+
if (ref && !skillNames.has(ref)) {
|
|
108
|
+
warnings.push(`${name}: sub_agents references "${ref}" which is not a local skill.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Derived skill index (large catalogs): if present, it must match the skills
|
|
114
|
+
// on disk and the catalog must actually be over the threshold.
|
|
115
|
+
const index = readSkillIndex(targetRoot);
|
|
116
|
+
if (index) {
|
|
117
|
+
if (index._invalid) {
|
|
118
|
+
errors.push("agent/skills/.index.json is not valid JSON. Run `npm run refact:sync`.");
|
|
119
|
+
} else {
|
|
120
|
+
// Full-slice compare, not just names: a changed when_to_use / when_not_to_use /
|
|
121
|
+
// next_skills with no re-sync is real resolver drift the agent would act on.
|
|
122
|
+
const expected = skills.map((n) => skillFrontmatterSlice(targetRoot, n));
|
|
123
|
+
const indexed = index.skills || [];
|
|
124
|
+
if (JSON.stringify(indexed) !== JSON.stringify(expected)) {
|
|
125
|
+
errors.push("Skill index drift: agent/skills/.index.json does not match agent/skills/ frontmatter. Run `npm run refact:sync`.");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} else if (skills.length >= INDEX_THRESHOLD) {
|
|
129
|
+
warnings.push(`agent/skills/ has ${skills.length} skills (>= ${INDEX_THRESHOLD}) but no generated index. Run \`npm run refact:sync\` to build one.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Adapters in sync: every canonical skill should have a generated .cursor wrapper.
|
|
133
|
+
const cursorSkills = path.join(targetRoot, ".cursor", "skills");
|
|
134
|
+
if (fs.existsSync(cursorSkills)) {
|
|
135
|
+
for (const name of skills) {
|
|
136
|
+
if (!fs.existsSync(path.join(cursorSkills, name, "SKILL.md"))) {
|
|
137
|
+
warnings.push(`Adapter drift: .cursor/skills/${name}/ missing. Run \`npm run refact:sync\`.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { ok: errors.length === 0, errors, warnings, skillCount: skills.length };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { validateRepo, parseFrontmatter };
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@refactco/refact-os",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Installable scaffolder for the agent-first repo standard: minimum-seed substrate + canonical agent/ skill catalog with generated tool adapters",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"agents",
|
|
8
|
+
"agents-md",
|
|
9
|
+
"agent-first",
|
|
10
|
+
"scaffolder",
|
|
11
|
+
"ai",
|
|
12
|
+
"claude-code",
|
|
13
|
+
"cursor",
|
|
14
|
+
"skills",
|
|
15
|
+
"developer-tools"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Refact (https://refact.co)",
|
|
19
|
+
"homepage": "https://github.com/refactco/refact-os#readme",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/refactco/refact-os.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/refactco/refact-os/issues"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"lib",
|
|
33
|
+
"templates",
|
|
34
|
+
"README.md",
|
|
35
|
+
"CHANGELOG.md"
|
|
36
|
+
],
|
|
37
|
+
"bin": {
|
|
38
|
+
"refact-os": "bin/refact-os.js"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"init": "node ./bin/refact-os.js init"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
> Thin pointer. The canonical contract for this engagement is [`agent/AGENTS.md`](agent/AGENTS.md).
|
|
4
|
+
|
|
5
|
+
- **Contract** (stack, hard rules, growth triggers): [`agent/AGENTS.md`](agent/AGENTS.md)
|
|
6
|
+
- **Project index** (where everything lives): [`docs/index.md`](docs/index.md)
|
|
7
|
+
- **Available moves** (skills): read each `SKILL.md` frontmatter under [`agent/skills/`](agent/skills/)
|
|
8
|
+
|
|
9
|
+
`.cursor/` and `.claude/` are generated from `agent/` — never edit them by hand.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# <TODO: project name>
|
|
2
|
+
|
|
3
|
+
> <TODO: one-line description of this engagement.>
|
|
4
|
+
|
|
5
|
+
A Refact engagement scaffolded by [`refact-os`](https://github.com/refactco/refact-os). The entry point for agents is [`agent/AGENTS.md`](agent/AGENTS.md); the project map is [`docs/index.md`](docs/index.md).
|
|
6
|
+
|
|
7
|
+
## Quickstart
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
cp .env.example .env # fill in per-user tokens
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Where things live
|
|
15
|
+
|
|
16
|
+
| Path | What's in it |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `agent/` | Canonical agent contract, skills, hooks, scripts. `.cursor/` and `.claude/` are generated from here. |
|
|
19
|
+
| `docs/sources/raw/` | Evidence — inbound material as received (email, transcripts, agent chats). |
|
|
20
|
+
| `docs/context/`, `docs/decisions.md` | Knowledge — curated truth, decisions, open questions, learnings. |
|
|
21
|
+
| `docs/task/` | Task — tickets (`open/`, `closed/`). |
|
|
22
|
+
| `docs/deliverables/` | Output — reviewed, ready-to-share artifacts. |
|
|
23
|
+
| `apps/` | Product code, tracked as part of this engagement. |
|
|
24
|
+
| `.refact-os.json` | Scaffold metadata: project `stack` (types, hosting, runtime, per-env deploy config) and Asana project ID. |
|
|
25
|
+
|
|
26
|
+
## Common commands
|
|
27
|
+
|
|
28
|
+
In Cursor / Claude Code, via the `refact` skill:
|
|
29
|
+
|
|
30
|
+
- `/refact init` — post-scaffold checklist: complete `.refact-os.json` (stack types, hosting, runtime, environments), create `.env`, set up git, etc.
|
|
31
|
+
- `/refact process docs` — walk new files under `docs/sources/raw/`, update `docs/context/`, mark them processed.
|
|
32
|
+
- `/refact status` — show what's unprocessed and which decisions are open.
|
|
33
|
+
- `/refact get chat history` — sync agent chats into `docs/sources/raw/agent-transcripts/`.
|
|
34
|
+
- `/refact sync asana` — pull Asana tickets into `docs/task/`.
|
|
35
|
+
- `/refact update the package` — bump or reinstall `refact-os` itself.
|
|
36
|
+
|
|
37
|
+
In the shell:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm run refact:sync # regenerate .cursor/ and .claude/ from agent/
|
|
41
|
+
npm run refact:validate # check structure + skill frontmatter
|
|
42
|
+
npm run refact:update # re-pull the refact-os package payload
|
|
43
|
+
npm run chats:import # import agent chat history
|
|
44
|
+
npm run asana:sync # sync Asana tickets
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
> `refact:*` are backed by the `refact-os` devDependency. If one reports `command not found`, run `npm install` once; for subcommands without an alias use `npx refact-os <cmd>` (e.g. `npx refact-os sync company`).
|
|
48
|
+
|
|
49
|
+
## Conventions
|
|
50
|
+
|
|
51
|
+
- Decisions go in `docs/decisions.md` with the source data they were based on.
|
|
52
|
+
- Pending decisions go in `docs/context/open-decisions.md` with a responsible person.
|
|
53
|
+
- Per-user secrets live in `.env` (gitignored). Template: `.env.example`.
|
|
54
|
+
- Never edit `.cursor/` or `.claude/` by hand — change `agent/` and run `npm run refact:sync`.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
> The canonical contract for this engagement. Map, not a manual — if you need detail, follow the link or load the relevant skill.
|
|
4
|
+
>
|
|
5
|
+
> Stack, hosting, and per-environment deploy config live in `.refact-os.json` — run `/refact init` to fill them in.
|
|
6
|
+
|
|
7
|
+
## What this is
|
|
8
|
+
|
|
9
|
+
A Refact engagement scaffolded by `refact-os`. This file is the shared agent contract; `docs/` is the substrate (evidence, knowledge, task, output) and `agent/skills/` is the move set. Tool-native folders (`.cursor/`, `.claude/`) are **generated** from this `agent/` directory — never edit them by hand.
|
|
10
|
+
|
|
11
|
+
## Where to find what
|
|
12
|
+
|
|
13
|
+
| You need… | Go to |
|
|
14
|
+
|---|---|
|
|
15
|
+
| The project index / canonical record | `docs/index.md` |
|
|
16
|
+
| Inbound material as received (evidence) | `docs/sources/raw/` |
|
|
17
|
+
| Curated truth (knowledge) | `docs/context/`, `docs/decisions.md` |
|
|
18
|
+
| Company context (brand, team, pitch) | `docs/context/company/` — synced from `refact-operation` via `npx refact-os sync company`; do not edit here |
|
|
19
|
+
| In-progress work (tickets) | `docs/task/` (`open/`, `closed/` emerge as tickets accumulate) |
|
|
20
|
+
| Shipped artifacts | `docs/deliverables/` — present only on client-facing engagements (`client` overlay) |
|
|
21
|
+
| Product code | `apps/` — present only when the repo has code (`code` overlay) |
|
|
22
|
+
| Stack, hosting & per-env deploy config | `.refact-os.json` — keyed by project type (the keys are the type list); each holds `hosting`, `runtime`, and `environments` |
|
|
23
|
+
| What moves the agent can make | `agent/skills/` (read each `SKILL.md` frontmatter) |
|
|
24
|
+
|
|
25
|
+
## Hard rules (never)
|
|
26
|
+
|
|
27
|
+
1. Never commit secrets — `.env` is gitignored; per-user tokens stay there.
|
|
28
|
+
2. Never disable auth/capability/sanitization to "make it work".
|
|
29
|
+
3. Never push directly to `main` or force-push to `main`; confirm QA approved on staging before any production promotion.
|
|
30
|
+
4. Never record a finalized decision in `docs/decisions.md` without the source data it was based on (see the file's entry format).
|
|
31
|
+
5. Never edit generated tool folders (`.cursor/`, `.claude/`) by hand — change `agent/` and re-run `npm run refact:sync`.
|
|
32
|
+
6. Always default to the most agentic-first path: the right answer to "how do I do this?" is to connect to the service and do it yourself, not to hand work back to a human. When blocked by missing access, never stop at "I can't" — give the human the exact steps to grant it (which CLI to authenticate, which token/scope to set, which integration to connect) and proceed once granted. Solutions that need human intervention are less favorable than ones you can run end-to-end. Where a self-service path carries real risk, add the check-and-balance instead of stopping (surface the diff for sign-off, gate risky writes, treat untrusted sources cautiously).
|
|
33
|
+
|
|
34
|
+
## Structure growth (the agent owns this past the seed)
|
|
35
|
+
|
|
36
|
+
The seed is small and fixed. Grow it as content earns it:
|
|
37
|
+
|
|
38
|
+
- New inbound material → invoke `ingest-input` (saves to `docs/sources/raw/`).
|
|
39
|
+
- First ticket → `docs/task/open/<yyyy-mm-dd>-<slug>.md`.
|
|
40
|
+
- First draft of an artifact → `docs/internal/<type>/<slug>.md` (create `internal/` on the fly).
|
|
41
|
+
- Draft approved → promote it to `docs/deliverables/` and flip `status: sent` (the `create-deliverable` skill, from the `client` overlay, does this).
|
|
42
|
+
- Canonical truth worth pinning down → create the canonical record file and link it from `docs/index.md`.
|
|
43
|
+
- `docs/decisions.md` gets painful to scan → split by date range (`docs/decisions/2026-Q1.md`), not by topic.
|
|
44
|
+
- Same move executed a **third** time → capture it as a skill under `agent/skills/<verb-object>/` (set `pattern:`), then run `npm run refact:sync`. (The second time, just note it — you don't yet know which variations matter.)
|
|
45
|
+
- Writing that skill → make it *process, not content* (read project facts from `docs/`, don't bake them in); put any counting/scanning/parsing in a `scripts/` helper the skill runs, so the model interprets results rather than enumerating by hand; and declare the full resolver frontmatter (`when_to_use`, `when_not_to_use`, `next_skills` — `[]` if terminal). Reach for a script the skill calls, not a new always-on tool.
|
|
46
|
+
|
|
47
|
+
## Branches & PRs
|
|
48
|
+
|
|
49
|
+
- Branches: `feat/<ticket>-<slug>`, `fix/<ticket>-<slug>`, `chore/<slug>`.
|
|
50
|
+
- PR titles: Conventional Commits. On repos with code, changes go through the `code-development` skill (`code` overlay).
|
|
51
|
+
|
|
52
|
+
## Tooling (`refact-os`)
|
|
53
|
+
|
|
54
|
+
- `npm run refact:sync` (regenerate `.cursor/`/`.claude/` from `agent/`), `npm run refact:validate` (check structure + skill frontmatter), `npm run refact:update` (re-pull the package payload). These are npm scripts backed by the `refact-os` devDependency.
|
|
55
|
+
- If a script errors with `refact-os: command not found` (or `Missing script`), the package isn't installed yet — run `npm install` once, then retry. To run a subcommand without a script alias (e.g. `sync company`), use `npx refact-os sync company`.
|
|
56
|
+
|
|
57
|
+
## When unsure
|
|
58
|
+
|
|
59
|
+
- Ask one focused clarifying question instead of guessing on scope, naming, or role.
|
|
60
|
+
- For decisions affecting client data or billing: stop, log it in `docs/context/open-decisions.md` with a responsible person, and wait for sign-off.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
Claude Code entry point for this engagement. The canonical contract is [AGENTS.md](./AGENTS.md) in this same folder — read it first.
|
|
4
|
+
|
|
5
|
+
Skills live in `agent/skills/` (canonical) and are mirrored into `.claude/skills/` (generated). Discover available moves by reading each `SKILL.md`'s frontmatter; load a body only when you select that skill.
|
|
6
|
+
|
|
7
|
+
Do not edit `.claude/` or `.cursor/` by hand — they are generated from `agent/`. Change `agent/` and run `npm run refact:sync`.
|