plain-forge 1.0.14 → 1.0.16
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 +29 -11
- package/bin/cli.mjs +405 -21
- package/forge/rules/definitions.md +2 -1
- package/forge/rules/exported-concepts.md +2 -1
- package/forge/rules/func-specs.md +3 -3
- package/forge/rules/impl-reqs.md +2 -1
- package/forge/rules/import-modules.md +2 -1
- package/forge/rules/integration-embedded-testing.md +2 -1
- package/forge/rules/integration-embedded.md +2 -1
- package/forge/rules/integration-standalone.md +2 -1
- package/forge/rules/integrations.md +2 -1
- package/forge/rules/line-length.md +2 -1
- package/forge/rules/linked-resources.md +11 -10
- package/forge/rules/required-concepts.md +2 -1
- package/forge/rules/requires-modules.md +2 -1
- package/forge/rules/test-reqs.md +2 -1
- package/forge/skills/add-resource/SKILL.md +5 -6
- package/forge/skills/analyze-if-func-spec-too-complex/SKILL.md +4 -6
- package/forge/skills/break-down-func-spec/SKILL.md +4 -4
- package/forge/skills/load-plain-reference/SKILL.md +10 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,29 +58,47 @@ npx plain-forge install --agent claude --scope project
|
|
|
58
58
|
|
|
59
59
|
**Agent options:**
|
|
60
60
|
|
|
61
|
-
| `--agent` | Installs into | Use when |
|
|
61
|
+
| `--agent` | Installs into (project · global) | Use when |
|
|
62
62
|
|-----------|---------------|----------|
|
|
63
|
-
| `claude` | `.claude/` | You use Claude Code |
|
|
64
|
-
| `codex` | `.
|
|
65
|
-
| `forgecode` | `.
|
|
66
|
-
| `
|
|
63
|
+
| `claude` | `.claude/` · `~/.claude/` | You use Claude Code |
|
|
64
|
+
| `codex` | `.agents/` · `~/.agents/` | You use the OpenAI Codex CLI |
|
|
65
|
+
| `forgecode` | `.forge/` · `~/forge/` | You use ForgeCode |
|
|
66
|
+
| `opencode` | `.opencode/` · `~/.config/opencode/` | You use OpenCode |
|
|
67
|
+
| `universal` | `.agents/` · `~/.agents/` | You want a runtime-neutral layout that any agent reading from `.agents/` can pick up |
|
|
68
|
+
|
|
69
|
+
> **Why `codex` and `forgecode` don't install into `.codex/` or `.forgecode/`.** Those tools load *skills* from specific directories that are **not** named after the tool. Per the current (2026) docs, Codex reads skills from `.agents/skills` (`~/.agents/skills` globally) — `.codex/` is config-only — and ForgeCode reads them from `.forge/skills` (`~/forge/skills` globally). plain-forge installs where the skills will actually load. As a result `codex` and `universal` share the `.agents/` layout; the install manifest records which agent you chose so `update`/`uninstall` behave correctly.
|
|
67
70
|
|
|
68
71
|
**Scope options:**
|
|
69
72
|
|
|
70
73
|
| `--scope` | Installs into | Use when |
|
|
71
74
|
|-----------|---------------|----------|
|
|
72
|
-
| `project` |
|
|
73
|
-
| `global` |
|
|
75
|
+
| `project` | the agent's project dir in the current working directory | You want plain-forge in just this project |
|
|
76
|
+
| `global` | the agent's user-level dir in your home directory | You want plain-forge available in every project on the machine |
|
|
77
|
+
|
|
78
|
+
(See the per-agent table above for the exact project/global directory each agent uses — a few diverge from the plain `~/<dir>` pattern, e.g. OpenCode global is `~/.config/opencode/` and ForgeCode global is `~/forge/`.)
|
|
74
79
|
|
|
75
80
|
Each install writes three subfolders under the chosen directory:
|
|
76
81
|
|
|
77
82
|
```
|
|
78
83
|
<agent-dir>/
|
|
79
84
|
skills/ # every plain-forge skill
|
|
80
|
-
rules/ # spec-writing rules (
|
|
85
|
+
rules/ # spec-writing rules (see "How the rules get applied" below)
|
|
81
86
|
docs/ # shared reference docs
|
|
82
87
|
```
|
|
83
88
|
|
|
89
|
+
#### How the rules get applied per agent
|
|
90
|
+
|
|
91
|
+
The `skills/` are discovered natively by every supported agent. The `rules/` (the `.plain`-authoring guidance) are applied differently because each agent loads instruction files its own way — so plain-forge does a bit of extra, agent-specific wiring:
|
|
92
|
+
|
|
93
|
+
| Agent | How rules are applied |
|
|
94
|
+
|-------|-----------------------|
|
|
95
|
+
| **Claude Code** | Native — Claude Code auto-loads `.claude/rules/*.md` at launch. Each rule carries `paths: "**/*.plain"` frontmatter so Claude scopes it to `.plain` files. No extra wiring. |
|
|
96
|
+
| **OpenCode** | OpenCode doesn't auto-load a `rules/` dir; it reads globs from the `instructions` array in `opencode.json`. plain-forge **merges** a rules glob there — `.opencode/rules/*.md` (project, written to `./opencode.json`) or the absolute `~/.config/opencode/rules/*.md` (global, written to `~/.config/opencode/opencode.json`; absolute because OpenCode doesn't expand `~`). |
|
|
97
|
+
| **Codex / ForgeCode** | Neither auto-loads a `rules/` dir; both read custom instructions only from `AGENTS.md`. plain-forge appends a fenced, **managed pointer block** to `AGENTS.md` telling the agent to read the installed rule files. Project → repo-root `./AGENTS.md`; global → `~/.codex/AGENTS.md` (Codex) or `~/forge/AGENTS.md` (ForgeCode). |
|
|
98
|
+
| **Universal** | Verbatim copy only — the rules land in `.agents/rules/`, and it's up to whichever agent reads `.agents/` to consume them. No config is generated. |
|
|
99
|
+
|
|
100
|
+
In every case where plain-forge touches a config or `AGENTS.md` file, it **merges** into what's already there — adding only its own glob/block and leaving all your other content intact — and it never writes into a malformed config (it warns instead). `uninstall` reverses the wiring: it removes only plain-forge's glob/block, deleting the file only if it contained nothing else.
|
|
101
|
+
|
|
84
102
|
`install` refuses to run if plain-forge is already present at the target directory — it prints a message pointing you at `update` and exits non-zero. Use `update` (below) to refresh an existing install.
|
|
85
103
|
|
|
86
104
|
#### Updating an existing install
|
|
@@ -101,7 +119,7 @@ Installs that predate the manifest (anyone who installed before this feature exi
|
|
|
101
119
|
npx plain-forge uninstall
|
|
102
120
|
```
|
|
103
121
|
|
|
104
|
-
`uninstall` reads the install manifest (`<agent-dir>/.plain-forge/manifest.json`) and deletes **exactly** the files plain-forge wrote, then the manifest itself, then any directory left empty (including the agent directory). Your own skills and third-party content are never in the manifest, so they are never touched.
|
|
122
|
+
`uninstall` reads the install manifest (`<agent-dir>/.plain-forge/manifest.json`) and deletes **exactly** the files plain-forge wrote, then the manifest itself, then removes its rules wiring (the `opencode.json` glob or the `AGENTS.md` pointer block, for the relevant agents), then any directory left empty (including the agent directory). Your own skills and third-party content are never in the manifest, so they are never touched.
|
|
105
123
|
|
|
106
124
|
By default it removes **every** agent layout in the **project** scope (the current folder). Narrow it with flags:
|
|
107
125
|
|
|
@@ -111,7 +129,7 @@ npx plain-forge uninstall --agent claude --scope global
|
|
|
111
129
|
|
|
112
130
|
| Flag | Default | Values |
|
|
113
131
|
|------|---------|--------|
|
|
114
|
-
| `--agent` | `*` (all agents) | `claude`, `codex`, `forgecode`, `universal`, or `*` |
|
|
132
|
+
| `--agent` | `*` (all agents) | `claude`, `codex`, `forgecode`, `opencode`, `universal`, or `*` |
|
|
115
133
|
| `--scope` | `project` | `project` (cwd) or `global` (home directory) |
|
|
116
134
|
|
|
117
135
|
If an install has **no manifest** (e.g. one that predates manifests), `uninstall` cannot tell which files are plain-forge's, so it refuses to delete anything: it prints an error, lists the directories to clean up by hand, and exits non-zero. Refresh such an install with `update` first (which writes a manifest going forward), then `uninstall`.
|
|
@@ -191,7 +209,7 @@ test/
|
|
|
191
209
|
package.json # ships only `bin/cli.mjs` and `forge/` to npm
|
|
192
210
|
```
|
|
193
211
|
|
|
194
|
-
On `install`, the CLI reads `forge/skills` and `forge/rules` and writes them into the chosen agent directory (`.claude/`, `.
|
|
212
|
+
On `install`, the CLI reads `forge/skills` and `forge/rules` and writes them into the chosen agent directory (`.claude/`, `.agents/` for Codex/universal, `.forge/`, or `.opencode/` — see the per-agent table above for global-scope paths), recording every file it wrote in `<agent-dir>/.plain-forge/manifest.json` so `update` can later refresh and prune precisely. For agents that need it, it also wires the rules into `opencode.json` or `AGENTS.md` (see [How the rules get applied per agent](#how-the-rules-get-applied-per-agent)).
|
|
195
213
|
|
|
196
214
|
## Available Skills
|
|
197
215
|
|
package/bin/cli.mjs
CHANGED
|
@@ -9,10 +9,22 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
9
9
|
const pkgRoot = path.resolve(path.dirname(__filename), "..");
|
|
10
10
|
const forgeDir = path.join(pkgRoot, "forge");
|
|
11
11
|
|
|
12
|
+
// Agent name → the project-scope content directory plain-forge writes into.
|
|
13
|
+
// These are the directories each tool actually loads skills from (verified
|
|
14
|
+
// against the 2026 docs), NOT necessarily a dir named after the tool:
|
|
15
|
+
// - Codex reads skills from .agents/skills (never .codex/skills); .codex is
|
|
16
|
+
// config-only. So codex shares the .agents layout with `universal`.
|
|
17
|
+
// - ForgeCode reads skills from .forge/skills (never .forgecode/skills); its
|
|
18
|
+
// global dir is ~/forge (no dot) — see resolveBaseDir.
|
|
19
|
+
// - OpenCode reads project skills from .opencode; global is ~/.config/opencode
|
|
20
|
+
// — see resolveBaseDir.
|
|
21
|
+
// Some scopes/agents diverge from `path.join(root, dir)`; resolveBaseDir owns
|
|
22
|
+
// those exceptions.
|
|
12
23
|
const AGENTS = {
|
|
13
24
|
claude: ".claude",
|
|
14
|
-
codex: ".
|
|
15
|
-
forgecode: ".
|
|
25
|
+
codex: ".agents",
|
|
26
|
+
forgecode: ".forge",
|
|
27
|
+
opencode: ".opencode",
|
|
16
28
|
universal: ".agents",
|
|
17
29
|
};
|
|
18
30
|
const SCOPES = ["project", "global"];
|
|
@@ -83,7 +95,8 @@ Commands:
|
|
|
83
95
|
uninstall Remove a plain-forge install using its manifest
|
|
84
96
|
|
|
85
97
|
Install options:
|
|
86
|
-
--agent <claude|codex|forgecode|universal>
|
|
98
|
+
--agent <claude|codex|forgecode|opencode|universal>
|
|
99
|
+
Target agent layout
|
|
87
100
|
--scope <project|global> Install into cwd or $HOME
|
|
88
101
|
-h, --help Show this help
|
|
89
102
|
|
|
@@ -92,7 +105,8 @@ Update options:
|
|
|
92
105
|
confirming each one
|
|
93
106
|
|
|
94
107
|
Uninstall options:
|
|
95
|
-
--agent <claude|codex|forgecode|universal|*>
|
|
108
|
+
--agent <claude|codex|forgecode|opencode|universal|*>
|
|
109
|
+
Which install to remove
|
|
96
110
|
(default: * — every agent)
|
|
97
111
|
--scope <project|global> Where to look (default: project)
|
|
98
112
|
|
|
@@ -292,6 +306,337 @@ function writeContent(baseDir) {
|
|
|
292
306
|
return { counts, files };
|
|
293
307
|
}
|
|
294
308
|
|
|
309
|
+
// Absolute install directory for an agent in a given scope. Global scope roots
|
|
310
|
+
// at $HOME, project scope at the cwd. OpenCode is the one exception: its global
|
|
311
|
+
// config lives under XDG at ~/.config/opencode — it does NOT read ~/.opencode —
|
|
312
|
+
// while its project layout is ./.opencode like every other agent.
|
|
313
|
+
function resolveBaseDir(agent, scope) {
|
|
314
|
+
const root = scope === "global" ? os.homedir() : process.cwd();
|
|
315
|
+
if (agent === "opencode" && scope === "global") {
|
|
316
|
+
return path.join(root, ".config", "opencode");
|
|
317
|
+
}
|
|
318
|
+
if (agent === "forgecode" && scope === "global") {
|
|
319
|
+
// ForgeCode's global content dir is ~/forge, not ~/.forge.
|
|
320
|
+
return path.join(root, "forge");
|
|
321
|
+
}
|
|
322
|
+
return path.join(root, AGENTS[agent]);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- OpenCode instructions wiring -------------------------------------------
|
|
326
|
+
// OpenCode does not auto-read a rules/ directory the way it does skills/; it
|
|
327
|
+
// loads extra instruction files from the `instructions` glob array in
|
|
328
|
+
// opencode.json. So for opencode we merge a glob pointing at our rules dir into
|
|
329
|
+
// that file (creating it if needed, never disturbing the user's own keys) so
|
|
330
|
+
// the ***plain rules actually apply. This is opencode-specific — every other
|
|
331
|
+
// agent just gets the verbatim file copy.
|
|
332
|
+
|
|
333
|
+
const OPENCODE_SCHEMA = "https://opencode.ai/config.json";
|
|
334
|
+
|
|
335
|
+
// Location of opencode.json for a scope. Project config lives at the project
|
|
336
|
+
// root (cwd), global config under ~/.config/opencode.
|
|
337
|
+
function opencodeConfigPath(scope) {
|
|
338
|
+
if (scope === "global") {
|
|
339
|
+
return path.join(os.homedir(), ".config", "opencode", "opencode.json");
|
|
340
|
+
}
|
|
341
|
+
return path.join(process.cwd(), "opencode.json");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The instructions glob to add. Project scope uses a cwd-relative glob (opencode
|
|
345
|
+
// resolves relative instruction paths against the launch cwd). Global scope must
|
|
346
|
+
// be absolute: opencode does not expand ~ and would resolve a relative path
|
|
347
|
+
// against the project cwd, not the config dir. Only top-level rules/*.md are
|
|
348
|
+
// wired in — the examples/ subtree holds reference scripts, not instructions.
|
|
349
|
+
function opencodeRulesGlob(scope) {
|
|
350
|
+
if (scope === "global") {
|
|
351
|
+
return toPosix(
|
|
352
|
+
path.join(os.homedir(), ".config", "opencode", "rules", "*.md"),
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return ".opencode/rules/*.md";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Add our rules glob to opencode.json's `instructions`, creating or merging the
|
|
359
|
+
// file. Returns a status: "created" | "merged" | "present" | "skipped".
|
|
360
|
+
function mergeOpencodeInstructions(scope) {
|
|
361
|
+
const configPath = opencodeConfigPath(scope);
|
|
362
|
+
const glob = opencodeRulesGlob(scope);
|
|
363
|
+
const existed = fs.existsSync(configPath);
|
|
364
|
+
|
|
365
|
+
let config = {};
|
|
366
|
+
if (existed) {
|
|
367
|
+
try {
|
|
368
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
369
|
+
} catch {
|
|
370
|
+
return { status: "skipped", reason: "malformed", configPath, glob };
|
|
371
|
+
}
|
|
372
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) {
|
|
373
|
+
return { status: "skipped", reason: "not-an-object", configPath, glob };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const list = Array.isArray(config.instructions) ? config.instructions : [];
|
|
378
|
+
if (list.includes(glob)) return { status: "present", configPath, glob };
|
|
379
|
+
|
|
380
|
+
// Only stamp $schema onto a file we are creating; never touch an existing one.
|
|
381
|
+
if (!existed && config.$schema === undefined) config.$schema = OPENCODE_SCHEMA;
|
|
382
|
+
config.instructions = list.concat(glob);
|
|
383
|
+
|
|
384
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
385
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
386
|
+
return { status: existed ? "merged" : "created", configPath, glob };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Reverse of the merge: drop our glob from opencode.json's `instructions`. If
|
|
390
|
+
// that leaves a file containing only our scaffold ($schema + now-empty
|
|
391
|
+
// instructions), delete it; otherwise write back the trimmed config, leaving
|
|
392
|
+
// every user-authored key intact. Returns "removed" | "updated" | "absent" |
|
|
393
|
+
// "skipped".
|
|
394
|
+
function unmergeOpencodeInstructions(scope) {
|
|
395
|
+
const configPath = opencodeConfigPath(scope);
|
|
396
|
+
const glob = opencodeRulesGlob(scope);
|
|
397
|
+
if (!fs.existsSync(configPath)) return { status: "absent", configPath, glob };
|
|
398
|
+
|
|
399
|
+
let config;
|
|
400
|
+
try {
|
|
401
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
402
|
+
} catch {
|
|
403
|
+
return { status: "skipped", reason: "malformed", configPath, glob };
|
|
404
|
+
}
|
|
405
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) {
|
|
406
|
+
return { status: "skipped", reason: "not-an-object", configPath, glob };
|
|
407
|
+
}
|
|
408
|
+
if (!Array.isArray(config.instructions) || !config.instructions.includes(glob)) {
|
|
409
|
+
return { status: "absent", configPath, glob };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
config.instructions = config.instructions.filter((g) => g !== glob);
|
|
413
|
+
|
|
414
|
+
const onlyScaffold =
|
|
415
|
+
config.instructions.length === 0 &&
|
|
416
|
+
Object.keys(config).every((k) => k === "$schema" || k === "instructions");
|
|
417
|
+
if (onlyScaffold) {
|
|
418
|
+
fs.rmSync(configPath, { force: true });
|
|
419
|
+
return { status: "removed", configPath, glob };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (config.instructions.length === 0) delete config.instructions;
|
|
423
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
424
|
+
return { status: "updated", configPath, glob };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Human-readable line for a merge result, printed under the install/update
|
|
428
|
+
// summary. A skip is a warning the user should see (their config is untouched).
|
|
429
|
+
function reportOpencodeMerge(result) {
|
|
430
|
+
switch (result.status) {
|
|
431
|
+
case "created":
|
|
432
|
+
console.log(` opencode.json: created, wired rules via "${result.glob}"`);
|
|
433
|
+
break;
|
|
434
|
+
case "merged":
|
|
435
|
+
console.log(` opencode.json: added rules glob "${result.glob}"`);
|
|
436
|
+
break;
|
|
437
|
+
case "present":
|
|
438
|
+
console.log(` opencode.json: rules glob already present`);
|
|
439
|
+
break;
|
|
440
|
+
case "skipped":
|
|
441
|
+
console.log(
|
|
442
|
+
` opencode.json: left untouched (${result.reason}) — add "${result.glob}" to its "instructions" manually`,
|
|
443
|
+
);
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Human-readable line for an unmerge result during uninstall.
|
|
449
|
+
function reportOpencodeUnmerge(result) {
|
|
450
|
+
switch (result.status) {
|
|
451
|
+
case "removed":
|
|
452
|
+
console.log(` opencode.json: removed (was only plain-forge's rules wiring)`);
|
|
453
|
+
break;
|
|
454
|
+
case "updated":
|
|
455
|
+
console.log(` opencode.json: removed rules glob, kept your other config`);
|
|
456
|
+
break;
|
|
457
|
+
case "skipped":
|
|
458
|
+
console.log(
|
|
459
|
+
` opencode.json: left untouched (${result.reason}) — remove "${result.glob}" from "instructions" manually`,
|
|
460
|
+
);
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// --- Codex / ForgeCode instructions wiring ----------------------------------
|
|
466
|
+
// Neither tool auto-reads a rules/ directory; both load custom instructions
|
|
467
|
+
// only from AGENTS.md (Codex: repo AGENTS.md or ~/.codex/AGENTS.md; ForgeCode:
|
|
468
|
+
// repo AGENTS.md or ~/forge/AGENTS.md). AGENTS.md has no include/glob mechanism,
|
|
469
|
+
// so we append a managed pointer block that tells the agent to read our rules
|
|
470
|
+
// dir when touching .plain files. The block is fenced by markers so it can be
|
|
471
|
+
// refreshed or removed idempotently without disturbing the user's own content.
|
|
472
|
+
|
|
473
|
+
const AGENTS_MD_BEGIN = "<!-- BEGIN plain-forge (managed) -->";
|
|
474
|
+
const AGENTS_MD_END = "<!-- END plain-forge (managed) -->";
|
|
475
|
+
|
|
476
|
+
// Agents whose rules are delivered through AGENTS.md rather than a natively
|
|
477
|
+
// auto-loaded rules/ dir (claude) or a config glob (opencode).
|
|
478
|
+
function usesAgentsMd(agent) {
|
|
479
|
+
return agent === "codex" || agent === "forgecode";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
483
|
+
|
|
484
|
+
// Where the tool reads AGENTS.md from for a scope. Project scope is the repo
|
|
485
|
+
// root (cwd) for both tools; global scope differs per tool.
|
|
486
|
+
function agentsMdPath(agent, scope) {
|
|
487
|
+
if (scope === "project") return path.join(process.cwd(), "AGENTS.md");
|
|
488
|
+
if (agent === "forgecode") return path.join(os.homedir(), "forge", "AGENTS.md");
|
|
489
|
+
return path.join(os.homedir(), ".codex", "AGENTS.md"); // codex global
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Glob the pointer block references — the installed rules dir. Project scope is
|
|
493
|
+
// written relative to the repo root; global scope must be absolute (these files
|
|
494
|
+
// are consulted from arbitrary working directories).
|
|
495
|
+
function agentsMdRulesGlob(agent, scope) {
|
|
496
|
+
const rulesDir = path.join(resolveBaseDir(agent, scope), "rules");
|
|
497
|
+
if (scope === "global") return toPosix(path.join(rulesDir, "*.md"));
|
|
498
|
+
return toPosix(path.join(path.relative(process.cwd(), rulesDir), "*.md"));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function agentsMdBlock(glob) {
|
|
502
|
+
return [
|
|
503
|
+
AGENTS_MD_BEGIN,
|
|
504
|
+
"## ***plain authoring rules (plain-forge)",
|
|
505
|
+
"",
|
|
506
|
+
"When creating or editing `.plain` specification files, first read and follow",
|
|
507
|
+
`every rule file matching \`${glob}\`. Each file covers one section or topic of`,
|
|
508
|
+
"the ***plain language; they are installed and maintained by plain-forge.",
|
|
509
|
+
AGENTS_MD_END,
|
|
510
|
+
].join("\n");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Add or refresh the managed pointer block in AGENTS.md, creating the file if
|
|
514
|
+
// needed and preserving all user content. Returns a status:
|
|
515
|
+
// "created" | "merged" | "refreshed" | "present".
|
|
516
|
+
function mergeAgentsMd(agent, scope) {
|
|
517
|
+
const filePath = agentsMdPath(agent, scope);
|
|
518
|
+
const glob = agentsMdRulesGlob(agent, scope);
|
|
519
|
+
const block = agentsMdBlock(glob);
|
|
520
|
+
const existed = fs.existsSync(filePath);
|
|
521
|
+
let content = existed ? fs.readFileSync(filePath, "utf8") : "";
|
|
522
|
+
|
|
523
|
+
const hadBlock = content.includes(AGENTS_MD_BEGIN);
|
|
524
|
+
if (hadBlock) {
|
|
525
|
+
const re = new RegExp(
|
|
526
|
+
`${escapeRe(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MD_END)}`,
|
|
527
|
+
);
|
|
528
|
+
const replaced = content.replace(re, block);
|
|
529
|
+
if (replaced === content) return { status: "present", filePath, glob };
|
|
530
|
+
content = replaced;
|
|
531
|
+
} else {
|
|
532
|
+
if (content && !content.endsWith("\n")) content += "\n";
|
|
533
|
+
if (content) content += "\n";
|
|
534
|
+
content += block + "\n";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
538
|
+
fs.writeFileSync(filePath, content);
|
|
539
|
+
const status = !existed ? "created" : hadBlock ? "refreshed" : "merged";
|
|
540
|
+
return { status, filePath, glob };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Remove the managed pointer block. Deletes the file if nothing but the block
|
|
544
|
+
// (and whitespace) remains; otherwise writes back the user's content.
|
|
545
|
+
// Returns "removed" | "updated" | "absent".
|
|
546
|
+
function unmergeAgentsMd(agent, scope) {
|
|
547
|
+
const filePath = agentsMdPath(agent, scope);
|
|
548
|
+
if (!fs.existsSync(filePath)) return { status: "absent", filePath };
|
|
549
|
+
let content = fs.readFileSync(filePath, "utf8");
|
|
550
|
+
if (!content.includes(AGENTS_MD_BEGIN)) return { status: "absent", filePath };
|
|
551
|
+
|
|
552
|
+
const re = new RegExp(
|
|
553
|
+
`\\n*${escapeRe(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MD_END)}\\n*`,
|
|
554
|
+
);
|
|
555
|
+
content = content.replace(re, "\n");
|
|
556
|
+
|
|
557
|
+
if (content.trim() === "") {
|
|
558
|
+
fs.rmSync(filePath, { force: true });
|
|
559
|
+
return { status: "removed", filePath };
|
|
560
|
+
}
|
|
561
|
+
content = content.replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
|
|
562
|
+
if (!content.endsWith("\n")) content += "\n";
|
|
563
|
+
fs.writeFileSync(filePath, content);
|
|
564
|
+
return { status: "updated", filePath };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function reportAgentsMdMerge(result) {
|
|
568
|
+
const rel = result.filePath;
|
|
569
|
+
switch (result.status) {
|
|
570
|
+
case "created":
|
|
571
|
+
console.log(` AGENTS.md: created at ${rel}, wired rules via "${result.glob}"`);
|
|
572
|
+
break;
|
|
573
|
+
case "merged":
|
|
574
|
+
console.log(` AGENTS.md: added rules block to ${rel}`);
|
|
575
|
+
break;
|
|
576
|
+
case "refreshed":
|
|
577
|
+
console.log(` AGENTS.md: refreshed rules block in ${rel}`);
|
|
578
|
+
break;
|
|
579
|
+
case "present":
|
|
580
|
+
console.log(` AGENTS.md: rules block already up to date`);
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function reportAgentsMdUnmerge(result) {
|
|
586
|
+
switch (result.status) {
|
|
587
|
+
case "removed":
|
|
588
|
+
console.log(` AGENTS.md: removed (was only plain-forge's rules block)`);
|
|
589
|
+
break;
|
|
590
|
+
case "updated":
|
|
591
|
+
console.log(` AGENTS.md: removed rules block, kept your other content`);
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Wire rules for an agent whose mechanism needs it, during install/update.
|
|
597
|
+
// The skills/rules/docs and manifest are already on disk by the time this runs,
|
|
598
|
+
// so a failure here (e.g. an unwritable opencode.json/AGENTS.md) must not fail
|
|
599
|
+
// the whole install — it degrades to a warning and the install still succeeds.
|
|
600
|
+
function wireRules(agent, scope) {
|
|
601
|
+
try {
|
|
602
|
+
if (agent === "opencode") {
|
|
603
|
+
reportOpencodeMerge(mergeOpencodeInstructions(scope));
|
|
604
|
+
} else if (usesAgentsMd(agent)) {
|
|
605
|
+
reportAgentsMdMerge(mergeAgentsMd(agent, scope));
|
|
606
|
+
}
|
|
607
|
+
} catch (err) {
|
|
608
|
+
warnRulesWiring("wire", agent, err);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Reverse of wireRules, during uninstall. Also non-fatal: the manifest files
|
|
613
|
+
// are already gone, so a failure to tidy up the rules wiring is a warning, not
|
|
614
|
+
// an uninstall failure.
|
|
615
|
+
function unwireRules(agent, scope) {
|
|
616
|
+
try {
|
|
617
|
+
if (agent === "opencode") {
|
|
618
|
+
reportOpencodeUnmerge(unmergeOpencodeInstructions(scope));
|
|
619
|
+
} else if (usesAgentsMd(agent)) {
|
|
620
|
+
reportAgentsMdUnmerge(unmergeAgentsMd(agent, scope));
|
|
621
|
+
}
|
|
622
|
+
} catch (err) {
|
|
623
|
+
warnRulesWiring("unwire", agent, err);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function warnRulesWiring(action, agent, err) {
|
|
628
|
+
const target = agent === "opencode" ? "opencode.json" : "AGENTS.md";
|
|
629
|
+
const verb = action === "wire" ? "wire up" : "clean up";
|
|
630
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
631
|
+
console.warn(
|
|
632
|
+
` warning: could not ${verb} the ${agent} rules in ${target} (${msg}).`,
|
|
633
|
+
);
|
|
634
|
+
console.warn(
|
|
635
|
+
` the skills and rule files were installed successfully; see the README`,
|
|
636
|
+
);
|
|
637
|
+
console.warn(` section "How the rules get applied per agent" to finish by hand.`);
|
|
638
|
+
}
|
|
639
|
+
|
|
295
640
|
function manifestPathFor(baseDir) {
|
|
296
641
|
return path.join(baseDir, MANIFEST_REL);
|
|
297
642
|
}
|
|
@@ -306,12 +651,16 @@ function readManifest(baseDir) {
|
|
|
306
651
|
return null;
|
|
307
652
|
}
|
|
308
653
|
|
|
309
|
-
function writeManifest(baseDir, files) {
|
|
654
|
+
function writeManifest(baseDir, files, agent) {
|
|
310
655
|
const target = manifestPathFor(baseDir);
|
|
311
656
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
657
|
+
// `agent` records which agent layout produced this install. It matters when
|
|
658
|
+
// two agents resolve to the same dir (codex and universal both use .agents):
|
|
659
|
+
// it lets detect/update/uninstall know whether AGENTS.md rule-wiring applies.
|
|
312
660
|
const manifest = {
|
|
313
661
|
name: "plain-forge",
|
|
314
662
|
version: readPkgVersion(),
|
|
663
|
+
...(agent ? { agent } : {}),
|
|
315
664
|
files: files.map(toPosix).sort(),
|
|
316
665
|
};
|
|
317
666
|
fs.writeFileSync(target, JSON.stringify(manifest, null, 2) + "\n");
|
|
@@ -361,15 +710,22 @@ function deleteForgeFile(baseDir, rel) {
|
|
|
361
710
|
// the flagship skill.
|
|
362
711
|
function detectInstalls() {
|
|
363
712
|
const installs = [];
|
|
713
|
+
const seen = new Set();
|
|
364
714
|
for (const scope of SCOPES) {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
715
|
+
for (const agent of Object.keys(AGENTS)) {
|
|
716
|
+
const baseDir = resolveBaseDir(agent, scope);
|
|
717
|
+
// Some agents share a directory (codex and universal both use .agents).
|
|
718
|
+
// Only inspect each physical dir once so a single install isn't detected
|
|
719
|
+
// — and later updated/pruned — twice.
|
|
720
|
+
if (seen.has(baseDir)) continue;
|
|
721
|
+
seen.add(baseDir);
|
|
368
722
|
if (!fs.existsSync(baseDir)) continue;
|
|
369
723
|
const manifest = readManifest(baseDir);
|
|
370
724
|
const isLegacy = !manifest && hasForgeSignature(baseDir);
|
|
371
725
|
if (manifest || isLegacy) {
|
|
372
|
-
|
|
726
|
+
// Prefer the agent recorded at install time; fall back to the dir's
|
|
727
|
+
// first-mapped agent for legacy/manifest-less installs.
|
|
728
|
+
installs.push({ agent: manifest?.agent ?? agent, scope, baseDir, manifest });
|
|
373
729
|
}
|
|
374
730
|
}
|
|
375
731
|
}
|
|
@@ -395,8 +751,7 @@ async function cmdInstall(args) {
|
|
|
395
751
|
process.exit(2);
|
|
396
752
|
}
|
|
397
753
|
|
|
398
|
-
const
|
|
399
|
-
const baseDir = path.join(root, AGENTS[agent]);
|
|
754
|
+
const baseDir = resolveBaseDir(agent, scope);
|
|
400
755
|
|
|
401
756
|
const alreadyInstalled =
|
|
402
757
|
readManifest(baseDir) !== null || hasForgeSignature(baseDir);
|
|
@@ -407,12 +762,13 @@ async function cmdInstall(args) {
|
|
|
407
762
|
}
|
|
408
763
|
|
|
409
764
|
const { counts, files } = writeContent(baseDir);
|
|
410
|
-
writeManifest(baseDir, files);
|
|
765
|
+
writeManifest(baseDir, files, agent);
|
|
411
766
|
|
|
412
767
|
console.log(`installed into ${baseDir}`);
|
|
413
768
|
console.log(` skills: ${counts.skills}`);
|
|
414
769
|
console.log(` rules: ${counts.rules}`);
|
|
415
770
|
console.log(` docs: ${counts.docs}`);
|
|
771
|
+
wireRules(agent, scope);
|
|
416
772
|
console.log();
|
|
417
773
|
printNextSteps(agent);
|
|
418
774
|
}
|
|
@@ -453,6 +809,10 @@ async function cmdUpdate(args) {
|
|
|
453
809
|
console.log(
|
|
454
810
|
` skills: ${counts.skills} rules: ${counts.rules} docs: ${counts.docs}`,
|
|
455
811
|
);
|
|
812
|
+
// Re-assert rules wiring (opencode.json / AGENTS.md) — installs from before
|
|
813
|
+
// this feature lack it; the merge is idempotent for those that already have
|
|
814
|
+
// it, and refreshes the glob if the layout changed.
|
|
815
|
+
wireRules(inst.agent, inst.scope);
|
|
456
816
|
|
|
457
817
|
// Pruning only applies to manifest-tracked installs. Each deprecated file
|
|
458
818
|
// is confirmed individually before removal; denied files stay on disk and
|
|
@@ -484,7 +844,7 @@ async function cmdUpdate(args) {
|
|
|
484
844
|
|
|
485
845
|
// Manifest reflects what's actually on disk: the fresh files plus any
|
|
486
846
|
// deprecated files the user chose to keep.
|
|
487
|
-
writeManifest(inst.baseDir, files.concat(kept));
|
|
847
|
+
writeManifest(inst.baseDir, files.concat(kept), inst.agent);
|
|
488
848
|
console.log();
|
|
489
849
|
updated++;
|
|
490
850
|
}
|
|
@@ -528,7 +888,7 @@ async function cmdUninstall(args) {
|
|
|
528
888
|
let hadError = false;
|
|
529
889
|
|
|
530
890
|
for (const agent of agents) {
|
|
531
|
-
const baseDir =
|
|
891
|
+
const baseDir = resolveBaseDir(agent, scope);
|
|
532
892
|
if (!fs.existsSync(baseDir)) continue;
|
|
533
893
|
|
|
534
894
|
const manifest = readManifest(baseDir);
|
|
@@ -560,19 +920,31 @@ async function cmdUninstall(args) {
|
|
|
560
920
|
if (deleteForgeFile(baseDir, rel)) deleted++;
|
|
561
921
|
else failed++;
|
|
562
922
|
}
|
|
563
|
-
//
|
|
564
|
-
// directory and the agent directory if nothing else remains in it.
|
|
923
|
+
// Remove the manifest itself.
|
|
565
924
|
fs.rmSync(manifestPathFor(baseDir), { force: true });
|
|
566
|
-
removeEmptyDirsUpward(
|
|
567
|
-
path.join(baseDir, path.dirname(MANIFEST_REL)),
|
|
568
|
-
baseDir,
|
|
569
|
-
);
|
|
570
|
-
removeEmptyDirsUpward(baseDir, root);
|
|
571
925
|
|
|
572
926
|
console.log(`uninstalled ${agent} (${scope}) from ${baseDir}`);
|
|
573
927
|
console.log(
|
|
574
928
|
` removed ${deleted} file(s)${failed ? `, ${failed} could not be removed` : ""} + manifest`,
|
|
575
929
|
);
|
|
930
|
+
|
|
931
|
+
// Undo the rules wiring (opencode.json / AGENTS.md). Keyed off the agent
|
|
932
|
+
// recorded at install time, not the requested name — codex and universal
|
|
933
|
+
// share the .agents dir, and only the codex install wired AGENTS.md. Done
|
|
934
|
+
// before the dir cleanup so a now-orphaned config file living inside baseDir
|
|
935
|
+
// (e.g. forgecode-global ~/forge/AGENTS.md) can be removed and let baseDir
|
|
936
|
+
// itself be pruned.
|
|
937
|
+
unwireRules(manifest.agent ?? agent, scope);
|
|
938
|
+
|
|
939
|
+
// Prune the now-empty .plain-forge directory and the agent directory if
|
|
940
|
+
// nothing else remains in it.
|
|
941
|
+
removeEmptyDirsUpward(
|
|
942
|
+
path.join(baseDir, path.dirname(MANIFEST_REL)),
|
|
943
|
+
baseDir,
|
|
944
|
+
);
|
|
945
|
+
// Stop at the agent dir's parent so only the agent dir itself is pruned
|
|
946
|
+
// (e.g. for opencode-global we remove ~/.config/opencode, never ~/.config).
|
|
947
|
+
removeEmptyDirsUpward(baseDir, path.dirname(baseDir));
|
|
576
948
|
console.log();
|
|
577
949
|
removed++;
|
|
578
950
|
}
|
|
@@ -626,6 +998,8 @@ function agentLabel(agent) {
|
|
|
626
998
|
return "Codex";
|
|
627
999
|
case "forgecode":
|
|
628
1000
|
return "ForgeCode";
|
|
1001
|
+
case "opencode":
|
|
1002
|
+
return "OpenCode";
|
|
629
1003
|
case "universal":
|
|
630
1004
|
return "AI coding agent";
|
|
631
1005
|
default:
|
|
@@ -698,6 +1072,16 @@ export {
|
|
|
698
1072
|
isUpToDate,
|
|
699
1073
|
copyTreeTracked,
|
|
700
1074
|
writeContent,
|
|
1075
|
+
resolveBaseDir,
|
|
1076
|
+
opencodeConfigPath,
|
|
1077
|
+
opencodeRulesGlob,
|
|
1078
|
+
mergeOpencodeInstructions,
|
|
1079
|
+
unmergeOpencodeInstructions,
|
|
1080
|
+
usesAgentsMd,
|
|
1081
|
+
agentsMdPath,
|
|
1082
|
+
agentsMdRulesGlob,
|
|
1083
|
+
mergeAgentsMd,
|
|
1084
|
+
unmergeAgentsMd,
|
|
701
1085
|
manifestPathFor,
|
|
702
1086
|
readManifest,
|
|
703
1087
|
writeManifest,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Rules for writing ***functional specs*** and ***acceptance tests*** in .plain files
|
|
3
|
-
|
|
3
|
+
paths:
|
|
4
|
+
- "**/*.plain"
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# Rules for writing `***functional specs***`
|
|
@@ -74,6 +75,5 @@ When writing or editing a `***functional specs***` section in a `.plain` file, a
|
|
|
74
75
|
- All :Participant: members of the :Conversation: can see the new :Message:.
|
|
75
76
|
|
|
76
77
|
***acceptance tests***
|
|
77
|
-
- Sending a :Message: to a :Conversation: with three participants should
|
|
78
|
-
make the message visible to all three.
|
|
78
|
+
- Sending a :Message: to a :Conversation: with three participants should make the message visible to all three.
|
|
79
79
|
```
|
package/forge/rules/impl-reqs.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Rules for linking external resources from .plain specs
|
|
3
|
-
|
|
3
|
+
paths:
|
|
4
|
+
- "**/*.plain"
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# Rules for linked resources in `.plain` files
|
|
@@ -14,8 +15,8 @@ A linked resource **must not** be any of the following:
|
|
|
14
15
|
1. **A folder / directory.** `[integrations](src/integrations/)`, `[host project](../host_project/)` are invalid — the renderer cannot ingest a directory. Pick the single most representative file inside (a `README.md`, an exemplar source file, a manifest) and link **that**.
|
|
15
16
|
2. **A URL / external location.** `[Stripe docs](https://stripe.com/docs/api)`, any `http://` / `https://` / `ftp://` / `git://` / `s3://` / `gs://` target. Linked resources are local-file only. If a URL's content is essential, fetch it once, save the response to a text file under `resources/`, and link the saved file.
|
|
16
17
|
3. **A binary file.** PNG, JPG, GIF, PDF, DOCX, XLSX, ZIP, MP3, MP4, compiled binaries (`.exe`, `.so`, `.class`, `.wasm`), and anything else that isn't human-readable text in its raw form. Binary content cannot be meaningfully consumed by the renderer — transcribe it into a text-based form first (a UI screenshot becomes a Markdown description, a PDF spec becomes a Markdown extract or the underlying JSON Schema / OpenAPI, an architecture diagram becomes a Mermaid block).
|
|
18
|
+
4. **Another `.plain` file.** `[Auth Module](auth.plain)` is invalid. To reference another module, use the `require` or `import` frontmatter directives. Do not link it as a markdown resource; the `plain-dry-run` will flag this as a syntax error.
|
|
17
19
|
|
|
18
|
-
If the markdown-link target ends with `/`, contains `://`, resolves to a directory, or has a binary extension, **stop** — it cannot be a linked resource. Convert it first.
|
|
19
20
|
|
|
20
21
|
## URLs and folder paths must not appear *anywhere* in `.plain` content
|
|
21
22
|
- The constraint is not only about markdown links — URLs and folder paths must not appear **anywhere** in `.plain` content (concept body prose, functional-spec text, implementation reqs, test reqs)
|
|
@@ -31,15 +32,15 @@ If the markdown-link target ends with `/`, contains `://`, resolves to a directo
|
|
|
31
32
|
```plain
|
|
32
33
|
***definitions***
|
|
33
34
|
|
|
34
|
-
- :TaskCreateRequest: is the JSON payload for creating a task
|
|
35
|
-
[resources/task_create_request.schema.json](resources/task_create_request.schema.json).
|
|
36
|
-
- :TasksAPI: is the public HTTP surface for tasks
|
|
37
|
-
[resources/tasks_openapi.yaml](resources/tasks_openapi.yaml).
|
|
35
|
+
- :TaskCreateRequest: is the JSON payload for creating a task.
|
|
36
|
+
- It is defined by [resources/task_create_request.schema.json](resources/task_create_request.schema.json).
|
|
37
|
+
- :TasksAPI: is the public HTTP surface for tasks.
|
|
38
|
+
- It is defined by [resources/tasks_openapi.yaml](resources/tasks_openapi.yaml).
|
|
38
39
|
|
|
39
40
|
***functional specs***
|
|
40
41
|
|
|
41
|
-
- :User: should be able to add :Task: by POSTing :TaskCreateRequest: to the
|
|
42
|
-
|
|
42
|
+
- :User: should be able to add :Task: by POSTing :TaskCreateRequest: to the `POST /tasks` endpoint of :TasksAPI:.
|
|
43
|
+
- The endpoint responds per :TasksAPI:.
|
|
43
44
|
```
|
|
44
45
|
|
|
45
46
|
## Each linked resource is referenced from exactly one place
|
|
@@ -50,8 +51,8 @@ If the markdown-link target ends with `/`, contains `://`, resolves to a directo
|
|
|
50
51
|
```plain
|
|
51
52
|
***definitions***
|
|
52
53
|
|
|
53
|
-
- :TaskModalSpec: is the user-interface contract for the task modal
|
|
54
|
-
fully described in [task_modal_specification.yaml](task_modal_specification.yaml).
|
|
54
|
+
- :TaskModalSpec: is the user-interface contract for the task modal.
|
|
55
|
+
- It is fully described in [task_modal_specification.yaml](task_modal_specification.yaml).
|
|
55
56
|
|
|
56
57
|
***functional specs***
|
|
57
58
|
|
package/forge/rules/test-reqs.md
CHANGED
|
@@ -26,17 +26,16 @@ Use standard markdown link syntax inside any spec section:
|
|
|
26
26
|
|
|
27
27
|
```plain
|
|
28
28
|
***definitions***
|
|
29
|
-
- :AuthData: is the authentication data structure.
|
|
30
|
-
in the [auth schema](resources/auth_schema.json).
|
|
29
|
+
- :AuthData: is the authentication data structure.
|
|
30
|
+
- Its format is defined in the [auth schema](resources/auth_schema.json).
|
|
31
31
|
|
|
32
32
|
***implementation reqs***
|
|
33
|
-
- When transforming :BackupData:, use the JOLT transform
|
|
34
|
-
[backup_transform.jolt](resources/backup_transform.jolt).
|
|
33
|
+
- When transforming :BackupData:, use the JOLT transform.
|
|
34
|
+
- The transform is defined in [backup_transform.jolt](resources/backup_transform.jolt).
|
|
35
35
|
|
|
36
36
|
***functional specs***
|
|
37
37
|
|
|
38
|
-
- The system should expose an API conforming to the
|
|
39
|
-
[API specification](resources/api_spec.yaml).
|
|
38
|
+
- The system should expose an API conforming to the [API specification](resources/api_spec.yaml).
|
|
40
39
|
```
|
|
41
40
|
|
|
42
41
|
## Path Rules
|
|
@@ -69,10 +69,9 @@ Too complex:
|
|
|
69
69
|
|
|
70
70
|
Acceptable (separate the modes):
|
|
71
71
|
- The system should process standard :Order: by validating and storing it.
|
|
72
|
-
- The system should process express :Order: by queuing it for immediate dispatch
|
|
73
|
-
|
|
74
|
-
-
|
|
75
|
-
items each and processing them individually.
|
|
72
|
+
- The system should process express :Order: by queuing it for immediate dispatch without validation.
|
|
73
|
+
- The system should process bulk :Order: by splitting it into sub-orders of 100 items each.
|
|
74
|
+
- Each sub-order is processed individually.
|
|
76
75
|
```
|
|
77
76
|
|
|
78
77
|
### 4. Cross-Cutting Concerns
|
|
@@ -88,8 +87,7 @@ Too complex:
|
|
|
88
87
|
Acceptable (separate concerns):
|
|
89
88
|
- The system should fetch :Resource: items from the external API.
|
|
90
89
|
- The system should paginate when fetching :Resource: items from the external API.
|
|
91
|
-
- The system should retry fetching :Resource: on transient errors using
|
|
92
|
-
exponential backoff.
|
|
90
|
+
- The system should retry fetching :Resource: on transient errors using exponential backoff.
|
|
93
91
|
```
|
|
94
92
|
|
|
95
93
|
### 5. UI Complexity
|
|
@@ -111,10 +111,10 @@ If the spec describes different modes or branches, give each its own spec.
|
|
|
111
111
|
**After:**
|
|
112
112
|
```plain
|
|
113
113
|
- The system should generate a standard :MealPlan: using round-robin :Recipe: assignment.
|
|
114
|
-
- The system should generate a restrictive :MealPlan
|
|
115
|
-
:Ingredient: items
|
|
116
|
-
-
|
|
117
|
-
|
|
114
|
+
- The system should generate a restrictive :MealPlan:.
|
|
115
|
+
- Excluded :Ingredient: items are filtered out first.
|
|
116
|
+
- Round-robin :Recipe: assignment is then applied.
|
|
117
|
+
- The system should allow :User: to manually assign :Recipe: items to :Slot: items for a custom :MealPlan:.
|
|
118
118
|
```
|
|
119
119
|
|
|
120
120
|
### Strategy 5: Build UI Incrementally
|
|
@@ -184,8 +184,8 @@ The frontmatter is enclosed between `---` markers and supports:
|
|
|
184
184
|
Specifications can reference external files using markdown link syntax. The linked resource is passed along with the spec to the renderer. File paths are resolved relative to the `.plain` file location. Only files in the same folder (and subfolders) are supported.
|
|
185
185
|
|
|
186
186
|
```plain
|
|
187
|
-
- :User: should be able to add :Task:.
|
|
188
|
-
|
|
187
|
+
- :User: should be able to add :Task:.
|
|
188
|
+
- The user-interface details are in [task_modal_specification.yaml](task_modal_specification.yaml).
|
|
189
189
|
```
|
|
190
190
|
|
|
191
191
|
#### Hard constraint: a linked resource is always a single, text-based file on disk
|
|
@@ -220,15 +220,15 @@ The accompanying spec line should describe the *role* of the artifact ("the requ
|
|
|
220
220
|
```plain
|
|
221
221
|
***definitions***
|
|
222
222
|
|
|
223
|
-
- :TaskCreateRequest: is the JSON payload for creating a task
|
|
224
|
-
[resources/task_create_request.schema.json](resources/task_create_request.schema.json).
|
|
225
|
-
- :TasksAPI: is the public HTTP surface for tasks
|
|
226
|
-
[resources/tasks_openapi.yaml](resources/tasks_openapi.yaml).
|
|
223
|
+
- :TaskCreateRequest: is the JSON payload for creating a task.
|
|
224
|
+
- It is defined by [resources/task_create_request.schema.json](resources/task_create_request.schema.json).
|
|
225
|
+
- :TasksAPI: is the public HTTP surface for tasks.
|
|
226
|
+
- It is defined by [resources/tasks_openapi.yaml](resources/tasks_openapi.yaml).
|
|
227
227
|
|
|
228
228
|
***functional specs***
|
|
229
229
|
|
|
230
|
-
- :User: should be able to add :Task: by POSTing :TaskCreateRequest: to the
|
|
231
|
-
|
|
230
|
+
- :User: should be able to add :Task: by POSTing :TaskCreateRequest: to the `POST /tasks` endpoint of :TasksAPI:.
|
|
231
|
+
- The endpoint responds per :TasksAPI:.
|
|
232
232
|
```
|
|
233
233
|
|
|
234
234
|
**Each linked resource must be referenced from exactly one place** in the project — a single functional spec, implementation requirement, or `***definitions***` entry. Linking the same file from two functional specs (or from a functional spec *and* a requirement, etc.) creates two independent sources of truth: any later edit to one site silently diverges from the other, and the renderer has no way to know which one is canonical.
|
|
@@ -243,8 +243,8 @@ For example, instead of linking `task_modal_specification.yaml` from two differe
|
|
|
243
243
|
```plain
|
|
244
244
|
***definitions***
|
|
245
245
|
|
|
246
|
-
- :TaskModalSpec: is the user-interface contract for the task modal
|
|
247
|
-
fully described in [task_modal_specification.yaml](task_modal_specification.yaml).
|
|
246
|
+
- :TaskModalSpec: is the user-interface contract for the task modal.
|
|
247
|
+
- It is fully described in [task_modal_specification.yaml](task_modal_specification.yaml).
|
|
248
248
|
|
|
249
249
|
***functional specs***
|
|
250
250
|
|