@warnyin/sdlc 0.5.2 → 0.7.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 +228 -172
- package/LICENSE +21 -21
- package/README.md +1 -1
- package/bin/cli.mjs +668 -596
- package/lib/active.mjs +199 -0
- package/lib/caps.mjs +45 -45
- package/lib/config.mjs +41 -41
- package/lib/delta.mjs +227 -227
- package/lib/frontmatter.mjs +59 -59
- package/lib/glob.mjs +29 -29
- package/lib/journal.mjs +128 -0
- package/lib/manifest.mjs +99 -99
- package/lib/observe.mjs +19 -16
- package/lib/settings-merge.mjs +63 -63
- package/lib/validate.mjs +196 -196
- package/package.json +42 -42
- package/payload/adapters/agents-md.md +8 -8
- package/payload/adapters/claude/agents/sdlc-architect.md +12 -12
- package/payload/adapters/claude/agents/sdlc-builder.md +14 -14
- package/payload/adapters/claude/agents/sdlc-contractor.md +13 -13
- package/payload/adapters/claude/agents/sdlc-evaluator.md +13 -13
- package/payload/adapters/claude/agents/sdlc-learner.md +16 -16
- package/payload/adapters/claude/agents/sdlc-ops.md +11 -11
- package/payload/adapters/claude/agents/sdlc-quality.md +13 -13
- package/payload/adapters/claude/agents/sdlc-security.md +12 -12
- package/payload/adapters/claude/commands/sdlc/converge.md +5 -5
- package/payload/adapters/claude/commands/sdlc/init.md +4 -4
- package/payload/adapters/claude/commands/sdlc/next.md +4 -4
- package/payload/adapters/claude/commands/sdlc/observe.md +4 -4
- package/payload/adapters/claude/commands/sdlc/steer.md +4 -4
- package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -26
- package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +36 -36
- package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +29 -26
- package/payload/adapters/cline.md +8 -8
- package/payload/adapters/copilot.md +8 -8
- package/payload/adapters/cursor.mdc +7 -7
- package/payload/adapters/gemini.md +8 -8
- package/payload/adapters/windsurf.md +4 -4
- package/payload/hooks/_shared.mjs +138 -154
- package/payload/hooks/guard-writes.mjs +87 -83
- package/payload/hooks/inject-context.mjs +57 -55
- package/payload/hooks/journal.mjs +66 -58
- package/payload/hooks/session-summary.mjs +52 -50
- package/payload/hooks/validate-artifact.mjs +84 -80
- package/payload/playbook/auto.md +12 -0
- package/payload/playbook/context.md +26 -26
- package/payload/playbook/converge.md +19 -19
- package/payload/playbook/init.md +22 -22
- package/payload/playbook/next.md +24 -14
- package/payload/playbook/observe.md +20 -20
- package/payload/playbook/principles.md +28 -28
- package/payload/playbook/routing.md +19 -19
- package/payload/playbook/rules-card.md +16 -16
- package/payload/playbook/ship.md +35 -35
- package/payload/playbook/steer.md +21 -21
- package/payload/templates/change-deep.md +29 -29
- package/payload/templates/change-standard.md +28 -28
- package/payload/templates/change-vibe.md +19 -19
- package/payload/templates/config.yaml +8 -8
- package/payload/templates/constitution.md +14 -14
- package/payload/templates/contract-evals.md +9 -9
- package/payload/templates/contract-tests.md +9 -9
- package/payload/templates/harness.md +33 -33
- package/payload/templates/spec.md +14 -14
- package/payload/templates/steering.md +9 -9
- package/scripts/validate.mjs +47 -47
package/lib/active.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Active change — which change a session (and the project as a whole) is working on.
|
|
2
|
+
//
|
|
3
|
+
// Two pointers exist so two sessions never overwrite each other's focus: a per-session
|
|
4
|
+
// file under `.state/sessions/<id>.json`, and a project-wide fallback at `.state/active.json`
|
|
5
|
+
// for tools (and past sessions) that never set one. Resolution tries session, then
|
|
6
|
+
// project, then the most-recently-edited open change — never an error: a pointer that
|
|
7
|
+
// is missing, stale, or malformed just falls through to the next source.
|
|
8
|
+
//
|
|
9
|
+
// Shared by the CLI and the installed hooks, so `node:*` only.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { isSafeChangeId } from './journal.mjs';
|
|
14
|
+
|
|
15
|
+
// A session id reaches us from `CLAUDE_CODE_SESSION_ID` (shell) or stdin `session_id`
|
|
16
|
+
// (hooks) — both outside our control. It becomes a filename, so it is held to the same
|
|
17
|
+
// single-safe-segment rule as a change id rather than a separate one: two id kinds, one
|
|
18
|
+
// hazard (path traversal / device names / ADS colons), one rule to keep in sync.
|
|
19
|
+
export function isSafeSessionId(id) {
|
|
20
|
+
return isSafeChangeId(id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// stdin is the documented, per-invocation channel; the env var is inherited from a
|
|
24
|
+
// parent process and may be stale or belong to someone else. No safety check here —
|
|
25
|
+
// this is a raw pick, the caller validates before turning it into a path.
|
|
26
|
+
export function pickSessionId(stdinSessionId, envSessionId) {
|
|
27
|
+
if (typeof stdinSessionId === 'string' && stdinSessionId.length > 0) return stdinSessionId;
|
|
28
|
+
if (typeof envSessionId === 'string' && envSessionId.length > 0) return envSessionId;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `null` for an unsafe id, so callers fall back to the project pointer instead of
|
|
33
|
+
// writing or reading somewhere surprising.
|
|
34
|
+
export function sessionPointerPath(sdlcRoot, sessionId) {
|
|
35
|
+
if (!isSafeSessionId(sessionId)) return null;
|
|
36
|
+
return path.join(sdlcRoot, '.state', 'sessions', `${sessionId}.json`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function projectPointerPath(sdlcRoot) {
|
|
40
|
+
return path.join(sdlcRoot, '.state', 'active.json');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One rule for "this id names an open change", used both when a pointer is read and before
|
|
44
|
+
// one is written: a single safe segment, not the archive folder in any case (`ARCHIVE` opens
|
|
45
|
+
// it on Windows and macOS), and a folder that exists.
|
|
46
|
+
export function isOpenChange(sdlcRoot, change) {
|
|
47
|
+
if (typeof change !== 'string' || change.toLowerCase() === 'archive' || !isSafeChangeId(change)) return false;
|
|
48
|
+
try {
|
|
49
|
+
return fs.statSync(path.join(sdlcRoot, 'changes', change)).isDirectory();
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A pointer path is trusted only when its real location is the one it claims. `.state/` is
|
|
56
|
+
// gitignored but not unwritable — a repo can ship a link there — and a planted link at
|
|
57
|
+
// `.state`, `.state/sessions` or the pointer file itself would otherwise carry a read or a
|
|
58
|
+
// write out of the project. Missing paths fail too; callers only ask about existing ones.
|
|
59
|
+
function isRealPathInside(sdlcRoot, target) {
|
|
60
|
+
try {
|
|
61
|
+
const expected = path.join(fs.realpathSync.native(sdlcRoot), path.relative(sdlcRoot, target));
|
|
62
|
+
return fs.realpathSync.native(target) === expected;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Reads a pointer file and returns the change id it names, or null if the file is
|
|
69
|
+
// missing, malformed, names something unsafe/archived, the change folder is gone, or the
|
|
70
|
+
// file is not really where it claims to be. A stale, corrupt or redirected pointer must
|
|
71
|
+
// never throw — it is just evidence the caller ignores.
|
|
72
|
+
function readPointerChange(sdlcRoot, filePath) {
|
|
73
|
+
if (!filePath || !isRealPathInside(sdlcRoot, filePath)) return null;
|
|
74
|
+
try {
|
|
75
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
76
|
+
return isOpenChange(sdlcRoot, data?.change) ? data.change : null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The last-resort source: no pointer answers, so fall back to whichever open change was
|
|
83
|
+
// touched most recently. Never reported as this session's or the project's own choice.
|
|
84
|
+
function mostRecentOpenChange(sdlcRoot) {
|
|
85
|
+
const changesDir = path.join(sdlcRoot, 'changes');
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = fs.readdirSync(changesDir, { withFileTypes: true });
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
let best = null;
|
|
93
|
+
for (const d of entries) {
|
|
94
|
+
if (!d.isDirectory() || d.name === 'archive') continue;
|
|
95
|
+
const p = path.join(changesDir, d.name, 'change.md');
|
|
96
|
+
let mtime;
|
|
97
|
+
try {
|
|
98
|
+
mtime = fs.statSync(p).mtimeMs;
|
|
99
|
+
} catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!best || mtime > best.mtime) best = { change: d.name, mtime };
|
|
103
|
+
}
|
|
104
|
+
return best?.change ?? null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Session pointer, then project pointer, then the most-recently-edited open change.
|
|
108
|
+
// Fails open at every step: a thrown error anywhere resolves to null rather than
|
|
109
|
+
// surfacing to a hook, which must never block a session over a state-file glitch.
|
|
110
|
+
export function resolveActive(sdlcRoot, { sessionId } = {}) {
|
|
111
|
+
try {
|
|
112
|
+
if (isSafeSessionId(sessionId)) {
|
|
113
|
+
const change = readPointerChange(sdlcRoot, sessionPointerPath(sdlcRoot, sessionId));
|
|
114
|
+
if (change) return { change, source: 'session' };
|
|
115
|
+
}
|
|
116
|
+
const projectChange = readPointerChange(sdlcRoot, projectPointerPath(sdlcRoot));
|
|
117
|
+
if (projectChange) return { change: projectChange, source: 'project' };
|
|
118
|
+
const recent = mostRecentOpenChange(sdlcRoot);
|
|
119
|
+
if (recent) return { change: recent, source: 'recent' };
|
|
120
|
+
return null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Creates `dir` one segment at a time below `sdlcRoot`, checking each segment before the
|
|
127
|
+
// next is made: a recursive mkdir would first create `sessions/` inside whatever a planted
|
|
128
|
+
// `.state` link points at, and only then could the check notice.
|
|
129
|
+
function ensureRealDir(sdlcRoot, dir) {
|
|
130
|
+
let cur = sdlcRoot;
|
|
131
|
+
for (const seg of path.relative(sdlcRoot, dir).split(path.sep)) {
|
|
132
|
+
cur = path.join(cur, seg);
|
|
133
|
+
if (!hasEntry(cur)) fs.mkdirSync(cur);
|
|
134
|
+
if (!isRealPathInside(sdlcRoot, cur)) return false;
|
|
135
|
+
}
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Whether ANY directory entry sits at `p`, dangling links included. `existsSync` follows the
|
|
140
|
+
// link and reports a dangling one as absent — and `writeFileSync` would then create the
|
|
141
|
+
// link's target, wherever it points.
|
|
142
|
+
function hasEntry(p) {
|
|
143
|
+
try {
|
|
144
|
+
fs.lstatSync(p);
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Writes one pointer, refusing when its directory — or any entry already at the path,
|
|
152
|
+
// dangling link included — is really somewhere else. `writeFileSync` follows links, so the
|
|
153
|
+
// check has to come first.
|
|
154
|
+
function writePointer(sdlcRoot, filePath, change) {
|
|
155
|
+
try {
|
|
156
|
+
if (!ensureRealDir(sdlcRoot, path.dirname(filePath))) return false;
|
|
157
|
+
if (hasEntry(filePath) && !isRealPathInside(sdlcRoot, filePath)) return false;
|
|
158
|
+
fs.writeFileSync(filePath, JSON.stringify({ change }));
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Writes the project-wide fallback, and the session pointer only when the id is safe, so
|
|
166
|
+
// an unsafe id can never cause a file to be created anywhere. A redirected `.state` or
|
|
167
|
+
// `.state/sessions` makes the matching write a no-op rather than a write out of the project.
|
|
168
|
+
export function writeActive(sdlcRoot, change, { sessionId } = {}) {
|
|
169
|
+
const project = writePointer(sdlcRoot, projectPointerPath(sdlcRoot), change);
|
|
170
|
+
const sessionPath = sessionPointerPath(sdlcRoot, sessionId);
|
|
171
|
+
const session = sessionPath ? writePointer(sdlcRoot, sessionPath, change) : false;
|
|
172
|
+
return { project, session };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Releases every pointer naming `changeId`. Called at ship, after the folder has moved, so the
|
|
176
|
+
// id no longer resolves and is matched by name instead. Only real files inside `.state/` are
|
|
177
|
+
// read or removed — a planted link is left alone, never followed. Never throws: the ship has
|
|
178
|
+
// already happened by the time this runs.
|
|
179
|
+
export function clearPointersFor(sdlcRoot, changeId) {
|
|
180
|
+
const candidates = [projectPointerPath(sdlcRoot)];
|
|
181
|
+
const sessionsDir = path.join(sdlcRoot, '.state', 'sessions');
|
|
182
|
+
if (isRealPathInside(sdlcRoot, sessionsDir)) {
|
|
183
|
+
try {
|
|
184
|
+
for (const f of fs.readdirSync(sessionsDir)) {
|
|
185
|
+
if (f.endsWith('.json')) candidates.push(path.join(sessionsDir, f));
|
|
186
|
+
}
|
|
187
|
+
} catch { /* nothing to release */ }
|
|
188
|
+
}
|
|
189
|
+
let released = 0;
|
|
190
|
+
for (const p of candidates) {
|
|
191
|
+
try {
|
|
192
|
+
if (!isRealPathInside(sdlcRoot, p)) continue;
|
|
193
|
+
if (JSON.parse(fs.readFileSync(p, 'utf8'))?.change !== changeId) continue;
|
|
194
|
+
fs.rmSync(p);
|
|
195
|
+
released += 1;
|
|
196
|
+
} catch { /* missing, malformed or vanished — leave it */ }
|
|
197
|
+
}
|
|
198
|
+
return released;
|
|
199
|
+
}
|
package/lib/caps.mjs
CHANGED
|
@@ -1,45 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,41 +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
|
-
}
|
|
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
|
+
}
|