@ai-content-space/loopx 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -6
- package/README.zh-CN.md +56 -6
- package/package.json +3 -2
- package/plugins/loopx/.codex-plugin/plugin.json +1 -1
- package/plugins/loopx/skills/archive/SKILL.md +10 -4
- package/plugins/loopx/skills/autopilot/SKILL.md +4 -1
- package/plugins/loopx/skills/build/SKILL.md +9 -1
- package/plugins/loopx/skills/clarify/SKILL.md +9 -3
- package/plugins/loopx/skills/debug/SKILL.md +4 -1
- package/plugins/loopx/skills/go-style/SKILL.md +4 -1
- package/plugins/loopx/skills/kratos/SKILL.md +4 -1
- package/plugins/loopx/skills/plan/SKILL.md +31 -1
- package/plugins/loopx/skills/review/SKILL.md +9 -1
- package/plugins/loopx/skills/tdd/SKILL.md +4 -1
- package/plugins/loopx/skills/verify/SKILL.md +4 -1
- package/scripts/verify-skills.mjs +166 -0
- package/skills/RESOLVER.md +45 -0
- package/skills/archive/SKILL.md +10 -4
- package/skills/autopilot/SKILL.md +4 -1
- package/skills/build/SKILL.md +9 -1
- package/skills/clarify/SKILL.md +9 -3
- package/skills/debug/SKILL.md +4 -1
- package/skills/go-style/SKILL.md +4 -1
- package/skills/kratos/SKILL.md +4 -1
- package/skills/plan/SKILL.md +31 -1
- package/skills/review/SKILL.md +9 -1
- package/skills/tdd/SKILL.md +4 -1
- package/skills/verify/SKILL.md +4 -1
- package/src/context-manifest.mjs +2 -0
- package/src/project-discovery.mjs +163 -0
- package/src/workflow.mjs +91 -3
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
async function readJsonIfExists(path) {
|
|
6
|
+
if (!existsSync(path)) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function pathKind(path) {
|
|
17
|
+
if (!existsSync(path)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const info = await stat(path);
|
|
21
|
+
return info.isDirectory() ? 'directory' : 'file';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function candidate(path, label) {
|
|
25
|
+
const kind = await pathKind(path);
|
|
26
|
+
if (!kind) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return { path: label, kind };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function directoryChildren(root, label) {
|
|
33
|
+
if (!existsSync(root)) {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
const info = await stat(root);
|
|
37
|
+
if (!info.isDirectory()) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const entries = await readdir(root);
|
|
41
|
+
return entries
|
|
42
|
+
.filter((entry) => /\.(md|mdc|txt)$/i.test(entry))
|
|
43
|
+
.sort()
|
|
44
|
+
.map((entry) => ({ path: `${label}/${entry}`, kind: 'file' }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function discoverAiRules(cwd) {
|
|
48
|
+
const direct = await Promise.all([
|
|
49
|
+
candidate(join(cwd, 'AGENTS.md'), 'AGENTS.md'),
|
|
50
|
+
candidate(join(cwd, 'CLAUDE.md'), 'CLAUDE.md'),
|
|
51
|
+
candidate(join(cwd, '.cursor', 'rules'), '.cursor/rules'),
|
|
52
|
+
candidate(join(cwd, '.github', 'copilot-instructions.md'), '.github/copilot-instructions.md'),
|
|
53
|
+
]);
|
|
54
|
+
return [
|
|
55
|
+
...direct.filter(Boolean),
|
|
56
|
+
...await directoryChildren(join(cwd, '.cursor', 'rules'), '.cursor/rules'),
|
|
57
|
+
].filter((item, index, items) => items.findIndex((other) => other.path === item.path) === index);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function discoverSpecSources(cwd) {
|
|
61
|
+
const direct = await Promise.all([
|
|
62
|
+
candidate(join(cwd, 'openspec.yaml'), 'openspec.yaml'),
|
|
63
|
+
candidate(join(cwd, 'openspec.yml'), 'openspec.yml'),
|
|
64
|
+
candidate(join(cwd, 'openspec.json'), 'openspec.json'),
|
|
65
|
+
candidate(join(cwd, 'open-spec.yaml'), 'open-spec.yaml'),
|
|
66
|
+
candidate(join(cwd, '.specify'), '.specify'),
|
|
67
|
+
candidate(join(cwd, 'specs'), 'specs'),
|
|
68
|
+
candidate(join(cwd, 'docs', 'changes'), 'docs/changes'),
|
|
69
|
+
candidate(join(cwd, 'docs', 'specs'), 'docs/specs'),
|
|
70
|
+
candidate(join(cwd, 'docs', 'adr'), 'docs/adr'),
|
|
71
|
+
candidate(join(cwd, 'docs', 'rfcs'), 'docs/rfcs'),
|
|
72
|
+
]);
|
|
73
|
+
return direct.filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function packageRunner(cwd, packageJson) {
|
|
77
|
+
const packageManager = String(packageJson?.packageManager || '');
|
|
78
|
+
if (packageManager.startsWith('pnpm@') || existsSync(join(cwd, 'pnpm-lock.yaml'))) {
|
|
79
|
+
return 'pnpm';
|
|
80
|
+
}
|
|
81
|
+
if (packageManager.startsWith('yarn@') || existsSync(join(cwd, 'yarn.lock'))) {
|
|
82
|
+
return 'yarn';
|
|
83
|
+
}
|
|
84
|
+
if (packageManager.startsWith('bun@') || existsSync(join(cwd, 'bun.lock')) || existsSync(join(cwd, 'bun.lockb'))) {
|
|
85
|
+
return 'bun';
|
|
86
|
+
}
|
|
87
|
+
return 'npm';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function runScriptCommand(runner, scriptName) {
|
|
91
|
+
if (runner === 'npm') {
|
|
92
|
+
return scriptName === 'test' ? 'npm test' : `npm run ${scriptName}`;
|
|
93
|
+
}
|
|
94
|
+
return `${runner} ${scriptName}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function firstScript(scripts, names) {
|
|
98
|
+
return names.find((name) => Object.prototype.hasOwnProperty.call(scripts, name));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function discoverPackageCommands(cwd) {
|
|
102
|
+
const packageJson = await readJsonIfExists(join(cwd, 'package.json'));
|
|
103
|
+
if (!packageJson) {
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
const runner = packageRunner(cwd, packageJson);
|
|
107
|
+
const scripts = packageJson.scripts || {};
|
|
108
|
+
const install = runner === 'npm' && existsSync(join(cwd, 'package-lock.json'))
|
|
109
|
+
? 'npm ci'
|
|
110
|
+
: `${runner} install`;
|
|
111
|
+
return {
|
|
112
|
+
install,
|
|
113
|
+
test: scripts.test ? runScriptCommand(runner, 'test') : null,
|
|
114
|
+
lint: scripts.lint ? runScriptCommand(runner, 'lint') : null,
|
|
115
|
+
typecheck: scripts.typecheck ? runScriptCommand(runner, 'typecheck') : null,
|
|
116
|
+
build: scripts.build ? runScriptCommand(runner, 'build') : null,
|
|
117
|
+
e2e: (() => {
|
|
118
|
+
const script = firstScript(scripts, ['test:e2e', 'e2e', 'test:browser', 'playwright']);
|
|
119
|
+
return script ? runScriptCommand(runner, script) : null;
|
|
120
|
+
})(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function compactCommands(commands) {
|
|
125
|
+
return Object.fromEntries(
|
|
126
|
+
['install', 'test', 'lint', 'typecheck', 'build', 'e2e']
|
|
127
|
+
.map((key) => [key, commands[key] || null]),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function discoverVerificationCommands(cwd) {
|
|
132
|
+
const packageCommands = await discoverPackageCommands(cwd);
|
|
133
|
+
if (Object.keys(packageCommands).length > 0) {
|
|
134
|
+
return compactCommands(packageCommands);
|
|
135
|
+
}
|
|
136
|
+
if (existsSync(join(cwd, 'go.mod'))) {
|
|
137
|
+
return compactCommands({
|
|
138
|
+
test: 'go test ./...',
|
|
139
|
+
build: 'go build ./...',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (existsSync(join(cwd, 'pyproject.toml'))) {
|
|
143
|
+
return compactCommands({
|
|
144
|
+
install: 'pip install -e .',
|
|
145
|
+
test: 'pytest',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return compactCommands({});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function inspectProjectConventions(cwd) {
|
|
152
|
+
const [existingAiRules, existingSpecSources, verificationCommands] = await Promise.all([
|
|
153
|
+
discoverAiRules(cwd),
|
|
154
|
+
discoverSpecSources(cwd),
|
|
155
|
+
discoverVerificationCommands(cwd),
|
|
156
|
+
]);
|
|
157
|
+
return {
|
|
158
|
+
existing_ai_rules: existingAiRules,
|
|
159
|
+
existing_spec_sources: existingSpecSources,
|
|
160
|
+
verification_commands: verificationCommands,
|
|
161
|
+
source_of_truth_policy: 'preserve-existing-project-rules-and-use-loopx-artifacts-only-after-init',
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/workflow.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { doctorRuntime, ensureLoopxRoot, resolveLoopxRoot } from './runtime-maintenance.mjs';
|
|
17
17
|
import { DEFAULT_BUILD_MAX_ITERATIONS, createDefaultBuildAdapter } from './build-runtime.mjs';
|
|
18
18
|
import { DEFAULT_MAX_ITERATIONS, createDefaultPlanAdapter } from './plan-runtime.mjs';
|
|
19
|
+
import { inspectProjectConventions } from './project-discovery.mjs';
|
|
19
20
|
import { createDefaultReviewAdapter } from './review-runtime.mjs';
|
|
20
21
|
import { appendWorkspaceJournal } from './workspace-memory.mjs';
|
|
21
22
|
import { inspectWorkspaceContext, setupWorkspaceContext } from './workspace-context.mjs';
|
|
@@ -356,7 +357,7 @@ function buildWorkspaceReadme() {
|
|
|
356
357
|
'- `workflows/<slug>/spec.md`',
|
|
357
358
|
'- `workflows/<slug>/plan.md`, `architecture.md`, `development-plan.md`, and `test-plan.md`',
|
|
358
359
|
'- `workflows/<slug>/execution-record.md` and `review-report.md`',
|
|
359
|
-
'- `views/index.html` and `workflows/<slug>/view/index.html` after `loopx render`',
|
|
360
|
+
'- `views/index.html` and `workflows/<slug>/view/index.html` after `loopx plan` or `loopx render`',
|
|
360
361
|
'',
|
|
361
362
|
'Documents users may read and edit as workflow fact sources:',
|
|
362
363
|
'',
|
|
@@ -2507,8 +2508,31 @@ async function refreshExecutionStatus(root, state) {
|
|
|
2507
2508
|
};
|
|
2508
2509
|
}
|
|
2509
2510
|
|
|
2511
|
+
async function renderPlanReadingViews(cwd, root, state, slug) {
|
|
2512
|
+
try {
|
|
2513
|
+
const { renderHtmlViews } = await import('./html-views.mjs');
|
|
2514
|
+
const rendered = await renderHtmlViews(cwd, { slug });
|
|
2515
|
+
return {
|
|
2516
|
+
...state,
|
|
2517
|
+
html_view_status: 'written',
|
|
2518
|
+
html_view_path: rendered.workflowViewPath,
|
|
2519
|
+
workspace_view_path: rendered.workspaceViewPath,
|
|
2520
|
+
html_view_error: null,
|
|
2521
|
+
};
|
|
2522
|
+
} catch (error) {
|
|
2523
|
+
return {
|
|
2524
|
+
...state,
|
|
2525
|
+
html_view_status: 'failed',
|
|
2526
|
+
html_view_path: join(root, 'view', 'index.html'),
|
|
2527
|
+
workspace_view_path: join(resolveWorkspaceRoot(cwd), 'views', 'index.html'),
|
|
2528
|
+
html_view_error: error instanceof Error ? error.message : String(error),
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2510
2533
|
export async function initWorkspace(cwd, { slug } = {}) {
|
|
2511
2534
|
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
2535
|
+
const projectConventions = await inspectProjectConventions(cwd);
|
|
2512
2536
|
await ensureLoopxRoot(cwd);
|
|
2513
2537
|
await ensureDir(join(workspaceRoot, 'context'));
|
|
2514
2538
|
await ensureDir(join(workspaceRoot, 'intake'));
|
|
@@ -2527,6 +2551,12 @@ export async function initWorkspace(cwd, { slug } = {}) {
|
|
|
2527
2551
|
product_contract: 'skill-first-v1',
|
|
2528
2552
|
default_flow: ['clarify', 'plan', 'build', 'review', 'done', 'archive'],
|
|
2529
2553
|
preferred_surface: ['clarify', 'plan', 'build', 'review', 'archive', 'autopilot'],
|
|
2554
|
+
source_of_truth_policy: projectConventions.source_of_truth_policy,
|
|
2555
|
+
project_conventions: {
|
|
2556
|
+
existing_ai_rules: projectConventions.existing_ai_rules,
|
|
2557
|
+
existing_spec_sources: projectConventions.existing_spec_sources,
|
|
2558
|
+
},
|
|
2559
|
+
verification_commands: projectConventions.verification_commands,
|
|
2530
2560
|
};
|
|
2531
2561
|
|
|
2532
2562
|
if (!existsSync(workspaceConfigPath(workspaceRoot))) {
|
|
@@ -2777,8 +2807,64 @@ export async function approveStage(cwd, slug, { from, to }) {
|
|
|
2777
2807
|
return { root, state: next };
|
|
2778
2808
|
}
|
|
2779
2809
|
|
|
2810
|
+
function isDoneRoute(value) {
|
|
2811
|
+
return value === TRANSITIONS.REVIEW_TO_DONE || value === STAGES.DONE;
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
function canArchiveConsumePendingDoneApproval(state) {
|
|
2815
|
+
if (state.current_stage !== STAGES.REVIEW) {
|
|
2816
|
+
return false;
|
|
2817
|
+
}
|
|
2818
|
+
const reviewVerdict = String(state.review_verdict || '').trim().toLowerCase();
|
|
2819
|
+
const reviewApproved = reviewVerdict === 'approve' || reviewVerdict === 'go';
|
|
2820
|
+
const routesToDone = [
|
|
2821
|
+
state.pending_user_decision,
|
|
2822
|
+
state.requested_transition,
|
|
2823
|
+
state.review_route,
|
|
2824
|
+
state.requested_transition_after_review,
|
|
2825
|
+
].some(isDoneRoute);
|
|
2826
|
+
const completionRequested = [
|
|
2827
|
+
APPROVAL_STATES.REQUESTED,
|
|
2828
|
+
APPROVAL_STATES.APPROVED,
|
|
2829
|
+
].includes(state.approval?.complete) || state.execution_approved === true || state.execution_approved_for_review === true;
|
|
2830
|
+
return reviewApproved && routesToDone && completionRequested;
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
async function consumePendingDoneApprovalForArchive(cwd, root, state, slug) {
|
|
2834
|
+
if (!canArchiveConsumePendingDoneApproval(state)) {
|
|
2835
|
+
return { root, state, consumed: false };
|
|
2836
|
+
}
|
|
2837
|
+
const normalized = withRecommendedAction({
|
|
2838
|
+
...state,
|
|
2839
|
+
review_verdict: 'approve',
|
|
2840
|
+
pending_user_decision: TRANSITIONS.REVIEW_TO_DONE,
|
|
2841
|
+
requested_transition: TRANSITIONS.NONE,
|
|
2842
|
+
approval: {
|
|
2843
|
+
...state.approval,
|
|
2844
|
+
review: APPROVAL_STATES.APPROVED,
|
|
2845
|
+
complete: state.approval?.complete || APPROVAL_STATES.REQUESTED,
|
|
2846
|
+
},
|
|
2847
|
+
});
|
|
2848
|
+
await writeState(root, normalized);
|
|
2849
|
+
const done = await approveStage(cwd, slug, { from: STAGES.REVIEW, to: STAGES.DONE });
|
|
2850
|
+
return {
|
|
2851
|
+
root: done.root,
|
|
2852
|
+
state: {
|
|
2853
|
+
...done.state,
|
|
2854
|
+
archive_consumed_pending_done_approval: true,
|
|
2855
|
+
},
|
|
2856
|
+
consumed: true,
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2780
2860
|
export async function archiveStage(cwd, slug) {
|
|
2781
|
-
const
|
|
2861
|
+
const loaded = await loadWorkflowState(cwd, slug, { allowLegacy: false });
|
|
2862
|
+
const normalized = loaded.slug;
|
|
2863
|
+
let root = loaded.root;
|
|
2864
|
+
let state = loaded.state;
|
|
2865
|
+
const doneApproval = await consumePendingDoneApprovalForArchive(cwd, root, state, normalized);
|
|
2866
|
+
root = doneApproval.root;
|
|
2867
|
+
state = doneApproval.state;
|
|
2782
2868
|
if (state.current_stage !== STAGES.DONE || !state.completion_confirmed) {
|
|
2783
2869
|
throw new Error('archive_requires_done_workflow');
|
|
2784
2870
|
}
|
|
@@ -2996,7 +3082,9 @@ export async function planStage(cwd, slug, options = {}) {
|
|
|
2996
3082
|
build_context_manifest_path: buildManifest?.path || buildContextManifestPath(root),
|
|
2997
3083
|
});
|
|
2998
3084
|
await writeState(root, next);
|
|
2999
|
-
|
|
3085
|
+
const renderedNext = await renderPlanReadingViews(cwd, root, next, normalized);
|
|
3086
|
+
await writeState(root, renderedNext);
|
|
3087
|
+
return { root, state: renderedNext, architectReview, criticReview };
|
|
3000
3088
|
}
|
|
3001
3089
|
|
|
3002
3090
|
export async function buildStage(cwd, slug, options = {}) {
|