@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
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { stageFor, walkActiveChangeDirs } from '../change/collect.ts';
|
|
2
|
+
import {
|
|
3
|
+
extractFrontmatter,
|
|
4
|
+
readBinding,
|
|
5
|
+
readNeedsSpecsChange,
|
|
6
|
+
specsLanded,
|
|
7
|
+
} from '../change/frontmatter.ts';
|
|
8
|
+
import { CLOSE_OUT_TASK_HINT, closeOutTaskLines, parseTaskCheckboxes } from '../change/tasks.ts';
|
|
2
9
|
/**
|
|
3
|
-
* Change-domain validation (
|
|
10
|
+
* Change-domain validation (predecessor `commands/validate.rs` change path parity):
|
|
4
11
|
* frontmatter/depends_on gates, design/tasks constraints, completeness stage
|
|
5
12
|
* INFO, pattern gate and task gates. IO + git injected (pure).
|
|
6
13
|
*/
|
|
@@ -10,7 +17,7 @@ export type ChangeIssueLevel = 'ERROR' | 'WARNING' | 'INFO';
|
|
|
10
17
|
|
|
11
18
|
export interface ChangeIssue {
|
|
12
19
|
level: ChangeIssueLevel;
|
|
13
|
-
/**
|
|
20
|
+
/** predecessor-style anchor (issue path), e.g. `proposal.md/frontmatter.depends_on`. */
|
|
14
21
|
path: string;
|
|
15
22
|
message: string;
|
|
16
23
|
}
|
|
@@ -25,7 +32,6 @@ export interface ChangeCheckInput {
|
|
|
25
32
|
|
|
26
33
|
export interface ChangeCheckConfig {
|
|
27
34
|
strict_defer?: boolean | null;
|
|
28
|
-
min_completion_ratio?: number | null;
|
|
29
35
|
change_id_pattern?: string | null;
|
|
30
36
|
}
|
|
31
37
|
|
|
@@ -38,6 +44,62 @@ export interface ChangeFsIoLite {
|
|
|
38
44
|
|
|
39
45
|
export const STAGE_ORDER = ['draft', 'designed', 'planned', 'full'] as const;
|
|
40
46
|
|
|
47
|
+
/**
|
|
48
|
+
* r73 dependency-reference resolution: `active` matches a leaf change dir by
|
|
49
|
+
* name (nested groups included — same caliber as the CLI change scan),
|
|
50
|
+
* `archived` matches an `archive/<YYYY-MM-DD>-<id>` dir OR a flat frozen card
|
|
51
|
+
* `archive/<YYYY-MM-DD>-<id>.yaml` exactly (a suffix like
|
|
52
|
+
* `2026-01-01-the-other` must NOT satisfy id `other`). A missing
|
|
53
|
+
* `archive/` dir reads as empty and never throws.
|
|
54
|
+
*/
|
|
55
|
+
function resolveChangeRef(
|
|
56
|
+
io: ChangeFsIoLite,
|
|
57
|
+
changesRoot: string,
|
|
58
|
+
id: string,
|
|
59
|
+
): 'active' | 'archived' | 'unknown' {
|
|
60
|
+
let active = false;
|
|
61
|
+
walkActiveChangeDirs(io, changesRoot, (_dir, name) => {
|
|
62
|
+
if (name === id) active = true;
|
|
63
|
+
});
|
|
64
|
+
if (active) return 'active';
|
|
65
|
+
const archive = `${changesRoot}/archive`;
|
|
66
|
+
const entries = io.exists(archive) && io.isDirectory(archive) ? io.listDir(archive) : [];
|
|
67
|
+
// `YYYY-MM-DD-` is 11 chars; the remainder must equal the id exactly.
|
|
68
|
+
const archived = entries.some((name) => {
|
|
69
|
+
if (!/^\d{4}-\d{2}-\d{2}-/u.test(name)) return false;
|
|
70
|
+
const base = name.endsWith('.yaml') ? name.slice(0, -'.yaml'.length) : name;
|
|
71
|
+
return base.slice(11) === id;
|
|
72
|
+
});
|
|
73
|
+
return archived ? 'archived' : 'unknown';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* r87: global change-id uniqueness gate. Enumerates active change ids (leaf
|
|
78
|
+
* dir names), archived dir ids and frozen-card ids, and returns every id that
|
|
79
|
+
* appears more than once across the whole set. This is the cheap, enumerable
|
|
80
|
+
* form of the uniqueness contract: frozen ids are no longer hidden inside the
|
|
81
|
+
* cold backup, so collisions are checkable without thawing.
|
|
82
|
+
*/
|
|
83
|
+
export function checkGlobalChangeIdUniqueness(io: ChangeFsIoLite, changesRoot: string): string[] {
|
|
84
|
+
const archive = `${changesRoot}/archive`;
|
|
85
|
+
const entries = io.exists(archive) && io.isDirectory(archive) ? io.listDir(archive) : [];
|
|
86
|
+
const counts = new Map<string, number>();
|
|
87
|
+
walkActiveChangeDirs(io, changesRoot, (_dir, name) => {
|
|
88
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
89
|
+
});
|
|
90
|
+
for (const name of entries) {
|
|
91
|
+
if (!/^\d{4}-\d{2}-\d{2}-/u.test(name)) continue;
|
|
92
|
+
const base = name.endsWith('.yaml') ? name.slice(0, -'.yaml'.length) : name;
|
|
93
|
+
const id = base.slice(11);
|
|
94
|
+
if (id === '') continue;
|
|
95
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
96
|
+
}
|
|
97
|
+
return [...counts]
|
|
98
|
+
.filter(([, n]) => n > 1)
|
|
99
|
+
.map(([id]) => id)
|
|
100
|
+
.toSorted((a, b) => a.localeCompare(b));
|
|
101
|
+
}
|
|
102
|
+
|
|
41
103
|
export type StageGate = (typeof STAGE_ORDER)[number];
|
|
42
104
|
|
|
43
105
|
export interface ChangeCheckResult {
|
|
@@ -46,11 +108,14 @@ export interface ChangeCheckResult {
|
|
|
46
108
|
issues: ChangeIssue[];
|
|
47
109
|
}
|
|
48
110
|
|
|
49
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Legacy pure gate (r47 surface) — structural checks only (tasks, binding,
|
|
113
|
+
* pattern); the stage-ordinal branch was removed with the dual-caliber gate
|
|
114
|
+
* (the artifact-presence `--stage` gate lives in validateChange).
|
|
115
|
+
*/
|
|
50
116
|
export function checkChangeDoc(
|
|
51
117
|
input: ChangeCheckInput,
|
|
52
118
|
config: ChangeCheckConfig,
|
|
53
|
-
opts: { stage?: StageGate } = {},
|
|
54
119
|
): ChangeCheckResult {
|
|
55
120
|
const issues: ChangeIssue[] = [];
|
|
56
121
|
if (input.totalTasks > 0 && input.completedTasks < input.totalTasks) {
|
|
@@ -61,18 +126,6 @@ export function checkChangeDoc(
|
|
|
61
126
|
message: `${pending} unchecked task(s) in tasks.md`,
|
|
62
127
|
});
|
|
63
128
|
}
|
|
64
|
-
if (
|
|
65
|
-
config.min_completion_ratio !== undefined &&
|
|
66
|
-
config.min_completion_ratio !== null &&
|
|
67
|
-
input.totalTasks > 0 &&
|
|
68
|
-
input.completedTasks / input.totalTasks < config.min_completion_ratio
|
|
69
|
-
) {
|
|
70
|
-
issues.push({
|
|
71
|
-
level: 'ERROR',
|
|
72
|
-
path: 'tasks.md',
|
|
73
|
-
message: `completion ratio below archive.min_completion_ratio (${config.min_completion_ratio})`,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
129
|
if (!input.hasBinding) {
|
|
77
130
|
issues.push({
|
|
78
131
|
level: 'WARNING',
|
|
@@ -81,23 +134,20 @@ export function checkChangeDoc(
|
|
|
81
134
|
});
|
|
82
135
|
}
|
|
83
136
|
if (config.change_id_pattern) {
|
|
84
|
-
|
|
85
|
-
|
|
137
|
+
try {
|
|
138
|
+
const re = new RegExp(config.change_id_pattern, 'u');
|
|
139
|
+
if (!re.test(input.name)) {
|
|
140
|
+
issues.push({
|
|
141
|
+
level: 'ERROR',
|
|
142
|
+
path: 'change-id',
|
|
143
|
+
message: `Change id '${input.name}' does not match change_id.pattern '${config.change_id_pattern}' (active changes only; archived changes are not re-checked).`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
86
147
|
issues.push({
|
|
87
148
|
level: 'ERROR',
|
|
88
149
|
path: 'change-id',
|
|
89
|
-
message: `
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (opts.stage !== undefined) {
|
|
94
|
-
const currentIdx = STAGE_ORDER.indexOf(input.stage);
|
|
95
|
-
const requiredIdx = STAGE_ORDER.indexOf(opts.stage);
|
|
96
|
-
if (currentIdx < requiredIdx) {
|
|
97
|
-
issues.push({
|
|
98
|
-
level: 'ERROR',
|
|
99
|
-
path: 'stage',
|
|
100
|
-
message: `stage \`${input.stage}\` is below the required \`${opts.stage}\``,
|
|
150
|
+
message: `change_id.pattern is not a valid regular expression: ${(error as Error).message}`,
|
|
101
151
|
});
|
|
102
152
|
}
|
|
103
153
|
}
|
|
@@ -108,17 +158,17 @@ export type ChangeValidationConfig = ChangeCheckConfig;
|
|
|
108
158
|
|
|
109
159
|
const COMPLETENESS: Record<string, string> = {
|
|
110
160
|
draft:
|
|
111
|
-
"Change is in 'draft' stage (next: add design.md + tasks.md, then `llman
|
|
161
|
+
"Change is in 'draft' stage (next: add design.md + tasks.md, then `llman-sdd change start <id>` to enter a feature branch)",
|
|
112
162
|
designed:
|
|
113
|
-
"Change is in 'designed' stage (next: add tasks.md to reach 'planned', then `llman
|
|
163
|
+
"Change is in 'designed' stage (next: add tasks.md to reach 'planned', then `llman-sdd change start` to enter feature branch and reach 'full')",
|
|
114
164
|
planned:
|
|
115
|
-
"Change is in 'planned' stage (next: `llman
|
|
116
|
-
full: "Change is bound and stage is 'full' (verify readiness via `llman
|
|
165
|
+
"Change is in 'planned' stage (next: `llman-sdd change start` to enter feature branch and reach 'full')",
|
|
166
|
+
full: "Change is bound and stage is 'full' (verify readiness via `llman-sdd show <id> --json` readyToImplement)",
|
|
117
167
|
};
|
|
118
168
|
|
|
119
169
|
/**
|
|
120
|
-
* File-aware change validation with
|
|
121
|
-
* command). `strict` escalates WARNING issues to ERROR (
|
|
170
|
+
* File-aware change validation with predecessor messages/paths (used by the validate
|
|
171
|
+
* command). `strict` escalates WARNING issues to ERROR (predecessor build_report).
|
|
122
172
|
* `git` (optional) enables the r63 completeness WARNINGs — Full-not-ready
|
|
123
173
|
* with skill guidance, per change.
|
|
124
174
|
*/
|
|
@@ -137,12 +187,15 @@ export function validateChange(
|
|
|
137
187
|
const dir = `${root}/llmanspec/changes/${id}/`;
|
|
138
188
|
const baseDir = `${root}/llmanspec/changes`;
|
|
139
189
|
const proposal = `${dir}proposal.md`;
|
|
190
|
+
// Probed once here: shared by the stage inference below and the --stage
|
|
191
|
+
// artifact gate at the end (predecessor issue order preserved).
|
|
192
|
+
const hasDesign = io.exists(`${dir}design.md`);
|
|
193
|
+
const hasTasks = io.exists(`${dir}tasks.md`);
|
|
140
194
|
if (!io.exists(proposal)) {
|
|
141
195
|
push('ERROR', 'proposal.md', 'Change is missing proposal.md.');
|
|
142
196
|
} else {
|
|
143
197
|
const text = io.readText(proposal);
|
|
144
|
-
const
|
|
145
|
-
const fm = fmMatch?.[1] ?? '';
|
|
198
|
+
const fm = extractFrontmatter(text) ?? '';
|
|
146
199
|
const fmLines = fm.split('\n').map((l) => l.trim());
|
|
147
200
|
|
|
148
201
|
for (const key of ['depends_on', 'blocks'] as const) {
|
|
@@ -180,13 +233,7 @@ export function validateChange(
|
|
|
180
233
|
);
|
|
181
234
|
continue;
|
|
182
235
|
}
|
|
183
|
-
|
|
184
|
-
const archived = `${baseDir}/archive`;
|
|
185
|
-
if (
|
|
186
|
-
!io.exists(`${depDir}/proposal.md`) &&
|
|
187
|
-
!io.exists(dir.replace(/\/[^/]+\/$/u, '/archive/')) &&
|
|
188
|
-
!io.listDir(archived).some((n) => n.endsWith(`-${item}`))
|
|
189
|
-
) {
|
|
236
|
+
if (resolveChangeRef(io, baseDir, item) === 'unknown') {
|
|
190
237
|
push(
|
|
191
238
|
'ERROR',
|
|
192
239
|
`proposal.md/frontmatter.${key}`,
|
|
@@ -196,7 +243,7 @@ export function validateChange(
|
|
|
196
243
|
}
|
|
197
244
|
}
|
|
198
245
|
|
|
199
|
-
// Unknown frontmatter field gate (
|
|
246
|
+
// Unknown frontmatter field gate (predecessor).
|
|
200
247
|
for (const l of fmLines) {
|
|
201
248
|
if (l === '' || l.startsWith('-') || l.startsWith('#')) continue;
|
|
202
249
|
const m = l.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:/u);
|
|
@@ -204,22 +251,14 @@ export function validateChange(
|
|
|
204
251
|
push(
|
|
205
252
|
'ERROR',
|
|
206
253
|
'proposal.md/frontmatter',
|
|
207
|
-
`proposal.md frontmatter has unknown field '${m[1]}'; allowed fields are: ${ALLOWED_FIELDS.join(', ')}. Stage is inferred from on-disk artifacts (run \`llman
|
|
254
|
+
`proposal.md frontmatter has unknown field '${m[1]}'; allowed fields are: ${ALLOWED_FIELDS.join(', ')}. Stage is inferred from on-disk artifacts (run \`llman-sdd show\` / \`llman-sdd list\`); do not store lifecycle state in frontmatter.`,
|
|
208
255
|
);
|
|
209
256
|
}
|
|
210
257
|
}
|
|
211
258
|
|
|
212
|
-
const hasDesign = io.exists(`${dir}design.md`);
|
|
213
|
-
const hasTasks = io.exists(`${dir}tasks.md`);
|
|
214
259
|
const binding = readBinding(text);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
? binding !== null
|
|
218
|
-
? 'full'
|
|
219
|
-
: 'planned'
|
|
220
|
-
: hasDesign
|
|
221
|
-
? 'designed'
|
|
222
|
-
: 'draft';
|
|
260
|
+
// stageFor (change/collect) is the monotonic stage SSOT (r34).
|
|
261
|
+
const stage = stageFor(hasDesign, hasTasks, binding !== null);
|
|
223
262
|
|
|
224
263
|
if (hasTasks && !hasDesign) {
|
|
225
264
|
push(
|
|
@@ -230,16 +269,7 @@ export function validateChange(
|
|
|
230
269
|
}
|
|
231
270
|
|
|
232
271
|
if (hasTasks) {
|
|
233
|
-
const
|
|
234
|
-
let total = 0;
|
|
235
|
-
let completed = 0;
|
|
236
|
-
for (const line of tasks.split('\n')) {
|
|
237
|
-
const m = line.match(/^\s*-\s+\[( |x|X)\]/u);
|
|
238
|
-
if (m) {
|
|
239
|
-
total += 1;
|
|
240
|
-
if (m[1] !== ' ') completed += 1;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
272
|
+
const { completed, total, pendingLines } = parseTaskCheckboxes(io.readText(`${dir}tasks.md`));
|
|
243
273
|
if (total > 0 && completed < total) {
|
|
244
274
|
const n = total - completed;
|
|
245
275
|
push(
|
|
@@ -248,31 +278,41 @@ export function validateChange(
|
|
|
248
278
|
`${n} unchecked task(s) in tasks.md`,
|
|
249
279
|
);
|
|
250
280
|
}
|
|
281
|
+
// D9/B23 (active changes only — validate domain): an unchecked task whose
|
|
282
|
+
// title starts with a close-out verb is a pipeline-step pseudo-task; the
|
|
283
|
+
// task gate requires every task checked, so it can never legitimately
|
|
284
|
+
// stay as a task. Match on the leading verb only, so "fix finalize task
|
|
285
|
+
// gate" (a task ABOUT close-out) is not flagged.
|
|
286
|
+
for (const line of closeOutTaskLines(pendingLines)) {
|
|
287
|
+
const title = line.replace(/^-\s+\[ \]\s*/u, '').trim();
|
|
288
|
+
push(
|
|
289
|
+
'WARNING',
|
|
290
|
+
'tasks',
|
|
291
|
+
`task "${title}" looks like a close-out step; ${CLOSE_OUT_TASK_HINT}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
251
294
|
}
|
|
252
295
|
|
|
253
|
-
// completeness INFO (
|
|
296
|
+
// completeness INFO (predecessor surface).
|
|
254
297
|
push('INFO', 'completeness', COMPLETENESS[stage] ?? '');
|
|
255
298
|
|
|
256
|
-
// r63: Full-but-not-ready WARNING with skill guidance (
|
|
299
|
+
// r63: Full-but-not-ready WARNING with skill guidance (predecessor r1 surface).
|
|
257
300
|
if (opts.git !== undefined && binding !== null && stage === 'full') {
|
|
258
|
-
const needs =
|
|
259
|
-
|
|
260
|
-
? text.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1] === 'true'
|
|
261
|
-
: true;
|
|
262
|
-
const touched =
|
|
263
|
-
opts.git.runOpt(['diff', '--name-only', `${binding.baseBranch}...${binding.branch}`]) ?? '';
|
|
264
|
-
const landed = touched.includes('llmanspec/specs/');
|
|
301
|
+
const needs = readNeedsSpecsChange(fm);
|
|
302
|
+
const landed = specsLanded(opts.git, binding);
|
|
265
303
|
if (!landed && needs) {
|
|
266
304
|
push(
|
|
267
305
|
'WARNING',
|
|
268
306
|
'proposal.md',
|
|
269
|
-
`specs not landed: change bound to \`${binding.branch}\` but no changes under \`llmanspec/specs/\` on its bound branch. Edit live specs there and commit (or set \`needs_specs_change
|
|
307
|
+
`specs not landed: change bound to \`${binding.branch}\` but no changes under \`llmanspec/specs/\` on its bound branch. Edit live specs there and commit (or set \`needs_specs_change\` to false in the frontmatter if this change has no live contract edits). Skill: llman-sdd-propose — do NOT re-run change start when already attached; start llman-sdd-apply once the specs-landed gate passes (see \`llman-sdd show <id>\` gateChecks).`,
|
|
270
308
|
);
|
|
271
309
|
}
|
|
272
310
|
}
|
|
273
311
|
}
|
|
274
312
|
|
|
275
|
-
// pattern gate (
|
|
313
|
+
// pattern gate (predecessor change-id path). loadConfig compiles the pattern first
|
|
314
|
+
// on the CLI path; this branch defends direct core callers that build a
|
|
315
|
+
// ChangeCheckConfig without going through loadConfig.
|
|
276
316
|
if (config.change_id_pattern) {
|
|
277
317
|
try {
|
|
278
318
|
const re = new RegExp(config.change_id_pattern, 'u');
|
|
@@ -280,18 +320,20 @@ export function validateChange(
|
|
|
280
320
|
push(
|
|
281
321
|
'ERROR',
|
|
282
322
|
'change-id',
|
|
283
|
-
`Change id '${id}' does not match change_id.pattern '${config.change_id_pattern}' (
|
|
323
|
+
`Change id '${id}' does not match change_id.pattern '${config.change_id_pattern}' (active changes only; archived changes are not re-checked).`,
|
|
284
324
|
);
|
|
285
325
|
}
|
|
286
|
-
} catch {
|
|
287
|
-
|
|
326
|
+
} catch (error) {
|
|
327
|
+
push(
|
|
328
|
+
'ERROR',
|
|
329
|
+
'change-id',
|
|
330
|
+
`change_id.pattern is not a valid regular expression: ${(error as Error).message}`,
|
|
331
|
+
);
|
|
288
332
|
}
|
|
289
333
|
}
|
|
290
334
|
|
|
291
|
-
// stage gate (
|
|
335
|
+
// stage gate (predecessor --stage: artifact presence per gate).
|
|
292
336
|
if (opts.stage !== undefined) {
|
|
293
|
-
const hasDesign = io.exists(`${dir}design.md`);
|
|
294
|
-
const hasTasks = io.exists(`${dir}tasks.md`);
|
|
295
337
|
if (opts.stage === 'designed' && !hasDesign) {
|
|
296
338
|
push('ERROR', 'design.md', `Stage forced to 'designed' but design.md is missing`);
|
|
297
339
|
}
|
|
@@ -320,22 +362,3 @@ const ALLOWED_FIELDS = [
|
|
|
320
362
|
'base_sha',
|
|
321
363
|
'needs_specs_change',
|
|
322
364
|
];
|
|
323
|
-
|
|
324
|
-
export interface PlaceholderTarget {
|
|
325
|
-
featureDir: string;
|
|
326
|
-
featureName: string;
|
|
327
|
-
featurePath: string;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** True when run_command carries any r48 placeholder. */
|
|
331
|
-
export function hasPlaceholders(runCommand: string): boolean {
|
|
332
|
-
return /\{feature_dir\}|\{feature_name\}|\{feature_path\}/u.test(runCommand);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Expand {feature_dir}/{feature_name}/{feature_path} for one target (r48). */
|
|
336
|
-
export function expandRunCommand(runCommand: string, target: PlaceholderTarget): string {
|
|
337
|
-
return runCommand
|
|
338
|
-
.replaceAll('{feature_dir}', target.featureDir)
|
|
339
|
-
.replaceAll('{feature_name}', target.featureName)
|
|
340
|
-
.replaceAll('{feature_path}', target.featurePath);
|
|
341
|
-
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BDD harness execution for validate (validation capability, r13/r48):
|
|
3
|
+
* expands bdd.run_command per capability, executes each expanded command
|
|
4
|
+
* string at most once per validate invocation (batch-once), and maps results
|
|
5
|
+
* to per-capability issues. Pure — the subprocess runs through the injected
|
|
6
|
+
* HarnessRunner; this module never reads the environment or the wall clock
|
|
7
|
+
* (the nested-invocation guard is read by the CLI and passed in as a
|
|
8
|
+
* parameter).
|
|
9
|
+
*/
|
|
10
|
+
import type { ValidationItem } from './validate.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Harness runner port (r13/r48): executes one expanded bdd.run_command
|
|
14
|
+
* string; the CLI adapter owns the subprocess (and the nested-invocation
|
|
15
|
+
* guard env it exports). exitCode is null only when the command could not
|
|
16
|
+
* start (spawnError then carries the reason). Lives here rather than in
|
|
17
|
+
* ports.ts so the validation module gains no new cross-module edge (r72).
|
|
18
|
+
*/
|
|
19
|
+
export interface HarnessRunner {
|
|
20
|
+
run(
|
|
21
|
+
command: string,
|
|
22
|
+
cwd: string,
|
|
23
|
+
): {
|
|
24
|
+
exitCode: number | null;
|
|
25
|
+
output: string;
|
|
26
|
+
spawnError?: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HarnessTarget {
|
|
31
|
+
/** Capability id ({feature_name}). */
|
|
32
|
+
capability: string;
|
|
33
|
+
/** Repo-root-relative path of the capability's main .feature file. */
|
|
34
|
+
featurePath: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Trigger state assembled by the CLI (env guard + flags + config + runner). */
|
|
38
|
+
export interface HarnessGate {
|
|
39
|
+
/** LLMAN_SDD_HARNESS_ACTIVE=1 was set by an enclosing validate invocation. */
|
|
40
|
+
nested: boolean;
|
|
41
|
+
check: 'default' | 'on' | 'off';
|
|
42
|
+
/** Undefined when bdd.run_command is not configured (non-empty string). */
|
|
43
|
+
runner: HarnessRunner | undefined;
|
|
44
|
+
runCommand: string | null;
|
|
45
|
+
/** Project root — the cwd every harness subprocess runs in. */
|
|
46
|
+
cwd: string;
|
|
47
|
+
/** CLI emits its one-line stderr banner through this hook before the first execution. */
|
|
48
|
+
onBeforeFirstRun?: (expanded: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface HarnessRunOutcome {
|
|
52
|
+
/** Harness issues per capability, in target order. */
|
|
53
|
+
issuesByCapability: Map<string, ValidationItem[]>;
|
|
54
|
+
/** True when at least one command actually executed in this call. */
|
|
55
|
+
executed: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Plain-text placeholder expansion — capability ids are kebab-constrained. */
|
|
59
|
+
export function expandRunCommand(command: string, target: HarnessTarget): string {
|
|
60
|
+
const dirEnd = target.featurePath.lastIndexOf('/');
|
|
61
|
+
const featureDir = dirEnd === -1 ? target.featurePath : target.featurePath.slice(0, dirEnd);
|
|
62
|
+
return command
|
|
63
|
+
.replaceAll('{feature_path}', target.featurePath)
|
|
64
|
+
.replaceAll('{feature_dir}', featureDir)
|
|
65
|
+
.replaceAll('{feature_name}', target.capability);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface CacheEntry {
|
|
69
|
+
success: boolean;
|
|
70
|
+
/** First ERROR message (≤200 chars) so a cached failure points at the cause. */
|
|
71
|
+
failureSummary: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const OUTPUT_TAIL = 200;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Trigger matrix (r13): --no-check skips silently; a nested invocation skips
|
|
78
|
+
* with a per-spec INFO; an explicit --check without a configured run_command
|
|
79
|
+
* yields a single INFO on the first spec; otherwise every expanded command
|
|
80
|
+
* executes at most once (cache keyed by the expanded string).
|
|
81
|
+
*/
|
|
82
|
+
export function runHarnessForSpecs(
|
|
83
|
+
targets: readonly HarnessTarget[],
|
|
84
|
+
gate: HarnessGate,
|
|
85
|
+
): HarnessRunOutcome {
|
|
86
|
+
const issuesByCapability = new Map<string, ValidationItem[]>();
|
|
87
|
+
if (gate.check === 'off') return { issuesByCapability, executed: false };
|
|
88
|
+
if (gate.nested) {
|
|
89
|
+
for (const target of targets) {
|
|
90
|
+
issuesByCapability.set(target.capability, [
|
|
91
|
+
{
|
|
92
|
+
level: 'INFO',
|
|
93
|
+
id: target.featurePath,
|
|
94
|
+
message: 'bdd harness skipped: nested invocation',
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
return { issuesByCapability, executed: false };
|
|
99
|
+
}
|
|
100
|
+
if (gate.runner === undefined || gate.runCommand === null || gate.runCommand === '') {
|
|
101
|
+
if (gate.check === 'on' && targets.length > 0) {
|
|
102
|
+
const first = targets[0] as HarnessTarget;
|
|
103
|
+
issuesByCapability.set(first.capability, [
|
|
104
|
+
{
|
|
105
|
+
level: 'INFO',
|
|
106
|
+
id: first.featurePath,
|
|
107
|
+
message: '--check has no effect: bdd.run_command is not configured',
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
return { issuesByCapability, executed: false };
|
|
112
|
+
}
|
|
113
|
+
const cache = new Map<string, CacheEntry>();
|
|
114
|
+
let executed = false;
|
|
115
|
+
let announced = false;
|
|
116
|
+
for (const target of targets) {
|
|
117
|
+
const expanded = expandRunCommand(gate.runCommand, target);
|
|
118
|
+
const cached = cache.get(expanded);
|
|
119
|
+
let issues: ValidationItem[];
|
|
120
|
+
if (cached !== undefined) {
|
|
121
|
+
issues = cached.success
|
|
122
|
+
? [
|
|
123
|
+
{
|
|
124
|
+
level: 'INFO',
|
|
125
|
+
id: target.featurePath,
|
|
126
|
+
message: `bdd harness passed (cached): ${expanded}`,
|
|
127
|
+
},
|
|
128
|
+
]
|
|
129
|
+
: [
|
|
130
|
+
{
|
|
131
|
+
level: 'ERROR',
|
|
132
|
+
id: target.featurePath,
|
|
133
|
+
message: `bdd harness failed (cached result of ${expanded}): ${cached.failureSummary}`,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
} else {
|
|
137
|
+
if (!announced) {
|
|
138
|
+
gate.onBeforeFirstRun?.(expanded);
|
|
139
|
+
announced = true;
|
|
140
|
+
}
|
|
141
|
+
executed = true;
|
|
142
|
+
const result = gate.runner.run(expanded, gate.cwd);
|
|
143
|
+
const firstError =
|
|
144
|
+
result.spawnError !== undefined
|
|
145
|
+
? `bdd harness could not start: ${expanded}: ${result.spawnError}`
|
|
146
|
+
: result.exitCode === 0
|
|
147
|
+
? null
|
|
148
|
+
: `bdd harness failed (exit ${result.exitCode}): ${expanded}: ${result.output.slice(-OUTPUT_TAIL)}`;
|
|
149
|
+
issues =
|
|
150
|
+
firstError === null
|
|
151
|
+
? [{ level: 'INFO', id: target.featurePath, message: `bdd harness passed: ${expanded}` }]
|
|
152
|
+
: [{ level: 'ERROR', id: target.featurePath, message: firstError }];
|
|
153
|
+
cache.set(expanded, {
|
|
154
|
+
success: firstError === null,
|
|
155
|
+
failureSummary: firstError === null ? null : firstError.slice(0, OUTPUT_TAIL),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
issuesByCapability.set(target.capability, issues);
|
|
159
|
+
}
|
|
160
|
+
return { issuesByCapability, executed };
|
|
161
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Staleness evaluator (validation/staleness parity):
|
|
2
|
+
* Staleness evaluator (validation/staleness parity): predecessor `sdd/spec/staleness.rs`
|
|
3
3
|
* observable contract — status/baseRef/scope/touchedPaths/specUpdated/dirty/
|
|
4
4
|
* notes plus per-capability staleness issues. Pure: git + env are injected.
|
|
5
5
|
*/
|
|
@@ -62,9 +62,9 @@ const BASE_MISSING_MSG =
|
|
|
62
62
|
"Note: Unable to resolve base ref for staleness check. Set LLMANSPEC_BASE_REF (e.g. 'main' or 'origin/main'), or upgrade llman if below 0.0.60.";
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* Evaluate staleness for one capability spec —
|
|
65
|
+
* Evaluate staleness for one capability spec — predecessor status semantics.
|
|
66
66
|
* Returns the info object plus warning/info issues (strict escalation is the
|
|
67
|
-
* caller's job, matching
|
|
67
|
+
* caller's job, matching predecessor `apply_strict`).
|
|
68
68
|
*/
|
|
69
69
|
export function evaluateStaleness(deps: StalenessDeps): {
|
|
70
70
|
info: StalenessInfo;
|
|
@@ -125,7 +125,7 @@ export function evaluateStaleness(deps: StalenessDeps): {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// Tolerant: outside a git repo (or on git failure) treat as dirty (
|
|
128
|
+
// Tolerant: outside a git repo (or on git failure) treat as dirty (predecessor unwrap_or(true)).
|
|
129
129
|
const dirty = (git.runOpt(['status', '--porcelain']) ?? 'dirty') !== '';
|
|
130
130
|
if (dirty) {
|
|
131
131
|
if (status === 'OK' || status === 'STALE') status = 'INFO';
|
|
@@ -143,13 +143,17 @@ export function evaluateStaleness(deps: StalenessDeps): {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
const STALE_MSG =
|
|
146
|
+
const STALE_MSG =
|
|
147
|
+
"Note: Code in this spec's scope changed on this branch but the spec was not updated; re-review the spec.";
|
|
147
148
|
const SPEC_UPDATED_MSG = 'Note: Spec updated on this branch.';
|
|
148
149
|
|
|
149
150
|
function revParse(git: GitLike, ref: string): string | null {
|
|
150
151
|
return git.runOpt(['rev-parse', '--verify', '--quiet', ref]) ?? null;
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
// Deliberately NOT shared with defaultBranch() in git/spawnGit.ts — this
|
|
155
|
+
// predecessor staleness probe is local-only (main → master, 'main' fallback, no origin
|
|
156
|
+
// consultation). Do not merge the two.
|
|
153
157
|
function defaultBranchNameFn(git: GitLike): string {
|
|
154
158
|
if (git.runOpt(['show-ref', '--verify', '--quiet', 'refs/heads/main']) !== null) return 'main';
|
|
155
159
|
if (git.runOpt(['show-ref', '--verify', '--quiet', 'refs/heads/master']) !== null)
|