@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.
@@ -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 { root, state, slug: normalized } = await loadWorkflowState(cwd, slug, { allowLegacy: false });
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
- return { root, state: next, architectReview, criticReview };
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 = {}) {