@haposoft/cafekit 0.14.0 → 0.14.2

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 (66) hide show
  1. package/README.md +27 -3
  2. package/bin/__tests__/installer-safety.test.js +145 -0
  3. package/bin/install.js +9 -1
  4. package/bin/lib/backup.js +60 -16
  5. package/bin/lib/context.js +1 -1
  6. package/bin/phases/claude-runtime.js +57 -4
  7. package/bin/phases/post-install.js +39 -26
  8. package/bin/phases/root-config.js +24 -5
  9. package/bin/phases/select-platform.js +14 -9
  10. package/package.json +1 -1
  11. package/src/claude/CLAUDE.md +1 -1
  12. package/src/claude/agents/code-auditor.md +1 -1
  13. package/src/claude/agents/debugger.md +1 -0
  14. package/src/claude/agents/god-developer.md +10 -1
  15. package/src/claude/agents/inspector.md +1 -0
  16. package/src/claude/agents/spec-maker.md +1 -1
  17. package/src/claude/agents/test-runner.md +2 -2
  18. package/src/claude/gitignore +11 -4
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +50 -10
  20. package/src/claude/hooks/__tests__/spec-gate.test.js +75 -1
  21. package/src/claude/hooks/__tests__/state.test.js +65 -0
  22. package/src/claude/hooks/docs-sync.cjs +15 -21
  23. package/src/claude/hooks/inspect-block.cjs +1 -1
  24. package/src/claude/hooks/lib/config.cjs +0 -5
  25. package/src/claude/hooks/privacy-block.cjs +39 -62
  26. package/src/claude/hooks/spec-gate.cjs +18 -7
  27. package/src/claude/hooks/spec-state.cjs +4 -0
  28. package/src/claude/hooks/state.cjs +13 -3
  29. package/src/claude/hooks/usage.cjs +6 -0
  30. package/src/claude/migration-manifest.json +1 -0
  31. package/src/claude/rules/orchestrator.md +2 -2
  32. package/src/claude/rules/skill-domain-routing.md +27 -86
  33. package/src/claude/rules/skill-workflow-routing.md +11 -74
  34. package/src/claude/rules/state-sync.md +1 -1
  35. package/src/claude/runtime.json +7 -7
  36. package/src/claude/scripts/validate-spec-output.cjs +6 -0
  37. package/src/claude/skills/code-review/references/spec-compliance-review.md +1 -1
  38. package/src/claude/skills/delegate/SKILL.md +124 -0
  39. package/src/claude/skills/delegate/references/codex-delegation.md +71 -0
  40. package/src/claude/skills/delegate/references/grok-delegation.md +84 -0
  41. package/src/claude/skills/develop/SKILL.md +26 -5
  42. package/src/claude/skills/develop/references/parallel-waves.md +112 -0
  43. package/src/claude/skills/develop/references/quality-gate.md +10 -2
  44. package/src/claude/skills/hotfix/references/parallel-patterns.md +1 -1
  45. package/src/claude/skills/inspect/SKILL.md +42 -113
  46. package/src/claude/skills/inspect/references/internal-inspection.md +16 -135
  47. package/src/claude/skills/specs/SKILL.md +134 -386
  48. package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
  49. package/src/claude/skills/specs/templates/task.md +3 -1
  50. package/src/claude/skills/sync/SKILL.md +2 -2
  51. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  52. package/src/claude/skills/test/SKILL.md +1 -1
  53. package/src/claude/status.cjs +9 -6
  54. package/src/opencode/plugins/docs-sync.ts +10 -16
  55. package/src/claude/archive-command/code/SKILL.md +0 -55
  56. package/src/claude/archive-command/code/references/execution-loop.md +0 -21
  57. package/src/claude/archive-command/code.md +0 -17
  58. package/src/claude/archive-command/docs.md +0 -609
  59. package/src/claude/archive-command/review/codebase/parallel.md +0 -76
  60. package/src/claude/archive-command/spec-design.md +0 -247
  61. package/src/claude/archive-command/spec-init.md +0 -120
  62. package/src/claude/archive-command/spec-requirements.md +0 -138
  63. package/src/claude/archive-command/spec-status.md +0 -98
  64. package/src/claude/archive-command/spec-tasks.md +0 -173
  65. package/src/claude/archive-command/spec-validate.md +0 -118
  66. package/src/claude/archive-command/test.md +0 -8
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Claude Code-first spec-driven workflow and runtime bundle for AI coding assistants.
4
4
 
