@warpgogol/forge 2.21.0 → 2.21.3

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
@@ -16,6 +16,18 @@ Install https://npmjs.com/package/@warpgogol/forge in this folder and set up my
16
16
 
17
17
  Replace `[describe your project]` with your idea — a game, a library, a knowledge base, anything. The AI agent installs Forge, scaffolds the project, and sets up a live preview. From there on, you just talk: describe what you want, and the agent builds it. No programming, no terminal, no commands.
18
18
 
19
+ ### After the prompt completes — the Bootstrap skill
20
+
21
+ Once the AI agent has installed Forge and scaffolded your project, it will run the `/forge-bootstrap` skill. This is a required step that configures your project interactively:
22
+
23
+ - The language the AI uses to communicate with you
24
+ - The language for project documentation (RFCs, ADRs, READMEs) — defaults to English
25
+ - Your preferred working style — business or creative
26
+ - Your name and how you want to be addressed
27
+ - Your stack configuration (or migration of an existing project)
28
+
29
+ The skill asks a few simple questions in the chat, then sets everything up. After it completes, you're ready to create — just tell the AI agent what you want to build.
30
+
19
31
  ---
20
32
 
21
33
  ## What you can build with Forge
package/README.uk.md CHANGED
@@ -16,6 +16,18 @@ Install https://npmjs.com/package/@warpgogol/forge in this folder and set up my
16
16
 
17
17
  Замініть `[опишіть ваш проєкт]` на вашу ідею — гра, бібліотека, база знань, будь-що. ШІ-агент встановить Forge, згенерує каркас проєкту та налаштує живий перегляд. Далі ви просто розмовляєте: описуєте, що хочете, і агент будує це. Жодного програмування, жодного терміналу, жодних команд.
18
18
 
19
+ ### Після виконання промпту — навичка Bootstrap
20
+
21
+ Коли ШІ-агент встановить Forge і згенерує каркас проєкту, він запустить навичку `/forge-bootstrap`. Це обов'язковий крок, який інтерактивно налаштовує ваш проєкт:
22
+
23
+ - Мову, якою ШІ спілкується з вами
24
+ - Мову документації проєкту (RFC, ADR, README) — за замовчуванням англійська
25
+ - Ваш стиль роботи — діловий або творчий
26
+ - Ваше ім'я та форму звертання
27
+ - Конфігурацію стеку (або міграцію наявного проєкту)
28
+
29
+ Навичка ставить кілька простих запитань у чаті, а потім налаштовує все. Після завершення ви готові творити — просто скажіть ШІ-агенту, що ви хочете створити.
30
+
19
31
  ---
20
32
 
21
33
  ## Що можна створити за допомогою Forge
@@ -147,16 +147,16 @@ describe("mission.archive", () => {
147
147
  expect(data.skipped.some((s) => s.reason === "destination exists")).toBe(true);
148
148
  });
149
149
 
