@warnyin/sdlc 0.1.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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/bin/cli.mjs +470 -0
- package/lib/caps.mjs +45 -0
- package/lib/config.mjs +41 -0
- package/lib/delta.mjs +160 -0
- package/lib/frontmatter.mjs +59 -0
- package/lib/glob.mjs +29 -0
- package/lib/manifest.mjs +99 -0
- package/lib/observe.mjs +174 -0
- package/lib/settings-merge.mjs +63 -0
- package/lib/usage.mjs +46 -0
- package/lib/validate.mjs +186 -0
- package/package.json +42 -0
- package/payload/adapters/agents-md.md +8 -0
- package/payload/adapters/claude/agents/sdlc-architect.md +12 -0
- package/payload/adapters/claude/agents/sdlc-builder.md +14 -0
- package/payload/adapters/claude/agents/sdlc-contractor.md +13 -0
- package/payload/adapters/claude/agents/sdlc-evaluator.md +13 -0
- package/payload/adapters/claude/agents/sdlc-learner.md +16 -0
- package/payload/adapters/claude/agents/sdlc-ops.md +11 -0
- package/payload/adapters/claude/agents/sdlc-quality.md +13 -0
- package/payload/adapters/claude/agents/sdlc-security.md +12 -0
- package/payload/adapters/claude/commands/sdlc/auto.md +5 -0
- package/payload/adapters/claude/commands/sdlc/build.md +5 -0
- package/payload/adapters/claude/commands/sdlc/contract.md +5 -0
- package/payload/adapters/claude/commands/sdlc/converge.md +5 -0
- package/payload/adapters/claude/commands/sdlc/design.md +5 -0
- package/payload/adapters/claude/commands/sdlc/init.md +4 -0
- package/payload/adapters/claude/commands/sdlc/new.md +5 -0
- package/payload/adapters/claude/commands/sdlc/next.md +4 -0
- package/payload/adapters/claude/commands/sdlc/observe.md +4 -0
- package/payload/adapters/claude/commands/sdlc/review.md +5 -0
- package/payload/adapters/claude/commands/sdlc/ship.md +5 -0
- package/payload/adapters/claude/commands/sdlc/steer.md +4 -0
- package/payload/adapters/claude/commands/sdlc/verify.md +5 -0
- package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -0
- package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +33 -0
- package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +26 -0
- package/payload/adapters/cline.md +8 -0
- package/payload/adapters/copilot.md +8 -0
- package/payload/adapters/cursor.mdc +7 -0
- package/payload/adapters/gemini.md +8 -0
- package/payload/adapters/windsurf.md +4 -0
- package/payload/hooks/_shared.mjs +141 -0
- package/payload/hooks/guard-writes.mjs +83 -0
- package/payload/hooks/inject-context.mjs +55 -0
- package/payload/hooks/journal.mjs +58 -0
- package/payload/hooks/session-summary.mjs +50 -0
- package/payload/hooks/validate-artifact.mjs +80 -0
- package/payload/playbook/README.md +30 -0
- package/payload/playbook/auto.md +21 -0
- package/payload/playbook/build.md +23 -0
- package/payload/playbook/context.md +26 -0
- package/payload/playbook/contract.md +23 -0
- package/payload/playbook/converge.md +19 -0
- package/payload/playbook/design.md +20 -0
- package/payload/playbook/init.md +22 -0
- package/payload/playbook/new.md +22 -0
- package/payload/playbook/next.md +12 -0
- package/payload/playbook/observe.md +20 -0
- package/payload/playbook/principles.md +28 -0
- package/payload/playbook/review.md +17 -0
- package/payload/playbook/routing.md +19 -0
- package/payload/playbook/rules-card.md +16 -0
- package/payload/playbook/ship.md +24 -0
- package/payload/playbook/steer.md +21 -0
- package/payload/playbook/verify.md +24 -0
- package/payload/templates/change-deep.md +29 -0
- package/payload/templates/change-standard.md +28 -0
- package/payload/templates/change-vibe.md +19 -0
- package/payload/templates/config.yaml +8 -0
- package/payload/templates/constitution.md +14 -0
- package/payload/templates/contract-evals.md +9 -0
- package/payload/templates/contract-tests.md +9 -0
- package/payload/templates/harness.md +33 -0
- package/payload/templates/spec.md +14 -0
- package/payload/templates/steering.md +9 -0
- package/scripts/validate.mjs +38 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-08-20)
|
|
4
|
+
|
|
5
|
+
Initial release — the full Day-1 SDLC loop:
|
|
6
|
+
|
|
7
|
+
- CLI: `init` (multi-tool: claude/cursor/windsurf/copilot/cline/gemini/agents-md),
|
|
8
|
+
`update` (ownership-aware refresh + guarded prune), `validate`, `status`,
|
|
9
|
+
`observe`, `archive`.
|
|
10
|
+
- Artifact model: constitution (≤30) + steering with inclusion modes + harness
|
|
11
|
+
(routing/triage/autonomy policy) + living specs + delta-based changes
|
|
12
|
+
(vibe/standard/deep caps 40/100/150) + contracts (tests ≤60, evals ≤40).
|
|
13
|
+
- Managed Claude Code hooks: static-context injector, spec write-lock with TTL
|
|
14
|
+
gates, artifact validator + steering pointers, session token/cost journaling,
|
|
15
|
+
compact-event tracking.
|
|
16
|
+
- 13 stage playbooks + 13 `/sdlc:*` commands + 3 background skills + 8 agents
|
|
17
|
+
with model routing (cheap/balanced/deepest).
|
|
18
|
+
- Observability: per-change tokens/cost, first-pass rate, lead time, dead
|
|
19
|
+
steering and residency flags; post-ship learner loop (distill, never bloat).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 warnyin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# @warnyin/sdlc
|
|
2
|
+
|
|
3
|
+
**Spec-driven, AI-driven SDLC for coding agents — token-lean by construction.**
|
|
4
|
+
|
|
5
|
+
Operationalizes the *"New SDLC with Vibe Coding"* (Day-1) work process: the human configures
|
|
6
|
+
the harness once; the AI drives changes end-to-end through contract-first gates; deterministic
|
|
7
|
+
hooks and a validator enforce the rules; a journal prices every change in real tokens.
|
|
8
|
+
|
|
9
|
+
Inspired by OpenSpec (delta specs, archive lifecycle), spec-kit (artifact grammar), and
|
|
10
|
+
Kiro (steering + enforced hooks) — tuned for minimum context residency.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
cd your-project
|
|
16
|
+
npx @warnyin/sdlc init # pick tools: claude, cursor, windsurf, copilot, cline, gemini, agents-md
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then in your coding agent:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
/sdlc:init # interview → constitution + harness (the one human gate)
|
|
23
|
+
/sdlc:auto Add rate limiting # AI runs new → contract → build → verify → ship
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
sdlc/
|
|
30
|
+
├── context/constitution.md ≤30 lines — the ONLY always-loaded prose (hook-injected)
|
|
31
|
+
├── context/steering/*.md scoped knowledge · inclusion: always|paths|manual|agent
|
|
32
|
+
├── harness.md tools, model routing, tier triage, autonomy policy
|
|
33
|
+
├── specs/<capability>/ living specs — WHEN/THEN SHALL, merged mechanically at ship
|
|
34
|
+
├── changes/<id>/ one change: change.md (Why+Delta+Tasks) + contract/ + journal
|
|
35
|
+
└── changes/archive/ shipped changes + digests (the async human touchpoint)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- **Contract-first**: tests + evals are written *before* code and gate everything after.
|
|
39
|
+
- **AI-driven, exception-only humans**: the autonomy policy in `harness.md` decides what
|
|
40
|
+
auto-ships and what escalates (hard-floor: security/payments/data-loss/irreversible).
|
|
41
|
+
- **Managed hooks** (Claude Code): SessionStart injects ≤60 lines of static context;
|
|
42
|
+
PreToolUse write-locks living specs; PostToolUse validates caps and points at steering;
|
|
43
|
+
Stop journals real token usage. Other tools get the same rules as prose + the validator.
|
|
44
|
+
- **Self-improving, leaner over time**: a post-ship learner proposes rules with evidence;
|
|
45
|
+
the always-loaded budget is fixed, so learning must distill, not accumulate.
|
|
46
|
+
- **Measured**: `npx @warnyin/sdlc observe` — tokens/cost per change, first-pass rate,
|
|
47
|
+
lead time, dead steering, context-overflow flags.
|
|
48
|
+
|
|
49
|
+
## CLI
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
warnyin-sdlc init [--tool ...] scaffold + adapters + hooks
|
|
53
|
+
warnyin-sdlc update [--force] refresh payload, guarded prune of stale files
|
|
54
|
+
warnyin-sdlc validate [id] [--strict]
|
|
55
|
+
warnyin-sdlc status | observe [--json]
|
|
56
|
+
warnyin-sdlc archive <id> merge deltas into living specs + archive
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Commands (in your agent)
|
|
60
|
+
|
|
61
|
+
`/sdlc:init` · `/sdlc:auto` · `/sdlc:new` · `/sdlc:design` · `/sdlc:contract` · `/sdlc:build`
|
|
62
|
+
· `/sdlc:verify` · `/sdlc:review` · `/sdlc:ship` · `/sdlc:observe` · `/sdlc:converge`
|
|
63
|
+
· `/sdlc:steer` · `/sdlc:next`
|
|
64
|
+
|
|
65
|
+
Playbooks live in `sdlc/.playbook/` — behavior is defined once there; commands are thin pointers.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
Zero dependencies, Node ≥ 20. `npm test` runs the black-box suite (temp dirs, real CLI spawns).
|
|
70
|
+
This repo self-hosts: its own development flows through `sdlc/changes/`. After cloning, run
|
|
71
|
+
`npm run setup:dogfood` to regenerate the installer-owned mirrors (`sdlc/.playbook/`,
|
|
72
|
+
`sdlc/.hooks/`, `.claude/`).
|
|
73
|
+
|
|
74
|
+
MIT
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @warnyin/sdlc CLI — OpenSpec-style installer + lifecycle mechanics.
|
|
3
|
+
// Zero-dependency, Node >= 20, cross-platform. Exported functions are pure
|
|
4
|
+
// where possible so tests can exercise them; `main()` is guarded.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import process from 'node:process';
|
|
10
|
+
import readline from 'node:readline';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { parseFrontmatter } from '../lib/frontmatter.mjs';
|
|
14
|
+
import { parseDelta, mergeDelta } from '../lib/delta.mjs';
|
|
15
|
+
import { parseConfig } from '../lib/config.mjs';
|
|
16
|
+
import { mergeHookSettings } from '../lib/settings-merge.mjs';
|
|
17
|
+
import { buildReport, renderReport } from '../lib/observe.mjs';
|
|
18
|
+
import { parseManifest, renderManifest, computeStale, containedIn, hasSymlinkSegment, PRUNE_BLAST_CAP } from '../lib/manifest.mjs';
|
|
19
|
+
import { validateAll, formatIssues, listChangeDirs } from '../lib/validate.mjs';
|
|
20
|
+
|
|
21
|
+
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const PAYLOAD = path.join(PKG_ROOT, 'payload');
|
|
23
|
+
const MARKER = '<!-- sdlc:start -->';
|
|
24
|
+
|
|
25
|
+
export const TOOLS = Object.freeze([
|
|
26
|
+
'claude', 'cursor', 'windsurf', 'copilot', 'cline', 'gemini', 'agents-md',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const TEXT_EXT = new Set(['.md', '.mdc', '.mjs', '.json', '.yaml', '.yml', '.txt']);
|
|
30
|
+
|
|
31
|
+
// ---------- small pure helpers ----------
|
|
32
|
+
|
|
33
|
+
export function normalizeEol(content) {
|
|
34
|
+
return content.replace(/\r\n/g, '\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function toPosix(p) {
|
|
38
|
+
return p.split(path.sep).join('/');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sha256(content) {
|
|
42
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseArgs(argv) {
|
|
46
|
+
const args = { _: [], tool: null, strict: false, force: false, json: false, help: false };
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
const a = argv[i];
|
|
49
|
+
if (a === '--tool') args.tool = (argv[++i] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
50
|
+
else if (a === '--strict') args.strict = true;
|
|
51
|
+
else if (a === '--force') args.force = true;
|
|
52
|
+
else if (a === '--json') args.json = true;
|
|
53
|
+
else if (a === '--help' || a === '-h') args.help = true;
|
|
54
|
+
else if (a.startsWith('--')) console.warn(`unknown flag ${a} (ignored)`);
|
|
55
|
+
else args._.push(a);
|
|
56
|
+
}
|
|
57
|
+
return args;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------- payload-owned file installation (ownership semantics) ----------
|
|
61
|
+
// install mode: missing → write · byte-equal → claim · different → user's, keep.
|
|
62
|
+
// update mode: additionally, disk == old manifest hash (ours, unmodified) → refresh.
|
|
63
|
+
|
|
64
|
+
function writeFileNormalized(dest, content) {
|
|
65
|
+
const ext = path.extname(dest);
|
|
66
|
+
const out = TEXT_EXT.has(ext) ? normalizeEol(content) : content;
|
|
67
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
68
|
+
fs.writeFileSync(dest, out);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function installFile(projectRoot, destRel, content, ctx) {
|
|
73
|
+
const relPosix = toPosix(destRel);
|
|
74
|
+
const dest = path.join(projectRoot, destRel);
|
|
75
|
+
const next = normalizeEol(content);
|
|
76
|
+
const nextHash = sha256(next);
|
|
77
|
+
|
|
78
|
+
if (!fs.existsSync(dest)) {
|
|
79
|
+
writeFileNormalized(dest, next);
|
|
80
|
+
ctx.manifest.set(relPosix, nextHash);
|
|
81
|
+
return 'written';
|
|
82
|
+
}
|
|
83
|
+
const current = normalizeEol(fs.readFileSync(dest, 'utf8'));
|
|
84
|
+
const currentHash = sha256(current);
|
|
85
|
+
if (currentHash === nextHash) {
|
|
86
|
+
ctx.manifest.set(relPosix, nextHash);
|
|
87
|
+
return 'current';
|
|
88
|
+
}
|
|
89
|
+
if (ctx.mode === 'update' && ctx.oldManifest?.get(relPosix) === currentHash) {
|
|
90
|
+
writeFileNormalized(dest, next);
|
|
91
|
+
ctx.manifest.set(relPosix, nextHash);
|
|
92
|
+
return 'updated';
|
|
93
|
+
}
|
|
94
|
+
ctx.warnings.push(`kept (user-modified): ${relPosix}`);
|
|
95
|
+
return 'kept';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function copyTree(srcDir, destDirRel, projectRoot, ctx) {
|
|
99
|
+
if (!fs.existsSync(srcDir)) return;
|
|
100
|
+
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
|
101
|
+
const src = path.join(srcDir, entry.name);
|
|
102
|
+
const destRel = path.join(destDirRel, entry.name);
|
|
103
|
+
if (entry.isDirectory()) copyTree(src, destRel, projectRoot, ctx);
|
|
104
|
+
else installFile(projectRoot, destRel, fs.readFileSync(src, 'utf8'), ctx);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function payloadText(rel) {
|
|
109
|
+
return normalizeEol(fs.readFileSync(path.join(PAYLOAD, rel), 'utf8'));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------- adapters ----------
|
|
113
|
+
|
|
114
|
+
function renderAdapter(templateRel) {
|
|
115
|
+
return payloadText(templateRel).replace('{{RULES_CARD}}', payloadText('playbook/rules-card.md').trim());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Marker adapters live inside user-owned files: append once, never rewrite.
|
|
119
|
+
function appendWithMarker(projectRoot, destRel, content) {
|
|
120
|
+
const dest = path.join(projectRoot, destRel);
|
|
121
|
+
if (fs.existsSync(dest)) {
|
|
122
|
+
const current = fs.readFileSync(dest, 'utf8');
|
|
123
|
+
if (current.includes(MARKER)) return 'present';
|
|
124
|
+
const sep = current.endsWith('\n') ? '\n' : '\n\n';
|
|
125
|
+
fs.writeFileSync(dest, current + sep + normalizeEol(content));
|
|
126
|
+
return 'appended';
|
|
127
|
+
}
|
|
128
|
+
writeFileNormalized(dest, content);
|
|
129
|
+
return 'written';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function installClaudeHooks(projectRoot) {
|
|
133
|
+
const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
|
|
134
|
+
let current = {};
|
|
135
|
+
if (fs.existsSync(settingsPath)) {
|
|
136
|
+
try {
|
|
137
|
+
current = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
138
|
+
} catch {
|
|
139
|
+
throw new Error('.claude/settings.json is not valid JSON — fix it, then re-run init');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const merged = mergeHookSettings(current);
|
|
143
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
144
|
+
fs.writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function installToolAdapters(projectRoot, tools, ctx) {
|
|
148
|
+
if (tools.includes('claude')) {
|
|
149
|
+
copyTree(path.join(PAYLOAD, 'adapters/claude/commands'), path.join('.claude', 'commands'), projectRoot, ctx);
|
|
150
|
+
copyTree(path.join(PAYLOAD, 'adapters/claude/skills'), path.join('.claude', 'skills'), projectRoot, ctx);
|
|
151
|
+
copyTree(path.join(PAYLOAD, 'adapters/claude/agents'), path.join('.claude', 'agents'), projectRoot, ctx);
|
|
152
|
+
installClaudeHooks(projectRoot);
|
|
153
|
+
}
|
|
154
|
+
if (tools.includes('cursor')) {
|
|
155
|
+
installFile(projectRoot, path.join('.cursor', 'rules', 'sdlc.mdc'), renderAdapter('adapters/cursor.mdc'), ctx);
|
|
156
|
+
}
|
|
157
|
+
if (tools.includes('windsurf')) {
|
|
158
|
+
installFile(projectRoot, path.join('.windsurf', 'rules', 'sdlc.md'), renderAdapter('adapters/windsurf.md'), ctx);
|
|
159
|
+
}
|
|
160
|
+
if (tools.includes('copilot')) {
|
|
161
|
+
appendWithMarker(projectRoot, path.join('.github', 'copilot-instructions.md'), renderAdapter('adapters/copilot.md'));
|
|
162
|
+
}
|
|
163
|
+
if (tools.includes('cline')) {
|
|
164
|
+
appendWithMarker(projectRoot, '.clinerules', renderAdapter('adapters/cline.md'));
|
|
165
|
+
}
|
|
166
|
+
if (tools.includes('gemini')) {
|
|
167
|
+
appendWithMarker(projectRoot, 'GEMINI.md', renderAdapter('adapters/gemini.md'));
|
|
168
|
+
}
|
|
169
|
+
if (tools.includes('agents-md')) {
|
|
170
|
+
appendWithMarker(projectRoot, 'AGENTS.md', renderAdapter('adapters/agents-md.md'));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------- scaffold ----------
|
|
175
|
+
|
|
176
|
+
function scaffoldSdlc(projectRoot, tools, ctx) {
|
|
177
|
+
const sdlcRoot = path.join(projectRoot, 'sdlc');
|
|
178
|
+
for (const dir of ['context/steering', 'specs', 'changes/archive', 'evals', '.state']) {
|
|
179
|
+
fs.mkdirSync(path.join(sdlcRoot, dir), { recursive: true });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Seeds are user-owned from birth: created once, never manifested/overwritten.
|
|
183
|
+
const seed = (rel, templateName, transform = (s) => s) => {
|
|
184
|
+
const dest = path.join(sdlcRoot, rel);
|
|
185
|
+
if (fs.existsSync(dest)) return;
|
|
186
|
+
writeFileNormalized(dest, transform(payloadText(`templates/${templateName}`)));
|
|
187
|
+
};
|
|
188
|
+
seed('config.yaml', 'config.yaml', (s) => s.replace('tools: []', `tools: [${tools.join(', ')}]`));
|
|
189
|
+
seed('context/constitution.md', 'constitution.md');
|
|
190
|
+
seed('harness.md', 'harness.md');
|
|
191
|
+
|
|
192
|
+
// Payload-owned trees (manifested, refreshed by `update`).
|
|
193
|
+
copyTree(path.join(PAYLOAD, 'playbook'), path.join('sdlc', '.playbook'), projectRoot, ctx);
|
|
194
|
+
copyTree(path.join(PAYLOAD, 'templates'), path.join('sdlc', '.playbook', 'templates'), projectRoot, ctx);
|
|
195
|
+
copyTree(path.join(PAYLOAD, 'hooks'), path.join('sdlc', '.hooks'), projectRoot, ctx);
|
|
196
|
+
copyTree(path.join(PKG_ROOT, 'lib'), path.join('sdlc', '.hooks', 'lib'), projectRoot, ctx);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function writeManifestFile(projectRoot, manifest) {
|
|
200
|
+
const statePath = path.join(projectRoot, 'sdlc', '.state');
|
|
201
|
+
fs.mkdirSync(statePath, { recursive: true });
|
|
202
|
+
fs.writeFileSync(path.join(statePath, 'manifest'), renderManifest(manifest));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function readManifestFile(projectRoot) {
|
|
206
|
+
const p = path.join(projectRoot, 'sdlc', '.state', 'manifest');
|
|
207
|
+
return fs.existsSync(p) ? parseManifest(fs.readFileSync(p, 'utf8')) : new Map();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function ensureGitignore(projectRoot) {
|
|
211
|
+
const giPath = path.join(projectRoot, '.gitignore');
|
|
212
|
+
const entry = 'sdlc/.state/';
|
|
213
|
+
let current = fs.existsSync(giPath) ? fs.readFileSync(giPath, 'utf8') : '';
|
|
214
|
+
if (current.split(/\r?\n/).some((l) => l.trim() === entry)) return;
|
|
215
|
+
if (current && !current.endsWith('\n')) current += '\n';
|
|
216
|
+
fs.writeFileSync(giPath, current + entry + '\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------- init ----------
|
|
220
|
+
|
|
221
|
+
export async function resolveTools(args, { interactive = process.stdin.isTTY && process.stdout.isTTY } = {}) {
|
|
222
|
+
if (args.tool?.length) {
|
|
223
|
+
const bad = args.tool.filter((t) => !TOOLS.includes(t));
|
|
224
|
+
if (bad.length) throw new Error(`unknown tool(s): ${bad.join(', ')} — valid: ${TOOLS.join(', ')}`);
|
|
225
|
+
return args.tool;
|
|
226
|
+
}
|
|
227
|
+
if (!interactive) return ['claude'];
|
|
228
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
229
|
+
const answer = await new Promise((resolve) => {
|
|
230
|
+
rl.question(`Tools to set up [${TOOLS.join(', ')}] (comma-separated, default: claude): `, resolve);
|
|
231
|
+
});
|
|
232
|
+
rl.close();
|
|
233
|
+
const picked = answer.split(',').map((s) => s.trim()).filter(Boolean);
|
|
234
|
+
if (!picked.length) return ['claude'];
|
|
235
|
+
const bad = picked.filter((t) => !TOOLS.includes(t));
|
|
236
|
+
if (bad.length) throw new Error(`unknown tool(s): ${bad.join(', ')}`);
|
|
237
|
+
return picked;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function cmdInit(projectRoot, args) {
|
|
241
|
+
const tools = await resolveTools(args);
|
|
242
|
+
const ctx = { mode: 'install', manifest: new Map(), oldManifest: readManifestFile(projectRoot), warnings: [] };
|
|
243
|
+
scaffoldSdlc(projectRoot, tools, ctx);
|
|
244
|
+
installToolAdapters(projectRoot, tools, ctx);
|
|
245
|
+
writeManifestFile(projectRoot, ctx.manifest);
|
|
246
|
+
ensureGitignore(projectRoot);
|
|
247
|
+
for (const w of ctx.warnings) console.warn(` ${w}`);
|
|
248
|
+
console.log(`sdlc/ initialized for: ${tools.join(', ')}`);
|
|
249
|
+
console.log('Next: open your coding agent and run /sdlc:init to write your constitution and harness.');
|
|
250
|
+
return { tools };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------- update + prune ----------
|
|
254
|
+
|
|
255
|
+
export function cmdUpdate(projectRoot, args) {
|
|
256
|
+
const sdlcRoot = path.join(projectRoot, 'sdlc');
|
|
257
|
+
requireSdlc(sdlcRoot);
|
|
258
|
+
const config = parseConfig(fs.readFileSync(path.join(sdlcRoot, 'config.yaml'), 'utf8'));
|
|
259
|
+
const tools = args.tool?.length ? args.tool : (config.tools.length ? config.tools : ['claude']);
|
|
260
|
+
|
|
261
|
+
// Persist an explicit --tool override so declared and installed state never
|
|
262
|
+
// diverge (otherwise pruning tool-specific files leaves config.yaml stale).
|
|
263
|
+
if (args.tool?.length) {
|
|
264
|
+
const configPath = path.join(sdlcRoot, 'config.yaml');
|
|
265
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
266
|
+
fs.writeFileSync(configPath, raw.replace(/^tools:.*$/m, `tools: [${tools.join(', ')}]`));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const oldManifest = readManifestFile(projectRoot);
|
|
270
|
+
const ctx = { mode: 'update', manifest: new Map(), oldManifest, warnings: [] };
|
|
271
|
+
scaffoldSdlc(projectRoot, tools, ctx);
|
|
272
|
+
installToolAdapters(projectRoot, tools, ctx);
|
|
273
|
+
|
|
274
|
+
// Prune: old-manifest entries no longer in the payload, guarded six ways.
|
|
275
|
+
const { stale, rejected, overCap } = computeStale(oldManifest, new Set(ctx.manifest.keys()));
|
|
276
|
+
for (const r of rejected) ctx.warnings.push(`prune rejected: ${r.path} (${r.reason})`);
|
|
277
|
+
let pruned = 0;
|
|
278
|
+
if (overCap && !args.force) {
|
|
279
|
+
ctx.warnings.push(`prune skipped: ${stale.length} stale files exceed the blast cap (${PRUNE_BLAST_CAP}) — re-run with --force after reviewing`);
|
|
280
|
+
} else {
|
|
281
|
+
const realRoot = fs.realpathSync.native(projectRoot);
|
|
282
|
+
const nominalRoot = path.resolve(projectRoot);
|
|
283
|
+
for (const { path: relPath, hash } of stale) {
|
|
284
|
+
const abs = path.join(projectRoot, relPath);
|
|
285
|
+
if (!fs.existsSync(abs)) continue;
|
|
286
|
+
if (hasSymlinkSegment(nominalRoot, abs)) {
|
|
287
|
+
ctx.warnings.push(`prune rejected: ${relPath} (symlink in path)`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const diskHash = sha256(normalizeEol(fs.readFileSync(abs, 'utf8')));
|
|
291
|
+
if (diskHash !== hash) { ctx.warnings.push(`prune kept (modified): ${relPath}`); continue; }
|
|
292
|
+
const realAbs = fs.realpathSync.native(abs);
|
|
293
|
+
if (!containedIn(realRoot, realAbs)) { ctx.warnings.push(`prune rejected: ${relPath} (escapes project)`); continue; }
|
|
294
|
+
fs.rmSync(abs);
|
|
295
|
+
pruned++;
|
|
296
|
+
let dir = path.dirname(abs);
|
|
297
|
+
while (containedIn(realRoot, dir)) {
|
|
298
|
+
try { fs.rmdirSync(dir); } catch { break; }
|
|
299
|
+
dir = path.dirname(dir);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
writeManifestFile(projectRoot, ctx.manifest);
|
|
305
|
+
for (const w of ctx.warnings) console.warn(` ${w}`);
|
|
306
|
+
console.log(`updated for: ${tools.join(', ')} · payload files: ${ctx.manifest.size} · pruned: ${pruned}`);
|
|
307
|
+
return { pruned, warnings: ctx.warnings };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------- status ----------
|
|
311
|
+
|
|
312
|
+
export function readChanges(sdlcRoot) {
|
|
313
|
+
return listChangeDirs(sdlcRoot).map((dir) => {
|
|
314
|
+
const changePath = path.join(dir, 'change.md');
|
|
315
|
+
const id = path.basename(dir);
|
|
316
|
+
if (!fs.existsSync(changePath)) return { id, tier: '?', status: '?', title: '(missing change.md)' };
|
|
317
|
+
const raw = fs.readFileSync(changePath, 'utf8');
|
|
318
|
+
const { data, body } = parseFrontmatter(raw);
|
|
319
|
+
const title = body.match(/^# Change:\s*(.+)$/m)?.[1] ?? '';
|
|
320
|
+
return { id, tier: data.tier ?? '?', status: data.status ?? '?', title };
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function cmdStatus(projectRoot, { json = false } = {}) {
|
|
325
|
+
const sdlcRoot = path.join(projectRoot, 'sdlc');
|
|
326
|
+
requireSdlc(sdlcRoot);
|
|
327
|
+
const changes = readChanges(sdlcRoot);
|
|
328
|
+
const archiveDir = path.join(sdlcRoot, 'changes', 'archive');
|
|
329
|
+
const archived = fs.existsSync(archiveDir)
|
|
330
|
+
? fs.readdirSync(archiveDir, { withFileTypes: true }).filter((d) => d.isDirectory()).length
|
|
331
|
+
: 0;
|
|
332
|
+
if (json) {
|
|
333
|
+
console.log(JSON.stringify({ changes, archived }, null, 2));
|
|
334
|
+
} else if (!changes.length) {
|
|
335
|
+
console.log(`No active changes (${archived} archived). Start one with /sdlc:new or /sdlc:auto.`);
|
|
336
|
+
} else {
|
|
337
|
+
for (const c of changes) console.log(`${c.id} [${c.tier}/${c.status}] ${c.title}`);
|
|
338
|
+
console.log(`${changes.length} active · ${archived} archived`);
|
|
339
|
+
}
|
|
340
|
+
return { changes, archived };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------- observe ----------
|
|
344
|
+
|
|
345
|
+
export function cmdObserve(projectRoot, { json = false } = {}) {
|
|
346
|
+
const sdlcRoot = path.join(projectRoot, 'sdlc');
|
|
347
|
+
requireSdlc(sdlcRoot);
|
|
348
|
+
const report = buildReport(sdlcRoot);
|
|
349
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderReport(report));
|
|
350
|
+
return report;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------- archive (= mechanical part of ship) ----------
|
|
354
|
+
|
|
355
|
+
export function appendJournal(changeDir, event) {
|
|
356
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...event });
|
|
357
|
+
fs.appendFileSync(path.join(changeDir, 'journal.ndjson'), line + '\n');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function cmdArchive(projectRoot, changeId, { strict = true } = {}) {
|
|
361
|
+
const sdlcRoot = path.join(projectRoot, 'sdlc');
|
|
362
|
+
requireSdlc(sdlcRoot);
|
|
363
|
+
if (!changeId) throw new Error('usage: warnyin-sdlc archive <change-id>');
|
|
364
|
+
const changeDir = path.join(sdlcRoot, 'changes', changeId);
|
|
365
|
+
if (!fs.existsSync(changeDir)) throw new Error(`change "${changeId}" not found`);
|
|
366
|
+
|
|
367
|
+
// Atomicity: the archive destination must be checked BEFORE any write —
|
|
368
|
+
// otherwise a same-day id collision would mutate specs and stamp the change
|
|
369
|
+
// while reporting failure.
|
|
370
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
371
|
+
const destDir = path.join(sdlcRoot, 'changes', 'archive', `${date}-${changeId}`);
|
|
372
|
+
if (fs.existsSync(destDir)) {
|
|
373
|
+
throw new Error(`archive target already exists: ${toPosix(path.relative(projectRoot, destDir))} — nothing was merged`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const issues = validateAll(sdlcRoot, { strict, changeId });
|
|
377
|
+
const errors = issues.filter((i) => i.level === 'error');
|
|
378
|
+
if (errors.length) {
|
|
379
|
+
console.error(formatIssues(errors));
|
|
380
|
+
throw new Error(`validate --strict failed with ${errors.length} error(s) — not archiving`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const changeText = fs.readFileSync(path.join(changeDir, 'change.md'), 'utf8');
|
|
384
|
+
const { deltas, errors: parseErrors } = parseDelta(changeText);
|
|
385
|
+
if (parseErrors.length) throw new Error(`delta parse errors: ${parseErrors.join('; ')}`);
|
|
386
|
+
|
|
387
|
+
// Phase 1: compute every merge before writing anything (all-or-nothing).
|
|
388
|
+
const merged = [];
|
|
389
|
+
for (const d of deltas) {
|
|
390
|
+
const specPath = path.join(sdlcRoot, 'specs', d.capability, 'spec.md');
|
|
391
|
+
const specText = fs.existsSync(specPath) ? fs.readFileSync(specPath, 'utf8') : null;
|
|
392
|
+
const result = mergeDelta(specText, d.ops, d.capability);
|
|
393
|
+
if (!result.ok) throw new Error(`spec merge failed for "${d.capability}": ${result.errors.join('; ')}`);
|
|
394
|
+
merged.push({ specPath, content: result.content, capability: d.capability });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Phase 2: write specs, promote evals, stamp status, move to archive.
|
|
398
|
+
for (const m of merged) writeFileNormalized(m.specPath, m.content);
|
|
399
|
+
|
|
400
|
+
const evalsSrc = path.join(changeDir, 'contract', 'evals.md');
|
|
401
|
+
if (fs.existsSync(evalsSrc)) {
|
|
402
|
+
for (const m of merged) {
|
|
403
|
+
const rubricDest = path.join(sdlcRoot, 'evals', m.capability, 'rubric.md');
|
|
404
|
+
if (!fs.existsSync(rubricDest)) {
|
|
405
|
+
writeFileNormalized(rubricDest, fs.readFileSync(evalsSrc, 'utf8'));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const stamped = changeText.replace(/^status:\s*.*$/m, 'status: shipped');
|
|
411
|
+
writeFileNormalized(path.join(changeDir, 'change.md'), stamped);
|
|
412
|
+
appendJournal(changeDir, { event: 'ship', change: changeId, specs: merged.map((m) => m.capability) });
|
|
413
|
+
|
|
414
|
+
fs.renameSync(changeDir, destDir);
|
|
415
|
+
|
|
416
|
+
console.log(`shipped: ${changeId}`);
|
|
417
|
+
for (const m of merged) console.log(` spec merged: specs/${m.capability}/spec.md`);
|
|
418
|
+
console.log(` archived: changes/archive/${date}-${changeId}/`);
|
|
419
|
+
return { archived: `${date}-${changeId}`, specs: merged.map((m) => m.capability) };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---------- shared ----------
|
|
423
|
+
|
|
424
|
+
function requireSdlc(sdlcRoot) {
|
|
425
|
+
if (!fs.existsSync(sdlcRoot)) {
|
|
426
|
+
throw new Error('No sdlc/ directory here — run `npx @warnyin/sdlc init` first.');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function runValidate(projectRoot, args) {
|
|
431
|
+
const validator = path.join(PKG_ROOT, 'scripts', 'validate.mjs');
|
|
432
|
+
const spawnArgs = [validator, ...(args._.slice(1)), ...(args.strict ? ['--strict'] : []), '--root', projectRoot];
|
|
433
|
+
const res = spawnSync(process.execPath, spawnArgs, { stdio: 'inherit' });
|
|
434
|
+
process.exitCode = res.status ?? 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const HELP = `@warnyin/sdlc — spec-driven AI-SDLC framework
|
|
438
|
+
|
|
439
|
+
usage: warnyin-sdlc <command> [options]
|
|
440
|
+
|
|
441
|
+
init [--tool claude,cursor,...] scaffold sdlc/ + adapters + hooks into this project
|
|
442
|
+
update [--tool ...] [--force] refresh payload-owned files, prune stale ones (guarded)
|
|
443
|
+
validate [id] [--strict] structural validation (caps, delta grammar, gates)
|
|
444
|
+
status [--json] list active changes and their stage
|
|
445
|
+
observe [--json] tokens/cost per change, residency, steering hits, drift flags
|
|
446
|
+
archive <id> merge delta specs into living specs and archive the change
|
|
447
|
+
help this text
|
|
448
|
+
`;
|
|
449
|
+
|
|
450
|
+
export async function main(argv = process.argv.slice(2), projectRoot = process.cwd()) {
|
|
451
|
+
const args = parseArgs(argv);
|
|
452
|
+
const cmd = args._[0];
|
|
453
|
+
if (args.help || !cmd || cmd === 'help') { console.log(HELP); return; }
|
|
454
|
+
try {
|
|
455
|
+
if (cmd === 'init') await cmdInit(projectRoot, args);
|
|
456
|
+
else if (cmd === 'update') cmdUpdate(projectRoot, args);
|
|
457
|
+
else if (cmd === 'validate') runValidate(projectRoot, args);
|
|
458
|
+
else if (cmd === 'status') cmdStatus(projectRoot, { json: args.json });
|
|
459
|
+
else if (cmd === 'observe') cmdObserve(projectRoot, { json: args.json });
|
|
460
|
+
else if (cmd === 'archive') cmdArchive(projectRoot, args._[1]);
|
|
461
|
+
else { console.error(`unknown command: ${cmd}`); console.log(HELP); process.exitCode = 2; }
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error(String(err.message ?? err));
|
|
464
|
+
process.exitCode = 1;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
469
|
+
main();
|
|
470
|
+
}
|
package/lib/caps.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Canonical line-cap table — the single source of truth for artifact
|
|
2
|
+
// residency budgets. Templates quote these numbers in HTML comments and
|
|
3
|
+
// tests/caps-sync.test.mjs asserts both stay identical.
|
|
4
|
+
//
|
|
5
|
+
// Rationale (Day-1 economics): every resident line is paid for in every
|
|
6
|
+
// turn. Caps make "learning = distilling" structural, not disciplinary.
|
|
7
|
+
|
|
8
|
+
import { parseFrontmatter } from './frontmatter.mjs';
|
|
9
|
+
|
|
10
|
+
export const CAPS = Object.freeze({
|
|
11
|
+
constitution: 30, // sdlc/context/constitution.md — always loaded
|
|
12
|
+
steeringFile: 40, // each sdlc/context/steering/*.md
|
|
13
|
+
alwaysBudget: 60, // constitution + all `inclusion: always` steering, combined
|
|
14
|
+
harness: 60, // sdlc/harness.md
|
|
15
|
+
spec: 150, // sdlc/specs/<capability>/spec.md (soft — split capability beyond)
|
|
16
|
+
change: Object.freeze({ vibe: 40, standard: 100, deep: 150 }),
|
|
17
|
+
contractTests: 60, // changes/<id>/contract/tests.md
|
|
18
|
+
contractEvals: 40, // changes/<id>/contract/evals.md
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const TIERS = Object.freeze(['vibe', 'standard', 'deep']);
|
|
22
|
+
|
|
23
|
+
export const STATUSES = Object.freeze([
|
|
24
|
+
'new', 'contracted', 'building', 'verified', 'shipped',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Effective lines = body lines after frontmatter, excluding blanks and
|
|
28
|
+
// single-line HTML comments. Caps meter prose the model must carry, not
|
|
29
|
+
// machine metadata or annotation comments.
|
|
30
|
+
export function countEffectiveLines(text) {
|
|
31
|
+
const { body } = parseFrontmatter(text ?? '');
|
|
32
|
+
return body
|
|
33
|
+
.split(/\r?\n/)
|
|
34
|
+
.filter((line) => {
|
|
35
|
+
const t = line.trim();
|
|
36
|
+
if (t === '') return false;
|
|
37
|
+
if (t.startsWith('<!--') && t.endsWith('-->')) return false;
|
|
38
|
+
return true;
|
|
39
|
+
})
|
|
40
|
+
.length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function capForChange(tier) {
|
|
44
|
+
return CAPS.change[tier] ?? CAPS.change.standard;
|
|
45
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Minimal reader for sdlc/config.yaml — supports exactly what the template
|
|
2
|
+
// documents: scalar keys, inline arrays, and a `prices:` block of inline
|
|
3
|
+
// objects. Anything else is ignored (never crash a hook on config).
|
|
4
|
+
|
|
5
|
+
export function parseConfig(text) {
|
|
6
|
+
const config = { language: 'en', tools: [], prices: null };
|
|
7
|
+
const lines = (text ?? '').split(/\r?\n/);
|
|
8
|
+
let inPrices = false;
|
|
9
|
+
for (const raw of lines) {
|
|
10
|
+
if (!raw.trim() || raw.trim().startsWith('#')) continue;
|
|
11
|
+
const isIndented = /^\s/.test(raw);
|
|
12
|
+
if (!isIndented) inPrices = false;
|
|
13
|
+
|
|
14
|
+
if (inPrices) {
|
|
15
|
+
const m = raw.match(/^\s+([^:#]+):\s*\{(.*)\}\s*$/);
|
|
16
|
+
if (!m) continue;
|
|
17
|
+
const model = m[1].trim();
|
|
18
|
+
const obj = {};
|
|
19
|
+
for (const part of m[2].split(',')) {
|
|
20
|
+
const kv = part.split(':');
|
|
21
|
+
if (kv.length !== 2) continue;
|
|
22
|
+
const num = Number(kv[1].trim());
|
|
23
|
+
if (!Number.isNaN(num)) obj[kv[0].trim()] = num;
|
|
24
|
+
}
|
|
25
|
+
config.prices = { ...(config.prices ?? {}), [model]: obj };
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const kv = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
30
|
+
if (!kv) continue;
|
|
31
|
+
const [, key, valueRaw] = kv;
|
|
32
|
+
const value = valueRaw.replace(/\s+#.*$/, '').trim();
|
|
33
|
+
if (key === 'prices' && value === '') { inPrices = true; continue; }
|
|
34
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
35
|
+
config[key] = value.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
|
|
36
|
+
} else if (value !== '') {
|
|
37
|
+
config[key] = value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return config;
|
|
41
|
+
}
|