loop-engineering 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ // audit.mjs — scores a project's loop-readiness 0-100 against this skill's spec format.
2
+ //
3
+ // Rubric (documented here, not just in code, so the score is explainable):
4
+ // 20 pts — LOOP_SPEC.md exists
5
+ // 30 pts — spec passes lint_spec.mjs (5 pts removed per category of issue: missing
6
+ // section, placeholder content, weak verification, weak termination, weak
7
+ // scope, weak escalation — capped at 0)
8
+ // 15 pts — at least one run has been recorded in .loop/state.json
9
+ // 15 pts — most recent run did not end in an unresolved stop-escalate
10
+ // (i.e. either it succeeded, or a later run after it succeeded)
11
+ // 10 pts — skill is installed for at least one supported tool in this project
12
+ // 10 pts — project is a git repo (no-progress detection is more reliable with
13
+ // real git-diff signal than the content-hash fallback)
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { execSync } from "node:child_process";
18
+ import { TOOLS } from "./detect.mjs";
19
+
20
+ function scoreSpecExists(root) {
21
+ const specPath = join(root, "LOOP_SPEC.md");
22
+ return { points: existsSync(specPath) ? 20 : 0, max: 20, label: "LOOP_SPEC.md exists", specPath };
23
+ }
24
+
25
+ function scoreLint(root, lintModulePath) {
26
+ const specPath = join(root, "LOOP_SPEC.md");
27
+ if (!existsSync(specPath)) {
28
+ return { points: 0, max: 30, label: "Spec passes lint", detail: "no spec to lint" };
29
+ }
30
+ try {
31
+ execSync(`node "${lintModulePath}" "${specPath}"`, { stdio: "pipe" });
32
+ return { points: 30, max: 30, label: "Spec passes lint", detail: "PASS" };
33
+ } catch (err) {
34
+ const stderr = err.stderr ? err.stderr.toString() : "";
35
+ const issueCount = (stderr.match(/^\s+-/gm) || []).length || 1;
36
+ const deduction = Math.min(30, issueCount * 5);
37
+ return {
38
+ points: Math.max(0, 30 - deduction),
39
+ max: 30,
40
+ label: "Spec passes lint",
41
+ detail: `${issueCount} issue(s) reported by linter`,
42
+ };
43
+ }
44
+ }
45
+
46
+ function scoreRunHistory(root) {
47
+ const statePath = join(root, ".loop", "state.json");
48
+ if (!existsSync(statePath)) {
49
+ return { points: 0, max: 15, label: "Run history exists", detail: "no .loop/state.json" };
50
+ }
51
+ try {
52
+ const state = JSON.parse(readFileSync(statePath, "utf-8"));
53
+ const hasAnyRun = Object.values(state).some((entry) => entry.iterations && entry.iterations.length > 0);
54
+ return {
55
+ points: hasAnyRun ? 15 : 0,
56
+ max: 15,
57
+ label: "Run history exists",
58
+ detail: hasAnyRun ? "at least one recorded run" : "state file present but empty",
59
+ };
60
+ } catch {
61
+ return { points: 0, max: 15, label: "Run history exists", detail: "state.json unreadable" };
62
+ }
63
+ }
64
+
65
+ function scoreResolvedRuns(root) {
66
+ const statePath = join(root, ".loop", "state.json");
67
+ if (!existsSync(statePath)) {
68
+ return { points: 0, max: 15, label: "Last run resolved cleanly", detail: "no run history" };
69
+ }
70
+ try {
71
+ const state = JSON.parse(readFileSync(statePath, "utf-8"));
72
+ const entries = Object.values(state);
73
+ if (entries.length === 0) {
74
+ return { points: 0, max: 15, label: "Last run resolved cleanly", detail: "no run history" };
75
+ }
76
+ // "Resolved cleanly" here means: the most recent iteration recorded for any tracked
77
+ // spec exited 0. We can't see an explicit "escalated" flag in state.json (run_loop.mjs
78
+ // determines that at decision time, not storage time), so exitCode 0 on the latest
79
+ // iteration is the best proxy available from stored state alone.
80
+ const allResolved = entries.every((entry) => {
81
+ const last = entry.iterations[entry.iterations.length - 1];
82
+ return last && last.exitCode === 0;
83
+ });
84
+ return {
85
+ points: allResolved ? 15 : 0,
86
+ max: 15,
87
+ label: "Last run resolved cleanly",
88
+ detail: allResolved ? "most recent iteration succeeded" : "most recent iteration did not succeed",
89
+ };
90
+ } catch {
91
+ return { points: 0, max: 15, label: "Last run resolved cleanly", detail: "state.json unreadable" };
92
+ }
93
+ }
94
+
95
+ function scoreToolInstalled(root) {
96
+ let anyInstalled = false;
97
+ const found = [];
98
+ for (const [id, tool] of Object.entries(TOOLS)) {
99
+ const paths = tool.installPaths(root);
100
+ for (const p of paths) {
101
+ const checkPath = typeof p === "string" ? p : p.path;
102
+ if (existsSync(checkPath)) {
103
+ anyInstalled = true;
104
+ found.push(id);
105
+ }
106
+ }
107
+ }
108
+ return {
109
+ points: anyInstalled ? 10 : 0,
110
+ max: 10,
111
+ label: "Skill installed for at least one tool",
112
+ detail: found.length > 0 ? found.join(", ") : "none found",
113
+ };
114
+ }
115
+
116
+ function scoreGitRepo(root) {
117
+ try {
118
+ execSync("git rev-parse --is-inside-work-tree", { cwd: root, stdio: "pipe" });
119
+ return { points: 10, max: 10, label: "Project is a git repo", detail: "git detected — reliable no-progress signal" };
120
+ } catch {
121
+ return { points: 0, max: 10, label: "Project is a git repo", detail: "no git — falls back to content-hash signal" };
122
+ }
123
+ }
124
+
125
+ export function auditProject(root, lintModulePath) {
126
+ const checks = [
127
+ scoreSpecExists(root),
128
+ scoreLint(root, lintModulePath),
129
+ scoreRunHistory(root),
130
+ scoreResolvedRuns(root),
131
+ scoreToolInstalled(root),
132
+ scoreGitRepo(root),
133
+ ];
134
+ const total = checks.reduce((sum, c) => sum + c.points, 0);
135
+ const max = checks.reduce((sum, c) => sum + c.max, 0);
136
+ return { score: total, max, checks };
137
+ }
@@ -0,0 +1,72 @@
1
+ // cost.mjs — rough token cost estimate for running a loop spec, BEFORE committing to a run.
2
+ //
3
+ // This is deliberately a rough range, not a precise prediction — token usage depends on
4
+ // the specific model, how much surrounding context the agent's harness re-reads each
5
+ // turn, and how verbose the verification output is. The goal is to catch "this will burn
6
+ // through my plan before lunch" before it happens, not to bill accurately.
7
+ //
8
+ // Model (stated so it can be argued with):
9
+ // per-iteration tokens ≈ SKILL_OVERHEAD (re-reading SKILL.md + the relevant reference
10
+ // file, ~1 time per session, amortized) + SPEC_TOKENS (the spec itself, read every
11
+ // iteration) + VERIFIER_OUTPUT_TOKENS (stdout/stderr fed back, varies a lot by command)
12
+ // + AGENT_EDIT_TOKENS (the actual code-editing turn — by far the most variable and
13
+ // the dominant cost on anything beyond a trivial fix)
14
+ //
15
+ // Defaults below are deliberately conservative (biased toward overestimating) since the
16
+ // failure mode of underestimating is worse (surprise bill) than overestimating (caution).
17
+
18
+ const DEFAULTS = {
19
+ specTokens: 400, // a loop-ready spec with all 5 sections, read every iteration
20
+ verifierOutputTokens: 300, // typical test/build/lint stdout+stderr on failure
21
+ agentEditTokensPerIteration: 2000, // conservative floor for "read failure, make a real edit"
22
+ skillOverheadTokens: 1500, // SKILL.md + one reference file, paid once per session not per iteration
23
+ };
24
+
25
+ /**
26
+ * @param {object} opts
27
+ * @param {number} opts.maxIterations — from the spec's Termination section
28
+ * @param {number} [opts.specTokens]
29
+ * @param {number} [opts.verifierOutputTokens]
30
+ * @param {number} [opts.agentEditTokensPerIteration]
31
+ * @param {number} [opts.skillOverheadTokens]
32
+ */
33
+ export function estimateCost(opts) {
34
+ const {
35
+ maxIterations,
36
+ specTokens = DEFAULTS.specTokens,
37
+ verifierOutputTokens = DEFAULTS.verifierOutputTokens,
38
+ agentEditTokensPerIteration = DEFAULTS.agentEditTokensPerIteration,
39
+ skillOverheadTokens = DEFAULTS.skillOverheadTokens,
40
+ } = opts;
41
+
42
+ if (!maxIterations || maxIterations < 1) {
43
+ throw new Error("maxIterations must be a positive integer (read it from the spec's Termination section)");
44
+ }
45
+
46
+ const perIteration = specTokens + verifierOutputTokens + agentEditTokensPerIteration;
47
+ // Best case: succeeds on iteration 1. Worst case: runs the full cap.
48
+ const lowEstimate = skillOverheadTokens + perIteration * 1;
49
+ const highEstimate = skillOverheadTokens + perIteration * maxIterations;
50
+
51
+ return {
52
+ maxIterations,
53
+ perIterationTokens: perIteration,
54
+ lowEstimateTokens: lowEstimate,
55
+ highEstimateTokens: highEstimate,
56
+ assumptions: {
57
+ specTokens,
58
+ verifierOutputTokens,
59
+ agentEditTokensPerIteration,
60
+ skillOverheadTokens,
61
+ },
62
+ note: "Rough range, not a prediction. agentEditTokensPerIteration is the dominant and most variable term — override it with --edit-tokens if you have a sense of how big the actual changes will be.",
63
+ };
64
+ }
65
+
66
+ export function extractMaxIterationsFromSpec(specContent) {
67
+ const re = /(?:^|\n)##\s+Termination\s*\n([\s\S]*?)(?=\n##\s+|$)/;
68
+ const m = specContent.match(re);
69
+ if (!m) return null;
70
+ const numMatch = m[1].match(/max\s+iterations?\s*:?\s*(\d+)/i);
71
+ return numMatch ? parseInt(numMatch[1], 10) : null;
72
+ }
@@ -0,0 +1,108 @@
1
+ // detect.mjs — detects which AI coding tool directories exist in a project,
2
+ // and maps each supported tool to where this skill should be installed for it.
3
+ //
4
+ // Detection is presence-based (does the tool's config dir/file already exist?),
5
+ // not capability-based — we don't probe whether the tool is actually installed
6
+ // on the machine, just whether the project has already been touched by it.
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+
12
+ // Each entry: id, human label, a "signal" path (relative to project root) that indicates
13
+ // the tool is already in use in this project, and the install path(s) for the skill.
14
+ export const TOOLS = {
15
+ "claude-code": {
16
+ label: "Claude Code",
17
+ signal: (root) => join(root, ".claude"),
18
+ installPaths: (root) => [join(root, ".claude", "skills", "loop-engineering")],
19
+ },
20
+ codex: {
21
+ label: "Codex",
22
+ // .agents/skills is the specific convention (bare .agents/ is too generic a dirname
23
+ // to safely infer Codex usage from).
24
+ signal: (root) => join(root, ".agents", "skills"),
25
+ installPaths: (root) => [
26
+ join(root, ".agents", "skills", "loop-engineering"),
27
+ join(homedir(), ".codex", "skills", "loop-engineering"),
28
+ ],
29
+ },
30
+ windsurf: {
31
+ label: "Windsurf",
32
+ signal: (root) => join(root, ".windsurf"),
33
+ installPaths: (root) => [join(root, ".windsurf", "skills", "loop-engineering")],
34
+ },
35
+ antigravity: {
36
+ label: "Antigravity",
37
+ // No reliable per-PROJECT signal exists for Antigravity — its skills dir is global
38
+ // (~/.gemini/antigravity), so "does it exist" tells us about the machine, not this
39
+ // project, and would false-positive for any project once Antigravity has been used
40
+ // anywhere. Excluded from auto-detection; only included via explicit --tool or --all.
41
+ signal: () => null,
42
+ installPaths: () => [join(homedir(), ".gemini", "antigravity", "skills", "loop-engineering")],
43
+ },
44
+ cursor: {
45
+ label: "Cursor",
46
+ signal: (root) => join(root, ".cursor"),
47
+ installPaths: (root) => [
48
+ // Cursor has no skills mechanism — command + a script copy live separately.
49
+ { kind: "cursor-command", path: join(root, ".cursor", "commands", "loop.md") },
50
+ { kind: "cursor-scripts", path: join(root, ".loop", "scripts") },
51
+ ],
52
+ },
53
+ kiro: {
54
+ label: "Kiro",
55
+ signal: (root) => join(root, ".kiro"),
56
+ installPaths: (root) => [
57
+ // Kiro uses "Steering" files — individual markdown files in .kiro/steering/.
58
+ { kind: "kiro-steering", path: join(root, ".kiro", "steering", "loop-engineering.md") },
59
+ { kind: "kiro-scripts", path: join(root, ".loop", "scripts") },
60
+ ],
61
+ },
62
+ trae: {
63
+ label: "Trae",
64
+ signal: (root) => join(root, ".trae"),
65
+ installPaths: (root) => [
66
+ // Trae uses "Rules" — individual markdown files in .trae/rules/.
67
+ { kind: "trae-rule", path: join(root, ".trae", "rules", "loop-engineering.md") },
68
+ { kind: "trae-scripts", path: join(root, ".loop", "scripts") },
69
+ ],
70
+ },
71
+ opencode: {
72
+ label: "OpenCode",
73
+ signal: (root) => join(root, ".opencode"),
74
+ installPaths: (root) => [
75
+ join(root, ".opencode", "skills", "loop-engineering"),
76
+ join(homedir(), ".config", "opencode", "skills", "loop-engineering"),
77
+ ],
78
+ },
79
+ rovodev: {
80
+ label: "Rovodev",
81
+ signal: (root) => join(root, ".rovodev"),
82
+ installPaths: (root) => [
83
+ join(root, ".rovodev", "skills", "loop-engineering"),
84
+ join(homedir(), ".rovodev", "skills", "loop-engineering"),
85
+ ],
86
+ },
87
+ qoder: {
88
+ label: "Qoder",
89
+ signal: (root) => join(root, ".qoder"),
90
+ installPaths: (root) => [
91
+ join(root, ".qoder", "skills", "loop-engineering"),
92
+ join(homedir(), ".qoder", "skills", "loop-engineering"),
93
+ ],
94
+ },
95
+ };
96
+
97
+ export function detectTools(root) {
98
+ const detected = [];
99
+ for (const [id, tool] of Object.entries(TOOLS)) {
100
+ const signalPath = tool.signal(root);
101
+ if (signalPath && existsSync(signalPath)) detected.push(id);
102
+ }
103
+ return detected;
104
+ }
105
+
106
+ export function allToolIds() {
107
+ return Object.keys(TOOLS);
108
+ }
@@ -0,0 +1,112 @@
1
+ // install.mjs — copies the skill payload to wherever a given tool expects it.
2
+
3
+ import { existsSync, mkdirSync, cpSync, copyFileSync, chmodSync, readdirSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { TOOLS } from "./detect.mjs";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ // This file lives at <package>/src/lib/install.mjs — the skill payload is at <package>/skill.
10
+ const SKILL_SRC = join(__dirname, "..", "..", "skill");
11
+
12
+ function copySkillDir(destDir) {
13
+ if (existsSync(destDir)) {
14
+ return { installed: false, path: destDir, reason: "already exists" };
15
+ }
16
+ mkdirSync(dirname(destDir), { recursive: true });
17
+ cpSync(SKILL_SRC, destDir, { recursive: true });
18
+ const scriptsDir = join(destDir, "scripts");
19
+ if (existsSync(scriptsDir)) {
20
+ for (const f of readdirSync(scriptsDir)) {
21
+ if (f.endsWith(".mjs")) chmodSync(join(scriptsDir, f), 0o755);
22
+ }
23
+ }
24
+ return { installed: true, path: destDir };
25
+ }
26
+
27
+ function installScriptsOnly(destDir) {
28
+ if (existsSync(destDir)) {
29
+ return { installed: false, path: destDir, reason: "already exists" };
30
+ }
31
+ mkdirSync(destDir, { recursive: true });
32
+ for (const f of readdirSync(join(SKILL_SRC, "scripts"))) {
33
+ copyFileSync(join(SKILL_SRC, "scripts", f), join(destDir, f));
34
+ chmodSync(join(destDir, f), 0o755);
35
+ }
36
+ return { installed: true, path: destDir };
37
+ }
38
+
39
+ function installSingleFile(srcFile, destFile) {
40
+ if (existsSync(destFile)) {
41
+ return { installed: false, path: destFile, reason: "already exists" };
42
+ }
43
+ mkdirSync(dirname(destFile), { recursive: true });
44
+ copyFileSync(srcFile, destFile);
45
+ return { installed: true, path: destFile };
46
+ }
47
+
48
+ function installKiro(root) {
49
+ const steeringFile = join(root, ".kiro", "steering", "loop-engineering.md");
50
+ const scriptsDir = join(root, ".loop", "scripts");
51
+ return [
52
+ installSingleFile(join(SKILL_SRC, "SKILL.md"), steeringFile),
53
+ installScriptsOnly(scriptsDir),
54
+ ];
55
+ }
56
+
57
+ function installTrae(root) {
58
+ const ruleFile = join(root, ".trae", "rules", "loop-engineering.md");
59
+ const scriptsDir = join(root, ".loop", "scripts");
60
+ return [
61
+ installSingleFile(join(SKILL_SRC, "SKILL.md"), ruleFile),
62
+ installScriptsOnly(scriptsDir),
63
+ ];
64
+ }
65
+
66
+ function installCursor(root) {
67
+ const results = [];
68
+ const commandPath = join(root, ".cursor", "commands", "loop.md");
69
+ const cursorSrcCommand = join(__dirname, "..", "..", "patterns", "cursor-loop.md");
70
+ if (existsSync(commandPath)) {
71
+ results.push({ installed: false, path: commandPath, reason: "already exists" });
72
+ } else {
73
+ mkdirSync(dirname(commandPath), { recursive: true });
74
+ copyFileSync(cursorSrcCommand, commandPath);
75
+ results.push({ installed: true, path: commandPath });
76
+ }
77
+
78
+ const scriptsDestDir = join(root, ".loop", "scripts");
79
+ if (existsSync(scriptsDestDir)) {
80
+ results.push({ installed: false, path: scriptsDestDir, reason: "already exists" });
81
+ } else {
82
+ mkdirSync(scriptsDestDir, { recursive: true });
83
+ for (const f of readdirSync(join(SKILL_SRC, "scripts"))) {
84
+ copyFileSync(join(SKILL_SRC, "scripts", f), join(scriptsDestDir, f));
85
+ chmodSync(join(scriptsDestDir, f), 0o755);
86
+ }
87
+ results.push({ installed: true, path: scriptsDestDir });
88
+ }
89
+ return results;
90
+ }
91
+
92
+ /**
93
+ * Install the skill for a single tool id. Returns an array of result objects
94
+ * ({ installed, path, reason? }) — most tools produce one, cursor produces two.
95
+ */
96
+ export function installTool(toolId, root) {
97
+ if (toolId === "cursor") return installCursor(root);
98
+ if (toolId === "kiro") return installKiro(root);
99
+ if (toolId === "trae") return installTrae(root);
100
+ const tool = TOOLS[toolId];
101
+ if (!tool) throw new Error(`Unknown tool: ${toolId}`);
102
+ const paths = tool.installPaths(root);
103
+ return paths.map((p) => copySkillDir(p));
104
+ }
105
+
106
+ export function installTools(toolIds, root) {
107
+ const report = {};
108
+ for (const id of toolIds) {
109
+ report[id] = installTool(id, root);
110
+ }
111
+ return report;
112
+ }