@ai-content-space/loopx 0.2.8 → 0.2.9

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 (43) hide show
  1. package/README.md +16 -3
  2. package/README.zh-CN.md +16 -3
  3. package/docs/loopx/plans/2026-06-14-loopx-spec-memory-context-loading.md +948 -0
  4. package/package.json +1 -1
  5. package/plugins/loopx/.codex-plugin/plugin.json +1 -1
  6. package/plugins/loopx/skills/clarify/SKILL.md +12 -1
  7. package/plugins/loopx/skills/debug/SKILL.md +1 -1
  8. package/plugins/loopx/skills/doc-readability/SKILL.md +1 -1
  9. package/plugins/loopx/skills/exec/SKILL.md +1 -1
  10. package/plugins/loopx/skills/final-review/SKILL.md +1 -1
  11. package/plugins/loopx/skills/finish/SKILL.md +1 -1
  12. package/plugins/loopx/skills/fix-review/SKILL.md +1 -1
  13. package/plugins/loopx/skills/go-style/SKILL.md +1 -1
  14. package/plugins/loopx/skills/kratos/SKILL.md +1 -1
  15. package/plugins/loopx/skills/plan-to-exec/SKILL.md +12 -1
  16. package/plugins/loopx/skills/refactor-plan/SKILL.md +1 -1
  17. package/plugins/loopx/skills/review/SKILL.md +1 -1
  18. package/plugins/loopx/skills/spec/SKILL.md +12 -1
  19. package/plugins/loopx/skills/subagent-exec/SKILL.md +1 -1
  20. package/plugins/loopx/skills/tdd/SKILL.md +1 -1
  21. package/plugins/loopx/skills/verify/SKILL.md +1 -1
  22. package/skills/clarify/SKILL.md +12 -1
  23. package/skills/debug/SKILL.md +1 -1
  24. package/skills/doc-readability/SKILL.md +1 -1
  25. package/skills/exec/SKILL.md +1 -1
  26. package/skills/final-review/SKILL.md +1 -1
  27. package/skills/finish/SKILL.md +1 -1
  28. package/skills/fix-review/SKILL.md +1 -1
  29. package/skills/go-style/SKILL.md +1 -1
  30. package/skills/kratos/SKILL.md +1 -1
  31. package/skills/plan-to-exec/SKILL.md +12 -1
  32. package/skills/refactor-plan/SKILL.md +1 -1
  33. package/skills/review/SKILL.md +1 -1
  34. package/skills/spec/SKILL.md +12 -1
  35. package/skills/subagent-exec/SKILL.md +1 -1
  36. package/skills/tdd/SKILL.md +1 -1
  37. package/skills/verify/SKILL.md +1 -1
  38. package/src/cli.mjs +4 -1
  39. package/src/context-manifest.mjs +51 -1
  40. package/src/install-discovery.mjs +109 -0
  41. package/src/loopx-context-artifacts.mjs +114 -0
  42. package/src/project-discovery.mjs +1 -0
  43. package/src/workflow.mjs +47 -3
@@ -49,6 +49,18 @@ const LOOPX_MANAGED_SCRIPT_ITEMS = [
49
49
  targetRelativePath: '.claude/hooks/loopx-workflow-hook.mjs',
50
50
  },
51
51
  ];
