kracked-core 1.1.0 → 1.5.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.
package/README.md CHANGED
@@ -49,7 +49,12 @@ npx kracked-core init
49
49
 
50
50
  The wizard asks a few questions — name your agent, is this a new or existing project, which editor you use — and writes the files. Hold Enter through it and you'll get a sane setup.
51
51
 
52
- **Works with:** Antigravity · Claude Code · Cursor · Windsurf · anything that reads `AGENTS.md`
52
+ **Full support** (memory + all five commands):
53
+ **Antigravity** · **Claude Code** · **Kilo Code** (VS Code) · **Roo Code** (VS Code)
54
+
55
+ **Memory only:** Cursor, Windsurf, and anything else that reads `AGENTS.md` — your agent loads its
56
+ memory, but the `/kracked-*` commands aren't installed, because those editors use a different
57
+ skill format. You can still run the flow by asking in plain language.
53
58
 
54
59
  ## Use
55
60
 
@@ -82,7 +87,13 @@ your-project/.kracked/ PROJECT — this codebase only
82
87
  project.md stack, conventions, how to run it
83
88
  session.md where we are right now
84
89
  decisions.md why things are the way they are
85
- sdd/tracker.md story status + evidence
90
+ sdd/
91
+ tracker.md story status + evidence
92
+ specs/ what to build and why
93
+ epics/ groups of related stories
94
+ stories/ one shippable slice each
95
+ architecture/ how it's built (large work only)
96
+ decisions/ ADRs, numbered, never renumbered
86
97
  ```
87
98
 
88
99
  ### The boundary rule
@@ -123,6 +134,66 @@ Then it tracks the work:
123
134
 
124
135
  **A story cannot move to `done` without evidence.** Not "the tests pass" — what did you actually verify, and what did you *not*? Green tests are not a working feature. This one rule catches more bugs than any other part of the system.
125
136
 
137
+ ## Terminal commands
138
+
139
+ ```bash
140
+ npx kracked-core init # set up memory in this project
141
+ npx kracked-core status # what's installed, and is it current?
142
+ npx kracked-core update # refresh skills + loaders, keep your memory
143
+ npx kracked-core uninstall # remove it (asks before deleting anything)
144
+ ```
145
+
146
+ ## Am I on the latest version?
147
+
148
+ ```bash
149
+ npx kracked-core@latest status
150
+ ```
151
+
152
+ Shows the version that installed your files, the latest on npm, and tells you plainly whether
153
+ you need to update:
154
+
155
+ ```
156
+ This project ~/code/my-app
157
+ installed version: 1.4.0
158
+ .agents/skills: 5/5 skills
159
+
160
+ Global memory ~/.kracked
161
+ 4/4 files present
162
+ lessons learned: 12
163
+
164
+ Version
165
+ running now: 1.5.0
166
+ latest on npm: 1.5.0
167
+
168
+ Up to date.
169
+ ```
170
+
171
+ > The `@latest` matters — plain `npx kracked-core` can serve a cached older copy.
172
+
173
+ ## Updating
174
+
175
+ When a new version ships:
176
+
177
+ ```bash
178
+ npx kracked-core@latest update
179
+ ```
180
+
181
+ Refreshes the skills and loaders. **Your memory is never touched** — identity, preferences,
182
+ lessons, and everything under `.kracked/` stay exactly as they are. It keeps your agent's name too.
183
+
184
+ Restart your editor afterwards so it picks up the new skills.
185
+
186
+ ## Uninstalling
187
+
188
+ ```bash
189
+ npx kracked-core uninstall
190
+ ```
191
+
192
+ Shows what it will remove and asks before deleting — project files and global memory are separate
193
+ questions. It never touches a `CLAUDE.md` or `AGENTS.md` another tool wrote.
194
+
195
+ Full guide, including manual removal and how to keep your docs: [docs/UNINSTALL.md](https://github.com/Krackeddevs-Org/kracked-core/blob/main/docs/UNINSTALL.md)
196
+
126
197
  ## FAQ
127
198
 
128
199
  **Does this send my code anywhere?**
@@ -1,24 +1,58 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { runInit } from '../src/wizard.mjs';
4
+ import { runUninstall } from '../src/uninstall.mjs';
5
+ import { runUpdate } from '../src/update.mjs';
6
+ import { runStatus } from '../src/status.mjs';
7
+ import { beginTypeAhead } from '../src/prompt.mjs';
4
8
 
5
9
  const USAGE = `kracked-core — memory & workflow installer for AI coding agents
