@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.
Files changed (70) hide show
  1. package/package.json +1 -1
  2. package/src/archive/sevenzip.ts +1 -1
  3. package/src/context/indexStore.ts +6 -12
  4. package/src/git/spawnGit.ts +0 -8
  5. package/src/index.ts +32 -4
  6. package/src/init/defaultConfig.ts +2 -2
  7. package/src/init/init.ts +46 -56
  8. package/src/report/collect.ts +3 -3
  9. package/src/report/graph.ts +22 -34
  10. package/src/report/show.ts +14 -37
  11. package/src/report/specs.ts +19 -19
  12. package/src/review/review.ts +34 -9
  13. package/src/spec/ir.ts +7 -0
  14. package/src/spec/parser.ts +9 -4
  15. package/src/templates/embedded.ts +67 -0
  16. package/src/templates/engine.ts +3 -2
  17. package/src/validation/validate.ts +3 -5
  18. package/templates/en/agents-root-stub.md +1 -1
  19. package/templates/en/skills/llman-sdd-apply-cycle.md +7 -7
  20. package/templates/en/skills/llman-sdd-apply.md +12 -12
  21. package/templates/en/skills/llman-sdd-arch-review.md +2 -2
  22. package/templates/en/skills/llman-sdd-archive.md +12 -12
  23. package/templates/en/skills/llman-sdd-continue.md +8 -8
  24. package/templates/en/skills/llman-sdd-draft.md +5 -5
  25. package/templates/en/skills/llman-sdd-explore.md +5 -5
  26. package/templates/en/skills/llman-sdd-ff.md +7 -7
  27. package/templates/en/skills/llman-sdd-graph.md +10 -10
  28. package/templates/en/skills/llman-sdd-onboard.md +7 -7
  29. package/templates/en/skills/llman-sdd-propose.md +14 -14
  30. package/templates/en/skills/llman-sdd-quick.md +6 -6
  31. package/templates/en/skills/llman-sdd-research.md +2 -2
  32. package/templates/en/skills/llman-sdd-show.md +4 -4
  33. package/templates/en/skills/llman-sdd-specs-compact.md +6 -6
  34. package/templates/en/skills/llman-sdd-validate.md +5 -5
  35. package/templates/en/skills/llman-sdd-verify.md +7 -7
  36. package/templates/en/skills/llman-sdd-wayfinder.md +7 -7
  37. package/templates/en/units/migrate-prompt.md +5 -5
  38. package/templates/en/units/skills/ethics-governance.md +1 -1
  39. package/templates/en/units/skills/git-native-flow.md +2 -2
  40. package/templates/en/units/skills/stage-guard.md +1 -1
  41. package/templates/en/units/skills/structured-protocol.md +5 -5
  42. package/templates/en/units/skills/validation-hints.md +2 -2
  43. package/templates/en/units/workflow/archive-freeze-guidance.md +3 -3
  44. package/templates/shared/review.html +2 -2
  45. package/templates/zh-Hans/agents-root-stub.md +1 -1
  46. package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +7 -7
  47. package/templates/zh-Hans/skills/llman-sdd-apply.md +12 -12
  48. package/templates/zh-Hans/skills/llman-sdd-arch-review.md +2 -2
  49. package/templates/zh-Hans/skills/llman-sdd-archive.md +12 -12
  50. package/templates/zh-Hans/skills/llman-sdd-continue.md +8 -8
  51. package/templates/zh-Hans/skills/llman-sdd-draft.md +5 -5
  52. package/templates/zh-Hans/skills/llman-sdd-explore.md +5 -5
  53. package/templates/zh-Hans/skills/llman-sdd-ff.md +7 -7
  54. package/templates/zh-Hans/skills/llman-sdd-graph.md +10 -10
  55. package/templates/zh-Hans/skills/llman-sdd-onboard.md +7 -7
  56. package/templates/zh-Hans/skills/llman-sdd-propose.md +14 -14
  57. package/templates/zh-Hans/skills/llman-sdd-quick.md +6 -6
  58. package/templates/zh-Hans/skills/llman-sdd-research.md +2 -2
  59. package/templates/zh-Hans/skills/llman-sdd-show.md +4 -4
  60. package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +6 -6
  61. package/templates/zh-Hans/skills/llman-sdd-validate.md +5 -5
  62. package/templates/zh-Hans/skills/llman-sdd-verify.md +7 -7
  63. package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +7 -7
  64. package/templates/zh-Hans/units/migrate-prompt.md +5 -5
  65. package/templates/zh-Hans/units/skills/ethics-governance.md +1 -1
  66. package/templates/zh-Hans/units/skills/git-native-flow.md +2 -2
  67. package/templates/zh-Hans/units/skills/stage-guard.md +1 -1
  68. package/templates/zh-Hans/units/skills/structured-protocol.md +5 -5
  69. package/templates/zh-Hans/units/skills/validation-hints.md +2 -2
  70. package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llman-sdd/core",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Pure domain logic for llman-sdd (config, gherkin specs, validation, templates)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- inTable = rulesSeen >= 2 && rulesSeen <= 2 ? true : inTable;
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 HashIo, type SerializedTreeIndex } from './tree.ts';
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, root: string, specsDir: string): FreshnessResult {
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, root: string): SerializedTreeIndex | null {
123
- const path = join(root, TREE_JSON_REL);
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(path)) as SerializedTreeIndex;
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 };
@@ -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
- export const VERSION = '0.1.0';
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 { runInit, updateFileWithMarkers, type InitResult } from './init/init.ts';
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 sdd init --update\`, managed candidates are:
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 sdd init --update\` 时,管理候选集为:
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
- function writeDefaultConfig(root: string, locale: string): void {
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
- const dir = join(root, 'llmanspec');
52
- mkdirSync(dir, { recursive: true });
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
- root: string,
78
+ io: InitIo,
79
+ templates: TemplateIo,
66
80
  opts: { update: boolean; locale?: string; version: string },
67
81
  ): InitResult {
68
- const llmanspecDir = join(root, 'llmanspec');
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 = join(llmanspecDir, 'config.yaml');
73
- if (!existsSync(configPath)) writeDefaultConfig(root, opts.locale ?? 'en');
74
- const config = loadConfig(readFileSync(configPath, 'utf8'));
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
- mkdirSync(join(llmanspecDir, 'specs'), { recursive: true });
78
- mkdirSync(join(llmanspecDir, 'changes', 'archive'), { recursive: true });
79
- const gitkeepSpecs = join(llmanspecDir, 'specs', '.gitkeep');
80
- if (!existsSync(gitkeepSpecs)) writeFileSync(gitkeepSpecs, '');
81
- const gitkeepArchive = join(llmanspecDir, 'changes', 'archive', '.gitkeep');
82
- if (!existsSync(gitkeepArchive)) writeFileSync(gitkeepArchive, '');
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
- // skills render owns the unit registry; stubs reuse the same registry load.
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 rootStubRaw = loadLocaleResource(
97
- { exists: (p) => existsSync(p), readText: (p) => readFileSync(p, 'utf8') },
98
- TEMPLATES_ROOT,
99
- locales,
100
- 'agents-root-stub.md',
101
- );
102
- if (rootStubRaw !== null) {
103
- const agentsPath = join(root, 'AGENTS.md');
104
- const existing = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : '';
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
- const skillsBase = join(root, '.agents', 'skills');
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 readdirSync(skillsBase)) {
119
+ for (const entry of io.listDir(SKILLS_BASE)) {
128
120
  if (entry.startsWith('llman-sdd-') && !candidates.has(entry)) {
129
- rmSync(join(skillsBase, entry), { recursive: true, force: true });
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
- const skillDir = join(skillsBase2, dirName);
140
- mkdirSync(skillDir, { recursive: true });
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 };
@@ -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
 
@@ -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
- const deps: string[] = [];
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
- if (io.exists(proposal)) {
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
  }
@@ -1,53 +1,34 @@
1
1
  import { readBinding } from '../change/frontmatter.ts';
2
2
  import { CHANGES_DIR } from '../change/lifecycle.ts';
3
- import { isCleanTree, currentBranch, makeSpawnGit } from '../git/spawnGit.ts';
4
- import { discoverSpecs, type DiscoveryIo } from '../validation/discover.ts';
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 interface ShowFsIo extends ChangeFsIo {
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
- discovery: DiscoveryIo;
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, discovery, root, now } = deps;
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
- ? parseTasks(io.readText(`${dir}/tasks.md`))
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, discovery), {
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 sdd change start <id>`',
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 sdd validate <id> --strict`',
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: titleMatch?.[1]?.trim() ?? '',
103
+ title: firstH1(proposal),
127
104
  stage,
128
105
  artifacts,
129
106
  readyToImplement: gateChecks.every((g) => g.pass),
@@ -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, ScenarioIR } from '../spec/ir.ts';
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: ScenarioIR) => r.manual).length,
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
- id: e.doc.header.capability ?? e.fileName,
48
- title: e.doc.header.capability ?? e.fileName,
49
- purpose: e.doc.header.purpose ?? '',
50
- validScope: (e.doc.header.scope ?? '')
51
- .split(',')
52
- .map((s) => s.trim())
53
- .filter((s) => s !== ''),
54
- requirementCount: morphologyOf(e.doc).ruleCount,
55
- health: null,
56
- staleness: null,
57
- morphology: morphologyOf(e.doc),
58
- }));
59
- }
60
-
61
- function pad(s: string, width: number): string {
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[] {
@@ -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 sdd change diff <id>\``,
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 sdd validate --all` for details' : 'ok',
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('&', '&amp;')
149
+ .replaceAll('<', '&lt;')
150
+ .replaceAll('>', '&gt;')
151
+ .replaceAll('"', '&quot;');
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
+ }