claudeos-core 1.7.0 → 2.0.0

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 (39) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/CONTRIBUTING.md +92 -59
  3. package/README.de.md +465 -240
  4. package/README.es.md +446 -223
  5. package/README.fr.md +461 -238
  6. package/README.hi.md +485 -261
  7. package/README.ja.md +440 -235
  8. package/README.ko.md +244 -56
  9. package/README.md +215 -47
  10. package/README.ru.md +462 -238
  11. package/README.vi.md +454 -230
  12. package/README.zh-CN.md +476 -252
  13. package/bin/cli.js +144 -140
  14. package/bin/commands/init.js +550 -46
  15. package/bin/commands/memory.js +426 -0
  16. package/bin/lib/cli-utils.js +206 -143
  17. package/bootstrap.sh +81 -390
  18. package/content-validator/index.js +436 -340
  19. package/lib/expected-guides.js +23 -0
  20. package/lib/expected-outputs.js +91 -0
  21. package/lib/language-config.js +35 -0
  22. package/lib/memory-scaffold.js +1014 -0
  23. package/lib/plan-parser.js +153 -149
  24. package/lib/staged-rules.js +118 -0
  25. package/manifest-generator/index.js +176 -171
  26. package/package.json +1 -1
  27. package/pass-json-validator/index.js +337 -299
  28. package/pass-prompts/templates/common/pass3-footer.md +16 -0
  29. package/pass-prompts/templates/common/pass4.md +317 -0
  30. package/pass-prompts/templates/common/staging-override.md +26 -0
  31. package/pass-prompts/templates/python-flask/pass1.md +119 -0
  32. package/pass-prompts/templates/python-flask/pass2.md +85 -0
  33. package/pass-prompts/templates/python-flask/pass3.md +103 -0
  34. package/plan-installer/domain-grouper.js +2 -1
  35. package/plan-installer/prompt-generator.js +120 -96
  36. package/plan-installer/scanners/scan-frontend.js +219 -10
  37. package/plan-installer/scanners/scan-java.js +226 -223
  38. package/plan-installer/scanners/scan-python.js +21 -0
  39. package/sync-checker/index.js +133 -132
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ClaudeOS-Core — Init Command
3
3
  *
4
- * Runs the full 3-Pass pipeline: analyze → merge → generate.
4
+ * Runs the full 4-Pass pipeline: analyze → merge → generate → memory scaffold.
5
5
  * This is the main entry point for project bootstrapping.
6
6
  */
7
7
 
@@ -10,30 +10,15 @@ const path = require("path");
10
10
  const {
11
11
  TOOLS_DIR, PROJECT_ROOT, GENERATED_DIR,
12
12
  SUPPORTED_LANGS, LANG_CODES, isValidLang,
13
- log, header, run, runClaudePrompt,
13
+ log, header, run, runClaudePrompt, runClaudePromptAsync,
14
14
  ensureDir, fileExists, readFile, injectProjectRoot,
15
15
  pad, countFiles, countPass1Files,
16
16
  } = require("../lib/cli-utils");
17
17
  const { selectLangInteractive } = require("../lib/lang-selector");
18
18
  const { selectResumeMode } = require("../lib/resume-selector");
19
19
 
20
- // ─── i18n: claude -p waiting message ───────────────────────────
21
- const CLAUDE_WAIT_TMPL = {
22
- en: " ⏳ [{{PASS}}] Running claude -p (no output is normal, please wait)...",
23
- ko: " ⏳ [{{PASS}}] claude -p 실행 중 (출력이 없어도 정상입니다. 잠시 기다려주세요)...",
24
- "zh-CN": " ⏳ [{{PASS}}] 正在运行 claude -p(没有输出是正常的,请稍候)...",
25
- ja: " ⏳ [{{PASS}}] claude -p 実行中(出力がなくても正常です。しばらくお待ちください)...",
26
- es: " ⏳ [{{PASS}}] Ejecutando claude -p (es normal que no haya salida, por favor espere)...",
27
- vi: " ⏳ [{{PASS}}] Đang chạy claude -p (không có output là bình thường, vui lòng chờ)...",
28
- hi: " ⏳ [{{PASS}}] claude -p चल रहा है (कोई आउटपुट न होना सामान्य है, कृपया प्रतीक्षा करें)...",
29
- ru: " ⏳ [{{PASS}}] Выполняется claude -p (отсутствие вывода — это нормально, подождите)...",
30
- fr: " ⏳ [{{PASS}}] Exécution de claude -p (l'absence de sortie est normale, veuillez patienter)...",
31
- de: " ⏳ [{{PASS}}] claude -p wird ausgeführt (keine Ausgabe ist normal, bitte warten)...",
32
- };
33
-
34
- function claudeWaitMsg(lang, passLabel) {
35
- return (CLAUDE_WAIT_TMPL[lang] || CLAUDE_WAIT_TMPL.en).replace("{{PASS}}", passLabel);
36
- }
20
+ const { EXPECTED_GUIDE_FILES } = require("../../lib/expected-guides");
21
+ const { findMissingOutputs } = require("../../lib/expected-outputs");
37
22
 