6
10
 
7
11
  Usage:
8
- npx kracked-core init Set up global + project memory
12
+ npx kracked-core init Set up global + project memory
13
+ npx kracked-core status Show what's installed and whether it's current
14
+ npx kracked-core update Refresh skills + loaders, keeping your memory
15
+ npx kracked-core uninstall Remove kracked-core files (asks before deleting)
9
16
 
10
17
  Options:
11
- -h, --help Show this help
18
+ -h, --help Show this help
19
+ -v, --version Show the installed version
12
20
  `;
13
21
 
14
22
  async function main() {
15
23
  const [, , subcommand] = process.argv;
16
24
 
25
+ // Capture keystrokes from the very first millisecond — a student holding
26
+ // Enter starts before Node has finished booting.
27
+ if (['init', 'update', 'uninstall'].includes(subcommand)) beginTypeAhead();
28
+
17
29
  if (subcommand === 'init') {
18
30
  await runInit();
19
31
  return;
20
32
  }
21
33
 
34
+ if (subcommand === 'status') {
35
+ await runStatus();
36
+ return;
37
+ }
38
+
39
+ if (subcommand === 'update') {
40
+ await runUpdate();
41
+ return;
42
+ }
43
+
44
+ if (subcommand === 'uninstall') {
45
+ await runUninstall();
46
+ return;
47
+ }
48
+
49
+ if (subcommand === '--version' || subcommand === '-v') {
50
+ const { createRequire } = await import('node:module');
51
+ const pkg = createRequire(import.meta.url)('../package.json');
52
+ process.stdout.write(`${pkg.version}\n`);
53
+ return;
54
+ }
55
+
22
56
  if (subcommand === '--help' || subcommand === '-h' || !subcommand) {
23
57
  process.stdout.write(USAGE);
24
58
  process.exit(subcommand ? 0 : 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kracked-core",
3
- "version": "1.1.0",
3
+ "version": "1.5.0",
4
4
  "description": "Zero-dependency memory & workflow installer for AI coding agents — global identity, project memory, and skills that survive session restarts.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/detect.mjs CHANGED
@@ -5,6 +5,30 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import os from 'node:os';
7
7
 
8
+ /**
9
+ * Scrub a value read from an untrusted repo before it can reach a template.
10
+ *
11
+ * Everything scanned from a project (package.json name, script names) gets
12
+ * interpolated into AGENTS.md and ~/.kracked/projects.md — files an AI agent
13
+ * READS AS INSTRUCTIONS on every boot. A crafted `name` containing newlines and
14
+ * a markdown heading can therefore inject text that reads as package-authored
15
+ * instruction, and via the global registry it persists into every OTHER project
16
+ * the user opens. Sanitize here, at the boundary, so no caller can forget.
17
+ */
18
+ export function sanitizeScanned(value, maxLength = 64) {
19
+ if (typeof value !== 'string') return '';
20
+ return value
21
+ .replace(/[\r\n\t]+/g, ' ') // no line breaks — kills injected blocks
22
+ // eslint-disable-next-line no-control-regex
23
+ .replace(/[\u0000-\u001f\u007f]/g, '') // strip remaining control chars
24
+ .replace(/[|`<>]/g, '') // markdown table + code + html structure
25
+ .replace(/^[\s#>*\-=+]+/, '') // no leading heading/quote/list markers
26
+ .replace(/\s+/g, ' ')
27
+ .trim()
28
+ .slice(0, maxLength)
29
+ .trim();
30
+ }
31
+
8
32
  /** Does ~/.kracked/ already exist? */
9
33
  export function globalMemoryExists() {
10
34
  const dir = path.join(os.homedir(), '.kracked');
@@ -99,9 +123,17 @@ export function scanExistingProject(targetDir) {
99
123
  if (fs.existsSync(pkgPath)) {
100
124
  try {
101
125
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
102
- if (pkg.name) summary.name = pkg.name;
126
+
127
+ // Everything below comes from an untrusted repo and ends up inside files
128
+ // the agent reads as instructions — sanitize before it goes anywhere.
129
+ const cleanName = sanitizeScanned(pkg.name);
130
+ if (cleanName) summary.name = cleanName;
131
+
103
132
  if (pkg.scripts && typeof pkg.scripts === 'object') {
104
- summary.scripts = pkg.scripts;
133
+ // Keep only plausible script names; a hostile key is dropped, not rendered.
134
+ for (const key of Object.keys(pkg.scripts)) {
135
+ if (/^[a-zA-Z0-9:_-]{1,40}$/.test(key)) summary.scripts[key] = true;
136
+ }
105
137
  }
106
138
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
107
139
  const frameworkHints = [
@@ -127,13 +159,3 @@ export function stackSummaryLine(summary) {
127
159
  return parts.length ? parts.join(' ') : 'unknown — not auto-detected';
128
160
  }
129
161
 
130
- /** Which harnesses already have a footprint in this project dir? */
131
- export function detectHarnesses(targetDir) {
132
- return {
133
- claudeCode: fs.existsSync(path.join(targetDir, '.claude')) ||
134
- fs.existsSync(path.join(targetDir, 'CLAUDE.md')),
135
- antigravity: fs.existsSync(path.join(targetDir, '.agents')) ||
136
- fs.existsSync(path.join(targetDir, 'AGENTS.md')),
137
- cursor: fs.existsSync(path.join(targetDir, '.cursor')),
138
- };
139
- }
package/src/prompt.mjs CHANGED
@@ -27,32 +27,112 @@ function setCursor(visible) {
27
27
  stdout.write(visible ? `${ESC}[?25h` : `${ESC}[?25l`);
28
28
  }
29
29
 
30
+ // A throw anywhere outside the keypress handler would otherwise exit with the
31
+ // terminal still in raw mode and the cursor hidden — leaving the user's shell
32
+ // with invisible, unechoed input. One idempotent restore covers every exit path,
33
+ // including process.exit().
34
+ let restoreRegistered = false;
35
+ function registerTerminalRestore() {
36
+ if (restoreRegistered) return;
37
+ restoreRegistered = true;
38
+ process.on('exit', () => {
39
+ try {
40
+ if (stdin.isTTY && stdin.isRaw) stdin.setRawMode(false);
41
+ stdout.write(`${ESC}[?25h`);
42
+ } catch {
43
+ // Nothing useful to do while the process is already exiting.
44
+ }
45
+ });
46
+ }
47
+
30
48
  /** Move up n lines and clear from there down — used to redraw the menu in place. */
31
49
  function clearLines(n) {
32
50
  if (n > 0) stdout.write(`${ESC}[${n}A`);
33
51
  stdout.write(`${ESC}[0J`);
34
52
  }
35
53
 
54
+ /**
55
+ * Split one stdin chunk into individual keys.
56
+ *
57
+ * stdin delivers DATA, not keystrokes: holding Enter (key auto-repeat), typing
58
+ * fast, pasting, or running over SSH/tmux routinely packs several keys into one
59
+ * chunk. Comparing a whole chunk against '\r' then matches nothing, so the key
60
+ * is silently dropped and the prompt hangs — the worst kind of failure, because
61
+ * there is no error to see. Escape sequences (arrows) must stay intact as one
62
+ * key; everything else is per-character.
63
+ */
64
+ function splitKeys(chunk) {
65
+ const keys = [];
66
+ let i = 0;
67
+
68
+ while (i < chunk.length) {
69
+ if (chunk[i] === ESC) {
70
+ // CSI sequence: ESC [ ... final-byte (@ through ~). Arrows are ESC[A..D.
71
+ const match = /^\x1b\[[0-9;?]*[ -/]*[@-~]/.exec(chunk.slice(i));
72
+ if (match) {
73
+ keys.push(match[0]);
74
+ i += match[0].length;
75
+ continue;
76
+ }
77
+ // ESC alone, or an unrecognised sequence — take just the ESC.
78
+ keys.push(chunk[i]);
79
+ i += 1;
80
+ continue;
81
+ }
82
+ keys.push(chunk[i]);
83
+ i += 1;
84
+ }
85
+
86
+ return keys;
87
+ }
88
+
36
89
  /**
37
90
  * Read single keypresses until `onKey` signals completion.
38
91
  * onKey(key) returns { done: true, value } to finish, or falsy to keep listening.
39
92
  */
93
+ // Keys that arrive between prompts — while one question has resolved and the
94
+ // next hasn't attached its listener yet — would otherwise be dropped on the
95
+ // floor. Holding Enter produces exactly that. Buffer them and replay into the
96
+ // next prompt so no keystroke is ever silently lost.
97
+ const pendingKeys = [];
98
+
99
+ /**
100
+ * Start capturing keystrokes immediately, before the first prompt renders.
101
+ * Node takes a moment to boot and draw; anything typed in that window is
102
+ * otherwise lost. Call once at CLI start.
103
+ */
104
+ export function beginTypeAhead() {
105
+ if (!stdin.isTTY) return;
106
+ registerTerminalRestore();
107
+ stdin.setRawMode(true);
108
+ stdin.resume();
109
+ stdin.setEncoding('utf8');
110
+ stdin.on('data', bufferEarlyKeys);
111
+ }
112
+
113
+ function bufferEarlyKeys(chunk) {
114
+ pendingKeys.push(...splitKeys(chunk));
115
+ }
116
+
40
117
  function readKeys(onKey) {
41
118
  return new Promise((resolve, reject) => {
119
+ registerTerminalRestore();
42
120
  const wasRaw = stdin.isRaw;
43
121
  stdin.setRawMode(true);
44
122
  stdin.resume();
45
123
  stdin.setEncoding('utf8');
124
+ stdin.removeListener('data', bufferEarlyKeys);
46
125
 
47
126
  const cleanup = () => {
48
127
  stdin.setRawMode(wasRaw ?? false);
49
- stdin.pause();
50
128
  stdin.removeListener('data', handler);
129
+ // Resume early-capture so keys typed between prompts aren't lost.
130
+ stdin.on('data', bufferEarlyKeys);
51
131
  setCursor(true);
52
132
  };
53
133
 
54
- function handler(key) {
55
- // Ctrl+C / Ctrl+D must always escape, whatever the prompt is doing.
134
+ /** Feed one key to onKey. Returns true when the prompt is finished. */
135
+ const dispatch = (key) => {
56
136
  if (key === KEY.ctrlC || key === KEY.ctrlD) {
57
137
  cleanup();
58
138
  stdout.write('\n\nCancelled — nothing was written.\n');
@@ -65,12 +145,32 @@ function readKeys(onKey) {
65
145
  } catch (err) {
66
146
  cleanup();
67
147
  reject(err);
68
- return;
148
+ return true;
69
149
  }
70
150
 
71
151
  if (result && result.done) {
72
152
  cleanup();
73
153
  resolve(result.value);
154
+ return true;
155
+ }
156
+ return false;
157
+ };
158
+
159
+ // Replay anything typed ahead of this prompt before listening for more.
160
+ while (pendingKeys.length) {
161
+ if (dispatch(pendingKeys.shift())) return;
162
+ }
163
+
164
+ function handler(chunk) {
165
+ // One chunk can carry several keys. Dispatch them one at a time; once a
166
+ // key completes this prompt, park the rest for the next one rather than
167
+ // discarding them.
168
+ const keys = splitKeys(chunk);
169
+ for (let i = 0; i < keys.length; i++) {
170
+ if (dispatch(keys[i])) {
171
+ pendingKeys.push(...keys.slice(i + 1));
172
+ return;
173
+ }
74
174
  }
75
175
  }
76
176
 
@@ -210,8 +310,12 @@ export async function input(question, defaultValue = '') {
210
310
  render();
211
311
  return null;
212
312
  }
213
- // Ignore escape sequences (arrows etc.) and other control chars.
214
- if (key.startsWith(ESC) || key < ' ') return null;
313
+ // Ignore escape sequences (arrows etc.) and any control character. Compare
314
+ // the code point, not the string — `key < ' '` on a multi-char value tests
315
+ // lexicographic order, not "is this a control char".
316
+ if (key.startsWith(ESC)) return null;
317
+ const code = key.codePointAt(0);
318
+ if (code < 0x20 || code === 0x7f) return null;
215
319
 
216
320
  buffer += key;
217
321
  render();
package/src/scaffold.mjs CHANGED
@@ -9,7 +9,12 @@ import { fileURLToPath } from 'node:url';
9
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
10
  const TEMPLATES_ROOT = path.join(__dirname, '..', 'templates');
11
11
 
12
- const SKILL_NAMES = [
12
+ // Written into every loader kracked-core generates. `uninstall` requires this
13
+ // exact string before deleting a shared-name file (AGENTS.md / CLAUDE.md), so
14
+ // another tool's loader is never removed. Never change it without a migration.
15
+ export const OWNED_MARKER = '<!-- kracked-core:owned -->';
16
+
17
+ export const SKILL_NAMES = [
13
18
  'kracked-boot',
14
19
  'kracked-sdd',
15
20
  'kracked-wrap',
@@ -95,8 +100,10 @@ export async function writeGlobalMemory({ tokens, ask, report }) {
95
100
  export function registerProject({ tokens }) {
96
101
  const globalDir = path.join(os.homedir(), '.kracked');
97
102
  const projectsPath = path.join(globalDir, 'projects.md');
103
+ // Escape pipes — a path or name containing `|` would add phantom columns.
104
+ const cell = (v) => String(v ?? '').replace(/\|/g, '\\|');
98
105
  const stack = tokens.STACK && tokens.STACK.trim() ? tokens.STACK : '—';
99
- const row = `| ${tokens.PROJECT_NAME} | ${tokens.PROJECT_PATH} | ${stack} | active |`;
106
+ const row = `| ${cell(tokens.PROJECT_NAME)} | ${cell(tokens.PROJECT_PATH)} | ${cell(stack)} | active |`;
100
107
 
101
108
  fs.mkdirSync(globalDir, { recursive: true });
102
109
 
@@ -148,34 +155,83 @@ export async function writeProjectMemory({ projectDir, tokens, ask, report }) {
148
155
  await writeFileWithConflictCheck(destPath, content, ask, report);
149
156
  }
150
157
 
151
- // sdd/tracker.md + sdd/specs/.gitkeep
152
- const trackerDest = path.join(krackedDir, 'sdd', 'tracker.md');
153
- const trackerContent = applyTokens(readTemplate('project/sdd/tracker.md'), tokens);
154
- await writeFileWithConflictCheck(trackerDest, trackerContent, ask, report);
158
+ // SDD docs: tracker + the artifact folders, each with a template that
159
+ // explains what belongs there. An empty folder teaches nothing.
160
+ const sddDocs = [
161
+ 'sdd/tracker.md',
162
+ 'sdd/README.md',
163
+ 'sdd/specs/_TEMPLATE.md',
164
+ 'sdd/epics/_TEMPLATE.md',
165
+ 'sdd/stories/_TEMPLATE.md',
166
+ 'sdd/architecture/_TEMPLATE.md',
167
+ 'sdd/architecture/decisions/_TEMPLATE.md',
168
+ ];
169
+
170
+ for (const rel of sddDocs) {
171
+ const destPath = path.join(krackedDir, rel);
172
+ const content = applyTokens(readTemplate(`project/${rel}`), tokens);
173
+ await writeFileWithConflictCheck(destPath, content, ask, report);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Record which version wrote these files. Without this there is no way to
179
+ * answer "am I on the latest?" — you can see what npm serves, but not what
180
+ * you actually installed.
181
+ */
182
+ export function writeVersionStamp({ projectDir, version, report }) {
183
+ const dest = path.join(projectDir, '.kracked', '.version');
184
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
185
+ fs.writeFileSync(dest, `${version}\n`, 'utf8');
186
+ report({ path: dest, action: 'written' });
187
+ }
155
188
 
156
- const gitkeepDest = path.join(krackedDir, 'sdd', 'specs', '.gitkeep');
157
- if (!fs.existsSync(gitkeepDest)) {
158
- fs.mkdirSync(path.dirname(gitkeepDest), { recursive: true });
159
- fs.writeFileSync(gitkeepDest, '', 'utf8');
160
- report({ path: gitkeepDest, action: 'written' });
189
+ /** Read the version that last wrote this project's files, or null. */
190
+ export function readVersionStamp(projectDir) {
191
+ try {
192
+ return fs.readFileSync(path.join(projectDir, '.kracked', '.version'), 'utf8').trim();
193
+ } catch {
194
+ return null; // pre-1.5.0 install, or not installed
161
195
  }
162
196
  }
163
197
 
164
198
  /** Write AGENTS.md, CLAUDE.md (shim), and .agents/rules/kracked.md. */
165
- export async function writeLoaders({ projectDir, tokens, ask, report }) {
199
+ export async function writeLoaders({ projectDir, tokens, ask, report, editors }) {
166
200
  const agentsContent = applyTokens(readTemplate('loaders/AGENTS.md'), tokens);
167
201
  await writeFileWithConflictCheck(path.join(projectDir, 'AGENTS.md'), agentsContent, ask, report);
168
202
 
169
203
  const claudeContent = applyTokens(readTemplate('loaders/CLAUDE.md'), tokens);
170
204
  await writeFileWithConflictCheck(path.join(projectDir, 'CLAUDE.md'), claudeContent, ask, report);
171
205
 
172
- const krackedRulesContent = applyTokens(readTemplate('loaders/antigravity-rules.md'), tokens);
173
- await writeFileWithConflictCheck(
174
- path.join(projectDir, '.agents', 'rules', 'kracked.md'),
175
- krackedRulesContent,
176
- ask,
177
- report
178
- );
206
+ // Roo reads .roo/rules/ only — it does NOT read .agents/rules/, so without
207
+ // this mirror the pointer is invisible to Roo.
208
+ if (editors && editors.includes('roo')) {
209
+ const rooRules = applyTokens(readTemplate('loaders/antigravity-rules.md'), tokens);
210
+ await writeFileWithConflictCheck(
211
+ path.join(projectDir, '.roo', 'rules', 'kracked.md'),
212
+ rooRules,
213
+ ask,
214
+ report
215
+ );
216
+ }
217
+
218
+ // Kilo only loads a rules file if it's declared in kilo.jsonc's `instructions`
219
+ // array. Merge into an existing config rather than clobbering the user's.
220
+ if (editors && editors.includes('kilo')) {
221
+ await writeKiloConfig({ projectDir, report });
222
+ }
223
+
224
+ // Only write the Antigravity pointer when Antigravity is actually in use —
225
+ // `update` was creating .agents/ in Claude-only projects.
226
+ if (!editors || editors.includes('antigravity')) {
227
+ const krackedRulesContent = applyTokens(readTemplate('loaders/antigravity-rules.md'), tokens);
228
+ await writeFileWithConflictCheck(
229
+ path.join(projectDir, '.agents', 'rules', 'kracked.md'),
230
+ krackedRulesContent,
231
+ ask,
232
+ report
233
+ );
234
+ }
179
235
  }
180
236
 
181
237
  /**
@@ -196,3 +252,41 @@ export async function writeSkills({ projectDir, tokens, editors, ask, report })
196
252
  }
197
253
  }
198
254
  }
255
+
256
+ /**
257
+ * Add our rules file to kilo.jsonc's `instructions` array. Kilo does not
258
+ * auto-load `.kilo/rules/` — a file is only read if it's listed here, so
259
+ * without this the pointer is silently ignored.
260
+ * Merges into an existing config; never overwrites the user's settings.
261
+ */
262
+ async function writeKiloConfig({ projectDir, report }) {
263
+ const configPath = path.join(projectDir, 'kilo.jsonc');
264
+ const entry = '.agents/rules/kracked.md';
265
+
266
+ if (fs.existsSync(configPath)) {
267
+ const body = fs.readFileSync(configPath, 'utf8');
268
+ if (body.includes(entry)) {
269
+ report({ path: configPath, action: 'already configured' });
270
+ return;
271
+ }
272
+ // Append to the existing instructions array if there is one, else add it.
273
+ let updated;
274
+ if (/"instructions"\s*:\s*\[/.test(body)) {
275
+ updated = body.replace(/("instructions"\s*:\s*\[)/, `$1\n "${entry}",`);
276
+ } else {
277
+ updated = body.replace(/^\s*\{/, `{\n "instructions": ["${entry}"],`);
278
+ }
279
+ fs.writeFileSync(configPath, updated, 'utf8');
280
+ report({ path: configPath, action: 'updated (added instructions entry)' });
281
+ return;
282
+ }
283
+
284
+ const fresh = `{
285
+ // Kilo reads AGENTS.md automatically. This entry additionally loads the
286
+ // kracked-core rules pointer. Add your own settings alongside it.
287
+ "instructions": ["${entry}"]
288
+ }
289
+ `;
290
+ fs.writeFileSync(configPath, fresh, 'utf8');
291
+ report({ path: configPath, action: 'written' });
292
+ }