150
- test("unreadable manifest → skipped with 'unreadable manifest' reason", async () => {
150
+ test("unreadable manifest with empty dir → skipped with 'empty remnant' reason", async () => {
151
151
  const missionDir = path.join(missionsDir, "test-m008");
152
152
  await fs.mkdir(missionDir, { recursive: true });
153
- // No mission.yaml — unreadable
153
+ // No mission.yaml, no workpiece empty remnant
154
154
 
155
155
  const data = unwrap(await runMissionArchive(makeInput(), makeContext(tmpDir)));
156
156
 
157
157
  expect(data.moved).toHaveLength(0);
158
158
  expect(data.skipped).toHaveLength(1);
159
- expect(data.skipped[0].reason).toBe("unreadable manifest");
159
+ expect(data.skipped[0].reason).toBe("empty remnant — use --clean-orphans to remove");
160
160
  });
161
161
 
162
162
  test("open mission in archive/ → moved back to missions/ (bidirectional)", async () => {
@@ -388,4 +388,134 @@ describe("mission.archive", () => {
388
388
  );
389
389
  expect(installCall).toBeUndefined();
390
390
  });
391
+
392
+ // RFC-0982: Fallback state detection and --clean-orphans tests
393
+
394
+ test("RFC-0982: orphaned workpiece with only .astro/ cache → state detected as closed, archived", async () => {
395
+ const missionDir = path.join(missionsDir, "test-r982-01");
396
+ const workpieceDir = path.join(missionDir, "workpiece");
397
+ await fs.mkdir(path.join(workpieceDir, ".astro"), { recursive: true });
398
+ await fs.writeFile(path.join(workpieceDir, ".astro", "cache.txt"), "cache\n");
399
+ // No mission.yaml — orphaned remnant
400
+
401
+ const data = unwrap(await runMissionArchive(makeInput(), makeContext(tmpDir)));
402
+
403
+ expect(data.moved).toHaveLength(1);
404
+ expect(data.moved[0].missionId).toBe("test-r982-01");
405
+ expect(data.moved[0].state).toBe("closed");
406
+ expect(data.moved[0].direction).toBe("into-archive");
407
+ expect(existsSync(path.join(missionsDir, "archive", "closed", "test-r982-01"))).toBe(true);
408
+ });
409
+
410
+ test("RFC-0982: .closed marker fallback → state detected as closed", async () => {
411
+ const missionDir = path.join(missionsDir, "test-r982-02");
412
+ const workpieceDir = path.join(missionDir, "workpiece");
413
+ await fs.mkdir(workpieceDir, { recursive: true });
414
+ await fs.writeFile(path.join(workpieceDir, ".closed"), "2026-08-29T12:00:00Z\n");
415
+ // Also add a source file so it's not cache-only
416
+ await fs.writeFile(path.join(workpieceDir, "src.ts"), "// source\n");
417
+ // No mission.yaml — but .closed marker present
418
+
419
+ const data = unwrap(await runMissionArchive(makeInput(), makeContext(tmpDir)));
420
+
421
+ expect(data.moved).toHaveLength(1);
422
+ expect(data.moved[0].missionId).toBe("test-r982-02");
423
+ expect(data.moved[0].state).toBe("closed");
424
+ expect(existsSync(path.join(missionsDir, "archive", "closed", "test-r982-02"))).toBe(true);
425
+ });
426
+
427
+ test("RFC-0982: --clean-orphans trashes orphaned remnant dir", async () => {
428
+ const missionDir = path.join(missionsDir, "test-r982-03");
429
+ const workpieceDir = path.join(missionDir, "workpiece");
430
+ await fs.mkdir(path.join(workpieceDir, ".astro"), { recursive: true });
431
+ await fs.writeFile(path.join(workpieceDir, ".astro", "cache.txt"), "cache\n");
432
+ // No mission.yaml — orphaned remnant with only cache
433
+
434
+ const data = unwrap(
435
+ await runMissionArchive(makeInput({ "clean-orphans": true }), makeContext(tmpDir)),
436
+ );
437
+
438
+ expect(data.moved).toHaveLength(1);
439
+ expect(data.moved[0].missionId).toBe("test-r982-03");
440
+ expect(data.moved[0].direction).toBe("trashed-orphan");
441
+ expect(data.moved[0].to).toBe("(trashed)");
442
+ expect(existsSync(missionDir)).toBe(false);
443
+ });
444
+
445
+ test("RFC-0982: --clean-orphans --dry-run → reported but not trashed", async () => {
446
+ const missionDir = path.join(missionsDir, "test-r982-04");
447
+ const workpieceDir = path.join(missionDir, "workpiece");
448
+ await fs.mkdir(path.join(workpieceDir, ".astro"), { recursive: true });
449
+ await fs.writeFile(path.join(workpieceDir, ".astro", "cache.txt"), "cache\n");
450
+
451
+ const data = unwrap(
452
+ await runMissionArchive(
453
+ makeInput({ "clean-orphans": true, "dry-run": true }),
454
+ makeContext(tmpDir),
455
+ ),
456
+ );
457
+
458
+ expect(data.moved).toHaveLength(1);
459
+ expect(data.moved[0].direction).toBe("trashed-orphan");
460
+ expect(data.dryRun).toBe(true);
461
+ // Dir should still exist in dry-run
462
+ expect(existsSync(missionDir)).toBe(true);
463
+ });
464
+
465
+ test("RFC-0982: --clean-orphans skips non-orphaned dir with mission.yaml", async () => {
466
+ await writeMissionManifest(missionsDir, "test-r982-05", "closed");
467
+ const workpieceDir = path.join(missionsDir, "test-r982-05", "workpiece");
468
+ await fs.mkdir(path.join(workpieceDir, ".astro"), { recursive: true });
469
+
470
+ const data = unwrap(
471
+ await runMissionArchive(makeInput({ "clean-orphans": true }), makeContext(tmpDir)),
472
+ );
473
+
474
+ // Should be archived normally, not trashed
475
+ expect(data.moved).toHaveLength(1);
476
+ expect(data.moved[0].direction).toBe("into-archive");
477
+ expect(existsSync(path.join(missionsDir, "archive", "closed", "test-r982-05"))).toBe(true);
478
+ });
479
+
480
+ test("RFC-0982: skip reason 'manually inspect' for dir with non-cache content but no mission.yaml", async () => {
481
+ const missionDir = path.join(missionsDir, "test-r982-06");
482
+ const workpieceDir = path.join(missionDir, "workpiece");
483
+ await fs.mkdir(workpieceDir, { recursive: true });
484
+ // Source file in workpiece — non-cache content, no .closed marker
485
+ await fs.writeFile(path.join(workpieceDir, "src.ts"), "// source code\n");
486
+ // No mission.yaml
487
+
488
+ const data = unwrap(await runMissionArchive(makeInput(), makeContext(tmpDir)));
489
+
490
+ expect(data.moved).toHaveLength(0);
491
+ expect(data.skipped).toHaveLength(1);
492
+ expect(data.skipped[0].reason).toContain("manually inspect");
493
+ });
494
+
495
+ test("RFC-0982: --clean-orphans trashes empty remnant dir (no workpiece, no mission.yaml)", async () => {
496
+ const missionDir = path.join(missionsDir, "test-r982-08");
497
+ await fs.mkdir(missionDir, { recursive: true });
498
+ // No mission.yaml, no workpiece — empty remnant, state would be null
499
+
500
+ const data = unwrap(
501
+ await runMissionArchive(makeInput({ "clean-orphans": true }), makeContext(tmpDir)),
502
+ );
503
+
504
+ expect(data.moved).toHaveLength(1);
505
+ expect(data.moved[0].missionId).toBe("test-r982-08");
506
+ expect(data.moved[0].direction).toBe("trashed-orphan");
507
+ expect(existsSync(missionDir)).toBe(false);
508
+ });
509
+
510
+ test("RFC-0982: skip reason 'use --clean-orphans' for empty remnant dir", async () => {
511
+ const missionDir = path.join(missionsDir, "test-r982-07");
512
+ await fs.mkdir(missionDir, { recursive: true });
513
+ // No mission.yaml, no workpiece, no content — empty remnant
514
+
515
+ const data = unwrap(await runMissionArchive(makeInput(), makeContext(tmpDir)));
516
+
517
+ expect(data.moved).toHaveLength(0);
518
+ expect(data.skipped).toHaveLength(1);
519
+ expect(data.skipped[0].reason).toContain("use --clean-orphans");
520
+ });
391
521
  });
@@ -18,6 +18,7 @@ archive subdirectories back to missions/.
18
18
  <item>RFC-0801: add service-folder cleanup (node_modules, dist, .astro, .wrangler, .cache, .turbo) before archive move.</item>
19
19
  <item>RFC-0733: add pinned-files pre-check — skip pinned mission directories with warning instead of moving them.</item>
20
20
  <item>RFC-0804: auto-refresh pnpm-lock.yaml after directory moves.</item>
21
+ <item>RFC-0982: fallback state detection for orphaned workpiece dirs (no mission.yaml) via .closed marker and cache-only heuristic; --clean-orphans flag; improved skip reasons.</item>
21
22
  </CHANGE_SUMMARY>
22
23
  */
23
24
 
@@ -63,6 +64,11 @@ async function cleanServiceFolders(workpieceDir: string): Promise<string[]> {
63
64
  return removed;
64
65
  }
65
66
 
67
+ // RFC-0982: Shared cache-entry predicate — prevents filter logic divergence.
68
+ function isCacheEntry(name: string): boolean {
69
+ return name.startsWith(".") || name === "node_modules" || name === "dist";
70
+ }
71
+
66
72
  async function readMissionState(missionDir: string): Promise<string | null> {
67
73
  const manifestPath = path.join(missionDir, "mission.yaml");
68
74
  try {
@@ -70,10 +76,64 @@ async function readMissionState(missionDir: string): Promise<string | null> {
70
76
  const parsed = parseYaml(raw) as Record<string, unknown>;
71
77
  const state = parsed?.state;
72
78
  if (typeof state === "string") return state.trim();
73
- return null;
74
79
  } catch {
75
- return null;
80
+ // Fall through to secondary checks
81
+ }
82
+
83
+ // RFC-0982: Secondary — check for workpiece/.closed marker
84
+ const closedMarker = path.join(missionDir, "workpiece", ".closed");
85
+ if (existsSync(closedMarker)) return "closed";
86
+
87
+ // RFC-0982: Tertiary — if workpiece/ exists and contains only cache entries,
88
+ // treat as closed (post-close remnant pattern)
89
+ const workpieceDir = path.join(missionDir, "workpiece");
90
+ if (existsSync(workpieceDir)) {
91
+ const entries = await fs.readdir(workpieceDir);
92
+ const nonCacheEntries = entries.filter((e) => !isCacheEntry(e));
93
+ if (nonCacheEntries.length === 0) {
94
+ return "closed";
95
+ }
76
96
  }
97
+
98
+ return null;
99
+ }
100
+
101
+ // RFC-0982: Check if a mission directory is an orphaned remnant — no mission.yaml
102
+ // and workpiece/ contains only cache entries (or no workpiece at all).
103
+ async function isOrphanedRemnant(missionDir: string): Promise<boolean> {
104
+ const manifestPath = path.join(missionDir, "mission.yaml");
105
+ if (existsSync(manifestPath)) return false;
106
+
107
+ const workpieceDir = path.join(missionDir, "workpiece");
108
+ if (!existsSync(workpieceDir)) return true;
109
+
110
+ const entries = await fs.readdir(workpieceDir);
111
+ const nonCacheEntries = entries.filter((e) => !isCacheEntry(e));
112
+ return nonCacheEntries.length === 0;
113
+ }
114
+
115
+ // RFC-0982: Check if a mission directory has non-cache content (source files,
116
+ // configs, etc.) that warrants manual inspection rather than auto-cleanup.
117
+ async function hasNonCacheContent(missionDir: string): Promise<boolean> {
118
+ const entries = await fs.readdir(missionDir, { withFileTypes: true });
119
+ for (const entry of entries) {
120
+ if (entry.name === "workpiece") continue;
121
+ if (entry.isDirectory() && isCacheEntry(entry.name)) {
122
+ continue;
123
+ }
124
+ // Any file or non-cache directory at mission root is content
125
+ return true;
126
+ }
127
+
128
+ // Check workpiece/ for non-cache content
129
+ const workpieceDir = path.join(missionDir, "workpiece");
130
+ if (existsSync(workpieceDir)) {
131
+ const wpEntries = await fs.readdir(workpieceDir);
132
+ const nonCacheEntries = wpEntries.filter((e) => !isCacheEntry(e));
133
+ if (nonCacheEntries.length > 0) return true;
134
+ }
135
+
136
+ return false;
77
137
  }
78
138
 
79
139
  interface MoveAttempt {
@@ -158,6 +218,7 @@ export async function runMissionArchive(
158
218
 
159
219
  const dryRun = context.dryRun || input.flags["dry-run"] === true;
160
220
  const statusFilter = input.flags["status"] as string | undefined;
221
+ const cleanOrphans = input.flags["clean-orphans"] === true;
161
222
 
162
223
  if (statusFilter && !MISSION_TERMINAL_STATUSES.includes(statusFilter as never)) {
163
224
  throw new Error(
@@ -223,13 +284,35 @@ export async function runMissionArchive(
223
284
 
224
285
  for (const missionId of rootDirs) {
225
286
  const missionDir = path.join(missionsPath, missionId);
287
+ const sourceRel = `${MISSIONS_DIR}/${missionId}`;
288
+
289
+ // RFC-0982: --clean-orphans — trash orphaned remnant directories before state
290
+ // detection. This catches dirs where mission.yaml is missing and only cache
291
+ // entries remain, regardless of whether readMissionState returns "closed" or null.
292
+ if (cleanOrphans && (await isOrphanedRemnant(missionDir))) {
293
+ if (!dryRun) {
294
+ await trashPath(missionDir);
295
+ }
296
+ moved.push({
297
+ missionId,
298
+ state: "closed",
299
+ from: sourceRel,
300
+ to: "(trashed)",
301
+ direction: "trashed-orphan",
302
+ });
303
+ continue;
304
+ }
305
+
226
306
  const state = await readMissionState(missionDir);
227
307
 
228
308
  if (state === null) {
309
+ const hasContent = await hasNonCacheContent(missionDir);
229
310
  skipped.push({
230
311
  missionId,
231
312
  dir: `${MISSIONS_DIR}/${missionId}`,
232
- reason: "unreadable manifest",
313
+ reason: hasContent
314
+ ? "unreadable manifest — manually inspect or add mission.yaml"
315
+ : "empty remnant — use --clean-orphans to remove",
233
316
  });
234
317
  continue;
235
318
  }
@@ -257,7 +340,6 @@ export async function runMissionArchive(
257
340
  const targetDir = path.join(missionsPath, ARCHIVE_DIR_NAME, state);
258
341
  const targetPath = path.join(targetDir, missionId);
259
342
  const targetRel = `${MISSIONS_DIR}/${ARCHIVE_DIR_NAME}/${state}/${missionId}`;
260
- const sourceRel = `${MISSIONS_DIR}/${missionId}`;
261
343
 
262
344
  // RFC-0733: Check if mission directory is pinned before moving
263
345
  // Gap fix: exempt intra-directory moves (dir stays within the same pinned parent)
@@ -7,6 +7,7 @@
7
7
  </MODULE_CONTRACT>
8
8
  <CHANGE_SUMMARY>
9
9
  <item>RFC-0573: initial forgeMissionModule registering mission.archive command.</item>
10
+ <item>RFC-0982: add --clean-orphans flag for trashing orphaned remnant directories.</item>
10
11
  </CHANGE_SUMMARY>
11
12
  */
12
13
 
@@ -42,6 +43,11 @@ export const forgeMissionModule: ForgeModule = {
42
43
  kind: "string",
43
44
  description: "Filter to a single terminal status (closed, aborted).",
44
45
  },
46
+ "clean-orphans": {
47
+ kind: "boolean",
48
+ description:
49
+ "Trash orphaned directories (no mission.yaml, only cache files) instead of archiving them.",
50
+ },
45
51
  },
46
52
  execute: runMissionArchive,
47
53
  });
@@ -10,6 +10,7 @@ manifest state extraction, and archive result shapes.
10
10
  </MODULE_CONTRACT>
11
11
  <CHANGE_SUMMARY>
12
12
  <item>RFC-0573: initial mission archive types.</item>
13
+ <item>RFC-0982: add "trashed-orphan" to MissionArchiveMove.direction.</item>
13
14
  </CHANGE_SUMMARY>
14
15
  */
15
16
 
@@ -23,7 +24,7 @@ export interface MissionArchiveMove {
23
24
  state: string;
24
25
  from: string;
25
26
  to: string;
26
- direction: "into-archive" | "out-of-archive";
27
+ direction: "into-archive" | "out-of-archive" | "trashed-orphan";
27
28
  }
28
29
 
29
30
  export interface MissionArchiveSkip {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warpgogol/forge",
3
- "version": "2.21.0",
3
+ "version": "2.21.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -4,10 +4,10 @@
4
4
  Placeholders: projectName, projectStack, projectPm,
5
5
  rfcsDir, adrsDir, plansDir, auditsDir, specsDir,
6
6
  skillsDir, dynamicSections -->
7
+
7
8
  # Agent Guide: {{projectName}}
8
9
 
9
- > This file is generated by `forge.agents.generate` from `forge.yaml`.
10
- > Do not edit by hand — edit `forge.yaml` and regenerate.
10
+ > This file is generated by `forge.agents.generate` from `forge.yaml`. It is a living document — edit freely to add project-specific guidance.
11
11
 
12
12
  ## Project
13
13
 
@@ -52,8 +52,7 @@ dotnet build
52
52
  dotnet test
53
53
  ```
54
54
 
55
- Run the editor or game through the installed Godot .NET binary.
56
- Do not change the Godot version, .NET SDK, NuGet packages, or project settings without explicit need.
55
+ Run the editor or game through the installed Godot .NET binary. Do not change the Godot version, .NET SDK, NuGet packages, or project settings without explicit need.
57
56
 
58
57
  ## C# Rules
59
58
 
@@ -103,6 +102,7 @@ Before completing a task:
103
102
  - When facing an unclear architectural decision, propose options and ask for direction first.
104
103
 
105
104
  {{dynamicSections}}
105
+
106
106
  ## RTK — Token Optimization
107
107
 
108
108
  Always prefix shell commands with `rtk` to minimize token consumption. RTK filters and compresses command output before it reaches the LLM context, cutting up to 90% of bash output on common operations.
@@ -66,12 +66,20 @@ Before any operator interaction, silently check whether the installed `@warpgogo
66
66
 
67
67
  Read `PREFERENCES.md` at the project root. `forge create` writes a placeholder with `aiLanguage: en` and `documentationLanguage: en`.
68
68
 
69
- Ask the operator:
69
+ Ask the operator two questions, one at a time:
70
+
71
+ **Question 1 — AI communication language:**
70
72
 
71
- > In which language should the AI communicate with you? (e.g. en, ru, uk, de, es) In which language should project documentation be written? (RFCs, ADRs, READMEs)
73
+ > In which language should the AI communicate with you? (e.g. en, ru, uk, de, es)
72
74
 
73
75
  Accept free-form answers like "Russian", "русский", "uk" or "English". Prefer IETF BCP 47 language tags when the operator provides them.
74
76
 
77
+ **Question 2 — Documentation language:**
78
+
79
+ > In which language should project documentation be written? (RFCs, ADRs, READMEs) Press Enter to use English (default).
80
+
81
+ Accept free-form answers like "Russian", "русский", "uk" or "English". If the operator presses Enter or says "default" / "English", use `en`. Prefer IETF BCP 47 language tags when the operator provides them.
82
+
75
83
  Write or merge the values into `PREFERENCES.md`. **All subsequent communication in this skill session uses the operator's chosen `aiLanguage`.**
76
84
 
77
85
  If `PREFERENCES.md` already has non-default `aiLanguage` set (re-run of the skill), confirm the existing values with the operator instead of asking again. The operator may change them if desired.
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  writeFileAtomic,
44
44
  type WriteFileAtomicOptions,
45
45
  GENERATED_MARKER,
46
+ EDITABLE_GENERATED_MARKER,
46
47
  hasGeneratedMarker,
47
48
  stripGeneratedMarker,
48
49
  buildGeneratedHeader,
@@ -266,6 +266,7 @@ export async function runAgentsGenerate(
266
266
  filePath: "AGENTS.md",
267
267
  ownerCommand: "forge.agents.generate",
268
268
  commandPrefix: "forge",
269
+ editable: true,
269
270
  });
270
271
 
271
272
  // RFC-0643: determine register from PREFERENCES.md or profile
@@ -397,7 +397,7 @@ Your Forge project is ready. The next step is mandatory: run \`/forge-bootstrap\
397
397
  Run \`/forge-bootstrap\` now. It will ask you:
398
398
 
399
399
  - Which language the AI should communicate with you in
400
- - Which language project documentation should be written in
400
+ - Which language project documentation should be written in (defaults to English)
401
401
  - Whether you prefer a business or creative working style
402
402
  - Your name and how you want to be addressed
403
403
  - Your stack configuration (or migrate an existing project)
@@ -184,6 +184,7 @@ export function buildNestedAgentsMd(
184
184
  filePath: "AGENTS.md",
185
185
  ownerCommand: "forge.agents.generate",
186
186
  commandPrefix: "forge",
187
+ editable: true,
187
188
  });
188
189
 
189
190
  const lines: string[] = [];
@@ -200,7 +201,7 @@ export function buildNestedAgentsMd(
200
201
  }
201
202
 
202
203
  lines.push(`> This file is generated by \`forge.agents.generate\`.`);
203
- lines.push(`> Do not edit by handre-run \`forge agents generate\` to regenerate.`);
204
+ lines.push(`> It is a living documentedit freely to add workspace-specific guidance.`);
204
205
  lines.push("");
205
206
 
206
207
  lines.push(`**Workspace type:** ${TYPE_LABEL[workspace.type]}`);
@@ -7,7 +7,7 @@
7
7
  # Agent Guide: {{projectName}}
8
8
 
9
9
  > This file is generated by `forge.agents.generate` from `forge.yaml`.
10
- > Do not edit by hand — edit `forge.yaml` and regenerate.
10
+ > It is a living document — edit freely to add project-specific guidance.
11
11
 
12
12
  ## Project
13
13
 
@@ -7,7 +7,7 @@
7
7
  # Agent Guide: {{projectName}}
8
8
 
9
9
  > This file is generated by `forge.agents.generate` from `forge.yaml`.
10
- > Do not edit by hand — edit `forge.yaml` and regenerate.
10
+ > It is a living document — edit freely to add project-specific guidance.
11
11
 
12
12
  ## Project
13
13
 
@@ -1,6 +1,6 @@
1
1
  <!--
2
- GENERATED. Do not change this line unless the file contains project specific changes.
3
- DO NOT EDIT THIS FILE. Changes are overwritten on the next build.
2
+ GENERATED. Safe to edit project-specific changes are expected and encouraged.
3
+ This file is generated but editable — your changes are preserved on regeneration.
4
4
  Owner command: forge.agents.generate
5
5
  Edit instead: the forge.agents.generate generator source (not this file).
6
6
  Regenerate: forge forge.agents.generate
@@ -16,7 +16,7 @@
16
16
  # Agent Guide: test-project
17
17
 
18
18
  > This file is generated by `forge.agents.generate` from `forge.yaml`.
19
- > Do not edit by hand — edit `forge.yaml` and regenerate.
19
+ > It is a living document — edit freely to add project-specific guidance.
20
20
 
21
21
  ## Project
22
22
 
@@ -1,6 +1,7 @@
1
1
  import { test, expect, describe } from "vitest";
2
2
  import {
3
3
  GENERATED_MARKER,
4
+ EDITABLE_GENERATED_MARKER,
4
5
  hasGeneratedMarker,
5
6
  stripGeneratedMarker,
6
7
  buildGeneratedHeader,
@@ -141,6 +142,19 @@ describe("buildGeneratedHeader", () => {
141
142
  });
142
143
  expect(header).toContain("Edit instead: templates/foo.ts");
143
144
  });
145
+
146
+ test("editable mode uses permissive marker and advisory", () => {
147
+ const header = buildGeneratedHeader({
148
+ filePath: "AGENTS.md",
149
+ ownerCommand: "forge.agents.generate",
150
+ editable: true,
151
+ });
152
+ expect(header).toContain(EDITABLE_GENERATED_MARKER);
153
+ expect(header).not.toContain(GENERATED_MARKER);
154
+ expect(header).not.toContain("DO NOT EDIT");
155
+ expect(header).toContain("Safe to edit");
156
+ expect(header).toContain("editable");
157
+ });
144
158
  });
145
159
 
146
160
  describe("isGeneratedMarkerTextCandidate", () => {
@@ -189,6 +189,7 @@ declare module "@warpgogol/werkstatt-engine/kernel" {
189
189
  export const WriteFileAtomicOptions: any;
190
190
  export const writeFileIfChanged: any;
191
191
  export const GENERATED_MARKER: any;
192
+ export const EDITABLE_GENERATED_MARKER: any;
192
193
  export const hasGeneratedMarker: any;
193
194
  export const stripGeneratedMarker: any;
194
195
  export const isGeneratedMarkerTextCandidate: any;
@@ -301,6 +302,7 @@ declare module "@warpgogol/werkstatt-engine/kernel/generated-marker" {
301
302
  export function buildGeneratedHeader(...args: any[]): any;
302
303
  export function isGeneratedMarkerTextCandidate(...args: any[]): any;
303
304
  export const GENERATED_MARKER: any;
305
+ export const EDITABLE_GENERATED_MARKER: any;
304
306
  export interface StripGeneratedMarkerResult {}
305
307
  export interface GeneratedHeaderInput {}
306
308
  }
@@ -679,6 +681,9 @@ declare module "@warpgogol/werkstatt-engine/release" {
679
681
  export const BootSmokeRequestResult: any;
680
682
  export const planBootSmokeRequests: any;
681
683
  export const detectBootSmokeLanguages: any;
684
+ export const resolveWranglerConfig: any;
685
+ export const resolveLanguages: any;
686
+ export const WranglerResolution: any;
682
687
  export const simulateBindings: any;
683
688
  }
684
689
 
@@ -10,14 +10,22 @@ source (dependency inversion).</purpose>
10
10
  <CHANGE_SUMMARY>
11
11
  <item>Moved from @warpgogol/site-kernel/generated-marker to forge as canonical source.</item>
12
12
  <item>Added optional commandPrefix to GeneratedHeaderInput — defaults to "forge" for autonomous mode; site-kernel passes "pnpm exec werkstatt run".</item>
13
+ <item>Added editable flag to GeneratedHeaderInput — when true, emits a permissive marker and advisory that encourages agents to edit the file. AGENTS.md files use editable: true; other generated files keep the restrictive marker.</item>
13
14
  </CHANGE_SUMMARY>
14
15
  */
15
16
 
16
17
  export const GENERATED_MARKER =
17
18
  "GENERATED. Do not change this line unless the file contains project specific changes.";
18
19
 
20
+ export const EDITABLE_GENERATED_MARKER =
21
+ "GENERATED. Safe to edit — project-specific changes are expected and encouraged.";
22
+
23
+ const DO_NOT_EDIT_LINE = "DO NOT EDIT THIS FILE. Changes are overwritten on the next build.";
24
+ const SAFE_TO_EDIT_LINE =
25
+ "This file is generated but editable — your changes are preserved on regeneration.";
26
+
19
27
  export function hasGeneratedMarker(content: string): boolean {
20
- return content.includes(GENERATED_MARKER);
28
+ return content.includes(GENERATED_MARKER) || content.includes(EDITABLE_GENERATED_MARKER);
21
29
  }
22
30
 
23
31
  export interface StripGeneratedMarkerResult {
@@ -29,10 +37,7 @@ function escapeRegex(str: string): string {
29
37
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
30
38
  }
31
39
 
32
- const DO_NOT_EDIT_LINE = "DO NOT EDIT THIS FILE. Changes are overwritten on the next build.";
33
-
34
40
  export function stripGeneratedMarker(content: string): StripGeneratedMarkerResult {
35
- const raw = GENERATED_MARKER;
36
41
  let result = content;
37
42
  let changed = false;
38
43
 
@@ -46,38 +51,43 @@ export function stripGeneratedMarker(content: string): StripGeneratedMarkerResul
46
51
  }
47
52
  };
48
53
 
49
- applyAll([
50
- new RegExp(`<!--[ \\t]*\\n[\\s\\S]*?${escapeRegex(raw)}[\\s\\S]*?-->\\n?`, "g"),
51
- new RegExp(`/\\*[ \\t]*\\n[\\s\\S]*?${escapeRegex(raw)}[\\s\\S]*?\\*/\\n?`, "g"),
52
- ]);
53
-
54
- for (const token of ["//", "#"]) {
55
- const t = escapeRegex(token);
56
- const advisoryLine = (suffix: string): string => `(?:[ \\t]*${t}[ \\t]*${suffix}\\n)?`;
57
- const pattern = new RegExp(
58
- `^[ \\t]*${t}[ \\t]*${escapeRegex(raw)}\\n` +
59
- advisoryLine(escapeRegex(DO_NOT_EDIT_LINE)) +
60
- advisoryLine("Owner command:.*") +
61
- advisoryLine("Edit instead:.*") +
62
- advisoryLine("Regenerate:.*"),
63
- "gm",
64
- );
65
- applyAll([pattern]);
66
- }
54
+ for (const [raw, advisory] of [
55
+ [GENERATED_MARKER, DO_NOT_EDIT_LINE],
56
+ [EDITABLE_GENERATED_MARKER, SAFE_TO_EDIT_LINE],
57
+ ] as const) {
58
+ applyAll([
59
+ new RegExp(`<!--[ \\t]*\\n[\\s\\S]*?${escapeRegex(raw)}[\\s\\S]*?-->\\n?`, "g"),
60
+ new RegExp(`/\\*[ \\t]*\\n[\\s\\S]*?${escapeRegex(raw)}[\\s\\S]*?\\*/\\n?`, "g"),
61
+ ]);
62
+
63
+ for (const token of ["//", "#"]) {
64
+ const t = escapeRegex(token);
65
+ const advisoryLine = (suffix: string): string => `(?:[ \\t]*${t}[ \\t]*${suffix}\\n)?`;
66
+ const pattern = new RegExp(
67
+ `^[ \\t]*${t}[ \\t]*${escapeRegex(raw)}\\n` +
68
+ advisoryLine(escapeRegex(advisory)) +
69
+ advisoryLine("Owner command:.*") +
70
+ advisoryLine("Edit instead:.*") +
71
+ advisoryLine("Regenerate:.*"),
72
+ "gm",
73
+ );
74
+ applyAll([pattern]);
75
+ }
67
76
 
68
- applyAll([
69
- new RegExp(`^\\s*<!--\\s*${escapeRegex(raw)}\\s*-->\\n?`, "gm"),
70
- new RegExp(`^\\s*//\\s*${escapeRegex(raw)}\\n?`, "gm"),
71
- new RegExp(`^\\s*/\\*\\s*${escapeRegex(raw)}\\s*\\*/\\n?`, "gm"),
72
- new RegExp(`^\\s*#\\s*${escapeRegex(raw)}\\n?`, "gm"),
73
- new RegExp(`^\\s*${escapeRegex(raw)}\\n?`, "gm"),
74
- ]);
77
+ applyAll([
78
+ new RegExp(`^\\s*<!--\\s*${escapeRegex(raw)}\\s*-->\\n?`, "gm"),
79
+ new RegExp(`^\\s*//\\s*${escapeRegex(raw)}\\n?`, "gm"),
80
+ new RegExp(`^\\s*/\\*\\s*${escapeRegex(raw)}\\s*\\*/\\n?`, "gm"),
81
+ new RegExp(`^\\s*#\\s*${escapeRegex(raw)}\\n?`, "gm"),
82
+ new RegExp(`^\\s*${escapeRegex(raw)}\\n?`, "gm"),
83
+ ]);
75
84
 
76
- if (result.includes(raw)) {
77
- const next = result.split(raw).join("");
78
- if (next !== result) {
79
- changed = true;
80
- result = next;
85
+ if (result.includes(raw)) {
86
+ const next = result.split(raw).join("");
87
+ if (next !== result) {
88
+ changed = true;
89
+ result = next;
90
+ }
81
91
  }
82
92
  }
83
93
 
@@ -94,6 +104,12 @@ export interface GeneratedHeaderInput {
94
104
  * Site-kernel callers pass "pnpm exec werkstatt run" for Warpgogol context.
95
105
  */
96
106
  commandPrefix?: string;
107
+ /**
108
+ * When true, emits a permissive marker and advisory lines that encourage agents
109
+ * to edit the file. Used for AGENTS.md files that are generated but should be
110
+ * freely editable by agents working on the project. Defaults to false.
111
+ */
112
+ editable?: boolean;
97
113
  }
98
114
 
99
115
  type GeneratedHeaderCommentStyle = "line-slash" | "line-hash" | "block-html" | "block-css";
@@ -119,6 +135,9 @@ function commentStyleForPath(filePath: string): GeneratedHeaderCommentStyle {
119
135
  export function buildGeneratedHeader(input: GeneratedHeaderInput): string {
120
136
  const style = commentStyleForPath(input.filePath);
121
137
  const prefix = input.commandPrefix ?? "forge";
138
+ const editable = input.editable ?? false;
139
+ const marker = editable ? EDITABLE_GENERATED_MARKER : GENERATED_MARKER;
140
+ const advisoryLine = editable ? SAFE_TO_EDIT_LINE : DO_NOT_EDIT_LINE;
122
141
  const regenerateCommand = input.site
123
142
  ? `${prefix} ${input.ownerCommand} --site ${input.site}`
124
143
  : `${prefix} ${input.ownerCommand}`;
@@ -127,8 +146,8 @@ export function buildGeneratedHeader(input: GeneratedHeaderInput): string {
127
146
  : `Edit instead: the ${input.ownerCommand} generator source (not this file).`;
128
147
 
129
148
  const lines = [
130
- GENERATED_MARKER,
131
- DO_NOT_EDIT_LINE,
149
+ marker,
150
+ advisoryLine,
132
151
  `Owner command: ${input.ownerCommand}`,
133
152
  editInstead,
134
153
  `Regenerate: ${regenerateCommand}`,
@@ -15,6 +15,7 @@ export { writeFileAtomic, type WriteFileAtomicOptions } from "./fs-atomic.ts";
15
15
  export { writeFileIfChanged } from "./fs-idempotent.ts";
16
16
  export {
17
17
  GENERATED_MARKER,
18
+ EDITABLE_GENERATED_MARKER,
18
19
  hasGeneratedMarker,
19
20
  stripGeneratedMarker,
20
21
  buildGeneratedHeader,