@ai-content-space/loopx 0.1.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/README.md +110 -0
- package/package.json +32 -0
- package/plugins/loopx/.codex-plugin/plugin.json +13 -0
- package/plugins/loopx/scripts/plugin-install.mjs +56 -0
- package/plugins/loopx/scripts/plugin-install.test.mjs +94 -0
- package/plugins/loopx/skills/loopx-autopilot/SKILL.md +30 -0
- package/plugins/loopx/skills/loopx-build/SKILL.md +25 -0
- package/plugins/loopx/skills/loopx-clarify/SKILL.md +25 -0
- package/plugins/loopx/skills/loopx-plan/SKILL.md +25 -0
- package/plugins/loopx/skills/loopx-review/SKILL.md +25 -0
- package/scripts/install-skills.mjs +18 -0
- package/skills/loopx-autopilot/SKILL.md +30 -0
- package/skills/loopx-build/SKILL.md +25 -0
- package/skills/loopx-clarify/SKILL.md +25 -0
- package/skills/loopx-plan/SKILL.md +25 -0
- package/skills/loopx-review/SKILL.md +25 -0
- package/src/cli.mjs +167 -0
- package/src/install-discovery.mjs +367 -0
- package/src/runtime-maintenance.mjs +68 -0
- package/src/workflow.mjs +1008 -0
- package/templates/architecture.md +31 -0
- package/templates/development-plan.md +27 -0
- package/templates/execution-record.md +35 -0
- package/templates/plan.md +34 -0
- package/templates/review-report.md +31 -0
- package/templates/spec.md +33 -0
- package/templates/test-plan.md +23 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { autopilotStage, approveStage, buildStage, clarifyStage, initWorkspace, planStage, reviewStage, statusSummary } from './workflow.mjs';
|
|
4
|
+
import { installBundledSkills } from './install-discovery.mjs';
|
|
5
|
+
import { doctorRuntime, migrateLegacyRuntime } from './runtime-maintenance.mjs';
|
|
6
|
+
|
|
7
|
+
function usage() {
|
|
8
|
+
return [
|
|
9
|
+
'Usage:',
|
|
10
|
+
' loopx init [--slug <slug>]',
|
|
11
|
+
' loopx clarify <slug>',
|
|
12
|
+
' loopx approve <slug> --from <stage> --to <stage>',
|
|
13
|
+
' loopx plan <slug>',
|
|
14
|
+
' loopx build <slug>',
|
|
15
|
+
' loopx review <slug> [--reviewer <name>]',
|
|
16
|
+
' loopx autopilot <slug> [--reviewer <name>]',
|
|
17
|
+
' loopx status [slug] [--json]',
|
|
18
|
+
' loopx doctor',
|
|
19
|
+
' loopx migrate',
|
|
20
|
+
' loopx repair-install',
|
|
21
|
+
].join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const [command, ...rest] = argv;
|
|
26
|
+
const positionals = [];
|
|
27
|
+
const options = new Map();
|
|
28
|
+
|
|
29
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
30
|
+
const token = rest[index];
|
|
31
|
+
if (!token.startsWith('--')) {
|
|
32
|
+
positionals.push(token);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const next = rest[index + 1];
|
|
36
|
+
if (!next || next.startsWith('--')) {
|
|
37
|
+
options.set(token, true);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
options.set(token, next);
|
|
41
|
+
index += 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { command, positionals, options };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function printHumanStatus(status) {
|
|
48
|
+
if (!status.initialized) {
|
|
49
|
+
console.log('LoopX workspace is not initialized.');
|
|
50
|
+
console.log(status.next_action);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!status.slug) {
|
|
54
|
+
console.log(`workspace: ${status.workspaceRoot}`);
|
|
55
|
+
console.log(`workflows: ${status.workflow_count}`);
|
|
56
|
+
console.log(`legacy: ${status.summary.legacy}`);
|
|
57
|
+
for (const workflow of status.workflows) {
|
|
58
|
+
console.log(`- ${workflow.slug}: stage=${workflow.current_stage ?? '(none)'} contract=${workflow.contract}`);
|
|
59
|
+
}
|
|
60
|
+
console.log(`next: ${status.next_action}`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(`workflow: ${status.slug}`);
|
|
65
|
+
console.log(`contract: ${status.contract}`);
|
|
66
|
+
console.log(`schema_version: ${status.schema_version}`);
|
|
67
|
+
console.log(`stage: ${status.state?.current_stage ?? '(none)'}`);
|
|
68
|
+
console.log(`requested_transition: ${status.state?.requested_transition ?? 'none'}`);
|
|
69
|
+
console.log(`last_confirmed_transition: ${status.state?.last_confirmed_transition ?? 'none'}`);
|
|
70
|
+
console.log(`pending_user_decision: ${status.state?.pending_user_decision ?? 'none'}`);
|
|
71
|
+
console.log(`missing artifacts: ${status.missing_artifacts.length > 0 ? status.missing_artifacts.join(', ') : '(none)'}`);
|
|
72
|
+
console.log(`next: ${status.next_action}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function main() {
|
|
76
|
+
const { command, positionals, options } = parseArgs(process.argv.slice(2));
|
|
77
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
78
|
+
console.log(usage());
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
switch (command) {
|
|
84
|
+
case 'init': {
|
|
85
|
+
const result = await initWorkspace(process.cwd(), { slug: options.get('--slug') || positionals[0] });
|
|
86
|
+
console.log(JSON.stringify({ ok: true, command, workspaceRoot: result.workspaceRoot, workflow: result.workflow?.state ?? null }, null, 2));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
case 'clarify': {
|
|
90
|
+
const result = await clarifyStage(process.cwd(), positionals[0]);
|
|
91
|
+
console.log(JSON.stringify({ ok: true, command, root: result.root, state: result.state }, null, 2));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
case 'approve': {
|
|
95
|
+
const result = await approveStage(process.cwd(), positionals[0], {
|
|
96
|
+
from: options.get('--from'),
|
|
97
|
+
to: options.get('--to'),
|
|
98
|
+
});
|
|
99
|
+
console.log(JSON.stringify({ ok: true, command, root: result.root, state: result.state }, null, 2));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
case 'plan': {
|
|
103
|
+
const result = await planStage(process.cwd(), positionals[0]);
|
|
104
|
+
console.log(JSON.stringify({ ok: true, command, root: result.root, state: result.state }, null, 2));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
case 'build': {
|
|
108
|
+
const result = await buildStage(process.cwd(), positionals[0]);
|
|
109
|
+
console.log(JSON.stringify({ ok: true, command, root: result.root, state: result.state }, null, 2));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
case 'review': {
|
|
113
|
+
const result = await reviewStage(process.cwd(), positionals[0], {
|
|
114
|
+
reviewer: options.get('--reviewer') || 'independent-reviewer',
|
|
115
|
+
});
|
|
116
|
+
console.log(JSON.stringify({ ok: true, command, root: result.root, state: result.state, verdict: result.verdict }, null, 2));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
case 'autopilot': {
|
|
120
|
+
const result = await autopilotStage(process.cwd(), positionals[0], {
|
|
121
|
+
reviewer: options.get('--reviewer') || 'autopilot-reviewer',
|
|
122
|
+
});
|
|
123
|
+
console.log(JSON.stringify({ ok: true, command, root: result.root, state: result.state, runPath: result.runPath }, null, 2));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
case 'status': {
|
|
127
|
+
const result = await statusSummary(process.cwd(), positionals[0]);
|
|
128
|
+
if (options.get('--json')) {
|
|
129
|
+
console.log(JSON.stringify({ ok: true, command, ...result }, null, 2));
|
|
130
|
+
} else {
|
|
131
|
+
printHumanStatus(result);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
case 'doctor': {
|
|
136
|
+
const result = await doctorRuntime(process.cwd(), process.env);
|
|
137
|
+
console.log(JSON.stringify({ ok: !result.mixedRuntimeRoots && result.installCheck.ok, command, ...result }, null, 2));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
case 'migrate': {
|
|
141
|
+
const result = await migrateLegacyRuntime(process.cwd());
|
|
142
|
+
console.log(JSON.stringify({ ok: true, command, ...result }, null, 2));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
case 'repair-install': {
|
|
146
|
+
const result = await installBundledSkills(process.env);
|
|
147
|
+
const ok = result.ok !== false;
|
|
148
|
+
console.log(JSON.stringify({ ok, command, ...result }, null, 2));
|
|
149
|
+
if (!ok) {
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
default:
|
|
155
|
+
throw new Error(`unknown_command:${command}`);
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error(JSON.stringify({
|
|
159
|
+
ok: false,
|
|
160
|
+
command,
|
|
161
|
+
error: error instanceof Error ? error.message : String(error),
|
|
162
|
+
}, null, 2));
|
|
163
|
+
process.exitCode = 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await main();
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { cp, lstat, mkdir, readFile, readdir, readlink, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const PROJECT_ROOT = resolve(MODULE_DIR, '..');
|
|
9
|
+
const LOOPX_SKILLS = [
|
|
10
|
+
'loopx-clarify',
|
|
11
|
+
'loopx-plan',
|
|
12
|
+
'loopx-build',
|
|
13
|
+
'loopx-review',
|
|
14
|
+
'loopx-autopilot',
|
|
15
|
+
];
|
|
16
|
+
const LOOPX_INSTALLATION_IDENTITY = 'loopx';
|
|
17
|
+
|
|
18
|
+
function jsonClone(value) {
|
|
19
|
+
return JSON.parse(JSON.stringify(value));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isoNow() {
|
|
23
|
+
return new Date().toISOString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getProjectRoot(env = process.env) {
|
|
27
|
+
return resolve(env.LOOPX_PROJECT_ROOT || PROJECT_ROOT);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getAgentsRoot(env = process.env) {
|
|
31
|
+
const home = resolve(env.LOOPX_HOME || env.HOME || process.cwd());
|
|
32
|
+
return resolve(env.LOOPX_AGENTS_ROOT || join(home, '.agents'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getInstalledSkillsRoot(env = process.env) {
|
|
36
|
+
return resolve(env.LOOPX_SKILLS_ROOT || join(getAgentsRoot(env), 'skills'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getSkillLockPath(env = process.env) {
|
|
40
|
+
return resolve(env.LOOPX_SKILL_LOCK_PATH || join(getAgentsRoot(env), '.skill-lock.json'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getSkillSourceRoot(env = process.env) {
|
|
44
|
+
return resolve(env.LOOPX_SKILL_SOURCE_ROOT || join(getProjectRoot(env), 'skills'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getInstallOptions(options = {}, env = process.env) {
|
|
48
|
+
return {
|
|
49
|
+
installationIdentity: options.installationIdentity || env.LOOPX_INSTALLATION_IDENTITY || LOOPX_INSTALLATION_IDENTITY,
|
|
50
|
+
distributionChannel: options.distributionChannel || env.LOOPX_DISTRIBUTION_CHANNEL || 'npm',
|
|
51
|
+
sourceUrl: resolve(options.sourceUrl || env.LOOPX_SOURCE_URL || getProjectRoot(env)),
|
|
52
|
+
skillSourceRoot: resolve(options.skillSourceRoot || getSkillSourceRoot(env)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function skillSourceDir(skillName, env = process.env, skillSourceRoot = getSkillSourceRoot(env)) {
|
|
57
|
+
return join(skillSourceRoot, skillName);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function skillSourceEntry(skillName, env = process.env, skillSourceRoot = getSkillSourceRoot(env)) {
|
|
61
|
+
return join(skillSourceDir(skillName, env, skillSourceRoot), 'SKILL.md');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function installedSkillDir(skillName, env = process.env) {
|
|
65
|
+
return join(getInstalledSkillsRoot(env), skillName);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function fileHash(path) {
|
|
69
|
+
const hash = createHash('sha1');
|
|
70
|
+
const stat = await lstat(path);
|
|
71
|
+
if (stat.isDirectory()) {
|
|
72
|
+
const entries = (await readdir(path)).sort();
|
|
73
|
+
hash.update(path);
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
hash.update(await fileHash(join(path, entry)));
|
|
76
|
+
}
|
|
77
|
+
return hash.digest('hex');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
hash.update(await readFile(path));
|
|
81
|
+
return hash.digest('hex');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function ensureDir(path) {
|
|
85
|
+
await mkdir(path, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function readSkillLock(env = process.env) {
|
|
89
|
+
const path = getSkillLockPath(env);
|
|
90
|
+
if (!existsSync(path)) {
|
|
91
|
+
return {
|
|
92
|
+
path,
|
|
93
|
+
data: {
|
|
94
|
+
version: 3,
|
|
95
|
+
skills: {},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
path,
|
|
102
|
+
data: JSON.parse(await readFile(path, 'utf8')),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function writeSkillLock(data, env = process.env) {
|
|
107
|
+
const path = getSkillLockPath(env);
|
|
108
|
+
await ensureDir(dirname(path));
|
|
109
|
+
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function removeInstalledSkill(path) {
|
|
113
|
+
if (!existsSync(path)) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const stat = await lstat(path);
|
|
117
|
+
if (stat.isSymbolicLink()) {
|
|
118
|
+
await unlink(path);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
await rm(path, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function materializeSkill(skillName, env = process.env, options = {}) {
|
|
125
|
+
const sourceDir = skillSourceDir(skillName, env, options.skillSourceRoot);
|
|
126
|
+
if (!existsSync(sourceDir) || !existsSync(skillSourceEntry(skillName, env, options.skillSourceRoot))) {
|
|
127
|
+
throw new Error(`missing_skill_source:${skillName}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const targetDir = installedSkillDir(skillName, env);
|
|
131
|
+
await ensureDir(dirname(targetDir));
|
|
132
|
+
await removeInstalledSkill(targetDir);
|
|
133
|
+
|
|
134
|
+
let installMethod = 'symlink';
|
|
135
|
+
try {
|
|
136
|
+
await symlink(sourceDir, targetDir, 'dir');
|
|
137
|
+
} catch {
|
|
138
|
+
installMethod = 'copy';
|
|
139
|
+
await cp(sourceDir, targetDir, { recursive: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
skillName,
|
|
144
|
+
sourceDir,
|
|
145
|
+
targetDir,
|
|
146
|
+
installMethod,
|
|
147
|
+
skillFolderHash: await fileHash(sourceDir),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildRegistryRow(record, env = process.env, options = {}) {
|
|
152
|
+
return {
|
|
153
|
+
source: 'LoopX',
|
|
154
|
+
sourceType: 'local',
|
|
155
|
+
installationIdentity: options.installationIdentity,
|
|
156
|
+
distributionChannel: options.distributionChannel,
|
|
157
|
+
sourceUrl: options.sourceUrl,
|
|
158
|
+
skillPath: `skills/${record.skillName}/SKILL.md`,
|
|
159
|
+
installedPath: record.targetDir,
|
|
160
|
+
installMethod: record.installMethod,
|
|
161
|
+
installedAt: isoNow(),
|
|
162
|
+
updatedAt: isoNow(),
|
|
163
|
+
skillFolderHash: record.skillFolderHash,
|
|
164
|
+
provenance: [
|
|
165
|
+
{
|
|
166
|
+
distributionChannel: options.distributionChannel,
|
|
167
|
+
sourceUrl: options.sourceUrl,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isLoopxOwnedIdentity(skillName, row, env = process.env) {
|
|
174
|
+
return Boolean(
|
|
175
|
+
row
|
|
176
|
+
&& row.source === 'LoopX'
|
|
177
|
+
&& row.sourceType === 'local'
|
|
178
|
+
&& (
|
|
179
|
+
row.installationIdentity === LOOPX_INSTALLATION_IDENTITY
|
|
180
|
+
|| row.sourceUrl === getProjectRoot(env)
|
|
181
|
+
)
|
|
182
|
+
&& row.skillPath === `skills/${skillName}/SKILL.md`
|
|
183
|
+
&& typeof row.installedPath === 'string',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isLoopxOwnedRow(skillName, row, env = process.env) {
|
|
188
|
+
return Boolean(
|
|
189
|
+
isLoopxOwnedIdentity(skillName, row, env)
|
|
190
|
+
&& typeof row.installedPath === 'string'
|
|
191
|
+
&& row.installedPath === installedSkillDir(skillName, env),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function removeStaleOwnedInstall(currentRow) {
|
|
196
|
+
if (!currentRow?.installedPath || !existsSync(currentRow.installedPath)) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
await removeInstalledSkill(currentRow.installedPath);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function canonicalTargetOwnership(skillName, env = process.env, options = {}) {
|
|
203
|
+
const targetDir = installedSkillDir(skillName, env);
|
|
204
|
+
const sourceDir = skillSourceDir(skillName, env, options.skillSourceRoot);
|
|
205
|
+
if (!existsSync(targetDir)) {
|
|
206
|
+
return { exists: false, owned: false };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const stat = await lstat(targetDir);
|
|
210
|
+
if (stat.isSymbolicLink()) {
|
|
211
|
+
const linkTarget = await readlink(targetDir);
|
|
212
|
+
const resolvedLink = resolve(dirname(targetDir), linkTarget);
|
|
213
|
+
return {
|
|
214
|
+
exists: true,
|
|
215
|
+
owned: resolvedLink === sourceDir,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (stat.isDirectory()) {
|
|
220
|
+
return {
|
|
221
|
+
exists: true,
|
|
222
|
+
owned: await fileHash(targetDir) === await fileHash(sourceDir),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { exists: true, owned: false };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function assertLoopxOwnedTarget(skillName, currentRow, env = process.env, options = {}) {
|
|
230
|
+
const targetDir = installedSkillDir(skillName, env);
|
|
231
|
+
const dirExists = existsSync(targetDir);
|
|
232
|
+
const rowExists = currentRow !== null && currentRow !== undefined;
|
|
233
|
+
|
|
234
|
+
if (!dirExists && !rowExists) {
|
|
235
|
+
return { allowed: true, targetDir };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (isLoopxOwnedIdentity(skillName, currentRow, env)) {
|
|
239
|
+
if (currentRow.installedPath !== targetDir) {
|
|
240
|
+
const canonicalTarget = await canonicalTargetOwnership(skillName, env, options);
|
|
241
|
+
if (canonicalTarget.exists && !canonicalTarget.owned) {
|
|
242
|
+
return {
|
|
243
|
+
allowed: false,
|
|
244
|
+
targetDir,
|
|
245
|
+
reason: 'canonical_target_occupied',
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
await removeStaleOwnedInstall(currentRow);
|
|
249
|
+
return {
|
|
250
|
+
allowed: true,
|
|
251
|
+
targetDir,
|
|
252
|
+
staleOwned: true,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return { allowed: true, targetDir };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!isLoopxOwnedRow(skillName, currentRow, env)) {
|
|
259
|
+
return {
|
|
260
|
+
allowed: false,
|
|
261
|
+
targetDir,
|
|
262
|
+
reason: 'foreign_or_unowned_target',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { allowed: true, targetDir };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function inspectInstallState(env = process.env) {
|
|
270
|
+
const { data } = await readSkillLock(env);
|
|
271
|
+
const installedRoot = getInstalledSkillsRoot(env);
|
|
272
|
+
const bySkill = {};
|
|
273
|
+
|
|
274
|
+
for (const skillName of LOOPX_SKILLS) {
|
|
275
|
+
const targetDir = installedSkillDir(skillName, env);
|
|
276
|
+
const registryRow = data.skills?.[skillName] ?? null;
|
|
277
|
+
bySkill[skillName] = {
|
|
278
|
+
installedDirExists: existsSync(targetDir),
|
|
279
|
+
registryRowExists: registryRow !== null,
|
|
280
|
+
registryRow,
|
|
281
|
+
discovered: existsSync(targetDir) && isLoopxOwnedRow(skillName, registryRow, env),
|
|
282
|
+
loopxOwned: isLoopxOwnedIdentity(skillName, registryRow, env),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
projectRoot: getProjectRoot(env),
|
|
288
|
+
installedSkillsRoot: installedRoot,
|
|
289
|
+
skillLockPath: getSkillLockPath(env),
|
|
290
|
+
skills: bySkill,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function verifyInstallState(env = process.env) {
|
|
295
|
+
const inspection = await inspectInstallState(env);
|
|
296
|
+
const failures = [];
|
|
297
|
+
|
|
298
|
+
for (const skillName of LOOPX_SKILLS) {
|
|
299
|
+
const info = inspection.skills[skillName];
|
|
300
|
+
if (!info.installedDirExists) {
|
|
301
|
+
failures.push(`missing_installed_skill_dir:${skillName}`);
|
|
302
|
+
}
|
|
303
|
+
if (!info.registryRowExists) {
|
|
304
|
+
failures.push(`missing_skill_lock_row:${skillName}`);
|
|
305
|
+
}
|
|
306
|
+
if (!info.discovered) {
|
|
307
|
+
failures.push(`discovery_incomplete:${skillName}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
ok: failures.length === 0,
|
|
313
|
+
failures,
|
|
314
|
+
inspection,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function installBundledSkills(env = process.env, options = {}) {
|
|
319
|
+
const installOptions = getInstallOptions(options, env);
|
|
320
|
+
const { data } = await readSkillLock(env);
|
|
321
|
+
const nextData = jsonClone(data);
|
|
322
|
+
nextData.version = nextData.version || 3;
|
|
323
|
+
nextData.skills = nextData.skills || {};
|
|
324
|
+
|
|
325
|
+
const installed = [];
|
|
326
|
+
const conflicts = [];
|
|
327
|
+
for (const skillName of LOOPX_SKILLS) {
|
|
328
|
+
const current = nextData.skills[skillName];
|
|
329
|
+
const ownership = await assertLoopxOwnedTarget(skillName, current, env, installOptions);
|
|
330
|
+
if (!ownership.allowed) {
|
|
331
|
+
conflicts.push({
|
|
332
|
+
skillName,
|
|
333
|
+
reason: ownership.reason,
|
|
334
|
+
installedPath: ownership.targetDir,
|
|
335
|
+
});
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const record = await materializeSkill(skillName, env, installOptions);
|
|
339
|
+
const row = buildRegistryRow(record, env, installOptions);
|
|
340
|
+
if (current?.installedAt) {
|
|
341
|
+
row.installedAt = current.installedAt;
|
|
342
|
+
}
|
|
343
|
+
if (Array.isArray(current?.provenance)) {
|
|
344
|
+
const mergedProvenance = [
|
|
345
|
+
...current.provenance,
|
|
346
|
+
...row.provenance,
|
|
347
|
+
].filter((item, index, array) => array.findIndex((candidate) => candidate.distributionChannel === item.distributionChannel && candidate.sourceUrl === item.sourceUrl) === index);
|
|
348
|
+
row.provenance = mergedProvenance;
|
|
349
|
+
}
|
|
350
|
+
nextData.skills[skillName] = row;
|
|
351
|
+
installed.push(row);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
await writeSkillLock(nextData, env);
|
|
355
|
+
return {
|
|
356
|
+
ok: conflicts.length === 0,
|
|
357
|
+
installed,
|
|
358
|
+
conflicts,
|
|
359
|
+
inspection: await inspectInstallState(env),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function repairBundledSkills(env = process.env) {
|
|
364
|
+
return installBundledSkills(env);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export const LOOPX_BUNDLED_SKILLS = LOOPX_SKILLS;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdir, rename } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { inspectInstallState, verifyInstallState } from './install-discovery.mjs';
|
|
6
|
+
|
|
7
|
+
export function resolveLoopXRoot(cwd) {
|
|
8
|
+
return join(resolve(cwd), '.LoopX');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveLegacyRoot(cwd) {
|
|
12
|
+
return join(resolve(cwd), '.codex-helper');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function ensureLoopXRoot(cwd) {
|
|
16
|
+
const root = resolveLoopXRoot(cwd);
|
|
17
|
+
await mkdir(root, { recursive: true });
|
|
18
|
+
return root;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function migrateLegacyRuntime(cwd) {
|
|
22
|
+
const legacyRoot = resolveLegacyRoot(cwd);
|
|
23
|
+
const loopxRoot = resolveLoopXRoot(cwd);
|
|
24
|
+
const legacyExists = existsSync(legacyRoot);
|
|
25
|
+
const loopxExists = existsSync(loopxRoot);
|
|
26
|
+
|
|
27
|
+
if (!legacyExists) {
|
|
28
|
+
return {
|
|
29
|
+
migrated: false,
|
|
30
|
+
legacyExists: false,
|
|
31
|
+
loopxExists,
|
|
32
|
+
loopxRoot,
|
|
33
|
+
legacyRoot,
|
|
34
|
+
reason: 'legacy_root_missing',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (loopxExists) {
|
|
39
|
+
throw new Error('mixed_runtime_roots_detected');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await rename(legacyRoot, loopxRoot);
|
|
43
|
+
return {
|
|
44
|
+
migrated: true,
|
|
45
|
+
legacyExists: true,
|
|
46
|
+
loopxExists: true,
|
|
47
|
+
loopxRoot,
|
|
48
|
+
legacyRoot,
|
|
49
|
+
reason: 'migrated_legacy_runtime',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function doctorRuntime(cwd, env = process.env) {
|
|
54
|
+
const loopxRoot = resolveLoopXRoot(cwd);
|
|
55
|
+
const legacyRoot = resolveLegacyRoot(cwd);
|
|
56
|
+
const installState = await inspectInstallState(env);
|
|
57
|
+
const installCheck = await verifyInstallState(env);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
loopxRoot,
|
|
61
|
+
legacyRoot,
|
|
62
|
+
loopxExists: existsSync(loopxRoot),
|
|
63
|
+
legacyExists: existsSync(legacyRoot),
|
|
64
|
+
mixedRuntimeRoots: existsSync(loopxRoot) && existsSync(legacyRoot),
|
|
65
|
+
installState,
|
|
66
|
+
installCheck,
|
|
67
|
+
};
|
|
68
|
+
}
|