@llman-sdd/core 0.1.2 → 0.1.3
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 +1 -1
- package/src/archive/sevenzip.ts +1 -1
- package/src/context/indexStore.ts +6 -12
- package/src/git/spawnGit.ts +0 -8
- package/src/index.ts +32 -4
- package/src/init/defaultConfig.ts +2 -2
- package/src/init/init.ts +46 -56
- package/src/report/collect.ts +3 -3
- package/src/report/graph.ts +22 -34
- package/src/report/show.ts +14 -37
- package/src/report/specs.ts +19 -19
- package/src/review/review.ts +34 -9
- package/src/spec/ir.ts +7 -0
- package/src/spec/parser.ts +9 -4
- package/src/templates/embedded.ts +67 -0
- package/src/templates/engine.ts +3 -2
- package/src/validation/validate.ts +3 -5
- package/templates/en/agents-root-stub.md +1 -1
- package/templates/en/skills/llman-sdd-apply-cycle.md +7 -7
- package/templates/en/skills/llman-sdd-apply.md +12 -12
- package/templates/en/skills/llman-sdd-arch-review.md +2 -2
- package/templates/en/skills/llman-sdd-archive.md +12 -12
- package/templates/en/skills/llman-sdd-continue.md +8 -8
- package/templates/en/skills/llman-sdd-draft.md +5 -5
- package/templates/en/skills/llman-sdd-explore.md +5 -5
- package/templates/en/skills/llman-sdd-ff.md +7 -7
- package/templates/en/skills/llman-sdd-graph.md +10 -10
- package/templates/en/skills/llman-sdd-onboard.md +7 -7
- package/templates/en/skills/llman-sdd-propose.md +14 -14
- package/templates/en/skills/llman-sdd-quick.md +6 -6
- package/templates/en/skills/llman-sdd-research.md +2 -2
- package/templates/en/skills/llman-sdd-show.md +4 -4
- package/templates/en/skills/llman-sdd-specs-compact.md +6 -6
- package/templates/en/skills/llman-sdd-validate.md +5 -5
- package/templates/en/skills/llman-sdd-verify.md +7 -7
- package/templates/en/skills/llman-sdd-wayfinder.md +7 -7
- package/templates/en/units/migrate-prompt.md +5 -5
- package/templates/en/units/skills/ethics-governance.md +1 -1
- package/templates/en/units/skills/git-native-flow.md +2 -2
- package/templates/en/units/skills/stage-guard.md +1 -1
- package/templates/en/units/skills/structured-protocol.md +5 -5
- package/templates/en/units/skills/validation-hints.md +2 -2
- package/templates/en/units/workflow/archive-freeze-guidance.md +3 -3
- package/templates/shared/review.html +2 -2
- package/templates/zh-Hans/agents-root-stub.md +1 -1
- package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +7 -7
- package/templates/zh-Hans/skills/llman-sdd-apply.md +12 -12
- package/templates/zh-Hans/skills/llman-sdd-arch-review.md +2 -2
- package/templates/zh-Hans/skills/llman-sdd-archive.md +12 -12
- package/templates/zh-Hans/skills/llman-sdd-continue.md +8 -8
- package/templates/zh-Hans/skills/llman-sdd-draft.md +5 -5
- package/templates/zh-Hans/skills/llman-sdd-explore.md +5 -5
- package/templates/zh-Hans/skills/llman-sdd-ff.md +7 -7
- package/templates/zh-Hans/skills/llman-sdd-graph.md +10 -10
- package/templates/zh-Hans/skills/llman-sdd-onboard.md +7 -7
- package/templates/zh-Hans/skills/llman-sdd-propose.md +14 -14
- package/templates/zh-Hans/skills/llman-sdd-quick.md +6 -6
- package/templates/zh-Hans/skills/llman-sdd-research.md +2 -2
- package/templates/zh-Hans/skills/llman-sdd-show.md +4 -4
- package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +6 -6
- package/templates/zh-Hans/skills/llman-sdd-validate.md +5 -5
- package/templates/zh-Hans/skills/llman-sdd-verify.md +7 -7
- package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +7 -7
- package/templates/zh-Hans/units/migrate-prompt.md +5 -5
- package/templates/zh-Hans/units/skills/ethics-governance.md +1 -1
- package/templates/zh-Hans/units/skills/git-native-flow.md +2 -2
- package/templates/zh-Hans/units/skills/stage-guard.md +1 -1
- package/templates/zh-Hans/units/skills/structured-protocol.md +5 -5
- package/templates/zh-Hans/units/skills/validation-hints.md +2 -2
- package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +3 -3
package/package.json
CHANGED
package/src/archive/sevenzip.ts
CHANGED
|
@@ -61,7 +61,7 @@ function parseListNames(lines: string[]): string[] {
|
|
|
61
61
|
for (const line of lines) {
|
|
62
62
|
if (/^-+/u.test(line)) {
|
|
63
63
|
rulesSeen += 1;
|
|
64
|
-
|
|
64
|
+
if (rulesSeen === 2) inTable = true;
|
|
65
65
|
if (rulesSeen === 3) break;
|
|
66
66
|
continue;
|
|
67
67
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Index orchestration (context-index capability, r26): rebuild writes the
|
|
3
3
|
* pageindex tree under a create-new lock; check compares the stored spec_hash
|
|
4
|
-
* against a fresh one.
|
|
4
|
+
* against a fresh one. All paths are ROOT-RELATIVE through the injected IO.
|
|
5
5
|
*/
|
|
6
|
-
import { join } from 'node:path';
|
|
7
|
-
|
|
8
6
|
import type { SpecEntry } from '../validation/validate.ts';
|
|
9
|
-
import { buildTreeIndex, computeSpecHash, type
|
|
7
|
+
import { buildTreeIndex, computeSpecHash, type SerializedTreeIndex } from './tree.ts';
|
|
10
8
|
|
|
11
9
|
export const CONTEXT_DIR_REL = 'llmanspec/.context';
|
|
12
10
|
export const PAGEINDEX_DIR_REL = `${CONTEXT_DIR_REL}/pageindex`;
|
|
@@ -61,7 +59,6 @@ function acquireLock(io: IndexIo, lockPath: string): void {
|
|
|
61
59
|
|
|
62
60
|
export function rebuildIndex(
|
|
63
61
|
io: IndexIo,
|
|
64
|
-
root: string,
|
|
65
62
|
specsDir: string,
|
|
66
63
|
entries: readonly SpecEntry[],
|
|
67
64
|
opts: RebuildOpts,
|
|
@@ -95,7 +92,7 @@ export interface FreshnessResult {
|
|
|
95
92
|
lines: string[];
|
|
96
93
|
}
|
|
97
94
|
|
|
98
|
-
export function checkIndexFreshness(io: IndexIo,
|
|
95
|
+
export function checkIndexFreshness(io: IndexIo, specsDir: string): FreshnessResult {
|
|
99
96
|
if (!io.exists(TREE_JSON_REL)) {
|
|
100
97
|
return { fresh: false, lines: ['[pageindex] missing — run `index rebuild` first'] };
|
|
101
98
|
}
|
|
@@ -119,14 +116,11 @@ export function checkIndexFreshness(io: IndexIo, root: string, specsDir: string)
|
|
|
119
116
|
}
|
|
120
117
|
|
|
121
118
|
/** Load the tree for retrieval (absent/corrupt → null). */
|
|
122
|
-
export function loadTree(io: IndexIo
|
|
123
|
-
|
|
124
|
-
if (!io.exists(path)) return null;
|
|
119
|
+
export function loadTree(io: IndexIo): SerializedTreeIndex | null {
|
|
120
|
+
if (!io.exists(TREE_JSON_REL)) return null;
|
|
125
121
|
try {
|
|
126
|
-
return JSON.parse(io.readText(
|
|
122
|
+
return JSON.parse(io.readText(TREE_JSON_REL)) as SerializedTreeIndex;
|
|
127
123
|
} catch {
|
|
128
124
|
return null;
|
|
129
125
|
}
|
|
130
126
|
}
|
|
131
|
-
|
|
132
|
-
export type { HashIo };
|
package/src/git/spawnGit.ts
CHANGED
|
@@ -76,14 +76,6 @@ export function defaultBranch(git: GitLike): string {
|
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
export function mergeBase(a: string, b: string, git: GitLike): string {
|
|
80
|
-
return git.run(['merge-base', a, b]);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function branchExists(branch: string, git: GitLike): boolean {
|
|
84
|
-
return git.runOpt(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]) !== null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
79
|
export function revParseHead(git: GitLike): string {
|
|
88
80
|
return git.run(['rev-parse', 'HEAD']);
|
|
89
81
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
// 版本 SSOT 是包描述文件(发布产物)/git tag(二进制,经 LLMAN_SDD_VERSION 注入)。
|
|
2
|
+
// 运行时从所在包的 package.json 读取,避免双处维护。单文件二进制内
|
|
3
|
+
// import.meta.url 指向 $bunfs 虚拟路径,package.json 不存在——此处必须容错,
|
|
4
|
+
// 版本由构建期 define 注入,回退值不会对外暴露。
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
function readPackageVersion(): string {
|
|
9
|
+
try {
|
|
10
|
+
const pkg = JSON.parse(
|
|
11
|
+
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
|
|
12
|
+
) as { version: string };
|
|
13
|
+
return pkg.version;
|
|
14
|
+
} catch {
|
|
15
|
+
return '0.0.0';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export const VERSION = readPackageVersion();
|
|
2
19
|
|
|
3
20
|
export * from './ports.ts';
|
|
4
21
|
|
|
@@ -48,9 +65,7 @@ export {
|
|
|
48
65
|
defaultBranch,
|
|
49
66
|
isCleanTree,
|
|
50
67
|
makeSpawnGit,
|
|
51
|
-
mergeBase,
|
|
52
68
|
currentBranch,
|
|
53
|
-
branchExists,
|
|
54
69
|
revParseHead,
|
|
55
70
|
type GitLike,
|
|
56
71
|
} from './git/spawnGit.ts';
|
|
@@ -89,7 +104,19 @@ export {
|
|
|
89
104
|
type SkillTemplate,
|
|
90
105
|
type TemplateIo,
|
|
91
106
|
} from './templates/skills.ts';
|
|
92
|
-
export {
|
|
107
|
+
export {
|
|
108
|
+
embeddedTemplates,
|
|
109
|
+
makeEmbeddedTemplateIo,
|
|
110
|
+
resolveEmbeddedTable,
|
|
111
|
+
templateKeyFor,
|
|
112
|
+
} from './templates/embedded.ts';
|
|
113
|
+
export {
|
|
114
|
+
TEMPLATES_ROOT,
|
|
115
|
+
runInit,
|
|
116
|
+
updateFileWithMarkers,
|
|
117
|
+
type InitIo,
|
|
118
|
+
type InitResult,
|
|
119
|
+
} from './init/init.ts';
|
|
93
120
|
export {
|
|
94
121
|
collectChanges,
|
|
95
122
|
renderChangesList,
|
|
@@ -132,6 +159,7 @@ export {
|
|
|
132
159
|
} from './archive/freeze.ts';
|
|
133
160
|
export {
|
|
134
161
|
buildReview,
|
|
162
|
+
renderReviewHtml,
|
|
135
163
|
type ReviewInput,
|
|
136
164
|
type ReviewKind,
|
|
137
165
|
type ReviewResult,
|
|
@@ -9,7 +9,7 @@ export const DEFAULT_CONFIG_EN = `schema: spec-driven
|
|
|
9
9
|
locale: en
|
|
10
10
|
|
|
11
11
|
# Optional extra skills (disabled by default, uncomment to enable).
|
|
12
|
-
# On \`llman
|
|
12
|
+
# On \`llman-sdd init --update\`, managed candidates are:
|
|
13
13
|
# default workflow skills + entries listed here (extra_skills extend).
|
|
14
14
|
# Then \`.agents/skills/llman-sdd-*\` is scanned: anything not in that candidate
|
|
15
15
|
# set is removed first, then candidates are written/updated. Only the
|
|
@@ -40,7 +40,7 @@ export const DEFAULT_CONFIG_ZH_HANS = `schema: spec-driven
|
|
|
40
40
|
locale: zh-Hans
|
|
41
41
|
|
|
42
42
|
# 可选额外技能(默认禁用,取消注释以启用)。
|
|
43
|
-
# 运行 \`llman
|
|
43
|
+
# 运行 \`llman-sdd init --update\` 时,管理候选集为:
|
|
44
44
|
# 默认 workflow 技能 + 本列表(extra_skills 扩展)。
|
|
45
45
|
# 然后扫描 \`.agents/skills/llman-sdd-*\`:不在候选集中的先删除,再写入/更新候选。
|
|
46
46
|
# 仅处理 \`llman-sdd-\` 前缀(无此前缀的自定义技能不会被删)。
|
package/src/init/init.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* init / --update orchestration (init-generators capability, r19).
|
|
3
3
|
* Scaffold llmanspec/, write managed AGENTS.md blocks, render skills into
|
|
4
4
|
* the .agents/skills namespace (SKILL.md per skill dir), and clean the
|
|
5
|
-
* managed namespace.
|
|
5
|
+
* managed namespace. Pure: all paths are ROOT-RELATIVE strings resolved by
|
|
6
|
+
* the injected InitIo; template resources load through the injected
|
|
7
|
+
* TemplateIo (runtimes point it at TEMPLATES_ROOT).
|
|
6
8
|
*/
|
|
7
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
9
|
import { join } from 'node:path';
|
|
9
10
|
|
|
10
11
|
import { loadConfig } from '../config/load.ts';
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
loadLocaleResource,
|
|
17
18
|
loadSkillTemplates,
|
|
18
19
|
skillCandidates,
|
|
20
|
+
type TemplateIo,
|
|
19
21
|
} from '../templates/skills.ts';
|
|
20
22
|
import {
|
|
21
23
|
DEFAULT_CONFIG_EN,
|
|
@@ -46,11 +48,20 @@ export function updateFileWithMarkers(content: string, body: string): string {
|
|
|
46
48
|
|
|
47
49
|
export const TEMPLATES_ROOT = join(import.meta.dirname, '..', '..', 'templates');
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
/** Repo-root-relative filesystem effects needed by the init flow. */
|
|
52
|
+
export interface InitIo {
|
|
53
|
+
exists(path: string): boolean;
|
|
54
|
+
readText(path: string): string;
|
|
55
|
+
writeText(path: string, content: string): void;
|
|
56
|
+
mkdirp(path: string): void;
|
|
57
|
+
listDir(path: string): string[];
|
|
58
|
+
removeDir(path: string): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeDefaultConfig(io: InitIo, locale: string): void {
|
|
50
62
|
const raw = locale === 'zh-Hans' ? DEFAULT_CONFIG_ZH_HANS : DEFAULT_CONFIG_EN;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
writeFileSync(join(dir, 'config.yaml'), prependSchemaHeader(raw, LLMANSPEC_SCHEMA_URL));
|
|
63
|
+
io.mkdirp('llmanspec');
|
|
64
|
+
io.writeText('llmanspec/config.yaml', prependSchemaHeader(raw, LLMANSPEC_SCHEMA_URL));
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
export interface InitResult {
|
|
@@ -61,84 +72,63 @@ export interface InitResult {
|
|
|
61
72
|
configPath: string;
|
|
62
73
|
}
|
|
63
74
|
|
|
75
|
+
const SKILLS_BASE = '.agents/skills';
|
|
76
|
+
|
|
64
77
|
export function runInit(
|
|
65
|
-
|
|
78
|
+
io: InitIo,
|
|
79
|
+
templates: TemplateIo,
|
|
66
80
|
opts: { update: boolean; locale?: string; version: string },
|
|
67
81
|
): InitResult {
|
|
68
|
-
|
|
69
|
-
mkdirSync(llmanspecDir, { recursive: true });
|
|
82
|
+
io.mkdirp('llmanspec');
|
|
70
83
|
|
|
71
84
|
// 1) config: create with defaults if missing, then always load.
|
|
72
|
-
const configPath =
|
|
73
|
-
if (!
|
|
74
|
-
const config = loadConfig(
|
|
85
|
+
const configPath = 'llmanspec/config.yaml';
|
|
86
|
+
if (!io.exists(configPath)) writeDefaultConfig(io, opts.locale ?? 'en');
|
|
87
|
+
const config = loadConfig(io.readText(configPath));
|
|
75
88
|
|
|
76
89
|
// 2) scaffold directories.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (!
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
io.mkdirp('llmanspec/specs');
|
|
91
|
+
io.mkdirp('llmanspec/changes/archive');
|
|
92
|
+
if (!io.exists('llmanspec/specs/.gitkeep')) io.writeText('llmanspec/specs/.gitkeep', '');
|
|
93
|
+
if (!io.exists('llmanspec/changes/archive/.gitkeep')) {
|
|
94
|
+
io.writeText('llmanspec/changes/archive/.gitkeep', '');
|
|
95
|
+
}
|
|
83
96
|
|
|
84
97
|
// 3) AGENTS.md managed blocks (root + llmanspec), preserving content.
|
|
85
98
|
const vars = buildTemplateVars(config, opts.version);
|
|
86
99
|
const locales = localeFallbacks(config.locale);
|
|
87
|
-
|
|
88
|
-
const skillTemplates = loadSkillTemplates(
|
|
89
|
-
{ exists: (p) => existsSync(p), readText: (p) => readFileSync(p, 'utf8') },
|
|
90
|
-
TEMPLATES_ROOT,
|
|
91
|
-
config,
|
|
92
|
-
vars,
|
|
93
|
-
);
|
|
100
|
+
const skillTemplates = loadSkillTemplates(templates, TEMPLATES_ROOT, config, vars);
|
|
94
101
|
enforceEthicsGovernance(skillTemplates);
|
|
95
102
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
const body = renderTemplate(rootStubRaw, new Map(), vars);
|
|
106
|
-
writeFileSync(agentsPath, updateFileWithMarkers(existing, body));
|
|
107
|
-
}
|
|
108
|
-
const llmanspecStubRaw = loadLocaleResource(
|
|
109
|
-
{ exists: (p) => existsSync(p), readText: (p) => readFileSync(p, 'utf8') },
|
|
110
|
-
TEMPLATES_ROOT,
|
|
111
|
-
locales,
|
|
112
|
-
'llmanspec-agents-stub.md',
|
|
113
|
-
);
|
|
114
|
-
if (llmanspecStubRaw !== null) {
|
|
115
|
-
const agentsPath = join(llmanspecDir, 'AGENTS.md');
|
|
116
|
-
const existing = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : '';
|
|
117
|
-
const body = renderTemplate(llmanspecStubRaw, new Map(), vars);
|
|
118
|
-
writeFileSync(agentsPath, updateFileWithMarkers(existing, body));
|
|
103
|
+
for (const [stubPath, agentsPath] of [
|
|
104
|
+
['agents-root-stub.md', 'AGENTS.md'],
|
|
105
|
+
['llmanspec-agents-stub.md', 'llmanspec/AGENTS.md'],
|
|
106
|
+
] as const) {
|
|
107
|
+
const stubRaw = loadLocaleResource(templates, TEMPLATES_ROOT, locales, stubPath);
|
|
108
|
+
if (stubRaw === null) continue;
|
|
109
|
+
const existing = io.exists(agentsPath) ? io.readText(agentsPath) : '';
|
|
110
|
+
const body = renderTemplate(stubRaw, new Map(), vars);
|
|
111
|
+
io.writeText(agentsPath, updateFileWithMarkers(existing, body));
|
|
119
112
|
}
|
|
120
113
|
|
|
121
114
|
// 4) skills namespace cleanup (--update only): remove llman-sdd-* dirs
|
|
122
115
|
// outside the candidate set; un-prefixed custom skills stay untouched.
|
|
123
116
|
const removed: string[] = [];
|
|
124
|
-
|
|
125
|
-
if (opts.update && existsSync(skillsBase)) {
|
|
117
|
+
if (opts.update && io.exists(SKILLS_BASE)) {
|
|
126
118
|
const candidates = new Set(skillCandidates(config).map((f) => f.replace(/\.md$/u, '')));
|
|
127
|
-
for (const entry of
|
|
119
|
+
for (const entry of io.listDir(SKILLS_BASE)) {
|
|
128
120
|
if (entry.startsWith('llman-sdd-') && !candidates.has(entry)) {
|
|
129
|
-
|
|
121
|
+
io.removeDir(`${SKILLS_BASE}/${entry}`);
|
|
130
122
|
removed.push(entry);
|
|
131
123
|
}
|
|
132
124
|
}
|
|
133
125
|
}
|
|
134
126
|
|
|
135
127
|
// 5) write candidates: rendered product trimmed + single trailing newline.
|
|
136
|
-
const skillsBase2 = join(root, '.agents', 'skills');
|
|
137
128
|
for (const t of skillTemplates) {
|
|
138
129
|
const dirName = t.name.replace(/\.md$/u, '');
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
writeFileSync(join(skillDir, 'SKILL.md'), `${t.content.trimEnd()}\n`);
|
|
130
|
+
io.mkdirp(`${SKILLS_BASE}/${dirName}`);
|
|
131
|
+
io.writeText(`${SKILLS_BASE}/${dirName}/SKILL.md`, `${t.content.trimEnd()}\n`);
|
|
142
132
|
}
|
|
143
133
|
|
|
144
134
|
return { skills: skillTemplates.map((t) => t.name.replace(/\.md$/u, '')), removed, configPath };
|
package/src/report/collect.ts
CHANGED
|
@@ -43,7 +43,7 @@ export function stageFor(
|
|
|
43
43
|
return 'draft';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function countTasks(tasksMd: string): { completed: number; total: number } {
|
|
46
|
+
export function countTasks(tasksMd: string): { completed: number; total: number } {
|
|
47
47
|
let completed = 0;
|
|
48
48
|
let total = 0;
|
|
49
49
|
for (const line of tasksMd.split('\n')) {
|
|
@@ -56,7 +56,7 @@ function countTasks(tasksMd: string): { completed: number; total: number } {
|
|
|
56
56
|
return { completed, total };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function firstH1(md: string): string {
|
|
59
|
+
export function firstH1(md: string): string {
|
|
60
60
|
for (const line of md.split('\n')) {
|
|
61
61
|
const m = line.match(/^#\s+(.*)$/u);
|
|
62
62
|
if (m?.[1]) return m[1].trim();
|
|
@@ -123,7 +123,7 @@ export function relativeTime(from: Date, now: Date): string {
|
|
|
123
123
|
return `${Math.floor(hours / 24)}d ago`;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
function pad(s: string, width: number): string {
|
|
126
|
+
export function pad(s: string, width: number): string {
|
|
127
127
|
return s.length >= width ? s : s + ' '.repeat(width - s.length);
|
|
128
128
|
}
|
|
129
129
|
|
package/src/report/graph.ts
CHANGED
|
@@ -18,6 +18,26 @@ interface GraphNode {
|
|
|
18
18
|
dependsOn: string[];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Extract depends_on entries from a proposal's YAML frontmatter. */
|
|
22
|
+
function parseDeps(proposal: string): string[] {
|
|
23
|
+
const fm = proposal.match(/^---\n([\s\S]*?)\n---/u);
|
|
24
|
+
const deps: string[] = [];
|
|
25
|
+
if (!fm?.[1]) return deps;
|
|
26
|
+
let inDeps = false;
|
|
27
|
+
for (const line of fm[1].split('\n')) {
|
|
28
|
+
if (/^depends_on:\s*$/u.test(line)) {
|
|
29
|
+
inDeps = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (inDeps) {
|
|
33
|
+
const m = line.match(/^\s*-\s+(\S+)/u);
|
|
34
|
+
if (m?.[1]) deps.push(m[1]);
|
|
35
|
+
else if (line.trim() !== '') inDeps = false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return deps;
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
function collectNodes(io: GraphFsIo, root: string): GraphNode[] {
|
|
22
42
|
const nodes: GraphNode[] = [];
|
|
23
43
|
const scan = (dir: string, archived: boolean, stripDatePrefix: boolean): void => {
|
|
@@ -29,24 +49,7 @@ function collectNodes(io: GraphFsIo, root: string): GraphNode[] {
|
|
|
29
49
|
const proposal = `${full}/proposal.md`;
|
|
30
50
|
if (!io.exists(proposal)) continue;
|
|
31
51
|
const id = archived && stripDatePrefix ? name.replace(/^\d{4}-\d{2}-\d{2}-/u, '') : name;
|
|
32
|
-
|
|
33
|
-
const fm = io.readText(proposal).match(/^---\n([\s\S]*?)\n---/u);
|
|
34
|
-
if (fm?.[1]) {
|
|
35
|
-
const lines = fm[1].split('\n');
|
|
36
|
-
let inDeps = false;
|
|
37
|
-
for (const line of lines) {
|
|
38
|
-
if (/^depends_on:\s*$/u.test(line)) {
|
|
39
|
-
inDeps = true;
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
if (inDeps) {
|
|
43
|
-
const m = line.match(/^\s*-\s+(\S+)/u);
|
|
44
|
-
if (m?.[1]) deps.push(m[1]);
|
|
45
|
-
else if (line.trim() !== '') inDeps = false;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
nodes.push({ id, archived, dependsOn: deps });
|
|
52
|
+
nodes.push({ id, archived, dependsOn: parseDeps(io.readText(proposal)) });
|
|
50
53
|
}
|
|
51
54
|
};
|
|
52
55
|
scan(`${root}/${CHANGES_DIR}`, false, false);
|
|
@@ -59,23 +62,8 @@ function collectNodes(io: GraphFsIo, root: string): GraphNode[] {
|
|
|
59
62
|
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/u, '');
|
|
60
63
|
if (!referenced.has(id)) continue;
|
|
61
64
|
// archived nodes keep their real depends_on — v1 emits their edges too
|
|
62
|
-
const deps: string[] = [];
|
|
63
65
|
const proposal = `${archiveDir}/${name}/proposal.md`;
|
|
64
|
-
|
|
65
|
-
const fm = io.readText(proposal).match(/^---\n([\s\S]*?)\n---/u);
|
|
66
|
-
let inDeps = false;
|
|
67
|
-
for (const line of fm?.[1]?.split('\n') ?? []) {
|
|
68
|
-
if (/^depends_on:\s*$/u.test(line)) {
|
|
69
|
-
inDeps = true;
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (inDeps) {
|
|
73
|
-
const m = line.match(/^\s*-\s+(\S+)/u);
|
|
74
|
-
if (m?.[1]) deps.push(m[1]);
|
|
75
|
-
else if (line.trim() !== '') inDeps = false;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
66
|
+
const deps = io.exists(proposal) ? parseDeps(io.readText(proposal)) : [];
|
|
79
67
|
nodes.push({ id, archived: true, dependsOn: deps });
|
|
80
68
|
}
|
|
81
69
|
}
|
package/src/report/show.ts
CHANGED
|
@@ -1,53 +1,34 @@
|
|
|
1
1
|
import { readBinding } from '../change/frontmatter.ts';
|
|
2
2
|
import { CHANGES_DIR } from '../change/lifecycle.ts';
|
|
3
|
-
import {
|
|
4
|
-
import { discoverSpecs
|
|
3
|
+
import { currentBranch, isCleanTree, type GitLike } from '../git/spawnGit.ts';
|
|
4
|
+
import { discoverSpecs } from '../validation/discover.ts';
|
|
5
5
|
/**
|
|
6
6
|
* show change JSON (peripheral-commands capability, r21): v1 field set and
|
|
7
7
|
* gate structure (clean-tree / on-bound-branch / stage-complete /
|
|
8
8
|
* specs-landed / tasks-done / validate). Object keys are inserted in v1's
|
|
9
|
-
* order; consumers compare structurally.
|
|
9
|
+
* order; consumers compare structurally. Effects flow through the injected
|
|
10
|
+
* FsIo and GitLike ports — this module stays pure.
|
|
10
11
|
*/
|
|
11
12
|
import { validateAllSpecs } from '../validation/validate.ts';
|
|
12
|
-
import { stageFor, type ChangeFsIo } from './collect.ts';
|
|
13
|
+
import { countTasks, firstH1, stageFor, type ChangeFsIo } from './collect.ts';
|
|
13
14
|
|
|
14
|
-
export
|
|
15
|
-
readText(path: string): string;
|
|
16
|
-
}
|
|
15
|
+
export type ShowFsIo = ChangeFsIo;
|
|
17
16
|
|
|
18
17
|
export interface ShowDeps {
|
|
18
|
+
/** Single IO for change docs, tasks, and spec scope checks. */
|
|
19
19
|
io: ShowFsIo;
|
|
20
|
-
|
|
20
|
+
git: GitLike;
|
|
21
21
|
root: string;
|
|
22
22
|
specsDir: string;
|
|
23
|
-
now: Date;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function parseTasks(md: string): { completed: number; total: number } {
|
|
27
|
-
let completed = 0;
|
|
28
|
-
let total = 0;
|
|
29
|
-
for (const line of md.split('\n')) {
|
|
30
|
-
const m = line.match(/^\s*-\s+\[( |x|X)\]/u);
|
|
31
|
-
if (m) {
|
|
32
|
-
total += 1;
|
|
33
|
-
if (m[1] !== ' ') completed += 1;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return { completed, total };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function existsSyncLike(deps: ShowDeps, p: string): boolean {
|
|
40
|
-
return deps.io.exists(p) || deps.discovery.exists(p);
|
|
41
23
|
}
|
|
42
24
|
|
|
43
25
|
export function showChangeJson(deps: ShowDeps, id: string): Record<string, unknown> {
|
|
44
|
-
const { io,
|
|
26
|
+
const { io, git, root } = deps;
|
|
45
27
|
const dir = `${root}/${CHANGES_DIR}/${id}`;
|
|
46
28
|
const proposalPath = `${dir}/proposal.md`;
|
|
47
29
|
if (!io.exists(proposalPath)) throw new Error(`change not found: ${id}`);
|
|
48
30
|
|
|
49
31
|
const proposal = io.readText(proposalPath);
|
|
50
|
-
const titleMatch = proposal.match(/^#\s+(.*)$/mu);
|
|
51
32
|
const hasDesign = io.exists(`${dir}/design.md`);
|
|
52
33
|
const hasTasks = io.exists(`${dir}/tasks.md`);
|
|
53
34
|
const binding = readBinding(proposal);
|
|
@@ -57,10 +38,9 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
|
|
|
57
38
|
io.exists(`${dir}/${f}`),
|
|
58
39
|
);
|
|
59
40
|
const { completed, total } = hasTasks
|
|
60
|
-
?
|
|
41
|
+
? countTasks(io.readText(`${dir}/tasks.md`))
|
|
61
42
|
: { completed: 0, total: 0 };
|
|
62
43
|
|
|
63
|
-
const git = makeSpawnGit(root);
|
|
64
44
|
const cleanTree = isCleanTree(git);
|
|
65
45
|
const onBoundBranch = binding !== null && currentBranch(git) === binding.branch;
|
|
66
46
|
|
|
@@ -77,9 +57,7 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
|
|
|
77
57
|
const needsSpecsChange = declaredNeeds !== undefined ? declaredNeeds === 'true' : true;
|
|
78
58
|
|
|
79
59
|
const tasksDone = total > 0 && completed >= total;
|
|
80
|
-
const specReport = validateAllSpecs(discoverSpecs(deps.specsDir,
|
|
81
|
-
exists: (p: string) => existsSyncLike(deps, p),
|
|
82
|
-
});
|
|
60
|
+
const specReport = validateAllSpecs(discoverSpecs(deps.specsDir, io), io);
|
|
83
61
|
const validateOk = !specReport.failed && (total === 0 || tasksDone);
|
|
84
62
|
|
|
85
63
|
const stageComplete = stage === 'full';
|
|
@@ -93,7 +71,7 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
|
|
|
93
71
|
{
|
|
94
72
|
name: 'on-bound-branch',
|
|
95
73
|
pass: onBoundBranch,
|
|
96
|
-
hint: onBoundBranch ? '' : 'change is not attached; run `llman
|
|
74
|
+
hint: onBoundBranch ? '' : 'change is not attached; run `llman-sdd change start <id>`',
|
|
97
75
|
},
|
|
98
76
|
{
|
|
99
77
|
name: 'stage-complete',
|
|
@@ -115,15 +93,14 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
|
|
|
115
93
|
{
|
|
116
94
|
name: 'validate',
|
|
117
95
|
pass: validateOk,
|
|
118
|
-
hint: validateOk ? '' : 'fix issues reported by `llman
|
|
96
|
+
hint: validateOk ? '' : 'fix issues reported by `llman-sdd validate <id> --strict`',
|
|
119
97
|
},
|
|
120
98
|
];
|
|
121
|
-
void now;
|
|
122
99
|
|
|
123
100
|
return {
|
|
124
101
|
id,
|
|
125
102
|
path: id,
|
|
126
|
-
title:
|
|
103
|
+
title: firstH1(proposal),
|
|
127
104
|
stage,
|
|
128
105
|
artifacts,
|
|
129
106
|
readyToImplement: gateChecks.every((g) => g.pass),
|
package/src/report/specs.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* aligned with v1 — rules = @human scenarios; enforced = rules carrying an
|
|
4
4
|
* @req link that has an executable acceptance scenario; pending = the rest.
|
|
5
5
|
*/
|
|
6
|
-
import type { CapabilityDoc
|
|
6
|
+
import type { CapabilityDoc } from '../spec/ir.ts';
|
|
7
7
|
import type { SpecEntry } from '../validation/validate.ts';
|
|
8
|
+
import { pad } from './collect.ts';
|
|
8
9
|
|
|
9
10
|
export interface SpecMorphology {
|
|
10
11
|
ruleCount: number;
|
|
@@ -35,7 +36,7 @@ function morphologyOf(doc: CapabilityDoc): SpecMorphology {
|
|
|
35
36
|
return {
|
|
36
37
|
ruleCount: rules.length,
|
|
37
38
|
ruleEnforcedCount: enforced.length,
|
|
38
|
-
ruleManualCount: rules.filter((r
|
|
39
|
+
ruleManualCount: rules.filter((r) => r.manual).length,
|
|
39
40
|
rulePendingCount: rules.length - enforced.length,
|
|
40
41
|
acceptanceCount: acceptance.length,
|
|
41
42
|
orphanAcceptanceCount: orphan.length,
|
|
@@ -43,23 +44,22 @@ function morphologyOf(doc: CapabilityDoc): SpecMorphology {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export function collectSpecs(entries: readonly SpecEntry[]): SpecSummary[] {
|
|
46
|
-
return entries.map((e) =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.
|
|
52
|
-
.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return s.length >= width ? s : s + ' '.repeat(width - s.length);
|
|
47
|
+
return entries.map((e) => {
|
|
48
|
+
const morphology = morphologyOf(e.doc);
|
|
49
|
+
return {
|
|
50
|
+
id: e.doc.header.capability ?? e.fileName,
|
|
51
|
+
title: e.doc.header.capability ?? e.fileName,
|
|
52
|
+
purpose: e.doc.header.purpose ?? '',
|
|
53
|
+
validScope: (e.doc.header.scope ?? '')
|
|
54
|
+
.split(',')
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter((s) => s !== ''),
|
|
57
|
+
requirementCount: morphology.ruleCount,
|
|
58
|
+
health: null,
|
|
59
|
+
staleness: null,
|
|
60
|
+
morphology,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function renderSpecsList(summaries: readonly SpecSummary[]): string[] {
|
package/src/review/review.ts
CHANGED
|
@@ -50,15 +50,8 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
50
50
|
(c) => c.totalTasks > 0 && c.completedTasks < c.totalTasks,
|
|
51
51
|
);
|
|
52
52
|
const signals: ReviewSignal[] = [];
|
|
53
|
-
let critical = 0;
|
|
54
|
-
let warning = 0;
|
|
55
53
|
const push = (kind: ReviewKind, capability: string, count: number, detail = ''): void => {
|
|
56
54
|
signals.push({ kind, capability, count, detail });
|
|
57
|
-
if (kind === 'validate') {
|
|
58
|
-
critical += count;
|
|
59
|
-
} else if (count > 0) {
|
|
60
|
-
warning += count;
|
|
61
|
-
}
|
|
62
55
|
};
|
|
63
56
|
|
|
64
57
|
const sweep = validateAllSpecs(entries, io);
|
|
@@ -103,13 +96,13 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
103
96
|
'locked',
|
|
104
97
|
'-',
|
|
105
98
|
0,
|
|
106
|
-
`${boundChangeCount} bound change(s); inspect with \`llman
|
|
99
|
+
`${boundChangeCount} bound change(s); inspect with \`llman-sdd change diff <id>\``,
|
|
107
100
|
);
|
|
108
101
|
push(
|
|
109
102
|
'validate',
|
|
110
103
|
'-',
|
|
111
104
|
failed.length,
|
|
112
|
-
failed.length > 0 ? 'validate --all failed; run `llman
|
|
105
|
+
failed.length > 0 ? 'validate --all failed; run `llman-sdd validate --all` for details' : 'ok',
|
|
113
106
|
);
|
|
114
107
|
|
|
115
108
|
const warningCount = signals
|
|
@@ -140,3 +133,35 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
140
133
|
exitCode: criticalCount > 0 ? 1 : 0,
|
|
141
134
|
};
|
|
142
135
|
}
|
|
136
|
+
|
|
137
|
+
/** `review --export-html`: fill the v1 shared/review.html template (pure —
|
|
138
|
+
* callers read the template; CLI passes it via core's TEMPLATES_ROOT). */
|
|
139
|
+
export function renderReviewHtml(
|
|
140
|
+
template: string,
|
|
141
|
+
result: {
|
|
142
|
+
signals: readonly { kind: string; capability: string; count: number; detail: string }[];
|
|
143
|
+
summary: { criticalCount: number; warningCount: number };
|
|
144
|
+
},
|
|
145
|
+
): string {
|
|
146
|
+
const esc = (input: string): string =>
|
|
147
|
+
input
|
|
148
|
+
.replaceAll('&', '&')
|
|
149
|
+
.replaceAll('<', '<')
|
|
150
|
+
.replaceAll('>', '>')
|
|
151
|
+
.replaceAll('"', '"');
|
|
152
|
+
let mermaid = 'graph TD\n';
|
|
153
|
+
const sigJson: unknown[] = [];
|
|
154
|
+
result.signals.forEach((s, idx) => {
|
|
155
|
+
const label = esc(
|
|
156
|
+
`${s.capability} [${s.kind}] = ${s.count} — ${s.detail === '' ? 'ok' : s.detail}`,
|
|
157
|
+
);
|
|
158
|
+
mermaid += ` s${idx}["${label}"]\n`;
|
|
159
|
+
sigJson.push({ kind: s.kind, capability: s.capability, count: s.count, detail: s.detail });
|
|
160
|
+
});
|
|
161
|
+
return template
|
|
162
|
+
.replaceAll('__CRITICAL__', String(result.summary.criticalCount))
|
|
163
|
+
.replaceAll('__WARNING__', String(result.summary.warningCount))
|
|
164
|
+
.replaceAll('__SIGNALS__', JSON.stringify(sigJson))
|
|
165
|
+
.replaceAll('__MERMAID__', mermaid)
|
|
166
|
+
.replaceAll('__GENERATED__', new Date().toISOString());
|
|
167
|
+
}
|