5
- [![Version](https://img.shields.io/badge/version-0.9.3-blue.svg)](https://github.com/haposoft/cafekit)
5
+ [![Version](https://img.shields.io/badge/version-0.14.2-blue.svg)](https://github.com/haposoft/cafekit)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
  [![Claude%20Code](https://img.shields.io/badge/Claude%20Code-Primary-orange.svg)](https://claude.ai/code)
8
8
 
@@ -46,6 +46,26 @@ Requirements:
46
46
  - Node.js 18+
47
47
  - Claude Code project with `.claude/`, OpenCode project with `.opencode/` or `opencode.json`, or choose a runtime when prompted
48
48
 
49
+ ## Git ignore policy
50
+
51
+ On install, CafeKit updates the **project-root** `.gitignore` with:
52
+
53
+ ```text
54
+ # CafeKit / Ecosystem
55
+ specs/_shared/
56
+ plans/
57
+ !plans/templates/
58
+ .cafekit-backup/
59
+ .cafekit.lock
60
+ .claude/
61
+ .opencode/
62
+ ```
63
+
64
+ Runtime folders are local — reinstall with `npx @haposoft/cafekit` rather than
65
+ committing them. Inside the runtime, CafeKit also installs `.claude/.gitignore`
66
+ (or `.opencode/.gitignore`) so secrets, skill dependencies, session state, and
67
+ hook logs stay out of git even if someone force-adds the folder.
68
+
49
69
  ## What Gets Installed
50
70
 
51
71
  Claude Code targets:
@@ -62,10 +82,14 @@ Claude Code targets:
62
82
  ├── cafekit.json
63
83
  ├── status.cjs
64
84
  ├── runtime.json
65
- ├── settings.json
66
- └── CLAUDE.md
85
+ └── settings.json
86
+
87
+ CLAUDE.md
67
88
  ```
68
89
 
90
+ CafeKit owns only its marked block inside the project-root `CLAUDE.md`; project
91
+ instructions outside that block are preserved across installs and upgrades.
92
+
69
93
  OpenCode targets:
70
94
 
71
95
  ```text
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { test } = require('node:test');
8
+
9
+ const backup = require('../lib/backup');
10
+ const { copyClaudeMdFile } = require('../phases/claude-runtime');
11
+ const { configureAddressing } = require('../phases/post-install');
12
+
13
+ const START = '<!-- CAFEKIT CLAUDE START -->';
14
+ const END = '<!-- CAFEKIT CLAUDE END -->';
15
+ const TEMPLATE = fs.readFileSync(path.join(__dirname, '../../src/claude/CLAUDE.md'), 'utf8');
16
+
17
+ function inTempProject(run) {
18
+ const originalCwd = process.cwd();
19
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-installer-safety-'));
20
+ process.chdir(root);
21
+ try {
22
+ return run(root);
23
+ } finally {
24
+ process.chdir(originalCwd);
25
+ fs.rmSync(root, { recursive: true, force: true });
26
+ }
27
+ }
28
+
29
+ function installContext(pruned) {
30
+ return {
31
+ dryRun: false,
32
+ options: { forceOverwrite: true },
33
+ ownership: { '.claude': { files: {} } },
34
+ trackers: {
35
+ claude: {
36
+ recorded: () => null,
37
+ record: () => assert.fail('root CLAUDE.md must not be recorded'),
38
+ prune: (target) => pruned.push(target)
39
+ }
40
+ },
41
+ ui: { detail() {}, warn() {}, success() {} },
42
+ results: {
43
+ copied: 0,
44
+ updated: 0,
45
+ unchanged: 0,
46
+ preserved: 0,
47
+ missingDependencies: 0,
48
+ preservedFiles: []
49
+ }
50
+ };
51
+ }
52
+
53
+ test('managed CLAUDE block replacement preserves user bytes under force overwrite', () => {
54
+ inTempProject(() => {
55
+ const before = '# User preface\n\n';
56
+ const after = '\n\n## User tail\nKeep this exactly.\n';
57
+ fs.writeFileSync('CLAUDE.md', `${before}${START}\nold managed text\n${END}${after}`);
58
+
59
+ const pruned = [];
60
+ copyClaudeMdFile(installContext(pruned), 'claude');
61
+
62
+ const content = fs.readFileSync('CLAUDE.md', 'utf8');
63
+ assert.ok(content.startsWith(before));
64
+ assert.ok(content.endsWith(after));
65
+ assert.ok(content.includes(TEMPLATE.trimEnd()));
66
+ assert.equal(content.match(new RegExp(START, 'g')).length, 1);
67
+ assert.deepEqual(pruned, ['../CLAUDE.md']);
68
+ });
69
+ });
70
+
71
+ test('legacy pristine CLAUDE template migrates to one managed block', () => {
72
+ inTempProject(() => {
73
+ fs.writeFileSync('CLAUDE.md', TEMPLATE);
74
+ const pruned = [];
75
+
76
+ copyClaudeMdFile(installContext(pruned), 'claude');
77
+
78
+ const content = fs.readFileSync('CLAUDE.md', 'utf8');
79
+ assert.equal(content, `${START}\n${TEMPLATE.trimEnd()}\n${END}\n`);
80
+ assert.equal(content.match(new RegExp(START, 'g')).length, 1);
81
+ assert.deepEqual(pruned, ['../CLAUDE.md']);
82
+ });
83
+ });
84
+
85
+ test('addressing changes remain inside the managed CLAUDE block', () => {
86
+ inTempProject(() => {
87
+ const userSection = '## Addressing (Context Overflow Indicator)\n\nUser-owned wording.';
88
+ const managedSection = '## Addressing (Context Overflow Indicator)\n\nOld managed wording.';
89
+ fs.writeFileSync(
90
+ 'CLAUDE.md',
91
+ `${userSection}\n\n${START}\n${managedSection}\n${END}\n`
92
+ );
93
+ const ctx = {
94
+ platforms: ['claude'],
95
+ ui: { success() {}, warn() {} },
96
+ t: () => 'updated'
97
+ };
98
+
99
+ configureAddressing(ctx, 'anh');
100
+
101
+ const content = fs.readFileSync('CLAUDE.md', 'utf8');
102
+ assert.ok(content.startsWith(`${userSection}\n\n${START}`));
103
+ assert.ok(content.includes('Claude Code always addresses the user as "anh"'));
104
+ });
105
+ });
106
+
107
+ test('backup restores existing targets and removes targets absent before the run', () => {
108
+ inTempProject(() => {
109
+ fs.mkdirSync('.claude/nested', { recursive: true });
110
+ fs.writeFileSync('.claude/nested/runtime.txt', 'before\n');
111
+ fs.writeFileSync('CLAUDE.md', 'user instructions\n');
112
+
113
+ const backupDir = backup.snapshot(
114
+ ['.claude', 'CLAUDE.md', '.gitignore'],
115
+ '20260729-test'
116
+ );
117
+ const metadata = JSON.parse(
118
+ fs.readFileSync(path.join(backupDir, 'snapshot.json'), 'utf8')
119
+ );
120
+ assert.deepEqual(metadata.targets, [
121
+ { target: '.claude', existed: true },
122
+ { target: 'CLAUDE.md', existed: true },
123
+ { target: '.gitignore', existed: false }
124
+ ]);
125
+
126
+ fs.writeFileSync('.claude/nested/runtime.txt', 'after\n');
127
+ fs.writeFileSync('CLAUDE.md', 'overwritten\n');
128
+ fs.writeFileSync('.gitignore', 'generated\n');
129
+ backup.restore(backupDir);
130
+
131
+ assert.equal(fs.readFileSync('.claude/nested/runtime.txt', 'utf8'), 'before\n');
132
+ assert.equal(fs.readFileSync('CLAUDE.md', 'utf8'), 'user instructions\n');
133
+ assert.equal(fs.existsSync('.gitignore'), false);
134
+ assert.equal(fs.existsSync(path.join(backupDir, 'snapshot.json')), true);
135
+ });
136
+ });
137
+
138
+ test('backup rejects traversal targets before snapshot or restore deletion', () => {
139
+ inTempProject(() => {
140
+ assert.throws(
141
+ () => backup.snapshot(['../outside'], '20260729-test'),
142
+ /Unsafe backup target/
143
+ );
144
+ });
145
+ });
package/bin/install.js CHANGED
@@ -170,11 +170,19 @@ async function main() {
170
170
  await runPostInstall(ctx);
171
171
  await setupSkillDeps(ctx);
172
172
  await setupRtk(ctx);
173
+
174
+ // Phase handlers may report recoverable-looking errors through counters
175
+ // instead of throwing. Treat them as transactional failure so the snapshot
176
+ // is restored before any success summary or backup pruning occurs.
177
+ if (ctx.results.errors > 0) {
178
+ throw new Error(`Installer reported ${ctx.results.errors} error${ctx.results.errors === 1 ? '' : 's'}`);
179
+ }
180
+
173
181
  printSummary(ctx);
174
182
 
175
183
  if (!ctx.dryRun) backup.prune(3);
176
184
 
177
- exitCode = ctx.results.errors > 0 ? 1 : 0;
185
+ exitCode = 0;
178
186
  } catch (error) {
179
187
  const log = ctx && ctx.ui ? ctx.ui : { error: (m) => console.error(m) };
180
188
  const t = ctx ? ctx.t : (k) => k;
package/bin/lib/backup.js CHANGED
@@ -14,6 +14,33 @@ const fs = require('fs');
14
14
  const path = require('path');
15
15
 
16
16
  const BACKUP_ROOT = '.cafekit-backup';
17
+ const SNAPSHOT_METADATA = 'snapshot.json';
18
+ const SNAPSHOT_DATA = 'data';
19
+
20
+ function validateTarget(target) {
21
+ if (typeof target !== 'string' || !target || target.includes('\0')) {
22
+ throw new Error('Backup target must be a non-empty relative path');
23
+ }
24
+
25
+ // Validate both separator styles so a snapshot created on one platform
26
+ // cannot become unsafe when restored on another.
27
+ const portable = target.replace(/\\/g, '/');
28
+ const parts = portable.split('/');
29
+ if (path.isAbsolute(target) || portable.startsWith('/') || parts.includes('..') || parts.includes('')) {
30
+ throw new Error(`Unsafe backup target: ${target}`);
31
+ }
32
+ if (portable === '.' || portable === BACKUP_ROOT || portable.startsWith(`${BACKUP_ROOT}/`)) {
33
+ throw new Error(`Unsafe backup target: ${target}`);
34
+ }
35
+ return parts.join(path.sep);
36
+ }
37
+
38
+ function validateRunId(runId) {
39
+ if (typeof runId !== 'string' || !runId || runId === '.' || runId === '..' || /[\\/]/.test(runId)) {
40
+ throw new Error('Backup runId must be a single path segment');
41
+ }
42
+ return runId;
43
+ }
17
44
 
18
45
  /**
19
46
  * Recursively copy a directory tree (verbatim, no transforms). Used for
@@ -40,42 +67,59 @@ function copyTree(src, dest) {
40
67
  }
41
68
 
42
69
  /**
43
- * Snapshot the given platform folders (relative to cwd, e.g. ['.claude']).
44
- * Only existing folders are captured. Returns the backup directory path, or
45
- * null if nothing existed to back up (fresh install → no rollback target).
70
+ * Snapshot explicit targets (relative to cwd, e.g. ['.claude', 'CLAUDE.md']).
71
+ * Every target is recorded, including targets absent before the run.
46
72
  *
47
73
  * @param {string[]} folders
48
74
  * @param {string} runId unique id for this run (timestamp-based)
49
75
  */
50
76
  function snapshot(folders, runId) {
51
- const existing = folders.filter((f) => fs.existsSync(f));
52
- if (existing.length === 0) return null;
53
-
54
- const backupDir = path.join(BACKUP_ROOT, runId);
77
+ const targets = [...new Set(folders.map(validateTarget))];
78
+ const backupDir = path.join(BACKUP_ROOT, validateRunId(runId));
79
+ const dataDir = path.join(backupDir, SNAPSHOT_DATA);
55
80
  fs.mkdirSync(backupDir, { recursive: true });
56
81
 
57
- for (const folder of existing) {
58
- copyTree(folder, path.join(backupDir, folder));
82
+ const records = [];
83
+ for (const target of targets) {
84
+ const existed = fs.existsSync(target);
85
+ records.push({ target, existed });
86
+ if (existed) copyTree(target, path.join(dataDir, target));
59
87
  }
88
+ fs.writeFileSync(
89
+ path.join(backupDir, SNAPSHOT_METADATA),
90
+ `${JSON.stringify({ schemaVersion: 1, targets: records }, null, 2)}\n`,
91
+ 'utf8'
92
+ );
60
93
 
61
94
  return backupDir;
62
95
  }
63
96
 
64
97
  /**
65
- * Restore a snapshot back over the working tree. For each captured folder we
66
- * remove the current (possibly half-written) copy and replace it with the
67
- * snapshot, returning the tree to its pre-run state.
98
+ * Restore every explicit target to its pre-run state. Existing targets are
99
+ * replaced from backup; targets absent before the run are removed.
68
100
  *
69
101
  * @param {string} backupDir path returned by snapshot()
70
102
  */
71
103
  function restore(backupDir) {
72
104
  if (!backupDir || !fs.existsSync(backupDir)) return;
73
105
 
74
- for (const folder of fs.readdirSync(backupDir)) {
75
- const target = folder; // backup mirrors cwd-relative folder names
76
- const source = path.join(backupDir, folder);
106
+ const metadataPath = path.join(backupDir, SNAPSHOT_METADATA);
107
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
108
+ if (metadata?.schemaVersion !== 1 || !Array.isArray(metadata.targets)) {
109
+ throw new Error(`Invalid backup metadata: ${metadataPath}`);
110
+ }
111
+
112
+ for (const record of metadata.targets) {
113
+ if (!record || typeof record.existed !== 'boolean') {
114
+ throw new Error(`Invalid backup target metadata: ${metadataPath}`);
115
+ }
116
+ const target = validateTarget(record.target);
117
+ const source = path.join(backupDir, SNAPSHOT_DATA, target);
118
+ if (record.existed && !fs.existsSync(source)) {
119
+ throw new Error(`Backup data missing for target: ${target}`);
120
+ }
77
121
  fs.rmSync(target, { recursive: true, force: true });
78
- copyTree(source, target);
122
+ if (record.existed) copyTree(source, target);
79
123
  }
80
124
  }
81
125
 
@@ -93,7 +93,7 @@ const PLATFORMS = {
93
93
  skillsRef: '.opencode/skills',
94
94
  commandPrefix: '/',
95
95
  sourceDir: 'claude',
96
- sourceSubdir: 'archive-command'
96
+ sourceSubdir: 'commands' // Dead config (manifest commands.core is []); kept for shape parity
97
97
  }
98
98
  // Add new platforms here:
99
99
  // cursor: {
@@ -15,11 +15,50 @@ const {
15
15
  getRuntimeSupportTargetDir,
16
16
  getCopyOptions
17
17
  } = require('../lib/context');
18
+ const { sha256 } = require('../lib/manifest');
18
19
  const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
19
20
  const { report, treeAction } = require('./report');
20
21
  const { normalizeOpenCodeBody } = require('../lib/opencode-install');
21
22
 
22
23
  const SRC = path.join(__dirname, '../../src');
24
+ const CLAUDE_START = '<!-- CAFEKIT CLAUDE START -->';
25
+ const CLAUDE_END = '<!-- CAFEKIT CLAUDE END -->';
26
+
27
+ function managedClaudeBlock(template) {
28
+ return `${CLAUDE_START}\n${template.trimEnd()}\n${CLAUDE_END}`;
29
+ }
30
+
31
+ function managedClaudeRange(content) {
32
+ const start = content.indexOf(CLAUDE_START);
33
+ if (start === -1) return null;
34
+ const endStart = content.indexOf(CLAUDE_END, start + CLAUDE_START.length);
35
+ if (endStart === -1) return null;
36
+ return { start, end: endStart + CLAUDE_END.length, bodyStart: start + CLAUDE_START.length, bodyEnd: endStart };
37
+ }
38
+
39
+ /** Transform only the CafeKit-owned body, preserving all surrounding bytes. */
40
+ function transformManagedClaudeContent(content, transform) {
41
+ const range = managedClaudeRange(content);
42
+ if (!range) return content;
43
+ const body = content.slice(range.bodyStart, range.bodyEnd);
44
+ return `${content.slice(0, range.bodyStart)}${transform(body)}${content.slice(range.bodyEnd)}`;
45
+ }
46
+
47
+ function upsertManagedClaudeBlock(existing, template, legacyOwned) {
48
+ const block = managedClaudeBlock(template);
49
+ const range = managedClaudeRange(existing);
50
+ if (range) {
51
+ return `${existing.slice(0, range.start)}${block}${existing.slice(range.end)}`;
52
+ }
53
+
54
+ // Whole-file migration is deliberately limited to exact shipped content or
55
+ // a file whose bytes still match the legacy ownership manifest.
56
+ if (existing === template || legacyOwned) return `${block}\n`;
57
+ if (!existing) return `${block}\n`;
58
+
59
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
60
+ return `${existing}${separator}${block}\n`;
61
+ }
23
62
 
24
63
  /** Copy ROUTING.md (SKILLS_DIR substituted; OpenCode body normalized). */
25
64
  function copyRoutingFile(ctx, platformKey) {
@@ -105,9 +144,22 @@ function copyClaudeMdFile(ctx, platformKey) {
105
144
  return;
106
145
  }
107
146
 
108
- const { action } = writeManagedFile({
109
- src, dest, platformFolder: PLATFORMS.claude.folder, ctx, tracker: ctx.trackers.claude
110
- });
147
+ const tracker = ctx.trackers.claude;
148
+ // Older releases recorded the root file relative to .claude/. Never retain
149
+ // that whole-file ownership claim, even when this run makes no content change.
150
+ if (tracker) tracker.prune('../CLAUDE.md');
151
+
152
+ const template = fs.readFileSync(src, 'utf8');
153
+ const exists = fs.existsSync(dest);
154
+ const existing = exists ? fs.readFileSync(dest, 'utf8') : '';
155
+ const legacyRecord = ctx.ownership?.[PLATFORMS.claude.folder]?.files?.['../CLAUDE.md'];
156
+ const legacyOwned = Boolean(legacyRecord && legacyRecord.sha256 === sha256(existing));
157
+ const next = upsertManagedClaudeBlock(existing, template, legacyOwned);
158
+ const action = !exists ? 'created' : next === existing ? 'unchanged' : 'updated';
159
+
160
+ if (!ctx.dryRun && action !== 'unchanged') {
161
+ fs.writeFileSync(dest, next, 'utf8');
162
+ }
111
163
  report(ctx, action, 'CLAUDE.md');
112
164
  }
113
165
 
@@ -137,5 +189,6 @@ module.exports = {
137
189
  copyClaudeRuntimeFiles,
138
190
  removeObsoleteClaudeRuntimeFiles,
139
191
  copyClaudeMdFile,
140
- copyRulesDirectory
192
+ copyRulesDirectory,
193
+ transformManagedClaudeContent
141
194
  };
@@ -16,6 +16,25 @@ const path = require('path');
16
16
  const { PLATFORMS } = require('../lib/context');
17
17
  const { LANGUAGE_LABELS } = require('../lib/i18n');
18
18
  const { setupOpenCodeModel } = require('../lib/opencode-install');
19
+ const { transformManagedClaudeContent } = require('./claude-runtime');
20
+
21
+ function updateManagedClaudeFile(filePath, transform) {
22
+ const content = fs.readFileSync(filePath, 'utf8');
23
+ const next = transformManagedClaudeContent(content, transform);
24
+ if (next === content) return false;
25
+ fs.writeFileSync(filePath, next, 'utf8');
26
+ return true;
27
+ }
28
+
29
+ function readManagedClaudeBody(filePath) {
30
+ const content = fs.readFileSync(filePath, 'utf8');
31
+ let body = '';
32
+ transformManagedClaudeContent(content, (managed) => {
33
+ body = managed;
34
+ return managed;
35
+ });
36
+ return body;
37
+ }
19
38
 
20
39
  /** Write `"language"` field into .claude/settings.json so it's visible at a glance. */
21
40
  function patchSettingsLanguage(ctx) {
@@ -64,20 +83,14 @@ Always respond in **${label}**. Technical terms, code identifiers, and file path
64
83
 
65
84
  `;
66
85
 
67
- let content = fs.readFileSync(claudeMdFile, 'utf8');
68
- // Replace if marker present, else replace the generic section.
86
+ // Replace only inside the installer-owned block.
69
87
  const markerRe = /## Language Consistency <!-- cafekit:lang -->[\s\S]*?(?=\n##|\n*$)/;
70
88
  const genericRe = /## Language Consistency\n[\s\S]*?(?=\n##|\n*$)/;
71
-
72
- if (markerRe.test(content)) {
73
- content = content.replace(markerRe, newSection);
74
- } else if (genericRe.test(content)) {
75
- content = content.replace(genericRe, newSection);
76
- } else {
77
- content += `\n${newSection}\n`;
78
- }
79
-
80
- fs.writeFileSync(claudeMdFile, content, 'utf8');
89
+ updateManagedClaudeFile(claudeMdFile, (managed) => {
90
+ if (markerRe.test(managed)) return managed.replace(markerRe, newSection);
91
+ if (genericRe.test(managed)) return managed.replace(genericRe, newSection);
92
+ return `${managed.trimEnd()}\n\n${newSection}\n`;
93
+ });
81
94
  }
82
95
  function patchRuntimeLocale(ctx) {
83
96
  for (const key of ctx.platforms) {
@@ -93,6 +106,10 @@ function patchRuntimeLocale(ctx) {
93
106
  // Use locale (freeform label) so custom languages propagate to the AI hook.
94
107
  const locale = ctx.locale || ctx.lang;
95
108
  if (data.locale.responseLanguage === locale) continue;
109
+ // Hardening: never downgrade an existing configured label to a bare default
110
+ // code when this run never made an explicit language choice (ctx.locale
111
+ // empty). Protects user config on any code path that skips selectLanguage.
112
+ if (!ctx.locale && data.locale.responseLanguage) continue;
96
113
  data.locale.responseLanguage = locale;
97
114
  fs.writeFileSync(rtPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
98
115
  if (ctx.trackers[key]) ctx.trackers[key].record(rtPath);
@@ -112,19 +129,18 @@ function configureAddressing(ctx, userAddress) {
112
129
  }
113
130
 
114
131
  const name = assistantName(ctx);
115
- let content = fs.readFileSync(claudeMdFile, 'utf8');
116
132
  const addressingSection = `## Addressing (Context Overflow Indicator)
117
133
 
118
134
  ${name} always addresses the user as "${userAddress}" throughout the conversation. If it stops doing so, it is a sign the context has been compacted/truncated — tell the user to consider \`/clear\`.`;
119
135
 
120
- // Idempotent: replace existing section (shared marker) or append.
136
+ // Idempotent and scoped to the installer-owned block.
121
137
  const regex = /##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?(?=\n##|\n*$)/;
122
- content = regex.test(content)
123
- ? content.replace(regex, addressingSection)
124
- : `${content.endsWith('\n') ? content : `${content}\n`}\n${addressingSection}\n`;
125
-
126
- fs.writeFileSync(claudeMdFile, content, 'utf8');
127
- ctx.ui.success(ctx.t('addressingSet', { name, addr: userAddress }));
138
+ const changed = updateManagedClaudeFile(claudeMdFile, (managed) => (
139
+ regex.test(managed)
140
+ ? managed.replace(regex, addressingSection)
141
+ : `${managed.endsWith('\n') ? managed : `${managed}\n`}\n${addressingSection}\n`
142
+ ));
143
+ if (changed) ctx.ui.success(ctx.t('addressingSet', { name, addr: userAddress }));
128
144
  }
129
145
 
130
146
  async function setupAddressing(ctx) {
@@ -134,7 +150,7 @@ async function setupAddressing(ctx) {
134
150
  const claudeMdFile = path.join(process.cwd(), 'CLAUDE.md');
135
151
  let existingName = null;
136
152
  if (fs.existsSync(claudeMdFile)) {
137
- const content = fs.readFileSync(claudeMdFile, 'utf8');
153
+ const content = readManagedClaudeBody(claudeMdFile);
138
154
  const match = content.match(/##[^\n]*Context Overflow Indicator[^\n]*[\s\S]*?always addresses the user as "([^"]+)"/);
139
155
  if (match) {
140
156
  existingName = match[1];
@@ -199,11 +215,8 @@ async function runPostInstall(ctx) {
199
215
  // Write "language" field into .claude/settings.json for visibility.
200
216
  patchSettingsLanguage(ctx);
201
217
 
202
- // Re-record post-write baselines so installer-managed files stay "pristine",
203
- // then flush each touched platform tracker.
204
- if (ctx.platforms.includes('claude') && ctx.trackers.claude && fs.existsSync('CLAUDE.md')) {
205
- ctx.trackers.claude.record('CLAUDE.md');
206
- }
218
+ // Flush touched platform trackers. Root CLAUDE.md is block-managed and must
219
+ // never regain a whole-file ownership record.
207
220
  for (const key of ctx.platforms) {
208
221
  if (ctx.trackers[key]) ctx.trackers[key].write();
209
222
  }
@@ -1,13 +1,29 @@
1
1
  /**
2
2
  * Phase: root project configuration (.gitignore).
3
3
  *
4
- * Ensures the root .gitignore carries CafeKit-managed patterns, including the
5
- * new install backup dir and lock file so they never get committed. Honors dry-run.
4
+ * Ensures the root .gitignore carries CafeKit-managed patterns:
5
+ * - workflow dirs (plans, shared research)
6
+ * - install safety artifacts (backup, lock)
7
+ * - runtime folders (.claude/, .opencode/) — reinstall via
8
+ * `npx @haposoft/cafekit`; do not commit the local payload
9
+ *
10
+ * Layered with the in-folder `.claude/.gitignore` /
11
+ * `.opencode/.gitignore` templates (secrets, skill deps, session
12
+ * state) so force-adds and partial un-ignores stay safe.
13
+ *
14
+ * Honors dry-run.
6
15
  */
7
16
 
8
17
  const fs = require('fs');
9
18
  const path = require('path');
10
19
 
20
+ /** True if `lines` already carries `pattern` with or without a trailing slash. */
21
+ function hasPattern(lines, pattern) {
22
+ const bare = pattern.replace(/\/$/, '');
23
+ const withSlash = bare + '/';
24
+ return lines.includes(pattern) || lines.includes(bare) || lines.includes(withSlash);
25
+ }
26
+
11
27
  function ensureGitignore(ctx) {
12
28
  const gitignorePath = path.join(process.cwd(), '.gitignore');
13
29
  const header = '# CafeKit / Ecosystem';
@@ -16,7 +32,10 @@ function ensureGitignore(ctx) {
16
32
  'plans/',
17
33
  '!plans/templates/',
18
34
  '.cafekit-backup/',
19
- '.cafekit.lock'
35
+ '.cafekit.lock',
36
+ // Local runtime payload — reinstall with npx; keep out of git
37
+ '.claude/',
38
+ '.opencode/'
20
39
  ];
21
40
  const prefix = ctx.dryRun ? '[dry-run] ' : '';
22
41
 
@@ -30,7 +49,7 @@ function ensureGitignore(ctx) {
30
49
 
31
50
  const content = fs.readFileSync(gitignorePath, 'utf8');
32
51
  const lines = content.split('\n').map((l) => l.trim());
33
- const missing = patterns.filter((p) => !lines.includes(p));
52
+ const missing = patterns.filter((p) => !hasPattern(lines, p));
34
53
 
35
54
  if (missing.length > 0) {
36
55
  let newContent = content;
@@ -46,4 +65,4 @@ function ensureGitignore(ctx) {
46
65
  }
47
66
  }
48
67
 
49
- module.exports = { ensureGitignore };
68
+ module.exports = { ensureGitignore, hasPattern };
@@ -35,17 +35,22 @@ function getInstalledLocale() {
35
35
 
36
36
  /** First step: pick the installer/UI language (interactive only). */
37
37
  async function selectLanguage(ctx) {
38
- if (!ctx.interactive || ctx.options.lang) return ctx;
39
-
40
- // If already installed, restore saved locale and skip the prompt
41
- const savedLocale = getInstalledLocale();
42
- if (savedLocale) {
43
- const code = Object.keys(LANGUAGE_LABELS).find((k) => LANGUAGE_LABELS[k] === savedLocale) || 'en';
44
- ctx.setLang(code, savedLocale); // updates ctx.t to the saved language
45
- ctx.ui.info(ctx.t('langKept', { lang: savedLocale }));
46
- return ctx;
38
+ // Restore the saved locale BEFORE the interactivity check: a non-interactive
39
+ // upgrade (--yes) must not forget the configured language. Skipping this left
40
+ // ctx at the 'en' default, and patchRuntimeLocale then clobbered the user's
41
+ // responseLanguage on every upgrade.
42
+ if (!ctx.options.lang) {
43
+ const savedLocale = getInstalledLocale();
44
+ if (savedLocale) {
45
+ const code = Object.keys(LANGUAGE_LABELS).find((k) => LANGUAGE_LABELS[k] === savedLocale) || 'en';
46
+ ctx.setLang(code, savedLocale); // updates ctx.t to the saved language
47
+ if (ctx.interactive) ctx.ui.info(ctx.t('langKept', { lang: savedLocale }));
48
+ return ctx;
49
+ }
47
50
  }
48
51
 
52
+ if (!ctx.interactive || ctx.options.lang) return ctx;
53
+
49
54
  const options = [
50
55
  ...SUPPORTED.map((code) => ({ value: code, label: LANGUAGE_LABELS[code] })),
51
56
  { value: OTHER, label: OTHER_LABEL.en + ' / その他 / Ngôn ngữ khác' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Claude Code-first spec-driven workflow for AI coding assistants. Bundles CafeKit hapo: skills, runtime hooks, agents, and installer scaffolding.",
5
5
  "author": "Haposoft <nghialt@haposoft.com>",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@ These rules reduce common agent coding failures: hidden assumptions, overbuilt s
37
37
  ### 4. Goal-Driven Execution
38
38
 
39
39
  - Convert requests into verifiable success criteria.
40
- - For spec tasks, use `Completion Criteria` and `Evidence` as the source of truth. Existing task files may use `Task Test Plan & Verification Evidence` or legacy `Verification & Evidence`.
40
+ - For spec tasks, use `Completion Criteria` and `## Evidence` (legacy heading aliases still parse) as the source of truth.
41
41
  - For bugs, reproduce with a failing test or concrete evidence when feasible before fixing.
42
42
  - Loop until verification passes or a real blocker is recorded.
43
43
 
@@ -23,7 +23,7 @@ Extract and verify:
23
23
  1. Declared deliverables (files, routes, entrypoints, UI surfaces, schemas, migrations)
24
24
  2. Declared task scope (`Related Files` and direct support files that are clearly justified)
25
25
  3. Completion Criteria
26
- 4. Task Evidence expectations (or Task Test Plan & Verification Evidence / legacy Verification & Evidence)
26
+ 4. Task `## Evidence` (legacy heading aliases still parse) expectations
27
27
  5. Canonical Contracts & Invariants from the design
28
28
  6. Named technologies and runtime choices that the task/spec explicitly requires
29
29
  7. Runtime entrypoints/callers and reachability obligations from task evidence or the task-aware scout report