@ai-content-space/loopx 0.1.3 → 0.1.5

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.
Files changed (40) hide show
  1. package/README.md +123 -6
  2. package/README.zh-CN.md +143 -10
  3. package/assets/logo.svg +89 -0
  4. package/package.json +4 -2
  5. package/plugins/loopx/.codex-plugin/plugin.json +1 -1
  6. package/plugins/loopx/scripts/plugin-install.test.mjs +13 -0
  7. package/plugins/loopx/skills/archive/SKILL.md +14 -1
  8. package/plugins/loopx/skills/autopilot/SKILL.md +4 -1
  9. package/plugins/loopx/skills/build/SKILL.md +7 -1
  10. package/plugins/loopx/skills/clarify/SKILL.md +13 -9
  11. package/plugins/loopx/skills/debug/SKILL.md +4 -1
  12. package/plugins/loopx/skills/go-style/SKILL.md +4 -1
  13. package/plugins/loopx/skills/kratos/SKILL.md +4 -1
  14. package/plugins/loopx/skills/plan/SKILL.md +8 -4
  15. package/plugins/loopx/skills/review/SKILL.md +7 -1
  16. package/plugins/loopx/skills/tdd/SKILL.md +4 -1
  17. package/plugins/loopx/skills/verify/SKILL.md +4 -1
  18. package/scripts/codex-workflow-hook.mjs +101 -6
  19. package/scripts/verify-skills.mjs +166 -0
  20. package/skills/RESOLVER.md +45 -0
  21. package/skills/archive/SKILL.md +14 -1
  22. package/skills/autopilot/SKILL.md +4 -1
  23. package/skills/build/SKILL.md +7 -1
  24. package/skills/clarify/SKILL.md +13 -9
  25. package/skills/debug/SKILL.md +4 -1
  26. package/skills/go-style/SKILL.md +4 -1
  27. package/skills/kratos/SKILL.md +4 -1
  28. package/skills/plan/SKILL.md +8 -4
  29. package/skills/review/SKILL.md +7 -1
  30. package/skills/tdd/SKILL.md +4 -1
  31. package/skills/verify/SKILL.md +4 -1
  32. package/src/build-runtime.mjs +8 -0
  33. package/src/cli.mjs +10 -0
  34. package/src/context-manifest.mjs +3 -1
  35. package/src/html-views.mjs +316 -0
  36. package/src/plan-runtime.mjs +23 -0
  37. package/src/project-discovery.mjs +163 -0
  38. package/src/review-runtime.mjs +203 -23
  39. package/src/runtime-maintenance.mjs +1 -0
  40. package/src/workflow.mjs +499 -94