38
23
  class InitError extends Error {
39
24
  constructor(msg) { super(msg); this.name = "InitError"; }
@@ -47,8 +32,61 @@ function formatElapsed(ms) {
47
32
  return rem > 0 ? `${min}m ${rem}s` : `${min}m`;
48
33
  }
49
34
 
35
+ // Creates an onTick/clearLine pair for long-running claude -p passes. We can
36
+ // only observe progress externally (elapsed time, sometimes filesystem delta).
37
+ // TTYs get a single \r-rewritten line; CI/piped stdout gets periodic new lines.
38
+ // Modes via opts:
39
+ // - elapsed-only (no baselineCount): ⏳ label running... 45s
40
+ // - file delta (baselineCount set): 📝 label generating... 24 new files | 45s
41
+ // - fixed target (+ totalExpected): 📝 label generating... 8/12 files (67%) | 45s
42
+ function makePassTicker(label, startTime, opts = {}) {
43
+ const isTTY = Boolean(process.stdout.isTTY);
44
+ const { baselineCount, totalExpected } = opts;
45
+ const trackFiles = typeof baselineCount === "number";
46
+ let lastLineLen = 0;
47
+ function onTick() {
48
+ const elapsed = formatElapsed(Date.now() - startTime);
49
+ let line;
50
+ if (!trackFiles) {
51
+ line = ` ⏳ ${label} running... ${elapsed} elapsed`;
52
+ } else {
53
+ const current = countFiles();
54
+ const delta = typeof current === "number" ? Math.max(0, current - baselineCount) : null;
55
+ let progress;
56
+ if (delta === null) progress = "? new files";
57
+ else if (typeof totalExpected === "number" && totalExpected > 0) {
58
+ const capped = Math.min(delta, totalExpected);
59
+ const pct = Math.round((capped / totalExpected) * 100);
60
+ progress = `${capped}/${totalExpected} files (${pct}%)`;
61
+ } else {
62
+ progress = `${delta} new files`;
63
+ }
64
+ line = ` 📝 ${label} generating... ${progress} | ${elapsed} elapsed`;
65
+ }
66
+ if (isTTY) {
67
+ const pad = " ".repeat(Math.max(0, lastLineLen - line.length));
68
+ process.stdout.write("\r" + line + pad);
69
+ lastLineLen = line.length;
70
+ } else {
71
+ log(line);
72
+ }
73
+ }
74
+ function clearLine() {
75
+ if (isTTY && lastLineLen > 0) {
76
+ process.stdout.write("\r" + " ".repeat(lastLineLen) + "\r");
77
+ lastLineLen = 0;
78
+ }
79
+ }
80
+ return { onTick, clearLine, tickMs: isTTY ? 1000 : 15000 };
81
+ }
82
+
50
83
  async function cmdInit(parsedArgs) {
51
84
  const totalStart = Date.now();
85
+ // Tracks whether we just wiped generated state via --force or "fresh" resume
86
+ // mode. Used by the Pass 3 backfill guard below: fresh/force explicitly
87
+ // means "regenerate from scratch", so a leftover CLAUDE.md from a prior run
88
+ // must NOT cause Pass 3 to be skipped via the v1.7.x migration backfill.
89
+ let wasFreshClean = false;
52
90
 
53
91
  // ─── Prerequisites check ───────────────────────────────────
54
92
  const hasProjectMarker = [".git", "package.json", "build.gradle", "build.gradle.kts", "pom.xml", "pyproject.toml", "requirements.txt"].some(
@@ -87,6 +125,24 @@ async function cmdInit(parsedArgs) {
87
125
  if (!isValidLang(lang)) {
88
126
  throw new InitError(`Unsupported language: "${lang}"\n Supported: ${LANG_CODES.join(", ")}`);
89
127
  }
128
+
129
+ // Early incompatibility check: CLAUDEOS_SKIP_TRANSLATION is a test-only
130
+ // env var that short-circuits lib/memory-scaffold.js translation path.
131
+ // If set AND the user chose a non-English language, Pass 4's static fallback
132
+ // and gap-fill would throw mid-run with a confusing "translation skipped"
133
+ // error. Fail fast here with a clear message so the user can unset the var
134
+ // or pick --lang en before the pipeline starts.
135
+ if (process.env.CLAUDEOS_SKIP_TRANSLATION === "1" && lang !== "en") {
136
+ throw new InitError(
137
+ `CLAUDEOS_SKIP_TRANSLATION=1 is set but --lang='${lang}' requires translation.\n` +
138
+ ` This env var is a test-only escape hatch that blocks calls to \`claude -p\`\n` +
139
+ ` from lib/memory-scaffold.js. Pass 4 would crash later with a hard-to-\n` +
140
+ ` diagnose error.\n\n` +
141
+ ` Either unset the env var: unset CLAUDEOS_SKIP_TRANSLATION\n` +
142
+ ` Or run with English output: npx claudeos-core init --lang en`
143
+ );
144
+ }
145
+
90
146
  process.env.CLAUDEOS_LANG = lang;
91
147
 
92
148
  // ─── Resume / Fresh selection ────────────────────────────
@@ -99,6 +155,19 @@ async function cmdInit(parsedArgs) {
99
155
  // --force: clean all generated files for truly fresh start
100
156
  const genFiles = fs.readdirSync(GENERATED_DIR).filter(f => f.endsWith(".json") || f.endsWith(".md"));
101
157
  for (const f of genFiles) fs.unlinkSync(path.join(GENERATED_DIR, f));
158
+ // Also clean any leftover .staged-rules/ from a prior crashed run
159
+ // (only .json/.md are unlinked above; directories aren't touched).
160
+ const stagedDir = path.join(GENERATED_DIR, ".staged-rules");
161
+ if (fileExists(stagedDir)) fs.rmSync(stagedDir, { recursive: true, force: true });
162
+ // Also wipe .claude/rules/ so Guard 2 (zero-rules detection) can't
163
+ // false-negative on stale rules from a previous run when the fresh
164
+ // Pass 3 run fails silently (e.g. Claude ignores staging-override).
165
+ // Step [2] recreates the subdirs from scratch. Any manual edits the
166
+ // user made to rule files are lost — acceptable under --force
167
+ // ("truly fresh start").
168
+ const rulesDir = path.join(PROJECT_ROOT, ".claude/rules");
169
+ if (fileExists(rulesDir)) fs.rmSync(rulesDir, { recursive: true, force: true });
170
+ wasFreshClean = true;
102
171
  log(" 🔄 Previous results deleted (--force)\n");
103
172
  } else {
104
173
  const status = { pass1Done: existingPass1.length, pass2Done: pass2Exists };
@@ -107,6 +176,20 @@ async function cmdInit(parsedArgs) {
107
176
  if (mode === "fresh") {
108
177
  for (const f of existingPass1) fs.unlinkSync(path.join(GENERATED_DIR, f));
109
178
  if (pass2Exists) fs.unlinkSync(path.join(GENERATED_DIR, "pass2-merged.json"));
179
+ // Also reset pass 3 & pass 4 markers so they re-run
180
+ const pass3M = path.join(GENERATED_DIR, "pass3-complete.json");
181
+ const pass4M = path.join(GENERATED_DIR, "pass4-memory.json");
182
+ if (fileExists(pass3M)) fs.unlinkSync(pass3M);
183
+ if (fileExists(pass4M)) fs.unlinkSync(pass4M);
184
+ // Clean .staged-rules/ leftover from a prior crashed run (same reason as --force branch).
185
+ const stagedDir = path.join(GENERATED_DIR, ".staged-rules");
186
+ if (fileExists(stagedDir)) fs.rmSync(stagedDir, { recursive: true, force: true });
187
+ // Wipe .claude/rules/ for the same Guard 2 false-negative reason as
188
+ // the --force branch. Step [2] recreates the subdirs; any manual
189
+ // edits are lost — acceptable under an explicit "fresh" choice.
190
+ const rulesDir = path.join(PROJECT_ROOT, ".claude/rules");
191
+ if (fileExists(rulesDir)) fs.rmSync(rulesDir, { recursive: true, force: true });
192
+ wasFreshClean = true;
110
193
  } else if (mode === "continue" && existingPass1.length === 0 && pass2Exists) {
111
194
  // pass2 exists but no pass1 → pass2 is stale, force re-run
112
195
  fs.unlinkSync(path.join(GENERATED_DIR, "pass2-merged.json"));
@@ -118,7 +201,7 @@ async function cmdInit(parsedArgs) {
118
201
 
119
202
  log("");
120
203
  log("╔════════════════════════════════════════════════════╗");
121
- log("║ ClaudeOS-Core — Bootstrap (3-Pass) ║");
204
+ log("║ ClaudeOS-Core — Bootstrap (4-Pass) ║");
122
205
  log("╚════════════════════════════════════════════════════╝");
123
206
  log(` Project root: ${PROJECT_ROOT}`);
124
207
  log(` Language: ${SUPPORTED_LANGS[lang]} (${lang})`);
@@ -160,6 +243,8 @@ async function cmdInit(parsedArgs) {
160
243
  "claudeos-core/guide/04.architecture",
161
244
  "claudeos-core/database",
162
245
  "claudeos-core/mcp-guide",
246
+ "claudeos-core/memory",
247
+ ".claude/rules/60.memory",
163
248
  ];
164
249
  for (const d of dirs) {
165
250
  ensureDir(path.join(PROJECT_ROOT, d));
@@ -205,8 +290,8 @@ async function cmdInit(parsedArgs) {
205
290
  throw new InitError(`domain-groups.json is malformed: expected ${totalGroups} groups, found ${domainGroups.groups ? domainGroups.groups.length : 0}`);
206
291
  }
207
292
 
208
- // Progress tracking: Pass 1 (N groups) + Pass 2 + Pass 3 = totalSteps
209
- const totalSteps = totalGroups + 2;
293
+ // Progress tracking: Pass 1 (N groups) + Pass 2 + Pass 3 + Pass 4 = totalSteps
294
+ const totalSteps = totalGroups + 3;
210
295
  let completedSteps = 0;
211
296
  const stepTimes = [];
212
297
  const passStart = Date.now();
@@ -256,15 +341,22 @@ async function cmdInit(parsedArgs) {
256
341
  throw new InitError(`No pass1 prompt found for type: ${groupType}`);
257
342
  }
258
343
 
259
- // Placeholder substitution
344
+ // Placeholder substitution — use replacement functions (not string form)
345
+ // so that `$`, `$1`, `$&`, `$$` etc. in domainList are preserved as
346
+ // literal characters rather than interpreted as regex back-references.
347
+ // (Same bug class as bug #18 in lib/plan-parser.js replaceFileBlock.)
260
348
  let prompt = template
261
- .replace(/\{\{DOMAIN_GROUP\}\}/g, domainList)
262
- .replace(/\{\{PASS_NUM\}\}/g, String(i));
349
+ .replace(/\{\{DOMAIN_GROUP\}\}/g, () => domainList)
350
+ .replace(/\{\{PASS_NUM\}\}/g, () => String(i));
263
351
  prompt = injectProjectRoot(prompt);
264
352
 
265
- log(claudeWaitMsg(lang, `Pass 1-${i}/${totalGroups}`));
266
353
  const t1 = Date.now();
267
- const ok = runClaudePrompt(prompt, { ignoreError: true });
354
+ const ticker1 = makePassTicker(`Pass 1-${i}/${totalGroups}`, t1);
355
+ const ok = await runClaudePromptAsync(prompt, {
356
+ onTick: ticker1.onTick,
357
+ tickMs: ticker1.tickMs,
358
+ });
359
+ ticker1.clearLine();
268
360
  const elapsed1 = Date.now() - t1;
269
361
  stepTimes.push(elapsed1);
270
362
 
@@ -285,7 +377,29 @@ async function cmdInit(parsedArgs) {
285
377
  header("[5] Pass 2 — Merging analysis results...");
286
378
 
287
379
  const pass2Json = path.join(GENERATED_DIR, "pass2-merged.json");
380
+
381
+ // H3: resume-path structural validation. existsSync alone isn't enough —
382
+ // a prior crashed run may have left a skeleton (`{}`) or malformed JSON
383
+ // that passes existsSync but silently poisons Pass 3 (which parses this
384
+ // file as its analysis input). Mirrors pass1's malformed-detection at
385
+ // the pass1 loop above, and the "<5 top-level keys = INSUFFICIENT_KEYS"
386
+ // threshold from pass-json-validator/index.js (ERROR level).
387
+ let pass2IsValid = false;
288
388
  if (fileExists(pass2Json)) {
389
+ try {
390
+ const existing = JSON.parse(readFile(pass2Json));
391
+ if (existing && typeof existing === "object" && !Array.isArray(existing)
392
+ && Object.keys(existing).length >= 5) {
393
+ pass2IsValid = true;
394
+ }
395
+ } catch (_e) { /* malformed — fall through to re-run */ }
396
+ if (!pass2IsValid) {
397
+ log(" ⚠️ pass2-merged.json exists but is malformed or incomplete (<5 top-level keys), re-running");
398
+ try { fs.unlinkSync(pass2Json); } catch (_e) { /* best-effort cleanup */ }
399
+ }
400
+ }
401
+
402
+ if (pass2IsValid) {
289
403
  log(" ⏭️ pass2-merged.json already exists, skipping");
290
404
  completedSteps++;
291
405
  } else {
@@ -295,9 +409,13 @@ async function cmdInit(parsedArgs) {
295
409
  }
296
410
  let prompt = injectProjectRoot(readFile(pass2PromptFile));
297
411
 
298
- log(claudeWaitMsg(lang, "Pass 2"));
299
412
  const t2 = Date.now();
300
- const ok = runClaudePrompt(prompt, { ignoreError: true });
413
+ const ticker2 = makePassTicker("Pass 2", t2);
414
+ const ok = await runClaudePromptAsync(prompt, {
415
+ onTick: ticker2.onTick,
416
+ tickMs: ticker2.tickMs,
417
+ });
418
+ ticker2.clearLine();
301
419
  const elapsed2 = Date.now() - t2;
302
420
  stepTimes.push(elapsed2);
303
421
 
@@ -317,31 +435,413 @@ async function cmdInit(parsedArgs) {
317
435
  // ─── [6] Pass 3: Generate + verify ─────────────────────────
318
436
  header("[6] Pass 3 — Generating all files...");
319
437
 
320
- const pass3PromptFile = path.join(GENERATED_DIR, "pass3-prompt.md");
321
- if (!fileExists(pass3PromptFile)) {
322
- throw new InitError("pass3-prompt.md not found. Re-run plan-installer.");
438
+ const pass3Marker = path.join(GENERATED_DIR, "pass3-complete.json");
439
+ const claudeMdPath = path.join(PROJECT_ROOT, "CLAUDE.md");
440
+
441
+ // v1.7.1 → v1.7.2 migration: if CLAUDE.md exists from prior version but marker
442
+ // is missing, backfill marker to preserve user's existing output.
443
+ //
444
+ // Gated by !wasFreshClean: --force and "fresh" resume mode wipe the marker
445
+ // on purpose to force Pass 3 to re-run. They do NOT delete CLAUDE.md (user
446
+ // may have manual edits worth preserving in the continue flow), so without
447
+ // this gate the backfill would fire on a fresh run, re-write the marker,
448
+ // and Pass 3 would skip — leaving stale CLAUDE.md + regenerated pass1/2 +
449
+ // wiped rules/, which fails sync-checker and content-validator.
450
+ if (!wasFreshClean && fileExists(claudeMdPath) && !fileExists(pass3Marker) && fileExists(path.join(GENERATED_DIR, "pass2-merged.json"))) {
451
+ const { writeFileSafe: wfsMig } = require("../../lib/safe-fs");
452
+ const backfillOk = wfsMig(pass3Marker, JSON.stringify({
453
+ completedAt: new Date().toISOString(),
454
+ backfilled: true,
455
+ reason: "CLAUDE.md exists from prior version; marker backfilled to prevent regeneration. To force re-run, use --force or pick 'fresh' in the resume prompt.",
456
+ }, null, 2));
457
+ if (backfillOk) {
458
+ log(" ℹ️ Detected existing CLAUDE.md — Pass 3 marker backfilled (use --force to regenerate)");
459
+ } else {
460
+ log(" ⚠️ CLAUDE.md exists but marker backfill failed — Pass 3 will re-run");
461
+ }
462
+ }
463
+
464
+ // Stale marker detection: if marker exists but CLAUDE.md was deleted externally,
465
+ // treat marker as stale and re-run (user clearly wants regeneration).
466
+ //
467
+ // Unlink is surfaced as InitError on failure (symmetric with Pass 4
468
+ // dropStalePass4Marker). Silently ignoring the error would leave the stale
469
+ // marker in place, and the `if (fileExists(pass3Marker))` check below would
470
+ // accept it — skipping Pass 3 while CLAUDE.md is still missing. That
471
+ // silent-skip is the exact bug class this audit round closes.
472
+ if (fileExists(pass3Marker) && !fileExists(claudeMdPath)) {
473
+ log(" ⚠️ pass3-complete.json exists but CLAUDE.md is missing — treating marker as stale, re-running Pass 3");
474
+ try { fs.unlinkSync(pass3Marker); } catch (e) {
475
+ log(` ❌ Failed to delete stale pass3-complete.json: ${e.code || e.message}`);
476
+ throw new InitError(
477
+ `Could not delete stale pass3-complete.json at:\n ${pass3Marker}\n` +
478
+ ` The file is likely locked by another process (Windows antivirus or a file-watcher).\n` +
479
+ ` Close any editor/AV scanner holding the file and re-run \`npx claudeos-core init\`.`
480
+ );
481
+ }
323
482
  }
324
- let prompt = injectProjectRoot(readFile(pass3PromptFile));
325
483
 
326
- log(claudeWaitMsg(lang, "Pass 3"));
327
- const t3 = Date.now();
328
- const ok3 = runClaudePrompt(prompt, { ignoreError: true });
329
- const elapsed3 = Date.now() - t3;
330
- stepTimes.push(elapsed3);
484
+ if (fileExists(pass3Marker)) {
485
+ log(" ⏭️ pass3-complete.json already exists, skipping");
486
+ completedSteps++;
487
+ } else {
488
+ const pass3PromptFile = path.join(GENERATED_DIR, "pass3-prompt.md");
489
+ if (!fileExists(pass3PromptFile)) {
490
+ throw new InitError("pass3-prompt.md not found. Re-run plan-installer.");
491
+ }
492
+ let prompt = injectProjectRoot(readFile(pass3PromptFile));
493
+
494
+ // Clear any stale .staged-rules/ before running Claude, so we don't
495
+ // accidentally move leftover files from a prior crashed run alongside
496
+ // the new output. Safe no-op when the dir doesn't exist.
497
+ const stagedBeforeP3 = path.join(GENERATED_DIR, ".staged-rules");
498
+ if (fileExists(stagedBeforeP3)) fs.rmSync(stagedBeforeP3, { recursive: true, force: true });
499
+
500
+ const t3 = Date.now();
501
+ // Pass 3 writes many files across .claude/ and claudeos-core/; we can't
502
+ // know the total in advance (stack-dependent), so we show the delta only.
503
+ const ticker3 = makePassTicker("Pass 3", t3, { baselineCount: countFiles() });
504
+ const ok3 = await runClaudePromptAsync(prompt, {
505
+ onTick: ticker3.onTick,
506
+ tickMs: ticker3.tickMs,
507
+ });
508
+ ticker3.clearLine();
509
+ const elapsed3 = Date.now() - t3;
510
+ stepTimes.push(elapsed3);
511
+
512
+ if (!ok3) {
513
+ throw new InitError("Pass 3 failed. Check the claude error output above.\n If this persists, try: npx claudeos-core init --force");
514
+ }
515
+
516
+ // Move rule files that Pass 3 wrote to the staging dir (workaround for
517
+ // Claude Code's .claude/ sensitive-path block). See lib/staged-rules.js.
518
+ const { moveStagedRules: mvP3, countFilesRecursive } = require("../../lib/staged-rules");
519
+ const p3Move = mvP3(PROJECT_ROOT);
520
+ if (p3Move.failed > 0) {
521
+ log(` ⚠️ Pass 3 staged-rules: ${p3Move.moved} moved, ${p3Move.failed} failed`);
522
+ for (const err of p3Move.errors) log(` • ${err}`);
523
+ } else if (p3Move.moved > 0) {
524
+ log(` 📦 Pass 3 staged-rules: ${p3Move.moved} rule files moved to .claude/rules/`);
525
+ }
526
+
527
+ // Guard 1 (Risk #1): Partial move failure. We do NOT write the pass3
528
+ // completion marker, so the next `init` run re-executes Pass 3 via the
529
+ // continue-mode path. Transient causes (Windows file locks, antivirus
530
+ // scanners) usually clear on retry. The partially-moved rules stay in
531
+ // .claude/rules/ — they're overwritten on re-run.
532
+ if (p3Move.failed > 0) {
533
+ throw new InitError(
534
+ `Pass 3 finished but ${p3Move.failed} rule file(s) could not be moved from staging.\n` +
535
+ ` See the warnings above. This is usually a transient file-lock issue.\n` +
536
+ ` Re-run \`npx claudeos-core init\` — Pass 3 will retry automatically.`
537
+ );
538
+ }
331
539
 
332
- if (!ok3) {
333
- throw new InitError("Pass 3 failed. Check the claude error output above.\n If this persists, try: npx claudeos-core init --force");
540
+ // Guard 2 (Risk #2): Empty .claude/rules/. If Claude ignored the
541
+ // staging-override directive and tried to write directly to .claude/,
542
+ // those writes are blocked by Claude Code and the staging dir stays
543
+ // empty. Pass 3 reliably generates at least 00.standard-reference.md,
544
+ // so zero files is a strong signal that generation failed silently.
545
+ const ruleFilesCount = countFilesRecursive(path.join(PROJECT_ROOT, ".claude/rules"));
546
+ if (ruleFilesCount === 0) {
547
+ throw new InitError(
548
+ "Pass 3 produced 0 rule files under .claude/rules/.\n" +
549
+ " This usually means Claude ignored the staging-override directive\n" +
550
+ " and attempted to write to .claude/ directly, where Claude Code's\n" +
551
+ " sensitive-path policy blocks writes.\n" +
552
+ " Re-run with --force: `npx claudeos-core init --force`"
553
+ );
554
+ }
555
+
556
+ if (!fileExists(claudeMdPath)) {
557
+ throw new InitError("CLAUDE.md was not created. Claude ran but did not produce CLAUDE.md.\n Verify pass3-prompt.md instructs Claude to create CLAUDE.md at project root.");
558
+ }
559
+
560
+ // Guard 3 (Risk #3): Incomplete generation. Claude occasionally truncates
561
+ // mid-response after writing CLAUDE.md + rules/ but before reaching the
562
+ // guide/ section of the prompt. It also occasionally writes only a heading
563
+ // and truncates before the body — giving us an empty file that satisfies
564
+ // existsSync but fails content-validator's trim-length check. Both cases
565
+ // leave the project permanently broken on subsequent runs (step [8]
566
+ // content-validator errors are non-fatal), so gate the marker here.
567
+ const guideDir = path.join(PROJECT_ROOT, "claudeos-core/guide");
568
+ const missingOrEmptyGuides = EXPECTED_GUIDE_FILES.filter(g => {
569
+ const fp = path.join(guideDir, g);
570
+ if (!fileExists(fp)) return true;
571
+ try {
572
+ // Strip UTF-8 BOM before trim — String.prototype.trim doesn't remove
573
+ // U+FEFF (not in Unicode White_Space). Otherwise a BOM-only file
574
+ // (3 bytes, no text) would pass the empty check and Guard 3 would
575
+ // silently accept it. Mirrors content-validator/index.js:115.
576
+ return fs.readFileSync(fp, "utf-8").replace(/^\uFEFF/, "").trim().length === 0;
577
+ } catch (_e) { return true; } // unreadable counts as missing
578
+ });
579
+ if (missingOrEmptyGuides.length > 0) {
580
+ const preview = missingOrEmptyGuides.slice(0, 5).map(g => ` • claudeos-core/guide/${g}`).join("\n");
581
+ const more = missingOrEmptyGuides.length > 5 ? `\n • ... and ${missingOrEmptyGuides.length - 5} more` : "";
582
+ throw new InitError(
583
+ `Pass 3 produced CLAUDE.md and rules but ${missingOrEmptyGuides.length}/${EXPECTED_GUIDE_FILES.length} guide files are missing or empty:\n` +
584
+ preview + more + "\n" +
585
+ " Claude likely truncated the response before reaching or finishing the guide/ section.\n" +
586
+ " Re-run with --force: `npx claudeos-core init --force`"
587
+ );
588
+ }
589
+
590
+ // Guard 3 extension (H1): The same truncation pattern can cut off Claude's
591
+ // response AFTER the guide/ section but before standard/, skills/, or
592
+ // plan/. content-validator flags these as ERROR-level but step [8] runs
593
+ // with ignoreError:true so nothing blocks the marker. Validate each
594
+ // directory here — a specific sentinel file for standard/, and a
595
+ // "≥1 non-empty .md" check for skills/ and plan/. database/ and
596
+ // mcp-guide/ are intentionally excluded (validator: WARNING-level; stacks
597
+ // legitimately produce zero files when no DB or MCP integration exists).
598
+ const missingOutputs = findMissingOutputs(PROJECT_ROOT);
599
+ if (missingOutputs.length > 0) {
600
+ const preview = missingOutputs.map(m => ` • ${m}`).join("\n");
601
+ throw new InitError(
602
+ `Pass 3 finished but the following required output(s) are missing or empty:\n` +
603
+ preview + "\n" +
604
+ " Claude likely truncated the response before completing all output sections.\n" +
605
+ " Re-run with --force: `npx claudeos-core init --force`"
606
+ );
607
+ }
608
+
609
+ // Write completion marker so subsequent `init` runs skip Pass 3 under "continue" mode.
610
+ const { writeFileSafe: wfs } = require("../../lib/safe-fs");
611
+ const markerOk = wfs(pass3Marker, JSON.stringify({ completedAt: new Date().toISOString() }, null, 2));
612
+ if (!markerOk) {
613
+ throw new InitError(`Failed to write ${path.basename(pass3Marker)}. Check disk space and permissions on claudeos-core/generated/.\n Without this marker, subsequent \`init\` runs will regenerate CLAUDE.md.`);
614
+ }
615
+ completedSteps++;
616
+ progressBar(completedSteps, `Pass 3 complete (${formatElapsed(elapsed3)})`);
617
+ }
618
+ log("");
619
+
620
+ // ─── [7] Pass 4: L4 memory scaffolding ────────────
621
+ header("[7] Pass 4 — Memory scaffolding...");
622
+
623
+ const pass4Marker = path.join(GENERATED_DIR, "pass4-memory.json");
624
+ const pass4PromptFile = path.join(GENERATED_DIR, "pass4-prompt.md");
625
+
626
+ const { scaffoldMemory, scaffoldRules, appendClaudeMdL4Memory, scaffoldMasterPlans, scaffoldDocWritingGuide } = require("../../lib/memory-scaffold");
627
+ const { writeFileSafe } = require("../../lib/safe-fs");
628
+
629
+ const memoryPath = path.join(PROJECT_ROOT, "claudeos-core/memory");
630
+ const planPath = path.join(PROJECT_ROOT, "claudeos-core/plan");
631
+ const rulesPath = path.join(PROJECT_ROOT, ".claude/rules");
632
+ const standardCorePath = path.join(PROJECT_ROOT, "claudeos-core/standard/00.core");
633
+
634
+ function applyStaticFallback() {
635
+ try {
636
+ scaffoldMemory(memoryPath, { lang });
637
+ scaffoldRules(rulesPath, { lang });
638
+ scaffoldDocWritingGuide(standardCorePath, { lang });
639
+ scaffoldMasterPlans(planPath, memoryPath, { lang });
640
+ appendClaudeMdL4Memory(claudeMdPath, { lang });
641
+ } catch (err) {
642
+ // When lang !== "en", translation is REQUIRED. If it fails, we surface
643
+ // a clear error rather than silently writing English (which would
644
+ // contradict the user's --lang choice).
645
+ throw new InitError(
646
+ `Static fallback failed while translating to lang='${lang}':\n` +
647
+ ` ${err.message}\n` +
648
+ ` Ensure the \`claude\` CLI is available and authenticated, then re-run.\n` +
649
+ ` Alternatively re-run with --lang en to skip translation.`
650
+ );
651
+ }
652
+ const markerBody = JSON.stringify({
653
+ analyzedAt: new Date().toISOString(),
654
+ passNum: 4,
655
+ fallback: true,
656
+ lang,
657
+ memoryFiles: [
658
+ "claudeos-core/memory/decision-log.md",
659
+ "claudeos-core/memory/failure-patterns.md",
660
+ "claudeos-core/memory/compaction.md",
661
+ "claudeos-core/memory/auto-rule-update.md",
662
+ ],
663
+ ruleFiles: [
664
+ ".claude/rules/00.core/51.doc-writing-rules.md",
665
+ ".claude/rules/00.core/52.ai-work-rules.md",
666
+ ".claude/rules/60.memory/01.decision-log.md",
667
+ ".claude/rules/60.memory/02.failure-patterns.md",
668
+ ".claude/rules/60.memory/03.compaction.md",
669
+ ".claude/rules/60.memory/04.auto-rule-update.md",
670
+ ],
671
+ planFiles: [
672
+ "claudeos-core/plan/50.memory-master.md",
673
+ ],
674
+ claudeMdAppended: true,
675
+ }, null, 2);
676
+ return writeFileSafe(pass4Marker, markerBody);
677
+ }
678
+
679
+ // M1: validate Pass 4 marker CONTENT, not just existence. Claude can emit
680
+ // a malformed marker on partial failure (e.g. `{"error":"timeout"}`) that
681
+ // still satisfies fileExists() and would cause the skip path to accept it
682
+ // forever. Pass 4 prompt (pass-prompts/templates/common/pass4.md:255-283)
683
+ // specifies the required structure; we check the minimum subset that
684
+ // distinguishes a real marker from junk: object shape + passNum === 4 +
685
+ // non-empty memoryFiles array.
686
+ function isValidPass4Marker(markerPath) {
687
+ if (!fileExists(markerPath)) return false;
688
+ try {
689
+ const data = JSON.parse(readFile(markerPath));
690
+ if (!data || typeof data !== "object" || Array.isArray(data)) return false;
691
+ if (data.passNum !== 4) return false;
692
+ if (!Array.isArray(data.memoryFiles) || data.memoryFiles.length === 0) return false;
693
+ return true;
694
+ } catch (_e) { return false; }
334
695
  }
335
696
 
336
- if (!fileExists(path.join(PROJECT_ROOT, "CLAUDE.md"))) {
337
- throw new InitError("CLAUDE.md was not created. Claude ran but did not produce CLAUDE.md.\n Verify pass3-prompt.md instructs Claude to create CLAUDE.md at project root.");
697
+ // Stale / invalid marker detection. Delete and re-run if either:
698
+ // (a) memory/ was deleted externally (original stale-detection), OR
699
+ // (b) the marker file itself is malformed (M1).
700
+ //
701
+ // Unlink can fail on Windows if an AV or file-watcher has the handle open.
702
+ // We surface that failure to the user — without it, the subsequent
703
+ // `if (fileExists(pass4Marker))` check below would accept the stale marker
704
+ // and skip Pass 4 silently, leaving the project with half-scaffolded memory
705
+ // state (exactly the silent-failure class this audit is eliminating).
706
+ const memoryAny = fileExists(path.join(PROJECT_ROOT, "claudeos-core/memory/decision-log.md"))
707
+ || fileExists(path.join(PROJECT_ROOT, "claudeos-core/memory/compaction.md"));
708
+ function dropStalePass4Marker(reasonLog) {
709
+ log(reasonLog);
710
+ try { fs.unlinkSync(pass4Marker); } catch (e) {
711
+ log(` ❌ Failed to delete stale pass4-memory.json: ${e.code || e.message}`);
712
+ throw new InitError(
713
+ `Could not delete stale pass4-memory.json at:\n ${pass4Marker}\n` +
714
+ ` The file is likely locked by another process (Windows antivirus or a file-watcher).\n` +
715
+ ` Close any editor/AV scanner holding the file and re-run \`npx claudeos-core init\`.`
716
+ );
717
+ }
718
+ }
719
+ if (fileExists(pass4Marker)) {
720
+ if (!memoryAny) {
721
+ dropStalePass4Marker(" ⚠️ pass4-memory.json exists but memory/ is empty — re-running Pass 4");
722
+ } else if (!isValidPass4Marker(pass4Marker)) {
723
+ dropStalePass4Marker(" ⚠️ pass4-memory.json exists but is malformed (missing passNum/memoryFiles) — re-running Pass 4");
724
+ }
725
+ }
726
+
727
+ const pass4Start = Date.now();
728
+ let pass4Label = "Pass 4 complete";
729
+
730
+ if (fileExists(pass4Marker)) {
731
+ log(" ⏭️ pass4-memory.json already exists, skipping");
732
+ pass4Label = "Pass 4 already present";
733
+ } else if (!fileExists(pass4PromptFile)) {
734
+ log(" ⚠️ pass4-prompt.md not found — falling back to static scaffold");
735
+ if (applyStaticFallback()) { log(" ✅ Memory/Rules/Plans scaffolded + CLAUDE.md appended (static fallback)"); pass4Label = "Pass 4 (static fallback)"; }
736
+ else { log(" ❌ Static fallback failed to write marker"); pass4Label = "Pass 4 fallback failed"; }
737
+ } else {
738
+ let prompt4 = injectProjectRoot(readFile(pass4PromptFile));
739
+
740
+ // Same stale-staging guard as Pass 3 (in case a previous Pass 4 crashed
741
+ // after writing to the staging dir but before the move could run).
742
+ const stagedBeforeP4 = path.join(GENERATED_DIR, ".staged-rules");
743
+ if (fileExists(stagedBeforeP4)) fs.rmSync(stagedBeforeP4, { recursive: true, force: true });
744
+
745
+ const t4 = Date.now();
746
+ // Pass 4 creates 12 files in total, but the 6 rule files go to
747
+ // .staged-rules/ (i.e. under claudeos-core/generated/) which countFiles()
748
+ // deliberately skips. So the ticker can only observe 6 files during the
749
+ // run: 4 memory + 1 plan + 1 standard. We set totalExpected to that
750
+ // observable max; the final "100%" shows up on the outer progressBar
751
+ // once the staged move + marker are done.
752
+ const ticker4 = makePassTicker("Pass 4", t4, {
753
+ baselineCount: countFiles(),
754
+ totalExpected: 6,
755
+ });
756
+ const ok4 = await runClaudePromptAsync(prompt4, {
757
+ onTick: ticker4.onTick,
758
+ tickMs: ticker4.tickMs,
759
+ });
760
+ ticker4.clearLine();
761
+ const elapsed4 = Date.now() - t4;
762
+
763
+ // Move any rule files Pass 4 wrote to the staging dir. This runs regardless
764
+ // of pass4Marker status, because Claude may have written rules before
765
+ // failing to produce the marker. Static fallback (below) writes directly
766
+ // to .claude/rules/ and is unaffected by the move (no-op on empty staging).
767
+ const { moveStagedRules: mvP4 } = require("../../lib/staged-rules");
768
+ const p4Move = mvP4(PROJECT_ROOT);
769
+ if (p4Move.failed > 0) {
770
+ log(` ⚠️ Pass 4 staged-rules: ${p4Move.moved} moved, ${p4Move.failed} failed`);
771
+ for (const err of p4Move.errors) log(` • ${err}`);
772
+ } else if (p4Move.moved > 0) {
773
+ log(` 📦 Pass 4 staged-rules: ${p4Move.moved} rule files moved to .claude/rules/`);
774
+ }
775
+
776
+ if (!ok4 || !isValidPass4Marker(pass4Marker)) {
777
+ log(" ⚠️ Pass 4 did not produce a valid pass4-memory.json — using static fallback");
778
+ if (applyStaticFallback()) { log(" ✅ Memory/Rules/Plans scaffolded + CLAUDE.md appended (static fallback)"); pass4Label = `Pass 4 (static fallback, ${formatElapsed(elapsed4)})`; }
779
+ else { log(" ❌ Static fallback failed to write marker"); pass4Label = "Pass 4 fallback failed"; }
780
+ } else {
781
+ // Claude-driven Pass 4 succeeded. Ensure memory + rules + plans + standard + CLAUDE.md append exist
782
+ // (Claude may have created them; fill any gaps with static fallback).
783
+ // scaffoldMemory is skip-safe: it only writes files that don't already exist,
784
+ // so it won't overwrite Claude's translated content — it only fills gaps.
785
+ // When lang !== "en", the gap-fill MUST translate: we do not silently
786
+ // write English into a non-English project (bug #21).
787
+ let gapResults;
788
+ try {
789
+ const memR = scaffoldMemory(memoryPath, { lang });
790
+ const ruleR = scaffoldRules(rulesPath, { lang });
791
+ const docR = scaffoldDocWritingGuide(standardCorePath, { lang });
792
+ const planR = scaffoldMasterPlans(planPath, memoryPath, { lang });
793
+ const claudeOk = appendClaudeMdL4Memory(claudeMdPath, { lang });
794
+ // Collect all statuses into one flat array for summary reporting.
795
+ gapResults = [
796
+ ...memR,
797
+ ...ruleR,
798
+ ...planR,
799
+ { file: docR.file, status: docR.status },
800
+ { file: "CLAUDE.md#(L4)", status: claudeOk ? "present-or-appended" : "error" },
801
+ ];
802
+ } catch (err) {
803
+ throw new InitError(
804
+ `Pass 4 gap-fill failed while translating to lang='${lang}':\n` +
805
+ ` ${err.message}\n` +
806
+ ` Pass 4 Claude succeeded but some files were missing and translation of the ` +
807
+ `static fallback to ${lang} failed.\n` +
808
+ ` Re-run when \`claude\` CLI is available, or use --lang en.`
809
+ );
810
+ }
811
+ // Summary: how many were already present (skipped) vs written by gap-fill.
812
+ // This surfaces the true state regardless of what Claude printed during Pass 4.
813
+ const skipped = gapResults.filter(r => r.status === "skipped" || r.status === "present-or-appended").length;
814
+ const written = gapResults.filter(r => r.status === "written").length;
815
+ const erroredItems = gapResults.filter(r => r.status === "error");
816
+ const errored = erroredItems.length;
817
+ log(` ✅ Pass 4 complete (${formatElapsed(elapsed4)})`);
818
+ if (written > 0 || errored > 0) {
819
+ log(` 📋 Gap-fill: ${skipped} already present, ${written} created via fallback, ${errored} errored`);
820
+ } else {
821
+ log(` 📋 Gap-fill: all ${skipped} expected files already present`);
822
+ }
823
+ // Surface which files errored so the user can investigate (instead of
824
+ // silently rolling them into a count). Common causes: write permission,
825
+ // disk full, or appendClaudeMdL4Memory returned false (CLAUDE.md missing
826
+ // or unwritable). All erroredItems are non-fatal — Pass 4 marker still
827
+ // gets written, but the user should know what was incomplete.
828
+ for (const item of erroredItems) {
829
+ log(` ❌ ${item.file} — write failed (check disk space, permissions)`);
830
+ }
831
+ pass4Label = `Pass 4 complete (${formatElapsed(elapsed4)})`;
832
+ }
338
833
  }
834
+ // Record Pass 4 in the overall progress bar, regardless of which branch
835
+ // (skip / static fallback / Claude-driven) ran above. Only feed stepTimes
836
+ // when we actually did real work, so ETA for future steps stays meaningful.
837
+ const pass4Elapsed = Date.now() - pass4Start;
838
+ if (pass4Elapsed > 500) stepTimes.push(pass4Elapsed);
339
839
  completedSteps++;
340
- progressBar(completedSteps, `Pass 3 complete (${formatElapsed(elapsed3)})`);
840
+ progressBar(completedSteps, pass4Label);
341
841
  log("");
342
842
 
343
- // ─── [7] Run verification tools ───────────────────────────────
344
- header("[7] Running verification tools...");
843
+ // ─── [8] Run verification tools ───────────────────────────────
844
+ header("[8] Running verification tools...");
345
845
 
346
846
  const verifyTools = [
347
847
  { name: "manifest-generator", script: path.join(TOOLS_DIR, "manifest-generator/index.js") },
@@ -365,14 +865,18 @@ async function cmdInit(parsedArgs) {
365
865
  const pass1Files = countPass1Files();
366
866
 
367
867
  log("");
868
+ const memoryReady = fileExists(path.join(PROJECT_ROOT, "claudeos-core/memory/decision-log.md"));
869
+ const rulesReady = fileExists(path.join(PROJECT_ROOT, ".claude/rules/60.memory/01.decision-log.md"));
870
+ const l4Status = (memoryReady && rulesReady) ? "memory + rules" : "partial";
368
871
  log("╔════════════════════════════════════════════════════╗");
369
872
  log("║ ✅ ClaudeOS-Core — Complete ║");
370
873
  log("║ ║");
371
874
  log(`║ Files created: ${pad(String(totalFiles), 29)}║`);
372
875
  log(`║ Domains analyzed: ${pad(totalGroups + " groups", 29)}║`);
373
876
  log(`║ Analysis passes: ${pad(pass1Files + " pass1 files", 29)}║`);
877
+ log(`║ L4 scaffolded: ${pad(l4Status, 29)}║`);
374
878
  log(`║ Output language: ${pad(SUPPORTED_LANGS[lang] || lang, 29)}║`);
375
- log(`║ Total time: ${pad(formatElapsed(Date.now() - totalStart), 29)}║`);
879
+ log(`║ Total time: ${pad(formatElapsed(Date.now() - totalStart), 29)}║`);
376
880
  log("║ ║");
377
881
  log("║ Verify anytime: ║");
378
882
  log("║ npx claudeos-core health ║");