@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.
- package/README.md +16 -3
- package/README.zh-CN.md +16 -3
- package/docs/loopx/plans/2026-06-14-loopx-spec-memory-context-loading.md +948 -0
- package/package.json +1 -1
- package/plugins/loopx/.codex-plugin/plugin.json +1 -1
- package/plugins/loopx/skills/clarify/SKILL.md +12 -1
- package/plugins/loopx/skills/debug/SKILL.md +1 -1
- package/plugins/loopx/skills/doc-readability/SKILL.md +1 -1
- package/plugins/loopx/skills/exec/SKILL.md +1 -1
- package/plugins/loopx/skills/final-review/SKILL.md +1 -1
- package/plugins/loopx/skills/finish/SKILL.md +1 -1
- package/plugins/loopx/skills/fix-review/SKILL.md +1 -1
- package/plugins/loopx/skills/go-style/SKILL.md +1 -1
- package/plugins/loopx/skills/kratos/SKILL.md +1 -1
- package/plugins/loopx/skills/plan-to-exec/SKILL.md +12 -1
- package/plugins/loopx/skills/refactor-plan/SKILL.md +1 -1
- package/plugins/loopx/skills/review/SKILL.md +1 -1
- package/plugins/loopx/skills/spec/SKILL.md +12 -1
- package/plugins/loopx/skills/subagent-exec/SKILL.md +1 -1
- package/plugins/loopx/skills/tdd/SKILL.md +1 -1
- package/plugins/loopx/skills/verify/SKILL.md +1 -1
- package/skills/clarify/SKILL.md +12 -1
- package/skills/debug/SKILL.md +1 -1
- package/skills/doc-readability/SKILL.md +1 -1
- package/skills/exec/SKILL.md +1 -1
- package/skills/final-review/SKILL.md +1 -1
- package/skills/finish/SKILL.md +1 -1
- package/skills/fix-review/SKILL.md +1 -1
- package/skills/go-style/SKILL.md +1 -1
- package/skills/kratos/SKILL.md +1 -1
- package/skills/plan-to-exec/SKILL.md +12 -1
- package/skills/refactor-plan/SKILL.md +1 -1
- package/skills/review/SKILL.md +1 -1
- package/skills/spec/SKILL.md +12 -1
- package/skills/subagent-exec/SKILL.md +1 -1
- package/skills/tdd/SKILL.md +1 -1
- package/skills/verify/SKILL.md +1 -1
- package/src/cli.mjs +4 -1
- package/src/context-manifest.mjs +51 -1
- package/src/install-discovery.mjs +109 -0
- package/src/loopx-context-artifacts.mjs +114 -0
- package/src/project-discovery.mjs +1 -0
- 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)) {
|