@@ -0,0 +1,316 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { basename, join, relative } from 'node:path';
4
+
5
+ import { nextSkillCommand } from './next-skill.mjs';
6
+ import { statusSummary } from './workflow.mjs';
7
+
8
+ const WORKFLOW_ARTIFACTS = [
9
+ { name: 'spec.md', label: '需求工作副本', page: 'intake.html' },
10
+ { name: 'plan.md', label: '计划', page: 'plan.html' },
11
+ { name: 'architecture.md', label: '架构', page: 'plan.html' },
12
+ { name: 'development-plan.md', label: '开发计划', page: 'plan.html' },
13
+ { name: 'test-plan.md', label: '测试计划', page: 'plan.html' },
14
+ { name: 'execution-record.md', label: '执行记录', page: 'build.html' },
15
+ { name: 'review-report.md', label: '评审报告', page: 'review.html' },
16
+ ];
17
+
18
+ const PAGE_GROUPS = [
19
+ { file: 'intake.html', title: '需求澄清', artifacts: ['spec.md'] },
20
+ { file: 'plan.html', title: '计划与架构', artifacts: ['plan.md', 'architecture.md', 'development-plan.md', 'test-plan.md'] },
21
+ { file: 'build.html', title: '执行与验证', artifacts: ['execution-record.md'] },
22
+ { file: 'review.html', title: '评审结论', artifacts: ['review-report.md'] },
23
+ ];
24
+
25
+ function escapeHtml(value) {
26
+ return String(value ?? '')
27
+ .replaceAll('&', '&')
28
+ .replaceAll('<', '&lt;')
29
+ .replaceAll('>', '&gt;')
30
+ .replaceAll('"', '&quot;')
31
+ .replaceAll("'", '&#39;');
32
+ }
33
+
34
+ function htmlDoc({ title, body }) {
35
+ return [
36
+ '<!doctype html>',
37
+ '<html lang="zh-CN">',
38
+ '<head>',
39
+ ' <meta charset="utf-8">',
40
+ ' <meta name="viewport" content="width=device-width, initial-scale=1">',
41
+ ` <title>${escapeHtml(title)}</title>`,
42
+ ' <style>',
43
+ ' :root { color-scheme: light; --text: #17202a; --muted: #5f6f7f; --line: #d8e0e8; --bg: #f6f8fa; --panel: #ffffff; --accent: #1769aa; }',
44
+ ' * { box-sizing: border-box; }',
45
+ ' body { margin: 0; font: 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--text); background: var(--bg); }',
46
+ ' main { max-width: 1080px; margin: 0 auto; padding: 28px 20px 48px; }',
47
+ ' header { margin-bottom: 20px; }',
48
+ ' h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.25; }',
49
+ ' h2 { margin: 24px 0 10px; font-size: 18px; }',
50
+ ' h3 { margin: 18px 0 8px; font-size: 16px; }',
51
+ ' a { color: var(--accent); text-decoration: none; }',
52
+ ' a:hover { text-decoration: underline; }',
53
+ ' .muted { color: var(--muted); }',
54
+ ' .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }',
55
+ ' .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; }',
56
+ ' .badge { display: inline-block; border: 1px solid var(--line); border-radius: 999px; padding: 2px 9px; margin: 2px 4px 2px 0; color: var(--muted); background: #fff; font-size: 12px; }',
57
+ ' table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); }',
58
+ ' th, td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }',
59
+ ' th { color: var(--muted); font-weight: 600; }',
60
+ ' pre { overflow: auto; padding: 12px; background: #0f1720; color: #edf4fb; border-radius: 8px; }',
61
+ ' code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }',
62
+ ' .markdown { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; }',
63
+ ' </style>',
64
+ '</head>',
65
+ '<body>',
66
+ '<main>',
67
+ body,
68
+ '</main>',
69
+ '</body>',
70
+ '</html>',
71
+ ].join('\n');
72
+ }
73
+
74
+ function listItems(items) {
75
+ const values = Array.isArray(items) ? items.filter(Boolean) : [];
76
+ if (values.length === 0) {
77
+ return '<span class="muted">无</span>';
78
+ }
79
+ return values.map((item) => `<span class="badge">${escapeHtml(item)}</span>`).join(' ');
80
+ }
81
+
82
+ function markdownToHtml(markdown) {
83
+ const lines = String(markdown || '').split('\n');
84
+ const html = [];
85
+ let inCode = false;
86
+ let listOpen = false;
87
+
88
+ const closeList = () => {
89
+ if (listOpen) {
90
+ html.push('</ul>');
91
+ listOpen = false;
92
+ }
93
+ };
94
+
95
+ for (const line of lines) {
96
+ if (line.startsWith('```')) {
97
+ if (inCode) {
98
+ html.push('</code></pre>');
99
+ inCode = false;
100
+ } else {
101
+ closeList();
102
+ html.push('<pre><code>');
103
+ inCode = true;
104
+ }
105
+ continue;
106
+ }
107
+ if (inCode) {
108
+ html.push(escapeHtml(line));
109
+ continue;
110
+ }
111
+ if (/^#{1,4}\s+/.test(line)) {
112
+ closeList();
113
+ const level = Math.min(4, line.match(/^#+/)?.[0].length || 2);
114
+ html.push(`<h${level}>${escapeHtml(line.replace(/^#{1,4}\s+/, ''))}</h${level}>`);
115
+ continue;
116
+ }
117
+ if (line.startsWith('- ')) {
118
+ if (!listOpen) {
119
+ html.push('<ul>');
120
+ listOpen = true;
121
+ }
122
+ html.push(`<li>${escapeHtml(line.slice(2))}</li>`);
123
+ continue;
124
+ }
125
+ if (!line.trim()) {
126
+ closeList();
127
+ continue;
128
+ }
129
+ closeList();
130
+ html.push(`<p>${escapeHtml(line)}</p>`);
131
+ }
132
+ closeList();
133
+ if (inCode) {
134
+ html.push('</code></pre>');
135
+ }
136
+ return html.join('\n');
137
+ }
138
+
139
+ function artifactLink(viewRoot, artifactPath, label) {
140
+ const href = relative(viewRoot, artifactPath).replaceAll('\\', '/');
141
+ return `<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
142
+ }
143
+
144
+ function statusPanels(status) {
145
+ const state = status.state || {};
146
+ const readiness = state.readiness || {};
147
+ const authorization = state.authorization || {};
148
+ const nextSkill = nextSkillCommand(state);
149
+ return [
150
+ '<section class="grid">',
151
+ `<div class="panel"><strong>阶段</strong><br>${escapeHtml(state.current_stage || '(none)')}</div>`,
152
+ `<div class="panel"><strong>状态</strong><br>${escapeHtml(state.stage_status || '(unknown)')}</div>`,
153
+ `<div class="panel"><strong>下一步</strong><br><code>${escapeHtml(nextSkill || status.next_action || 'none')}</code></div>`,
154
+ `<div class="panel"><strong>归档</strong><br>${escapeHtml(state.archive_status || 'pending')}</div>`,
155
+ '</section>',
156
+ '<section class="panel">',
157
+ '<h2>readiness / authorization</h2>',
158
+ '<table><thead><tr><th>关卡</th><th>ready</th><th>authorized</th><th>blockers</th></tr></thead><tbody>',
159
+ ...['plan', 'build', 'review', 'done', 'archive'].map((key) => [
160
+ '<tr>',
161
+ `<td>${escapeHtml(key)}</td>`,
162
+ `<td>${escapeHtml(readiness[key]?.ready ?? false)}</td>`,
163
+ `<td>${escapeHtml(authorization[key]?.authorized ?? false)}</td>`,
164
+ `<td>${listItems(readiness[key]?.blockers || [])}</td>`,
165
+ '</tr>',
166
+ ].join('')),
167
+ '</tbody></table>',
168
+ '</section>',
169
+ ].join('\n');
170
+ }
171
+
172
+ async function renderWorkflowPages(status) {
173
+ const root = status.root;
174
+ const viewRoot = join(root, 'view');
175
+ await mkdir(viewRoot, { recursive: true });
176
+
177
+ const artifactRows = WORKFLOW_ARTIFACTS.map((artifact) => {
178
+ const artifactPath = join(root, artifact.name);
179
+ return {
180
+ ...artifact,
181
+ path: artifactPath,
182
+ exists: existsSync(artifactPath),
183
+ };
184
+ });
185
+
186
+ for (const group of PAGE_GROUPS) {
187
+ const sections = [];
188
+ for (const artifactName of group.artifacts) {
189
+ const artifact = artifactRows.find((item) => item.name === artifactName);
190
+ if (!artifact?.exists) {
191
+ continue;
192
+ }
193
+ const text = await readFile(artifact.path, 'utf8');
194
+ sections.push([
195
+ `<section class="markdown">`,
196
+ `<p class="muted">${artifactLink(viewRoot, artifact.path, artifact.name)}</p>`,
197
+ markdownToHtml(text),
198
+ '</section>',
199
+ ].join('\n'));
200
+ }
201
+ await writeFile(join(viewRoot, group.file), htmlDoc({
202
+ title: `${group.title} - ${status.slug}`,
203
+ body: [
204
+ '<header>',
205
+ `<h1>${escapeHtml(group.title)}</h1>`,
206
+ `<p class="muted">工作流:${escapeHtml(status.slug)}</p>`,
207
+ '<p><a href="index.html">返回工作流首页</a></p>',
208
+ '</header>',
209
+ sections.length > 0 ? sections.join('\n') : '<section class="panel muted">暂无对应产物。</section>',
210
+ ].join('\n'),
211
+ }));
212
+ }
213
+
214
+ const indexBody = [
215
+ '<header>',
216
+ `<h1>工作流 ${escapeHtml(status.slug)}</h1>`,
217
+ `<p class="muted">HTML 是派生阅读视图;Markdown 和 JSON 仍是运行时事实源。</p>`,
218
+ '</header>',
219
+ statusPanels(status),
220
+ '<section class="panel">',
221
+ '<h2>关键产物</h2>',
222
+ '<table><thead><tr><th>产物</th><th>状态</th><th>阅读视图</th><th>原始文件</th></tr></thead><tbody>',
223
+ ...artifactRows.map((artifact) => [
224
+ '<tr>',
225
+ `<td>${escapeHtml(artifact.label)}</td>`,
226
+ `<td>${artifact.exists ? '存在' : '缺失'}</td>`,
227
+ `<td>${artifact.exists ? `<a href="${escapeHtml(artifact.page)}">${escapeHtml(basename(artifact.page))}</a>` : '<span class="muted">无</span>'}</td>`,
228
+ `<td>${artifact.exists ? artifactLink(viewRoot, artifact.path, artifact.name) : '<span class="muted">无</span>'}</td>`,
229
+ '</tr>',
230
+ ].join('')),
231
+ '</tbody></table>',
232
+ '</section>',
233
+ ].join('\n');
234
+
235
+ const workflowViewPath = join(viewRoot, 'index.html');
236
+ await writeFile(workflowViewPath, htmlDoc({
237
+ title: `loopx 工作流 ${status.slug}`,
238
+ body: indexBody,
239
+ }));
240
+
241
+ return workflowViewPath;
242
+ }
243
+
244
+ async function renderWorkspaceIndex(workspaceStatus, renderedSlugs = []) {
245
+ const viewsRoot = join(workspaceStatus.workspaceRoot, 'views');
246
+ await mkdir(viewsRoot, { recursive: true });
247
+ const rendered = new Set(renderedSlugs);
248
+ const rows = (workspaceStatus.workflows || []).map((workflow) => {
249
+ const href = `../workflows/${workflow.slug}/view/index.html`;
250
+ const link = rendered.has(workflow.slug)
251
+ ? `<a href="${escapeHtml(href)}">${escapeHtml(workflow.slug)}</a>`
252
+ : escapeHtml(workflow.slug);
253
+ return [
254
+ '<tr>',
255
+ `<td>${link}</td>`,
256
+ `<td>${escapeHtml(workflow.current_stage || '(none)')}</td>`,
257
+ `<td>${escapeHtml(workflow.contract)}</td>`,
258
+ `<td>${escapeHtml(workflow.missing_artifact_count)}</td>`,
259
+ '</tr>',
260
+ ].join('');
261
+ });
262
+ const workspaceViewPath = join(viewsRoot, 'index.html');
263
+ await writeFile(workspaceViewPath, htmlDoc({
264
+ title: 'loopx 工作台',
265
+ body: [
266
+ '<header>',
267
+ '<h1>loopx 工作台</h1>',
268
+ `<p class="muted">工作区:${escapeHtml(workspaceStatus.workspaceRoot)}</p>`,
269
+ '</header>',
270
+ '<section class="panel">',
271
+ '<h2>工作流</h2>',
272
+ '<table><thead><tr><th>工作流</th><th>阶段</th><th>契约</th><th>缺失产物数</th></tr></thead><tbody>',
273
+ rows.join('\n') || '<tr><td colspan="4" class="muted">暂无工作流。</td></tr>',
274
+ '</tbody></table>',
275
+ '</section>',
276
+ ].join('\n'),
277
+ }));
278
+ return workspaceViewPath;
279
+ }
280
+
281
+ export async function renderHtmlViews(cwd, { slug = null, all = false } = {}) {
282
+ const workspaceStatus = await statusSummary(cwd);
283
+ if (!workspaceStatus.initialized) {
284
+ throw new Error('loopx_workspace_not_initialized');
285
+ }
286
+
287
+ const workflowViews = [];
288
+ if (all || !slug) {
289
+ for (const workflow of workspaceStatus.workflows) {
290
+ if (workflow.legacy) {
291
+ continue;
292
+ }
293
+ const workflowStatus = await statusSummary(cwd, workflow.slug);
294
+ workflowViews.push({
295
+ slug: workflow.slug,
296
+ path: await renderWorkflowPages(workflowStatus),
297
+ });
298
+ }
299
+ } else if (slug) {
300
+ const workflowStatus = await statusSummary(cwd, slug);
301
+ if (!workflowStatus.state || workflowStatus.legacy) {
302
+ throw new Error('render_workflow_not_available');
303
+ }
304
+ workflowViews.push({
305
+ slug: workflowStatus.slug,
306
+ path: await renderWorkflowPages(workflowStatus),
307
+ });
308
+ }
309
+
310
+ const workspaceViewPath = await renderWorkspaceIndex(workspaceStatus, workflowViews.map((item) => item.slug));
311
+ return {
312
+ workflowViews,
313
+ workflowViewPath: workflowViews[0]?.path || null,
314
+ workspaceViewPath,
315
+ };
316
+ }
@@ -214,6 +214,25 @@ function reviewArtifact(kind, iteration, verdict, findings, extras = {}) {
214
214
  };
215
215
  }
216
216
 
217
+ function reviewHistoryText(reviewHistory = []) {
218
+ if (!Array.isArray(reviewHistory) || reviewHistory.length === 0) {
219
+ return 'None.';
220
+ }
221
+ return reviewHistory.map((entry) => [
222
+ `Iteration ${entry.iteration}:`,
223
+ `- Architect status: ${entry.architectReview?.status ?? 'unknown'}`,
224
+ `- Architect verdict: ${entry.architectReview?.verdict ?? 'unknown'}`,
225
+ `- Architect findings: ${(entry.architectReview?.findings || []).join(' | ') || 'none'}`,
226
+ `- Strongest objection: ${entry.architectReview?.strongestObjection || 'none'}`,
227
+ `- Tradeoff tension: ${entry.architectReview?.tradeoffTension || 'none'}`,
228
+ `- Critic verdict: ${entry.criticReview?.verdict ?? 'unknown'}`,
229
+ `- Critic findings: ${(entry.criticReview?.findings || []).join(' | ') || 'none'}`,
230
+ `- Acceptance criteria testable: ${Boolean(entry.criticReview?.acceptanceCriteriaTestable)}`,
231
+ `- Verification steps resolved: ${Boolean(entry.criticReview?.verificationStepsResolved)}`,
232
+ `- Execution inputs resolved: ${Boolean(entry.criticReview?.executionInputsResolved)}`,
233
+ ].join('\n')).join('\n\n');
234
+ }
235
+
217
236
  function defaultArchitectReview({ plannerDraft, iteration }) {
218
237
  const findings = [
219
238
  'Real planning orchestration needs an adapter seam so production runtime and deterministic tests can share one state machine.',
@@ -345,8 +364,12 @@ export function createRealPlanAdapter({ model } = {}) {
345
364
  `Deliberate mode: ${Boolean(context.deliberateMode)}`,
346
365
  '',
347
366
  'Use Chinese for planText / architectureText / developmentPlanText / testPlanText.',
367
+ 'If previous review feedback is present, revise the plan to explicitly resolve it. Do not repeat the same plan unchanged.',
348
368
  'Do not ask questions. Do not wrap JSON in markdown.',
349
369
  '',
370
+ 'Previous review feedback:',
371
+ reviewHistoryText(context.reviewHistory),
372
+ '',
350
373
  'Source requirements:',
351
374
  context.sourceText,
352
375
  ].join('\n');
@@ -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
+ }