@llman-sdd/core 0.3.1 → 0.5.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/package.json +2 -1
- package/src/archive/freeze.ts +86 -18
- package/src/archive/frozenCard.ts +105 -0
- package/src/archive/sevenzip.ts +15 -13
- package/src/change/closeOutHarness.ts +29 -0
- package/src/change/collect.ts +140 -0
- package/src/change/frontmatter.ts +48 -6
- package/src/change/id.ts +2 -6
- package/src/change/lifecycle.ts +285 -86
- package/src/change/nextId.ts +63 -2
- package/src/change/resolve.ts +2 -2
- package/src/change/tasks.ts +59 -0
- package/src/config/changeId.ts +14 -12
- package/src/config/load.ts +14 -0
- package/src/config/schema.ts +4 -41
- package/src/config/surface.ts +6 -36
- package/src/context/indexStore.ts +7 -3
- package/src/context/retrieve.ts +8 -10
- package/src/context/tree.ts +28 -24
- package/src/git/spawnGit.ts +90 -2
- package/src/index.ts +67 -59
- package/src/init/defaultConfig.ts +1 -5
- package/src/init/init.ts +19 -4
- package/src/ports.ts +1 -7
- package/src/project/migrateNotes.ts +104 -0
- package/src/render/machine.ts +30 -0
- package/src/report/collect.ts +11 -127
- package/src/report/graph/analysis.ts +152 -0
- package/src/report/graph/deps.ts +30 -0
- package/src/report/graph/graphData.ts +53 -0
- package/src/report/graph/nodes.ts +130 -0
- package/src/report/graph/render.ts +83 -0
- package/src/report/graph/types.ts +47 -0
- package/src/report/graph.ts +9 -381
- package/src/report/show.ts +20 -22
- package/src/report/specHelpers.ts +42 -21
- package/src/report/specs.ts +23 -25
- package/src/review/review.ts +45 -30
- package/src/spec/authoring.ts +91 -63
- package/src/spec/ir.ts +43 -15
- package/src/spec/migrateNative.ts +167 -0
- package/src/spec/parser.ts +73 -77
- package/src/spec/reqRegistry.ts +8 -9
- package/src/templates/embedded.ts +10 -16
- package/src/templates/engine.ts +10 -5
- package/src/templates/locale.ts +1 -1
- package/src/templates/skills.ts +4 -5
- package/src/validation/changeCheck.ts +128 -105
- package/src/validation/harness.ts +161 -0
- package/src/validation/staleness.ts +9 -5
- package/src/validation/validate.ts +60 -88
- package/templates/en/skills/llman-sdd-apply-cycle.md +20 -28
- package/templates/en/skills/llman-sdd-apply.md +58 -76
- package/templates/en/skills/llman-sdd-arch-review.md +12 -19
- package/templates/en/skills/llman-sdd-archive.md +27 -42
- package/templates/en/skills/llman-sdd-continue.md +17 -24
- package/templates/en/skills/llman-sdd-draft.md +17 -28
- package/templates/en/skills/llman-sdd-explore.md +29 -43
- package/templates/en/skills/llman-sdd-ff.md +12 -17
- package/templates/en/skills/llman-sdd-graph.md +14 -32
- package/templates/en/skills/llman-sdd-propose.md +48 -63
- package/templates/en/skills/llman-sdd-quick.md +12 -27
- package/templates/en/skills/llman-sdd-research.md +13 -24
- package/templates/en/skills/llman-sdd-specs-compact.md +14 -39
- package/templates/en/skills/llman-sdd-validate.md +11 -15
- package/templates/en/skills/llman-sdd-verify.md +23 -44
- package/templates/en/skills/llman-sdd-wayfinder.md +18 -22
- package/templates/en/units/skills/cli-footer.md +2 -0
- package/templates/en/units/skills/git-native-flow-brief.md +7 -6
- package/templates/en/units/skills/git-native-flow.md +21 -11
- package/templates/en/units/skills/human-readable-summary.md +2 -3
- package/templates/en/units/skills/stage-guard.md +7 -7
- package/templates/en/units/skills/structured-protocol.md +5 -8
- package/templates/en/units/skills/validation-hints.md +10 -14
- package/templates/en/units/spec/feature-contract.md +27 -16
- package/templates/en/units/workflow/archive-freeze-guidance.md +6 -3
- package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +23 -31
- package/templates/zh-Hans/skills/llman-sdd-apply.md +63 -81
- package/templates/zh-Hans/skills/llman-sdd-arch-review.md +21 -28
- package/templates/zh-Hans/skills/llman-sdd-archive.md +29 -44
- package/templates/zh-Hans/skills/llman-sdd-continue.md +17 -24
- package/templates/zh-Hans/skills/llman-sdd-draft.md +18 -29
- package/templates/zh-Hans/skills/llman-sdd-explore.md +34 -48
- package/templates/zh-Hans/skills/llman-sdd-ff.md +13 -18
- package/templates/zh-Hans/skills/llman-sdd-graph.md +16 -34
- package/templates/zh-Hans/skills/llman-sdd-propose.md +51 -65
- package/templates/zh-Hans/skills/llman-sdd-quick.md +15 -30
- package/templates/zh-Hans/skills/llman-sdd-research.md +17 -28
- package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +15 -40
- package/templates/zh-Hans/skills/llman-sdd-validate.md +11 -15
- package/templates/zh-Hans/skills/llman-sdd-verify.md +26 -47
- package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +25 -29
- package/templates/zh-Hans/units/skills/cli-footer.md +2 -0
- package/templates/zh-Hans/units/skills/git-native-flow-brief.md +7 -6
- package/templates/zh-Hans/units/skills/git-native-flow.md +22 -12
- package/templates/zh-Hans/units/skills/human-readable-summary.md +4 -5
- package/templates/zh-Hans/units/skills/stage-guard.md +9 -9
- package/templates/zh-Hans/units/skills/structured-protocol.md +5 -8
- package/templates/zh-Hans/units/skills/validation-hints.md +10 -14
- package/templates/zh-Hans/units/spec/feature-contract.md +25 -16
- package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +6 -2
- package/templates/en/skills/llman-sdd-onboard.md +0 -34
- package/templates/en/skills/llman-sdd-show.md +0 -24
- package/templates/en/units/migrate-prompt.md +0 -28
- package/templates/zh-Hans/skills/llman-sdd-onboard.md +0 -34
- package/templates/zh-Hans/skills/llman-sdd-show.md +0 -24
- package/templates/zh-Hans/units/migrate-prompt.md +0 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llman-sdd/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Pure domain logic for llman-sdd (config, gherkin specs, validation, templates)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"7z-wasm": "^1.2.0",
|
|
28
28
|
"@cucumber/gherkin": "^42.0.1",
|
|
29
29
|
"@cucumber/messages": "^34.2.1",
|
|
30
|
+
"@toon-format/toon": "^4.1.1",
|
|
30
31
|
"nunjucks": "^3.2.4",
|
|
31
32
|
"yaml": "^2.9.1",
|
|
32
33
|
"zod": "^4.6.5"
|
package/src/archive/freeze.ts
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Archive freeze/thaw orchestration (review-freeze capability, r24/r25).
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* Candidates = dated archive dirs; freeze writes a flat relationship-index
|
|
4
|
+
* card `<YYYY-MM-DD>-<id>.yaml` (only `title` + `depends_on`), adds the body
|
|
5
|
+
* files into `freezed_changes.7z.archived` (7z a updates existing archives),
|
|
6
|
+
* then removes the original dirs; thaw extracts selected dirs back and removes
|
|
7
|
+
* the card. All filesystem effects flow through the injected FreezeIo.
|
|
7
8
|
*/
|
|
8
9
|
import { join } from 'node:path';
|
|
9
10
|
|
|
11
|
+
import { firstH1 } from '../change/collect.ts';
|
|
12
|
+
import { extractFrontmatter } from '../change/frontmatter.ts';
|
|
13
|
+
import {
|
|
14
|
+
FROZEN_CARD_EXT,
|
|
15
|
+
composeFrozenCard,
|
|
16
|
+
frozenCardIdOf,
|
|
17
|
+
frozenCardName,
|
|
18
|
+
isFrozenCard,
|
|
19
|
+
} from './frozenCard.ts';
|
|
10
20
|
import type { SevenZipPort } from './sevenzip.ts';
|
|
11
21
|
|
|
12
22
|
export const FREEZE_ARCHIVE_NAME = 'freezed_changes.7z.archived';
|
|
@@ -15,8 +25,13 @@ export const ARCHIVE_DIR_REL = 'llmanspec/changes/archive';
|
|
|
15
25
|
export interface FreezeIo {
|
|
16
26
|
exists(path: string): boolean;
|
|
17
27
|
listDir(path: string): string[];
|
|
28
|
+
readText(path: string): string;
|
|
29
|
+
writeText(path: string, content: string): void;
|
|
30
|
+
isDirectory(path: string): boolean;
|
|
18
31
|
/** rm -rf */
|
|
19
32
|
removeDir(path: string): void;
|
|
33
|
+
/** Remove a single file. */
|
|
34
|
+
remove(path: string): void;
|
|
20
35
|
mkdirp(path: string): void;
|
|
21
36
|
/** Move a directory within the repo (same-device; adapter may copy). */
|
|
22
37
|
moveDir(from: string, to: string): void;
|
|
@@ -24,6 +39,11 @@ export interface FreezeIo {
|
|
|
24
39
|
|
|
25
40
|
const DATED_RE = /^\d{4}-\d{2}-\d{2}-/u;
|
|
26
41
|
|
|
42
|
+
/**
|
|
43
|
+
* r24: freeze candidates = date-prefixed entries that are directories with a
|
|
44
|
+
* proposal.md body (i.e. not yet frozen — frozen dirs have been replaced by
|
|
45
|
+
* the flat `<date>-<id>.yaml` card, which is a file, not a dir).
|
|
46
|
+
*/
|
|
27
47
|
export function freezeCandidates(
|
|
28
48
|
io: FreezeIo,
|
|
29
49
|
archiveDir: string,
|
|
@@ -32,7 +52,7 @@ export function freezeCandidates(
|
|
|
32
52
|
const all = io.exists(archiveDir)
|
|
33
53
|
? io
|
|
34
54
|
.listDir(archiveDir)
|
|
35
|
-
.filter((n) => DATED_RE.test(n))
|
|
55
|
+
.filter((n) => DATED_RE.test(n) && !isFrozenCard(n) && io.isDirectory(`${archiveDir}/${n}`))
|
|
36
56
|
.toSorted()
|
|
37
57
|
: [];
|
|
38
58
|
let candidates = opts.before ? all.filter((n) => n.slice(0, 10) < (opts.before ?? '')) : [...all];
|
|
@@ -72,8 +92,31 @@ export async function runFreeze(
|
|
|
72
92
|
],
|
|
73
93
|
};
|
|
74
94
|
}
|
|
95
|
+
|
|
96
|
+
// Card = authoritative record that bodies are in the cold backup. Write
|
|
97
|
+
// cards first; on `7z add` failure roll them back and keep the original dirs.
|
|
75
98
|
const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
|
|
76
|
-
|
|
99
|
+
|
|
100
|
+
const cards: Array<{ datedId: string; source: string }> = [];
|
|
101
|
+
for (const c of candidates) {
|
|
102
|
+
const proposalRel = `${ARCHIVE_DIR_REL}/${c}/proposal.md`;
|
|
103
|
+
const proposal = io.exists(proposalRel) ? io.readText(proposalRel) : '';
|
|
104
|
+
const frontmatter = extractFrontmatter(proposal) ?? '';
|
|
105
|
+
cards.push({
|
|
106
|
+
datedId: c,
|
|
107
|
+
source: composeFrozenCard(firstH1(proposal), frontmatter),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const card of cards) {
|
|
112
|
+
io.writeText(`${ARCHIVE_DIR_REL}/${frozenCardName(card.datedId)}`, card.source);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await sz.add(archiveAbs, join(rootAbs, ARCHIVE_DIR_REL), candidates);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
for (const card of cards) io.remove(`${ARCHIVE_DIR_REL}/${frozenCardName(card.datedId)}`);
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
77
120
|
for (const c of candidates) {
|
|
78
121
|
io.removeDir(`${ARCHIVE_DIR_REL}/${c}`);
|
|
79
122
|
}
|
|
@@ -85,25 +128,39 @@ export async function runFreeze(
|
|
|
85
128
|
};
|
|
86
129
|
}
|
|
87
130
|
|
|
131
|
+
/**
|
|
132
|
+
* r24: `--list` enumerates frozen changes from the flat cards on disk (no 7z
|
|
133
|
+
* parse needed). Legacy entries frozen without cards (predecessor format) are
|
|
134
|
+
* derived from the 7z listing so old archives stay listable (r25 legacy compat).
|
|
135
|
+
*/
|
|
88
136
|
export async function runList(io: FreezeIo, sz: SevenZipPort, rootAbs: string): Promise<string[]> {
|
|
137
|
+
let names = new Set<string>();
|
|
138
|
+
const archiveDirRel = ARCHIVE_DIR_REL;
|
|
139
|
+
if (io.exists(archiveDirRel)) {
|
|
140
|
+
for (const name of io.listDir(archiveDirRel)) {
|
|
141
|
+
if (isFrozenCard(name) && frozenCardIdOf(name) !== null) {
|
|
142
|
+
names.add(name.slice(0, -FROZEN_CARD_EXT.length));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
89
146
|
const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
|
|
90
|
-
if (
|
|
91
|
-
|
|
147
|
+
if (io.exists(archiveAbs)) {
|
|
148
|
+
// Legacy/unexpected entries present only inside the 7z (no card on disk).
|
|
149
|
+
const entries = (await sz.listEntries(archiveAbs)).map((n) => n.replace(/\/$/u, ''));
|
|
150
|
+
const top = (n: string): string => n.split('/')[0] ?? n;
|
|
151
|
+
for (const e of entries.map(top).filter((n) => DATED_RE.test(n))) {
|
|
152
|
+
if (!names.has(e)) names.add(e);
|
|
153
|
+
}
|
|
92
154
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// archived change names from the leading path segment.
|
|
96
|
-
const entries = (await sz.listEntries(archiveAbs)).map((n) => n.replace(/\/$/u, ''));
|
|
97
|
-
const top = (n: string): string => n.split('/')[0] ?? n;
|
|
98
|
-
const unique = [...new Set(entries.map(top).filter((n) => DATED_RE.test(n)))].toSorted();
|
|
99
|
-
if (unique.length === 0) {
|
|
155
|
+
const sorted = [...names].toSorted();
|
|
156
|
+
if (sorted.length === 0) {
|
|
100
157
|
return [
|
|
101
158
|
`Freeze archive ${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME} contains no archived changes`,
|
|
102
159
|
];
|
|
103
160
|
}
|
|
104
161
|
return [
|
|
105
|
-
`Frozen archived changes in ${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME} (${
|
|
106
|
-
...
|
|
162
|
+
`Frozen archived changes in ${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME} (${sorted.length}):`,
|
|
163
|
+
...sorted.map((c) => ` - ${c}`),
|
|
107
164
|
];
|
|
108
165
|
}
|
|
109
166
|
|
|
@@ -130,7 +187,16 @@ export async function runThaw(
|
|
|
130
187
|
io.removeDir(tmpRel);
|
|
131
188
|
try {
|
|
132
189
|
await sz.extractAll(archiveAbs, tmpAbs);
|
|
133
|
-
const available = new Set(
|
|
190
|
+
const available = new Set(
|
|
191
|
+
io.exists(tmpRel) ? io.listDir(tmpRel).filter((n) => DATED_RE.test(n)) : [],
|
|
192
|
+
);
|
|
193
|
+
// Cards on disk also count as available (they are the authoritative record
|
|
194
|
+
// that bodies are in the cold backup, even before any extraction listing).
|
|
195
|
+
for (const name of io.exists(ARCHIVE_DIR_REL) ? io.listDir(ARCHIVE_DIR_REL) : []) {
|
|
196
|
+
if (isFrozenCard(name) && frozenCardIdOf(name) !== null) {
|
|
197
|
+
available.add(name.slice(0, -FROZEN_CARD_EXT.length));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
134
200
|
const missing = names.filter((n) => !available.has(n));
|
|
135
201
|
if (missing.length > 0) {
|
|
136
202
|
throw new Error(
|
|
@@ -143,6 +209,8 @@ export async function runThaw(
|
|
|
143
209
|
throw new Error(`target already exists: ${name}`);
|
|
144
210
|
}
|
|
145
211
|
io.moveDir(`${tmpRel}/${name}`, `${destRel}/${name}`);
|
|
212
|
+
const cardRel = `${ARCHIVE_DIR_REL}/${frozenCardName(name)}`;
|
|
213
|
+
if (io.exists(cardRel)) io.remove(cardRel);
|
|
146
214
|
restored.push(name);
|
|
147
215
|
}
|
|
148
216
|
return {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen-change relationship-index card contract (review-freeze capability,
|
|
3
|
+
* r24/r25): `freeze` replaces a dated archive dir with a flat card
|
|
4
|
+
* `<YYYY-MM-DD>-<id>.yaml` whose content carries only the change's `title`
|
|
5
|
+
* (proposal H1) and `depends_on` (proposal frontmatter) — a human/agent
|
|
6
|
+
* readable navigation index. id and date are implied by the file name; bodies
|
|
7
|
+
* live in the 7z cold backup. Cards are the authoritative record that a change
|
|
8
|
+
* is in the cold backup.
|
|
9
|
+
*
|
|
10
|
+
* The card is a fenced YAML document (same `---` convention as proposal.md) so
|
|
11
|
+
* existing frontmatter readers (`extractFrontmatter` / `parseDeps`) work
|
|
12
|
+
* unchanged — parseDeps on a card text reads the preserved `depends_on`.
|
|
13
|
+
*
|
|
14
|
+
* Depends_on extraction is deliberately dependency-free (no `yaml` package):
|
|
15
|
+
* it recognizes single-line flow `[a, b]`, block `- item` lists and multi-line
|
|
16
|
+
* flow blocks, then normalizes to a single-line flow array so `parseDeps`
|
|
17
|
+
* reads it unchanged. Anything unparseable degrades to `depends_on: []` (never
|
|
18
|
+
* blocks the freeze).
|
|
19
|
+
*
|
|
20
|
+
* Pure string logic — all filesystem effects live in freeze.ts (FreezeIo).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const FROZEN_CARD_EXT = '.yaml';
|
|
24
|
+
|
|
25
|
+
/** `2026-01-01-old` → `2026-01-01-old.yaml` */
|
|
26
|
+
export function frozenCardName(datedId: string): string {
|
|
27
|
+
return `${datedId}${FROZEN_CARD_EXT}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** id for a card filename (`2026-01-01-old.yaml` → `old`); null when not a card. */
|
|
31
|
+
export function frozenCardIdOf(name: string): string | null {
|
|
32
|
+
const m = name.match(/^\d{4}-\d{2}-\d{2}-(.+)\.yaml$/u);
|
|
33
|
+
return m?.[1] ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isFrozenCard(name: string): boolean {
|
|
37
|
+
return frozenCardIdOf(name) !== null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function escapeYamlScalar(value: string): string {
|
|
41
|
+
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Normalize a flow-list inner string (comma-separated, quotes stripped). */
|
|
45
|
+
function splitFlowItems(inner: string): string[] {
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
for (const raw of inner.split(',')) {
|
|
48
|
+
const item = raw.trim().replaceAll(/^['"]|['"]$/gu, '');
|
|
49
|
+
if (item !== '') out.push(item);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toFlow(items: string[]): string {
|
|
55
|
+
return items.length === 0 ? 'depends_on: []' : `depends_on: [${items.join(', ')}]`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract `depends_on` from a proposal frontmatter block. Falls back to
|
|
60
|
+
* `depends_on: []` when absent/empty/unparseable — a malformed dependency list
|
|
61
|
+
* never breaks the freeze.
|
|
62
|
+
*/
|
|
63
|
+
export function extractDependsOn(frontmatter: string): string {
|
|
64
|
+
const lines = frontmatter.split('\n');
|
|
65
|
+
const flowIdx = lines.findIndex((l) => /^depends_on\s*:\s*\[/u.test(l));
|
|
66
|
+
if (flowIdx !== -1) {
|
|
67
|
+
// Single-line flow `depends_on: [a, b]` (may span lines).
|
|
68
|
+
let acc = (lines[flowIdx] ?? '').replace(/^depends_on\s*:/u, '').trim();
|
|
69
|
+
let j = flowIdx;
|
|
70
|
+
while (!/\]\s*$/u.test(acc) && j < lines.length - 1) {
|
|
71
|
+
j++;
|
|
72
|
+
acc += ` ${(lines[j] ?? '').trim()}`;
|
|
73
|
+
}
|
|
74
|
+
const inner = acc.replace(/^\[/u, '').replace(/\]\s*/u, '');
|
|
75
|
+
return toFlow(splitFlowItems(inner));
|
|
76
|
+
}
|
|
77
|
+
const blockIdx = lines.findIndex((l) => /^depends_on\s*:\s*$/u.test(l));
|
|
78
|
+
if (blockIdx !== -1) {
|
|
79
|
+
// Block list `- a` lines.
|
|
80
|
+
const items: string[] = [];
|
|
81
|
+
for (const l of lines.slice(blockIdx + 1)) {
|
|
82
|
+
const m = l.match(/^\s*-\s+(\S+)/u);
|
|
83
|
+
if (m?.[1]) items.push(m[1].replaceAll(/['"]/gu, ''));
|
|
84
|
+
// multi-line flow handled below
|
|
85
|
+
else if (/^\s*\[/u.test(l)) break;
|
|
86
|
+
else if (l.trim() !== '') break;
|
|
87
|
+
}
|
|
88
|
+
if (items.length > 0) return toFlow(items);
|
|
89
|
+
// Multi-line flow `[\n a,\n b\n ]`.
|
|
90
|
+
let acc = lines
|
|
91
|
+
.slice(blockIdx + 1)
|
|
92
|
+
.join(' ')
|
|
93
|
+
.replace(/^[^[]*\[/u, '');
|
|
94
|
+
acc = (acc.split(']')[0] ?? '').trim();
|
|
95
|
+
if (acc !== '') return toFlow(splitFlowItems(acc));
|
|
96
|
+
return 'depends_on: []';
|
|
97
|
+
}
|
|
98
|
+
return 'depends_on: []';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Compose a relationship-index card: `title` + `depends_on` inside fences. */
|
|
102
|
+
export function composeFrozenCard(title: string, frontmatter: string): string {
|
|
103
|
+
const dependsOn = extractDependsOn(frontmatter);
|
|
104
|
+
return ['---', `title: "${escapeYamlScalar(title)}"`, dependsOn, '---'].join('\n') + '\n';
|
|
105
|
+
}
|
package/src/archive/sevenzip.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
-
import { mkdirSync } from 'node:fs';
|
|
3
2
|
import { basename, join } from 'node:path';
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -9,12 +8,12 @@ import { basename, join } from 'node:path';
|
|
|
9
8
|
* (WASM 7z refuses existing -o dirs). Spike-proven on Bun: compress / list /
|
|
10
9
|
* extract keep directory structure.
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* as before.
|
|
11
|
+
* Purity: the wasm payload and the extraction-dir creation are caller-injected
|
|
12
|
+
* (`wasmB64` / `mkdirp`). Compiled binaries carry no on-disk 7zz.wasm
|
|
13
|
+
* (Emscripten would probe $bunfs and abort), so the CLI seam passes the
|
|
14
|
+
* build-time base64 define through `wasmB64`
|
|
15
|
+
* (see apps/cli/src/commands/archive.ts); unset in source/npm/Node runs →
|
|
16
|
+
* glue loads the .wasm from disk as before.
|
|
18
17
|
*/
|
|
19
18
|
import SevenZip from '7z-wasm';
|
|
20
19
|
|
|
@@ -57,9 +56,12 @@ export function resolveEmbeddedWasmB64(value: unknown): Uint8Array | undefined {
|
|
|
57
56
|
return new Uint8Array(bytes);
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
/**
|
|
61
|
-
export
|
|
62
|
-
|
|
59
|
+
/** Caller-injected side effects for the 7z adapter (see module doc). */
|
|
60
|
+
export interface WasmSevenZipDeps {
|
|
61
|
+
/** Base64 wasm blob (build-time define at the CLI seam); undefined → disk. */
|
|
62
|
+
wasmB64?: string;
|
|
63
|
+
/** Create the extraction target directory recursively. */
|
|
64
|
+
mkdirp: (dir: string) => void;
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
async function initModule(
|
|
@@ -105,8 +107,8 @@ function parseListNames(lines: string[]): string[] {
|
|
|
105
107
|
return names;
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
export async function makeWasmSevenZip(): Promise<SevenZipPort> {
|
|
109
|
-
const wasmBinary =
|
|
110
|
+
export async function makeWasmSevenZip(deps: WasmSevenZipDeps): Promise<SevenZipPort> {
|
|
111
|
+
const wasmBinary = resolveEmbeddedWasmB64(deps.wasmB64);
|
|
110
112
|
return {
|
|
111
113
|
async add(archivePath: string, baseDir: string, entries: string[]): Promise<void> {
|
|
112
114
|
const mod = await initModule(undefined, wasmBinary);
|
|
@@ -129,7 +131,7 @@ export async function makeWasmSevenZip(): Promise<SevenZipPort> {
|
|
|
129
131
|
async extractAll(archivePath: string, destDir: string): Promise<void> {
|
|
130
132
|
const mod = await initModule(undefined, wasmBinary);
|
|
131
133
|
const archMount = mount(mod, 1, join(archivePath, '..'));
|
|
132
|
-
|
|
134
|
+
deps.mkdirp(destDir);
|
|
133
135
|
const destMount = mount(mod, 2, destDir);
|
|
134
136
|
mod.FS.chdir(destMount);
|
|
135
137
|
const rc = mod.callMain(['x', `${archMount}/${basename(archivePath)}`, '-y']);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a close-out (finalize / archive) must run bdd.run_command before
|
|
3
|
+
* any merge or rename. Pure: the CLI supplies the flags, the spec scan, and
|
|
4
|
+
* the nested-invocation bit.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type CloseOutHarnessDecision =
|
|
8
|
+
| { kind: 'skip'; announce: boolean }
|
|
9
|
+
| { kind: 'abort'; message: string }
|
|
10
|
+
| { kind: 'run'; command: string };
|
|
11
|
+
|
|
12
|
+
export function decideCloseOutHarness(input: {
|
|
13
|
+
noCheck: boolean;
|
|
14
|
+
needsSpecsChange: boolean;
|
|
15
|
+
hasExecutable: boolean;
|
|
16
|
+
runCommand: string | null;
|
|
17
|
+
nested: boolean;
|
|
18
|
+
}): CloseOutHarnessDecision {
|
|
19
|
+
if (input.noCheck) return { kind: 'skip', announce: true };
|
|
20
|
+
if (!input.needsSpecsChange || !input.hasExecutable) return { kind: 'skip', announce: false };
|
|
21
|
+
const command = input.runCommand?.trim() ?? '';
|
|
22
|
+
if (command === '') {
|
|
23
|
+
return { kind: 'abort', message: 'executable scenarios have no bdd.run_command' };
|
|
24
|
+
}
|
|
25
|
+
if (input.nested) {
|
|
26
|
+
return { kind: 'abort', message: 'bdd harness skipped: nested invocation' };
|
|
27
|
+
}
|
|
28
|
+
return { kind: 'run', command };
|
|
29
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readBinding } from './frontmatter.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Change collection + stage derivation (change domain, peripheral-commands
|
|
4
|
+
* capability r20/r34 shapes; predecessor commands/list.rs port). Pure — IO injected.
|
|
5
|
+
* Lives in change/ (not report/) so the domain never imports its renderers:
|
|
6
|
+
* report/{show,specs}.ts and validation/changeCheck.ts consume this one-way.
|
|
7
|
+
*/
|
|
8
|
+
import { CHANGES_DIR } from './lifecycle.ts';
|
|
9
|
+
import { parseTaskCheckboxes } from './tasks.ts';
|
|
10
|
+
|
|
11
|
+
export interface ChangeFsIo {
|
|
12
|
+
exists(path: string): boolean;
|
|
13
|
+
readText(path: string): string;
|
|
14
|
+
listDir(path: string): string[];
|
|
15
|
+
isDirectory(path: string): boolean;
|
|
16
|
+
mtimeMs(path: string): number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Minimal structural io for the active-change walk (r58/r73 shared caliber). */
|
|
20
|
+
export interface ChangeScanIo {
|
|
21
|
+
exists(path: string): boolean;
|
|
22
|
+
listDir(path: string): string[];
|
|
23
|
+
isDirectory(path: string): boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* r58/r73 shared walk: visits every active change dir (a dir directly holding
|
|
28
|
+
* proposal.md) under changesRoot — nested groups allowed, leaf name = change
|
|
29
|
+
* id, `archive/` and dot-dirs skipped, depth-limited. The CLI change scan and
|
|
30
|
+
* the dependency-reference resolver share this one traversal.
|
|
31
|
+
*/
|
|
32
|
+
export function walkActiveChangeDirs(
|
|
33
|
+
io: ChangeScanIo,
|
|
34
|
+
changesRoot: string,
|
|
35
|
+
visit: (dir: string, name: string) => void,
|
|
36
|
+
opts: { maxScanDepth?: number } = {},
|
|
37
|
+
): void {
|
|
38
|
+
if (!io.exists(changesRoot) || !io.isDirectory(changesRoot)) return;
|
|
39
|
+
const maxDepth = opts.maxScanDepth ?? 8;
|
|
40
|
+
const walk = (dir: string, depth: number): void => {
|
|
41
|
+
if (depth > maxDepth) return;
|
|
42
|
+
for (const name of io.listDir(dir).toSorted()) {
|
|
43
|
+
if (name === 'archive' || name.startsWith('.')) continue;
|
|
44
|
+
const child = `${dir}/${name}`;
|
|
45
|
+
if (!io.isDirectory(child)) continue;
|
|
46
|
+
if (io.exists(`${child}/proposal.md`)) visit(child, name);
|
|
47
|
+
else walk(child, depth + 1);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(changesRoot, 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ChangeSummary {
|
|
54
|
+
name: string;
|
|
55
|
+
path: string;
|
|
56
|
+
title: string;
|
|
57
|
+
stage: 'draft' | 'designed' | 'planned' | 'full';
|
|
58
|
+
hasBinding: boolean;
|
|
59
|
+
completedTasks: number;
|
|
60
|
+
totalTasks: number;
|
|
61
|
+
lastModified: Date;
|
|
62
|
+
idleDays: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ChangeStatus = 'no-tasks' | 'complete' | 'in-progress';
|
|
66
|
+
|
|
67
|
+
export function statusFor(total: number, completed: number): ChangeStatus {
|
|
68
|
+
if (total === 0) return 'no-tasks';
|
|
69
|
+
return completed >= total ? 'complete' : 'in-progress';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function stageFor(
|
|
73
|
+
hasDesign: boolean,
|
|
74
|
+
hasTasks: boolean,
|
|
75
|
+
hasBinding: boolean,
|
|
76
|
+
): ChangeSummary['stage'] {
|
|
77
|
+
// Monotonic predecessor parity (r34): design.md gates designed, tasks.md only
|
|
78
|
+
// upgrades on top of design, binding only upgrades the complete set.
|
|
79
|
+
if (hasDesign && hasTasks && hasBinding) return 'full';
|
|
80
|
+
if (hasDesign && hasTasks) return 'planned';
|
|
81
|
+
if (hasDesign) return 'designed';
|
|
82
|
+
return 'draft';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function countTasks(tasksMd: string): { completed: number; total: number } {
|
|
86
|
+
const { completed, total } = parseTaskCheckboxes(tasksMd);
|
|
87
|
+
return { completed, total };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function firstH1(md: string): string {
|
|
91
|
+
for (const line of md.split('\n')) {
|
|
92
|
+
const m = line.match(/^#\s+(.*)$/u);
|
|
93
|
+
if (m?.[1]) return m[1].trim();
|
|
94
|
+
}
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function collectChanges(
|
|
99
|
+
io: ChangeFsIo,
|
|
100
|
+
root: string,
|
|
101
|
+
now: Date,
|
|
102
|
+
opts: { maxScanDepth?: number } = {},
|
|
103
|
+
): ChangeSummary[] {
|
|
104
|
+
const changesDir = `${root}/${CHANGES_DIR}`;
|
|
105
|
+
if (!io.exists(changesDir) || !io.isDirectory(changesDir)) return [];
|
|
106
|
+
const out: ChangeSummary[] = [];
|
|
107
|
+
const readChangeDir = (dir: string, name: string): void => {
|
|
108
|
+
const proposal = `${dir}/proposal.md`;
|
|
109
|
+
const hasDesign = io.exists(`${dir}/design.md`);
|
|
110
|
+
const hasTasks = io.exists(`${dir}/tasks.md`);
|
|
111
|
+
const hasBinding = readBinding(io.readText(proposal)) !== null;
|
|
112
|
+
const { completed, total } = hasTasks
|
|
113
|
+
? countTasks(io.readText(`${dir}/tasks.md`))
|
|
114
|
+
: { completed: 0, total: 0 };
|
|
115
|
+
|
|
116
|
+
// lastModified = newest mtime across the change dir
|
|
117
|
+
let latest = io.mtimeMs(proposal);
|
|
118
|
+
for (const f of [hasDesign ? `${dir}/design.md` : null, hasTasks ? `${dir}/tasks.md` : null]) {
|
|
119
|
+
if (f) latest = Math.max(latest, io.mtimeMs(f));
|
|
120
|
+
}
|
|
121
|
+
const lastModified = new Date(latest);
|
|
122
|
+
const idleDays = Math.floor((now.getTime() - lastModified.getTime()) / 86_400_000);
|
|
123
|
+
|
|
124
|
+
out.push({
|
|
125
|
+
name,
|
|
126
|
+
path: name,
|
|
127
|
+
title: firstH1(io.readText(proposal)),
|
|
128
|
+
stage: stageFor(hasDesign, hasTasks, hasBinding),
|
|
129
|
+
hasBinding,
|
|
130
|
+
completedTasks: completed,
|
|
131
|
+
totalTasks: total,
|
|
132
|
+
lastModified,
|
|
133
|
+
idleDays,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
// r58: recursive, depth-limited proposal discovery (predecessor --max-scan-depth parity)
|
|
137
|
+
walkActiveChangeDirs(io, changesDir, readChangeDir, { maxScanDepth: opts.maxScanDepth });
|
|
138
|
+
// predecessor lists newest-first
|
|
139
|
+
return out.toSorted((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
|
140
|
+
}
|
|
@@ -5,20 +5,62 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { parseDocument } from 'yaml';
|
|
7
7
|
|
|
8
|
+
import type { GitLike } from '../git/spawnGit.ts';
|
|
9
|
+
|
|
8
10
|
export interface ChangeBinding {
|
|
9
11
|
branch: string;
|
|
10
12
|
baseBranch: string;
|
|
11
13
|
baseSha: string;
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Single frontmatter splitter (r73): the block starts with `---\n` at offset 0
|
|
18
|
+
* and closes at the first standalone `---` line (followed by `\n` or EOF).
|
|
19
|
+
* Every read path (extractFrontmatter / readBinding / parseDeps /
|
|
20
|
+
* needs_specs_change) and the write path share this one contract.
|
|
21
|
+
*/
|
|
14
22
|
function splitFrontmatter(proposal: string): { body: string; frontmatter: string | null } {
|
|
15
23
|
if (!proposal.startsWith('---\n')) return { body: proposal, frontmatter: null };
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
const rest = proposal.slice(4);
|
|
25
|
+
let pos = 0;
|
|
26
|
+
while (pos <= rest.length) {
|
|
27
|
+
const nl = rest.indexOf('\n', pos);
|
|
28
|
+
const lineEnd = nl === -1 ? rest.length : nl;
|
|
29
|
+
if (rest.slice(pos, lineEnd) === '---') {
|
|
30
|
+
return {
|
|
31
|
+
frontmatter: pos === 0 ? '' : rest.slice(0, pos - 1),
|
|
32
|
+
body: nl === -1 ? '' : rest.slice(nl + 1),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (nl === -1) return { body: proposal, frontmatter: null };
|
|
36
|
+
pos = nl + 1;
|
|
37
|
+
}
|
|
38
|
+
return { body: proposal, frontmatter: null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Frontmatter block content (between the delimiters), or null without a block. */
|
|
42
|
+
export function extractFrontmatter(proposal: string): string | null {
|
|
43
|
+
return splitFrontmatter(proposal).frontmatter;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* r73: `needs_specs_change` is read from the frontmatter block only — a
|
|
48
|
+
* body line with the same text must not flip the verdict. Default true.
|
|
49
|
+
*/
|
|
50
|
+
export function readNeedsSpecsChange(frontmatter: string | null): boolean {
|
|
51
|
+
const m = frontmatter?.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1];
|
|
52
|
+
return m === undefined ? true : m === 'true';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* r73: live specs landed on the bound branch — per-line prefix match on
|
|
57
|
+
* `diff --name-only <base_branch>...<branch>` (a path like
|
|
58
|
+
* `docs/llmanspec/specs/x` must not count as landed).
|
|
59
|
+
*/
|
|
60
|
+
export function specsLanded(git: GitLike, binding: ChangeBinding): boolean {
|
|
61
|
+
const touched =
|
|
62
|
+
git.runOpt(['diff', '--name-only', `${binding.baseBranch}...${binding.branch}`]) ?? '';
|
|
63
|
+
return touched.split('\n').some((line) => line.startsWith('llmanspec/specs/'));
|
|
22
64
|
}
|
|
23
65
|
|
|
24
66
|
export function readBinding(proposal: string): ChangeBinding | null {
|
package/src/change/id.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Change id derivation (change-lifecycle capability):
|
|
2
|
+
* Change id derivation (change-lifecycle capability): predecessor `change/new.rs`
|
|
3
3
|
* `derive_change_id` parity — pure kebab sanitization (lowercase alnum,
|
|
4
4
|
* separators), no verb requirement, empty/oversized handling.
|
|
5
5
|
*/
|
|
@@ -8,11 +8,7 @@ const ID_CAP = 60;
|
|
|
8
8
|
|
|
9
9
|
export class ChangeIdError extends Error {}
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
return /^[a-z0-9-]+$/u.test(id) && id.length > 0 && id.length <= ID_CAP;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** v1 parity: sanitize to lowercase kebab (ASCII alnum only; CJK/punct dropped). */
|
|
11
|
+
/** predecessor parity: sanitize to lowercase kebab (ASCII alnum only; CJK/punct dropped). */
|
|
16
12
|
export function deriveChangeId(description: string): string {
|
|
17
13
|
const trimmed = description.trim();
|
|
18
14
|
if (trimmed === '') {
|