52
+ const LOOPX_AGENT_GUIDANCE_BLOCK_ID = 'specs-and-memory-context';
53
+ const LOOPX_AGENT_GUIDANCE_HEADING = '## loopx Specs And Memory';
54
+ const LOOPX_AGENT_GUIDANCE_CONTENT = [
55
+ LOOPX_AGENT_GUIDANCE_HEADING,
56
+ '',
57
+ 'When working in a repository that uses loopx:',
58
+ '',
59
+ '- If `docs/loopx/specs/` exists, inspect relevant specs before clarify, spec, plan, implementation, or review. Use `docs/loopx/specs/index.md` as a map when present, but do not require it.',
60
+ '- If `.loopx/memory/MEMORY.md` exists, read it as curated project memory.',
61
+ '- If `.loopx/memory/index.jsonl` exists, use it only to find relevant active memory cards.',
62
+ '- Treat current user instructions and named source documents as highest priority, repo specs as binding long-lived rules, and memory as advisory context.',
63
+ ].join('\n');
52
64
  const LOOPX_GOVERNED_SOURCE_ITEMS = [
53
65
  {
54
66
  name: 'loopx-plugin-manifest',
@@ -128,6 +140,19 @@ export function getClaudeSettingsPath(env = process.env) {
128
140
  return resolve(env.LOOPX_CLAUDE_SETTINGS_PATH || join(home, '.claude', 'settings.json'));
129
141
  }
130
142
 
143
+ export function getCodexAgentsPath(env = process.env) {
144
+ const home = resolve(env.LOOPX_HOME || env.HOME || process.cwd());
145
+ return resolve(env.LOOPX_CODEX_AGENTS_PATH || join(home, '.codex', 'AGENTS.md'));
146
+ }
147
+
148
+ export function getClaudeAgentsPath(env = process.env, options = {}) {
149
+ if (options.project === true) {
150
+ return resolve(env.LOOPX_INSTALL_CWD || process.cwd(), 'CLAUDE.md');
151
+ }
152
+ const home = resolve(env.LOOPX_HOME || env.HOME || process.cwd());
153
+ return resolve(env.LOOPX_CLAUDE_AGENTS_PATH || join(home, '.claude', 'CLAUDE.md'));
154
+ }
155
+
131
156
  export function getSkillLockPath(env = process.env) {
132
157
  return resolve(env.LOOPX_SKILL_LOCK_PATH || join(getAgentsRoot(env), '.skill-lock.json'));
133
158
  }
@@ -454,6 +479,85 @@ async function removeInstalledFile(path) {
454
479
  await rm(path, { force: true });
455
480
  }
456
481
 
482
+ function managedBlockMarkers(id) {
483
+ return {
484
+ start: `<!-- loopx:managed:block ${id} -->`,
485
+ end: `<!-- /loopx:managed:block ${id} -->`,
486
+ };
487
+ }
488
+
489
+ function renderManagedBlock(id, content) {
490
+ const markers = managedBlockMarkers(id);
491
+ return `${markers.start}\n${content.trim()}\n${markers.end}`;
492
+ }
493
+
494
+ function managedBlockPattern(id) {
495
+ const escaped = String(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
496
+ return new RegExp(`<!--\\s*loopx:managed:block\\s+${escaped}\\s*-->[\\s\\S]*?<!--\\s*\\/loopx:managed:block\\s+${escaped}\\s*-->`);
497
+ }
498
+
499
+ function upsertManagedBlock(existing, id, content) {
500
+ const nextBlock = renderManagedBlock(id, content);
501
+ const pattern = managedBlockPattern(id);
502
+ if (pattern.test(existing)) {
503
+ const nextContent = existing.replace(pattern, nextBlock);
504
+ return {
505
+ content: nextContent,
506
+ changed: nextContent !== existing,
507
+ existed: true,
508
+ };
509
+ }
510
+ const trimmed = existing.trimEnd();
511
+ const contentWithBlock = trimmed
512
+ ? `${trimmed}\n\n${nextBlock}\n`
513
+ : `${nextBlock}\n`;
514
+ return {
515
+ content: contentWithBlock,
516
+ changed: true,
517
+ existed: false,
518
+ };
519
+ }
520
+
521
+ export async function installAgentGuidanceFile(path, options = {}) {
522
+ const content = options.content || LOOPX_AGENT_GUIDANCE_CONTENT;
523
+ const id = options.id || LOOPX_AGENT_GUIDANCE_BLOCK_ID;
524
+ const existing = existsSync(path) ? await readFile(path, 'utf8') : '';
525
+ const existed = existsSync(path);
526
+ const next = upsertManagedBlock(existing, id, content);
527
+ if (!next.changed) {
528
+ return { status: 'already-current', path };
529
+ }
530
+ await ensureDir(dirname(path));
531
+ await writeFile(path, `${next.content.replace(/\s+$/, '')}\n`);
532
+ return {
533
+ status: next.existed ? 'updated' : (existed ? 'installed' : 'created'),
534
+ path,
535
+ };
536
+ }
537
+
538
+ function agentGuidanceEnabled(options = {}) {
539
+ return Boolean(options.agentGuidance || options.codexAgentsGuidance);
540
+ }
541
+
542
+ export async function installAgentGuidance(env = process.env, options = {}) {
543
+ const target = options.target || env.LOOPX_INSTALL_TARGET || 'codex';
544
+ const enabled = agentGuidanceEnabled(options);
545
+ const result = {};
546
+ if (target === 'codex' || target === 'all') {
547
+ const path = getCodexAgentsPath(env);
548
+ result.codex = enabled
549
+ ? await installAgentGuidanceFile(path)
550
+ : { status: 'recommended', path };
551
+ }
552
+ if (target === 'claude' || target === 'all') {
553
+ const path = getClaudeAgentsPath(env, options);
554
+ result.claude = enabled
555
+ ? await installAgentGuidanceFile(path)
556
+ : { status: 'recommended', path };
557
+ }
558
+ return result;
559
+ }
560
+
457
561
  async function canonicalTargetOwnership(skillName, env = process.env, options = {}) {
458
562
  const targetDir = installedSkillDir(skillName, env);
459
563
  const sourceDir = skillSourceDir(skillName, env, options.skillSourceRoot);
@@ -736,11 +840,16 @@ export async function installBundledSkills(env = process.env, options = {}) {
736
840
  items: nextTemplateItems,
737
841
  });
738
842
  const templateGovernance = await inspectTemplateGovernance(baselinePath);
843
+ const agentGuidance = await installAgentGuidance(env, {
844
+ ...options,
845
+ target: options.target || env.LOOPX_INSTALL_TARGET || 'codex',
846
+ });
739
847
  return {
740
848
  ok: conflicts.length === 0,
741
849
  installed,
742
850
  conflicts,
743
851
  skipped,
852
+ agentGuidance,
744
853
  templateGovernance,
745
854
  inspection: await inspectInstallState(env),
746
855
  };
@@ -0,0 +1,114 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { basename, join, relative, resolve } from 'node:path';
4
+
5
+ const MAX_SPEC_CONTEXT_FILES = 12;
6
+
7
+ function displayPath(cwd, path) {
8
+ const rel = relative(cwd, path);
9
+ return rel && !rel.startsWith('..') ? rel : path;
10
+ }
11
+
12
+ function normalizeChangedFiles(files = []) {
13
+ return Array.isArray(files)
14
+ ? files.map((file) => String(file || '').trim()).filter(Boolean)
15
+ : [];
16
+ }
17
+
18
+ async function listMarkdownFiles(root) {
19
+ if (!existsSync(root)) {
20
+ return [];
21
+ }
22
+ const found = [];
23
+ async function walk(dir) {
24
+ const entries = await readdir(dir, { withFileTypes: true });
25
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
26
+ const path = join(dir, entry.name);
27
+ if (entry.isDirectory()) {
28
+ await walk(path);
29
+ continue;
30
+ }
31
+ if (entry.isFile() && /\.md$/i.test(entry.name)) {
32
+ found.push(path);
33
+ }
34
+ }
35
+ }
36
+ await walk(root);
37
+ return found;
38
+ }
39
+
40
+ function pathParts(value) {
41
+ return String(value || '')
42
+ .toLowerCase()
43
+ .split(/[^a-z0-9]+/)
44
+ .filter((part) => part.length >= 3);
45
+ }
46
+
47
+ function frontmatterAppliesTo(text) {
48
+ if (!String(text || '').startsWith('---\n')) {
49
+ return [];
50
+ }
51
+ const end = text.indexOf('\n---\n', 4);
52
+ if (end === -1) {
53
+ return [];
54
+ }
55
+ const lines = text.slice(4, end).split('\n');
56
+ const values = [];
57
+ let inAppliesTo = false;
58
+ for (const line of lines) {
59
+ if (/^applies_to:\s*$/.test(line)) {
60
+ inAppliesTo = true;
61
+ continue;
62
+ }
63
+ if (inAppliesTo && /^\s+-\s+/.test(line)) {
64
+ values.push(line.replace(/^\s+-\s+/, '').trim().replace(/^['"]|['"]$/g, ''));
65
+ continue;
66
+ }
67
+ if (inAppliesTo && /^\S/.test(line)) {
68
+ inAppliesTo = false;
69
+ }
70
+ }
71
+ return values.filter(Boolean);
72
+ }
73
+
74
+ function appliesToChangedFile(pattern, changedFile) {
75
+ const normalizedPattern = String(pattern || '').replace(/\*\*?\/?/g, '').replace(/\/+$/, '');
76
+ const normalizedFile = String(changedFile || '');
77
+ return normalizedPattern && normalizedFile.includes(normalizedPattern);
78
+ }
79
+
80
+ async function specRecord(cwd, path, changedFiles) {
81
+ const text = await readFile(path, 'utf8');
82
+ const appliesTo = frontmatterAppliesTo(text);
83
+ const stemParts = pathParts(basename(path, '.md'));
84
+ const changedParts = new Set(changedFiles.flatMap(pathParts));
85
+ const filenameMatch = stemParts.some((part) => changedParts.has(part));
86
+ const appliesToMatch = appliesTo.some((pattern) => changedFiles.some((file) => appliesToChangedFile(pattern, file)));
87
+ const isIndex = /(^|\/)index\.md$/i.test(path);
88
+ const isInbox = /(^|\/)inbox\.md$/i.test(path);
89
+ return {
90
+ path: displayPath(cwd, path),
91
+ appliesTo,
92
+ relevant: isIndex || isInbox || filenameMatch || appliesToMatch || changedFiles.length === 0,
93
+ };
94
+ }
95
+
96
+ export async function discoverLoopxContextArtifacts(cwd, options = {}) {
97
+ const root = resolve(cwd);
98
+ const changedFiles = normalizeChangedFiles(options.changedFiles);
99
+ const specsRootPath = join(root, 'docs', 'loopx', 'specs');
100
+ const specPaths = await listMarkdownFiles(specsRootPath);
101
+ const records = await Promise.all(specPaths.map((path) => specRecord(root, path, changedFiles)));
102
+ const relevantSpecs = records
103
+ .filter((record) => record.relevant)
104
+ .sort((left, right) => left.path.localeCompare(right.path))
105
+ .slice(0, MAX_SPEC_CONTEXT_FILES);
106
+ const memorySummaryPath = join(root, '.loopx', 'memory', 'MEMORY.md');
107
+ const memoryIndexPath = join(root, '.loopx', 'memory', 'index.jsonl');
108
+ return {
109
+ specsRoot: existsSync(specsRootPath) ? displayPath(root, specsRootPath) : null,
110
+ specFiles: relevantSpecs,
111
+ memorySummary: existsSync(memorySummaryPath) ? { path: displayPath(root, memorySummaryPath) } : null,
112
+ memoryIndex: existsSync(memoryIndexPath) ? { path: displayPath(root, memoryIndexPath) } : null,
113
+ };
114
+ }
@@ -67,6 +67,7 @@ async function discoverSpecSources(cwd) {
67
67
  candidate(join(cwd, 'specs'), 'specs'),
68
68
  candidate(join(cwd, 'docs', 'changes'), 'docs/changes'),
69
69
  candidate(join(cwd, 'docs', 'specs'), 'docs/specs'),
70
+ candidate(join(cwd, 'docs', 'loopx', 'specs'), 'docs/loopx/specs'),
70
71
  candidate(join(cwd, 'docs', 'adr'), 'docs/adr'),
71
72
  candidate(join(cwd, 'docs', 'rfcs'), 'docs/rfcs'),
72
73
  ]);
package/src/workflow.mjs CHANGED
@@ -20,6 +20,7 @@ import { inspectProjectConventions } from './project-discovery.mjs';
20
20
  import { createDefaultReviewAdapter } from './review-runtime.mjs';
21
21
  import { appendWorkspaceJournal } from './workspace-memory.mjs';
22
22
  import { inspectWorkspaceContext, setupWorkspaceContext } from './workspace-context.mjs';
23
+ import { discoverLoopxContextArtifacts } from './loopx-context-artifacts.mjs';
23
24
 
24
25
  const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
25
26
  const WORKSPACE_SCHEMA_VERSION = 1;
@@ -325,9 +326,6 @@ function compactPlanningText(text, { html = false } = {}) {
325
326
  async function readPlanSourceText(cwd, state, sourceSpecPath) {
326
327
  const sourceText = await readFile(sourceSpecPath, 'utf8');
327
328
  const sourceDocumentPaths = sourceDocumentPathsFromSpecAndState(sourceSpecPath, sourceText, state);
328
- if (sourceDocumentPaths.length === 0) {
329
- return { sourceText, sourceDocumentPaths: [] };
330
- }
331
329
 
332
330
  const parts = [sourceText.trimEnd()];
333
331
  const loaded = [];
@@ -350,6 +348,11 @@ async function readPlanSourceText(cwd, state, sourceSpecPath) {
350
348
  break;
351
349
  }
352
350
  }
351
+ const repoContext = await readLoopxRepoContextText(cwd, sourceSpecPath);
352
+ if (repoContext.text) {
353
+ parts.push(repoContext.text);
354
+ loaded.push(...repoContext.paths);
355
+ }
353
356
 
354
357
  return {
355
358
  sourceText: parts.join('\n\n').slice(0, MAX_PLAN_SOURCE_BUNDLE_CHARS),
@@ -357,6 +360,47 @@ async function readPlanSourceText(cwd, state, sourceSpecPath) {
357
360
  };
358
361
  }
359
362
 
363
+ async function readLoopxRepoContextText(cwd, sourceSpecPath) {
364
+ const artifacts = await discoverLoopxContextArtifacts(cwd, {
365
+ changedFiles: [relative(cwd, sourceSpecPath)],
366
+ });
367
+ const paths = [
368
+ ...artifacts.specFiles.map((item) => item.path),
369
+ artifacts.memorySummary?.path,
370
+ ].filter(Boolean);
371
+ if (paths.length === 0) {
372
+ return { text: '', paths: [] };
373
+ }
374
+ const sections = [];
375
+ const loaded = [];
376
+ for (const display of paths) {
377
+ const absolute = resolve(cwd, display);
378
+ if (!existsSync(absolute)) {
379
+ continue;
380
+ }
381
+ const raw = await readFile(absolute, 'utf8');
382
+ loaded.push(absolute);
383
+ sections.push([
384
+ `# loopx context: ${display}`,
385
+ '',
386
+ compactPlanningText(raw),
387
+ ].join('\n'));
388
+ }
389
+ if (sections.length === 0) {
390
+ return { text: '', paths: [] };
391
+ }
392
+ return {
393
+ text: [
394
+ '# loopx Repo Specs And Memory Context',
395
+ '',
396
+ 'Current task instructions and named source documents have priority. Repo specs are binding long-lived rules. Memory is advisory.',
397
+ '',
398
+ ...sections,
399
+ ].join('\n\n'),
400
+ paths: loaded,
401
+ };
402
+ }
403
+
360
404
  function frontmatterBlock(values) {
361
405
  const lines = ['---'];
362
406
  for (const [key, value] of Object.entries(values)) {