@warnyin/sdlc 0.5.2 → 0.6.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 +28 -0
- package/LICENSE +21 -21
- package/README.md +1 -1
- package/bin/cli.mjs +49 -4
- 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 +1 -1
- 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 +2 -1
- 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 +150 -154
- package/payload/hooks/guard-writes.mjs +83 -83
- package/payload/hooks/inject-context.mjs +55 -55
- package/payload/hooks/journal.mjs +58 -58
- package/payload/hooks/session-summary.mjs +50 -50
- package/payload/hooks/validate-artifact.mjs +80 -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/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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 (2026-09-10)
|
|
4
|
+
|
|
5
|
+
- **Fix (auto)**: the Confirm step of an unattended run showed the scope it had settled on
|
|
6
|
+
and asked for approval, but never how it got there — so a derivation that searched the
|
|
7
|
+
wrong thing was approved as readily as a right one. It now shows, per scope item, the
|
|
8
|
+
command that established it and what that command returned; a summary of what a search
|
|
9
|
+
found does not count. Evidence that searched a term the request did not name is flagged
|
|
10
|
+
with both terms side by side, a narrowing the request never asked for (a folder pattern,
|
|
11
|
+
a naming convention) becomes its own refusable item, and an exclusion made on an empty
|
|
12
|
+
result must name the pattern searched — finding nothing is a claim about the pattern, not
|
|
13
|
+
a fact about the candidate. This is doctrine the model follows, checked by tests on the
|
|
14
|
+
doctrine and by the eval rubric; the confirmation is written at runtime, so nothing gates
|
|
15
|
+
it mechanically. (#2)
|
|
16
|
+
- **Fix (journal)**: telemetry the hooks append lived in `sdlc/changes/<id>/journal.ndjson`,
|
|
17
|
+
which git tracks, so merely opening a project modified a shared file no human touched —
|
|
18
|
+
`git pull` and branch switches refused to move until someone discarded it, and two people
|
|
19
|
+
on one change conflicted on the appended tail for a reason unrelated to the change under
|
|
20
|
+
review. While a change is open, telemetry now goes to `sdlc/.state/journal/<id>.ndjson`;
|
|
21
|
+
`.state/` is already git-ignored in every installed project, so no new `.gitignore` entry
|
|
22
|
+
and no `git rm --cached` is needed. `archive` seals the journal into the shipped change
|
|
23
|
+
folder in one write at ship, so `/sdlc:observe` still reports cost and verify history for
|
|
24
|
+
changes a teammate shipped. Projects installed before this keep their in-tree journal: it
|
|
25
|
+
is read alongside the new stream and consumed at ship, so no recorded event is lost.
|
|
26
|
+
Two interim states worth knowing: a project that runs `update` mid-change carries telemetry
|
|
27
|
+
split across the two files until that change ships, and downgrading to 0.5.2 afterwards
|
|
28
|
+
leaves anything under `.state/journal/` unread by the older code — it is still on disk,
|
|
29
|
+
but that version does not know to look there. (#3)
|
|
30
|
+
|
|
3
31
|
## 0.5.2 (2026-08-25)
|
|
4
32
|
|
|
5
33
|
- **Fix (delta)**: a `### MODIFIED Requirement:` body replaces the requirement wholesale,
|
package/LICENSE
CHANGED
|
@@ -1,21 +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.
|
|
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
CHANGED
|
@@ -47,7 +47,7 @@ sdlc/
|
|
|
47
47
|
├── context/steering/*.md scoped knowledge · inclusion: always|paths|manual|agent
|
|
48
48
|
├── harness.md tools, model routing, tier triage, autonomy policy
|
|
49
49
|
├── specs/<capability>/ living specs — WHEN/THEN SHALL, merged mechanically at ship
|
|
50
|
-
├── changes/<id>/ one change: change.md (Why+Delta+Tasks) + contract/
|
|
50
|
+
├── changes/<id>/ one change: change.md (Why+Delta+Tasks) + contract/
|
|
51
51
|
└── changes/archive/ shipped changes + digests (the async human touchpoint)
|
|
52
52
|
```
|
|
53
53
|
|
package/bin/cli.mjs
CHANGED
|
@@ -16,6 +16,10 @@ import { mergeHookSettings } from '../lib/settings-merge.mjs';
|
|
|
16
16
|
import { buildReport, renderReport } from '../lib/observe.mjs';
|
|
17
17
|
import { parseManifest, renderManifest, computeStale, containedIn, hasSymlinkSegment, PRUNE_BLAST_CAP } from '../lib/manifest.mjs';
|
|
18
18
|
import { validateAll, formatIssues, listChangeDirs } from '../lib/validate.mjs';
|
|
19
|
+
import {
|
|
20
|
+
readChangeJournal, liveJournalPath, sealedJournalPath, serializeJournal, appendEvent,
|
|
21
|
+
isSafeChangeId,
|
|
22
|
+
} from '../lib/journal.mjs';
|
|
19
23
|
import { detectTools, toolName } from './detect.mjs';
|
|
20
24
|
import { colorEnabled, createStyle, symbolsFor, summarizeInstall, startHints } from './ui.mjs';
|
|
21
25
|
import { multiSelect } from './multiselect.mjs';
|
|
@@ -358,6 +362,11 @@ export function cmdUpdate(projectRoot, args) {
|
|
|
358
362
|
const ctx = { mode: 'update', manifest: new Map(), oldManifest, warnings: [] };
|
|
359
363
|
scaffoldSdlc(projectRoot, tools, ctx);
|
|
360
364
|
installToolAdapters(projectRoot, tools, ctx);
|
|
365
|
+
// `update` is how an existing project acquires hooks that journal under .state/, and
|
|
366
|
+
// that whole design rests on the entry being there. Re-assert it: a project whose
|
|
367
|
+
// .gitignore never had it, or lost it, would otherwise start reporting telemetry as
|
|
368
|
+
// untracked noise — the same symptom in a subtler form.
|
|
369
|
+
ensureGitignore(projectRoot);
|
|
361
370
|
|
|
362
371
|
// Prune: old-manifest entries no longer in the payload, guarded six ways.
|
|
363
372
|
const { stale, rejected, overCap } = computeStale(oldManifest, new Set(ctx.manifest.keys()));
|
|
@@ -440,15 +449,25 @@ export function cmdObserve(projectRoot, { json = false } = {}) {
|
|
|
440
449
|
|
|
441
450
|
// ---------- archive (= mechanical part of ship) ----------
|
|
442
451
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
452
|
+
// The CLI's own events go to the same out-of-tree stream the hooks append to, so the
|
|
453
|
+
// ship event does not become the one write that dirties the tree.
|
|
454
|
+
export function appendJournal(sdlcRoot, changeId, event) {
|
|
455
|
+
const target = liveJournalPath(sdlcRoot, changeId);
|
|
456
|
+
if (!target) return;
|
|
457
|
+
appendEvent(target, { ts: new Date().toISOString(), ...event });
|
|
446
458
|
}
|
|
447
459
|
|
|
448
460
|
export function cmdArchive(projectRoot, changeId, { strict = true } = {}) {
|
|
449
461
|
const sdlcRoot = path.join(projectRoot, 'sdlc');
|
|
450
462
|
requireSdlc(sdlcRoot);
|
|
451
463
|
if (!changeId) throw new Error('usage: warnyin-sdlc archive <change-id>');
|
|
464
|
+
// Refuse before anything is read or written. An id like `a/../b` resolves to a real
|
|
465
|
+
// folder, so without this it would ship — merging specs and moving the folder — and
|
|
466
|
+
// only then fail on the journal paths that do gate the id, reporting a completed
|
|
467
|
+
// ship as an error.
|
|
468
|
+
if (!isSafeChangeId(changeId)) {
|
|
469
|
+
throw new Error(`"${changeId}" is not a valid change id — one path segment, no separators`);
|
|
470
|
+
}
|
|
452
471
|
const changeDir = path.join(sdlcRoot, 'changes', changeId);
|
|
453
472
|
if (!fs.existsSync(changeDir)) throw new Error(`change "${changeId}" not found`);
|
|
454
473
|
|
|
@@ -510,10 +529,36 @@ export function cmdArchive(projectRoot, changeId, { strict = true } = {}) {
|
|
|
510
529
|
|
|
511
530
|
const stamped = changeText.replace(/^status:\s*.*$/m, 'status: shipped');
|
|
512
531
|
writeFileNormalized(path.join(changeDir, 'change.md'), stamped);
|
|
513
|
-
appendJournal(
|
|
532
|
+
appendJournal(sdlcRoot, changeId, { event: 'ship', change: changeId, specs: merged.map((m) => m.capability) });
|
|
533
|
+
|
|
534
|
+
// Telemetry stays out of the tree for the whole life of the change and becomes
|
|
535
|
+
// tracked exactly once — here, in the ship commit — so no session can dirty it and no
|
|
536
|
+
// appended tail can conflict.
|
|
537
|
+
//
|
|
538
|
+
// Read before the move, write after it. For an open change `sealedJournalPath` and
|
|
539
|
+
// `legacyJournalPath` are the SAME file, so sealing first would leave the merged
|
|
540
|
+
// union sitting at the legacy path if the rename then failed (EPERM/EBUSY on Windows
|
|
541
|
+
// is the realistic way); the retry would merge that union with the still-present live
|
|
542
|
+
// stream and double every event. Reading first and writing into `destDir` means a
|
|
543
|
+
// failed rename has consumed nothing.
|
|
544
|
+
const sealed = readChangeJournal(sdlcRoot, changeId);
|
|
514
545
|
|
|
515
546
|
fs.renameSync(changeDir, destDir);
|
|
516
547
|
|
|
548
|
+
// Past the point of no return: specs are merged and the folder has moved. Nothing
|
|
549
|
+
// below may throw, or a completed ship reports as a failure and the human retries
|
|
550
|
+
// into "change not found".
|
|
551
|
+
try {
|
|
552
|
+
// Empty only if the id was never journalled at all — the ship event above normally
|
|
553
|
+
// guarantees at least one entry. An empty file would be worse than none.
|
|
554
|
+
if (sealed.length) {
|
|
555
|
+
writeFileNormalized(sealedJournalPath(destDir), serializeJournal(sealed));
|
|
556
|
+
}
|
|
557
|
+
fs.rmSync(liveJournalPath(sdlcRoot, changeId), { force: true });
|
|
558
|
+
} catch (err) {
|
|
559
|
+
console.error(`⚠ shipped, but the journal was not fully sealed: ${err.message}`);
|
|
560
|
+
}
|
|
561
|
+
|
|
517
562
|
console.log(`shipped: ${changeId}`);
|
|
518
563
|
for (const m of merged) console.log(` spec merged: specs/${m.capability}/spec.md`);
|
|
519
564
|
if (driftWarnings.length) {
|
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
|
+
}
|