@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
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { readdir, stat, mkdir, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
2
+ import { readdir, stat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { isAbsolute, join } from 'node:path';
4
5
  import { promisify } from 'node:util';
5
6
 
6
7
  import { runCodexReviewJson } from './codex-exec-runtime.mjs';
@@ -52,6 +53,14 @@ export function parseUntrackedFiles(statusText) {
52
53
  .filter((file) => file && !file.startsWith('.loopx/') && !file.startsWith('.codex-helper/') && !file.startsWith('.LoopX/'));
53
54
  }
54
55
 
56
+ function normalizeChangedFiles(files = []) {
57
+ return [...new Set((Array.isArray(files) ? files : [])
58
+ .map((file) => String(file || '').trim())
59
+ .filter(Boolean)
60
+ .filter((file) => !isAbsolute(file) && !file.split(/[\\/]+/).includes('..'))
61
+ .filter((file) => !file.startsWith('.loopx/') && !file.startsWith('.codex-helper/') && !file.startsWith('.LoopX/')))];
62
+ }
63
+
55
64
  async function expandUntrackedPath(cwd, file) {
56
65
  const fullPath = join(cwd, file);
57
66
  const info = await stat(fullPath);
@@ -65,9 +74,24 @@ async function expandUntrackedPath(cwd, file) {
65
74
  return nested.flat();
66
75
  }
67
76
 
68
- export async function buildReviewDiffEvidence(cwd, statusText) {
69
- const trackedDiff = await gitOutput(cwd, ['diff', 'HEAD', '--']);
70
- const untrackedFiles = parseUntrackedFiles(statusText);
77
+ async function scopedUntrackedFiles(cwd, scopedFiles) {
78
+ const batches = await Promise.all(scopedFiles.map(async (file) => {
79
+ const output = await gitOutputAllowExit(cwd, ['ls-files', '--others', '--exclude-standard', '--', file]);
80
+ return output.split('\n').map((item) => item.trim()).filter(Boolean);
81
+ }));
82
+ return normalizeChangedFiles(batches.flat());
83
+ }
84
+
85
+ export async function buildReviewDiffEvidence(cwd, statusText, { changedFiles = null, includeUntracked = false } = {}) {
86
+ const scopedFiles = normalizeChangedFiles(changedFiles);
87
+ const pathspec = scopedFiles.length > 0 ? scopedFiles : [];
88
+ const trackedDiff = await gitOutput(cwd, ['diff', 'HEAD', '--', ...pathspec]);
89
+ if (!includeUntracked && scopedFiles.length === 0) {
90
+ return trackedDiff;
91
+ }
92
+ const untrackedFiles = scopedFiles.length > 0
93
+ ? await scopedUntrackedFiles(cwd, scopedFiles)
94
+ : parseUntrackedFiles(statusText).filter(() => includeUntracked);
71
95
  const untrackedDiffs = [];
72
96
  for (const file of untrackedFiles) {
73
97
  for (const expandedFile of await expandUntrackedPath(cwd, file)) {
@@ -77,6 +101,21 @@ export async function buildReviewDiffEvidence(cwd, statusText) {
77
101
  return [trackedDiff, ...untrackedDiffs].filter(Boolean).join('\n\n');
78
102
  }
79
103
 
104
+ async function buildGitDiffStat(cwd, changedFiles = []) {
105
+ const scopedFiles = normalizeChangedFiles(changedFiles);
106
+ return gitOutput(cwd, ['diff', '--stat', 'HEAD', '--', ...scopedFiles]);
107
+ }
108
+
109
+ async function buildDiffCheck(cwd, changedFiles = []) {
110
+ const scopedFiles = normalizeChangedFiles(changedFiles);
111
+ try {
112
+ await gitOutput(cwd, ['diff', '--check', 'HEAD', '--', ...scopedFiles]);
113
+ return '';
114
+ } catch (error) {
115
+ return error?.stdout || error?.stderr || error?.message || String(error);
116
+ }
117
+ }
118
+
80
119
  function normalizeFinding(item) {
81
120
  if (typeof item === 'string') {
82
121
  return {
@@ -94,6 +133,56 @@ function normalizeFinding(item) {
94
133
  };
95
134
  }
96
135
 
136
+ function unavailableChangedFilesReview() {
137
+ return {
138
+ status: 'complete',
139
+ verdict: 'request-changes',
140
+ summary: 'build 执行记录未提供可审查的 changed_files 范围,review 已阻断以避免扩大到全工作区。',
141
+ rollbackTarget: 'build',
142
+ changedFiles: [],
143
+ findings: [{
144
+ severity: 'medium',
145
+ file: 'execution-record.md',
146
+ line: null,
147
+ message: 'execution-record.md 缺少 changed_files 或标记为 unavailable;需要 build 重新生成明确的 changed_files 后再 review。',
148
+ }],
149
+ };
150
+ }
151
+
152
+ function directoryChangedFilesReview(directoryFiles = []) {
153
+ return {
154
+ status: 'complete',
155
+ verdict: 'request-changes',
156
+ summary: 'build 执行记录的 changed_files 包含目录项,review 已阻断以避免扩大到目录内所有文件。',
157
+ rollbackTarget: 'build',
158
+ changedFiles: [],
159
+ findings: [{
160
+ severity: 'medium',
161
+ file: 'execution-record.md',
162
+ line: null,
163
+ message: `changed_files 必须列出具体文件,不能使用目录项:${directoryFiles.join(', ')}`,
164
+ }],
165
+ };
166
+ }
167
+
168
+ async function findDirectoryChangedFiles(cwd, changedFiles = []) {
169
+ const directories = [];
170
+ for (const file of changedFiles) {
171
+ if (file.endsWith('/') || file.endsWith('\\')) {
172
+ directories.push(file);
173
+ continue;
174
+ }
175
+ try {
176
+ if ((await stat(join(cwd, file))).isDirectory()) {
177
+ directories.push(file);
178
+ }
179
+ } catch {
180
+ // Missing paths can be valid deleted/renamed files in git diffs.
181
+ }
182
+ }
183
+ return directories;
184
+ }
185
+
97
186
  function normalizeArchitectureReview(raw = {}) {
98
187
  const normalizedVerdict = normalizeToken(raw?.verdict || 'pass');
99
188
  const verdict = normalizedVerdict === 'block'
@@ -132,6 +221,46 @@ function normalizeCodeReview(raw, changedFiles) {
132
221
  };
133
222
  }
134
223
 
224
+ async function withArchitectureReviewSchema(fn) {
225
+ const schemaDir = await mkdtemp(join(tmpdir(), 'loopx-architecture-review-schema-'));
226
+ const schemaPath = join(schemaDir, 'schema.json');
227
+ try {
228
+ await writeFile(schemaPath, JSON.stringify({
229
+ type: 'object',
230
+ additionalProperties: false,
231
+ required: ['status', 'verdict', 'summary', 'rollbackTarget', 'findings'],
232
+ properties: {
233
+ status: { enum: ['complete', 'skipped'] },
234
+ verdict: { enum: ['pass', 'warn', 'block'] },
235
+ summary: { type: 'string' },
236
+ rollbackTarget: {
237
+ anyOf: [
238
+ { enum: ['build', 'plan', 'clarify'] },
239
+ { type: 'null' },
240
+ ],
241
+ },
242
+ findings: {
243
+ type: 'array',
244
+ items: {
245
+ type: 'object',
246
+ additionalProperties: false,
247
+ required: ['severity', 'file', 'line', 'message'],
248
+ properties: {
249
+ severity: { enum: ['high', 'medium', 'low'] },
250
+ file: { anyOf: [{ type: 'string' }, { type: 'null' }] },
251
+ line: { anyOf: [{ type: 'number' }, { type: 'null' }] },
252
+ message: { type: 'string' },
253
+ },
254
+ },
255
+ },
256
+ },
257
+ }));
258
+ return await fn(schemaPath);
259
+ } finally {
260
+ await rm(schemaDir, { recursive: true, force: true });
261
+ }
262
+ }
263
+
135
264
  export function reviewContextPromptLines(context) {
136
265
  return [
137
266
  `reviewContextManifestStatus: ${context.contextManifestStatus || 'fallback'}`,
@@ -157,6 +286,14 @@ function truncateForPrompt(text, maxChars = MAX_DIFF_PROMPT_CHARS) {
157
286
  return `${value.slice(0, maxChars)}\n[truncated ${value.length - maxChars} chars]`;
158
287
  }
159
288
 
289
+ function gitStatusPreview(statusText, changedFiles = []) {
290
+ const scopedFiles = normalizeChangedFiles(changedFiles);
291
+ if (scopedFiles.length === 0) {
292
+ return statusText || '';
293
+ }
294
+ return scopedFiles.map((file) => `build-owned ${file}`).join('\n');
295
+ }
296
+
160
297
  export function buildCodeReviewPrompt(context, changedFiles, diffCheck = '') {
161
298
  const gitStatusShort = truncateForPrompt(context.gitStatusShort || '');
162
299
  const gitDiffStat = truncateForPrompt(context.gitDiffStat || '');
@@ -164,7 +301,9 @@ export function buildCodeReviewPrompt(context, changedFiles, diffCheck = '') {
164
301
  const gitDiffEvidencePath = context.gitDiffEvidencePath || '';
165
302
  return [
166
303
  `你是 loopx workflow "${context.slug}" 的独立 code reviewer。`,
167
- '请审查当前 git 工作区相对 HEAD 的代码差异,包括 staged、unstaged 和 untracked 文件。',
304
+ context.buildOwnedScope
305
+ ? '请审查 build 记录的变更文件;这些文件可以包含 tracked 修改和 build 新建的 untracked 文件,但不要审查未被 build 归属的本地文件。'
306
+ : '请审查当前 git 工作区相对 HEAD 的代码差异,包括 staged、unstaged 和 untracked 文件。',
168
307
  gitDiffEvidencePath
169
308
  ? '必须以本 prompt 中的当前 git status/diff 预览和完整 git diff evidence 文件为事实来源;不要把既有 review-report.md 或 review-support/code-review.json 当作当前事实来源。'
170
309
  : '必须以本 prompt 中的当前 git status/diff 为事实来源;不要把既有 review-report.md 或 review-support/code-review.json 当作当前事实来源。',
@@ -213,6 +352,9 @@ export function buildArchitectureReviewPrompt(context, changedFiles) {
213
352
  return [
214
353
  `你是 loopx workflow "${context.slug}" 的 architecture smell reviewer。`,
215
354
  '这是 `$review` 内部的轻量架构检查 lane,不是新阶段,不要修改文件,不要运行 build。',
355
+ context.buildOwnedScope
356
+ ? '审查范围限制为 build 记录的变更文件;不要审查未被 build 归属的本地文件。'
357
+ : '审查范围为当前 git 工作区相对 HEAD 的代码差异。',
216
358
  '你的目标是发现会影响长期可维护性、测试 seam、领域边界或 plan 架构假设落地的真实问题。',
217
359
  '只在问题足够严重、需要回退 plan/build/clarify 时返回 verdict "block";普通建议用 "warn";没有实质问题用 "pass"。',
218
360
  '',
@@ -256,7 +398,7 @@ export function buildArchitectureReviewPrompt(context, changedFiles) {
256
398
  ].join('\n');
257
399
  }
258
400
 
259
- export function createRealReviewAdapter({ model, codexReviewJson = runCodexReviewJson } = {}) {
401
+ export function createRealReviewAdapter({ model = DEFAULT_REVIEW_MODEL, codexReviewJson = runCodexReviewJson } = {}) {
260
402
  return {
261
403
  async codeReview(context) {
262
404
  if (!(await isGitWorktree(context.cwd))) {
@@ -270,7 +412,20 @@ export function createRealReviewAdapter({ model, codexReviewJson = runCodexRevie
270
412
  }
271
413
 
272
414
  const statusText = await gitOutput(context.cwd, ['status', '--short']);
273
- const changedFiles = parseChangedFiles(statusText);
415
+ if (context.buildOwnedChangedFilesStatus === 'unavailable') {
416
+ return unavailableChangedFilesReview();
417
+ }
418
+ const hasBuildOwnedScope = Object.hasOwn(context, 'buildOwnedChangedFiles') || context.buildOwnedChangedFilesStatus === 'present';
419
+ const buildOwnedChangedFiles = normalizeChangedFiles(context.buildOwnedChangedFiles);
420
+ const changedFiles = hasBuildOwnedScope
421
+ ? buildOwnedChangedFiles
422
+ : parseChangedFiles(statusText);
423
+ if (hasBuildOwnedScope) {
424
+ const directoryFiles = await findDirectoryChangedFiles(context.cwd, changedFiles);
425
+ if (directoryFiles.length > 0) {
426
+ return directoryChangedFilesReview(directoryFiles);
427
+ }
428
+ }
274
429
  if (changedFiles.length === 0) {
275
430
  return {
276
431
  status: 'complete',
@@ -281,14 +436,9 @@ export function createRealReviewAdapter({ model, codexReviewJson = runCodexRevie
281
436
  };
282
437
  }
283
438
 
284
- let diffCheck = '';
285
- try {
286
- await gitOutput(context.cwd, ['diff', '--check', 'HEAD', '--']);
287
- } catch (error) {
288
- diffCheck = error?.stdout || error?.stderr || error?.message || String(error);
289
- }
290
- const gitDiffStat = await gitOutput(context.cwd, ['diff', '--stat', 'HEAD', '--']);
291
- const gitDiff = await buildReviewDiffEvidence(context.cwd, statusText);
439
+ const diffCheck = await buildDiffCheck(context.cwd, changedFiles);
440
+ const gitDiffStat = await buildGitDiffStat(context.cwd, changedFiles);
441
+ const gitDiff = await buildReviewDiffEvidence(context.cwd, statusText, { changedFiles });
292
442
 
293
443
  await mkdir(join(context.root, 'review-support'), { recursive: true });
294
444
  const outputPath = join(context.root, 'review-support', 'code-review.raw.json');
@@ -296,7 +446,8 @@ export function createRealReviewAdapter({ model, codexReviewJson = runCodexRevie
296
446
  await writeFile(gitDiffEvidencePath, gitDiff || '');
297
447
  const prompt = buildCodeReviewPrompt({
298
448
  ...context,
299
- gitStatusShort: statusText,
449
+ buildOwnedScope: hasBuildOwnedScope,
450
+ gitStatusShort: gitStatusPreview(statusText, changedFiles),
300
451
  gitDiffStat,
301
452
  gitDiff,
302
453
  gitDiffEvidencePath,
@@ -322,7 +473,34 @@ export function createRealReviewAdapter({ model, codexReviewJson = runCodexRevie
322
473
  });
323
474
  }
324
475
  const statusText = await gitOutput(context.cwd, ['status', '--short']);
325
- const changedFiles = parseChangedFiles(statusText);
476
+ if (context.buildOwnedChangedFilesStatus === 'unavailable') {
477
+ return normalizeArchitectureReview({
478
+ status: 'complete',
479
+ verdict: 'block',
480
+ summary: 'build 执行记录未提供 changed_files 范围,架构 smell 扫描已阻断以避免扩大到全工作区。',
481
+ rollbackTarget: 'build',
482
+ findings: [{
483
+ severity: 'medium',
484
+ file: 'execution-record.md',
485
+ line: null,
486
+ message: 'execution-record.md 缺少 changed_files 或标记为 unavailable;需要 build 重新生成明确范围。',
487
+ }],
488
+ });
489
+ }
490
+ const hasBuildOwnedScope = Object.hasOwn(context, 'buildOwnedChangedFiles') || context.buildOwnedChangedFilesStatus === 'present';
491
+ const buildOwnedChangedFiles = normalizeChangedFiles(context.buildOwnedChangedFiles);
492
+ const changedFiles = hasBuildOwnedScope
493
+ ? buildOwnedChangedFiles
494
+ : parseChangedFiles(statusText);
495
+ if (hasBuildOwnedScope) {
496
+ const directoryFiles = await findDirectoryChangedFiles(context.cwd, changedFiles);
497
+ if (directoryFiles.length > 0) {
498
+ return normalizeArchitectureReview({
499
+ ...directoryChangedFilesReview(directoryFiles),
500
+ verdict: 'block',
501
+ });
502
+ }
503
+ }
326
504
  if (changedFiles.length === 0) {
327
505
  return normalizeArchitectureReview({
328
506
  status: 'complete',
@@ -331,26 +509,28 @@ export function createRealReviewAdapter({ model, codexReviewJson = runCodexRevie
331
509
  findings: [],
332
510
  });
333
511
  }
334
- const gitDiffStat = await gitOutput(context.cwd, ['diff', '--stat', 'HEAD', '--']);
335
- const gitDiff = await buildReviewDiffEvidence(context.cwd, statusText);
512
+ const gitDiffStat = await buildGitDiffStat(context.cwd, changedFiles);
513
+ const gitDiff = await buildReviewDiffEvidence(context.cwd, statusText, { changedFiles });
336
514
  await mkdir(join(context.root, 'review-support'), { recursive: true });
337
515
  const outputPath = join(context.root, 'review-support', 'architecture-smell.raw.json');
338
516
  const gitDiffEvidencePath = join(context.root, 'review-support', 'code-review-diff.patch');
339
517
  const prompt = buildArchitectureReviewPrompt({
340
518
  ...context,
341
- gitStatusShort: statusText,
519
+ buildOwnedScope: hasBuildOwnedScope,
520
+ gitStatusShort: gitStatusPreview(statusText, changedFiles),
342
521
  gitDiffStat,
343
522
  gitDiff,
344
523
  gitDiffEvidencePath,
345
524
  }, changedFiles);
346
- const raw = await codexReviewJson({
525
+ const raw = await withArchitectureReviewSchema((outputSchema) => codexReviewJson({
347
526
  cwd: context.cwd,
348
527
  prompt,
349
528
  outputPath,
350
529
  model,
351
530
  reviewMode: true,
352
531
  uncommitted: true,
353
- });
532
+ outputSchema,
533
+ }));
354
534
  return normalizeArchitectureReview(raw);
355
535
  },
356
536
  };
@@ -274,6 +274,7 @@ function createMigratedWorkflowBaseState(slug, legacyState, change) {
274
274
  plan_docs_status: 'missing',
275
275
  plan_docs_artifact_paths: null,
276
276
  plan_review_artifact_paths: [],
277
+ plan_review_history: [],
277
278
  plan_blockers: [],
278
279
  plan_source_spec_path: null,
279
280
  change_id: change?.changeId || `chg-${slug}`,