@llman-sdd/core 0.1.2 → 0.1.4

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 (71) hide show
  1. package/package.json +1 -1
  2. package/src/archive/freeze.ts +6 -4
  3. package/src/archive/sevenzip.ts +35 -6
  4. package/src/context/indexStore.ts +6 -12
  5. package/src/git/spawnGit.ts +0 -8
  6. package/src/index.ts +39 -5
  7. package/src/init/defaultConfig.ts +2 -2
  8. package/src/init/init.ts +46 -56
  9. package/src/report/collect.ts +3 -3
  10. package/src/report/graph.ts +22 -34
  11. package/src/report/show.ts +14 -37
  12. package/src/report/specs.ts +19 -19
  13. package/src/review/review.ts +34 -9
  14. package/src/spec/ir.ts +7 -0
  15. package/src/spec/parser.ts +9 -4
  16. package/src/templates/embedded.ts +68 -0
  17. package/src/templates/engine.ts +3 -2
  18. package/src/validation/validate.ts +3 -5
  19. package/templates/en/agents-root-stub.md +1 -1
  20. package/templates/en/skills/llman-sdd-apply-cycle.md +7 -7
  21. package/templates/en/skills/llman-sdd-apply.md +12 -12
  22. package/templates/en/skills/llman-sdd-arch-review.md +2 -2
  23. package/templates/en/skills/llman-sdd-archive.md +12 -12
  24. package/templates/en/skills/llman-sdd-continue.md +8 -8
  25. package/templates/en/skills/llman-sdd-draft.md +5 -5
  26. package/templates/en/skills/llman-sdd-explore.md +5 -5
  27. package/templates/en/skills/llman-sdd-ff.md +7 -7
  28. package/templates/en/skills/llman-sdd-graph.md +10 -10
  29. package/templates/en/skills/llman-sdd-onboard.md +7 -7
  30. package/templates/en/skills/llman-sdd-propose.md +14 -14
  31. package/templates/en/skills/llman-sdd-quick.md +6 -6
  32. package/templates/en/skills/llman-sdd-research.md +2 -2
  33. package/templates/en/skills/llman-sdd-show.md +4 -4
  34. package/templates/en/skills/llman-sdd-specs-compact.md +6 -6
  35. package/templates/en/skills/llman-sdd-validate.md +5 -5
  36. package/templates/en/skills/llman-sdd-verify.md +7 -7
  37. package/templates/en/skills/llman-sdd-wayfinder.md +7 -7
  38. package/templates/en/units/migrate-prompt.md +5 -5
  39. package/templates/en/units/skills/ethics-governance.md +1 -1
  40. package/templates/en/units/skills/git-native-flow.md +2 -2
  41. package/templates/en/units/skills/stage-guard.md +1 -1
  42. package/templates/en/units/skills/structured-protocol.md +5 -5
  43. package/templates/en/units/skills/validation-hints.md +2 -2
  44. package/templates/en/units/workflow/archive-freeze-guidance.md +3 -3
  45. package/templates/shared/review.html +2 -2
  46. package/templates/zh-Hans/agents-root-stub.md +1 -1
  47. package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +7 -7
  48. package/templates/zh-Hans/skills/llman-sdd-apply.md +12 -12
  49. package/templates/zh-Hans/skills/llman-sdd-arch-review.md +2 -2
  50. package/templates/zh-Hans/skills/llman-sdd-archive.md +12 -12
  51. package/templates/zh-Hans/skills/llman-sdd-continue.md +8 -8
  52. package/templates/zh-Hans/skills/llman-sdd-draft.md +5 -5
  53. package/templates/zh-Hans/skills/llman-sdd-explore.md +5 -5
  54. package/templates/zh-Hans/skills/llman-sdd-ff.md +7 -7
  55. package/templates/zh-Hans/skills/llman-sdd-graph.md +10 -10
  56. package/templates/zh-Hans/skills/llman-sdd-onboard.md +7 -7
  57. package/templates/zh-Hans/skills/llman-sdd-propose.md +14 -14
  58. package/templates/zh-Hans/skills/llman-sdd-quick.md +6 -6
  59. package/templates/zh-Hans/skills/llman-sdd-research.md +2 -2
  60. package/templates/zh-Hans/skills/llman-sdd-show.md +4 -4
  61. package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +6 -6
  62. package/templates/zh-Hans/skills/llman-sdd-validate.md +5 -5
  63. package/templates/zh-Hans/skills/llman-sdd-verify.md +7 -7
  64. package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +7 -7
  65. package/templates/zh-Hans/units/migrate-prompt.md +5 -5
  66. package/templates/zh-Hans/units/skills/ethics-governance.md +1 -1
  67. package/templates/zh-Hans/units/skills/git-native-flow.md +2 -2
  68. package/templates/zh-Hans/units/skills/stage-guard.md +1 -1
  69. package/templates/zh-Hans/units/skills/structured-protocol.md +5 -5
  70. package/templates/zh-Hans/units/skills/validation-hints.md +2 -2
  71. 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.4",
4
4
  "description": "Pure domain logic for llman-sdd (config, gherkin specs, validation, templates)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -90,10 +90,12 @@ export async function runList(io: FreezeIo, sz: SevenZipPort, rootAbs: string):
90
90
  if (!io.exists(archiveAbs)) {
91
91
  return [`No freeze archive found at ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`];
92
92
  }
93
- const entries = (await sz.listEntries(archiveAbs))
94
- .map((n) => n.replace(/\/$/u, ''))
95
- .filter((n) => DATED_RE.test(n) && n.split('/').length === 1);
96
- const unique = [...new Set(entries)].toSorted();
93
+ // Real 7z lists file paths under their directory (`<dir>/proposal.md`);
94
+ // directory entries themselves are skipped by parseListNames. Derive the
95
+ // archived change names from the leading path segment.
96
+ const entries = (await sz.listEntries(archiveAbs)).map((n) => n.replace(/\/$/u, ''));
97
+ const top = (n: string): string => n.split('/')[0] ?? n;
98
+ const unique = [...new Set(entries.map(top).filter((n) => DATED_RE.test(n)))].toSorted();
97
99
  if (unique.length === 0) {
98
100
  return [
99
101
  `Freeze archive ${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME} contains no archived changes`,
@@ -1,3 +1,4 @@
1
+ import { Buffer } from 'node:buffer';
1
2
  import { mkdirSync } from 'node:fs';
2
3
  import { basename, join } from 'node:path';
3
4
 
@@ -7,6 +8,13 @@ import { basename, join } from 'node:path';
7
8
  * instance per operation; extraction target must be a fresh directory
8
9
  * (WASM 7z refuses existing -o dirs). Spike-proven on Bun: compress / list /
9
10
  * extract keep directory structure.
11
+ *
12
+ * Compiled single-file binaries have no on-disk 7zz.wasm (Emscripten would
13
+ * probe $bunfs and abort), so build-binary.ts additionally injects the wasm
14
+ * base64 via the literal define `process.env.LLMAN_SDD_EMBEDDED_7ZZ_WASM_B64`
15
+ * (define only rewrites literal member access — do not route through a
16
+ * variable). Unset in source/npm/Node runs → glue loads the .wasm from disk
17
+ * as before.
10
18
  */
11
19
  import SevenZip from '7z-wasm';
12
20
 
@@ -35,14 +43,34 @@ type ModuleFactory = (opts: {
35
43
  noInitialRun: boolean;
36
44
  print?: (text: string) => void;
37
45
  printErr?: (text: string) => void;
46
+ wasmBinary?: Uint8Array;
38
47
  }) => Promise<EmscriptenModule>;
39
48
 
40
- async function initModule(print?: (text: string) => void): Promise<EmscriptenModule> {
49
+ const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d] as const;
50
+
51
+ /** Decode a base64 wasm blob; undefined when absent or not a WASM binary.
52
+ * Base64 decoding itself never throws, so the magic bytes are the guard. */
53
+ export function resolveEmbeddedWasmB64(value: unknown): Uint8Array | undefined {
54
+ if (typeof value !== 'string' || value === '') return undefined;
55
+ const bytes = Buffer.from(value, 'base64');
56
+ if (bytes.length < 4 || WASM_MAGIC.some((b, i) => bytes[i] !== b)) return undefined;
57
+ return new Uint8Array(bytes);
58
+ }
59
+
60
+ /** Read the build-injected wasm; undefined when absent or malformed. */
61
+ export function embeddedWasmBinary(): Uint8Array | undefined {
62
+ return resolveEmbeddedWasmB64(process.env.LLMAN_SDD_EMBEDDED_7ZZ_WASM_B64);
63
+ }
64
+
65
+ async function initModule(
66
+ print?: (text: string) => void,
67
+ wasmBinary?: Uint8Array,
68
+ ): Promise<EmscriptenModule> {
41
69
  const sink = (text: string): void => {
42
70
  if (print) print(text);
43
71
  };
44
72
  const factory = SevenZip as unknown as ModuleFactory;
45
- return factory({ noInitialRun: true, print: sink, printErr: sink });
73
+ return factory({ noInitialRun: true, print: sink, printErr: sink, wasmBinary });
46
74
  }
47
75
 
48
76
  function mount(mod: EmscriptenModule, seq: number, hostDir: string): string {
@@ -61,7 +89,7 @@ function parseListNames(lines: string[]): string[] {
61
89
  for (const line of lines) {
62
90
  if (/^-+/u.test(line)) {
63
91
  rulesSeen += 1;
64
- inTable = rulesSeen >= 2 && rulesSeen <= 2 ? true : inTable;
92
+ if (rulesSeen === 2) inTable = true;
65
93
  if (rulesSeen === 3) break;
66
94
  continue;
67
95
  }
@@ -78,9 +106,10 @@ function parseListNames(lines: string[]): string[] {
78
106
  }
79
107
 
80
108
  export async function makeWasmSevenZip(): Promise<SevenZipPort> {
109
+ const wasmBinary = embeddedWasmBinary();
81
110
  return {
82
111
  async add(archivePath: string, baseDir: string, entries: string[]): Promise<void> {
83
- const mod = await initModule();
112
+ const mod = await initModule(undefined, wasmBinary);
84
113
  const archMount = mount(mod, 1, join(archivePath, '..'));
85
114
  const baseMount = mount(mod, 2, baseDir);
86
115
  mod.FS.chdir(baseMount);
@@ -90,7 +119,7 @@ export async function makeWasmSevenZip(): Promise<SevenZipPort> {
90
119
 
91
120
  async listEntries(archivePath: string): Promise<string[]> {
92
121
  const lines: string[] = [];
93
- const mod = await initModule((text) => lines.push(text));
122
+ const mod = await initModule((text) => lines.push(text), wasmBinary);
94
123
  const archMount = mount(mod, 1, join(archivePath, '..'));
95
124
  const rc = mod.callMain(['l', `${archMount}/${basename(archivePath)}`]);
96
125
  if (rc !== 0) throw new SevenZipError(`7z list failed (rc=${rc})`);
@@ -98,7 +127,7 @@ export async function makeWasmSevenZip(): Promise<SevenZipPort> {
98
127
  },
99
128
 
100
129
  async extractAll(archivePath: string, destDir: string): Promise<void> {
101
- const mod = await initModule();
130
+ const mod = await initModule(undefined, wasmBinary);
102
131
  const archMount = mount(mod, 1, join(archivePath, '..'));
103
132
  mkdirSync(destDir, { recursive: true });
104
133
  const destMount = mount(mod, 2, destDir);
@@ -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,
@@ -117,7 +144,13 @@ export {
117
144
  type SpecMorphology,
118
145
  type SpecSummary,
119
146
  } from './report/specs.ts';
120
- export { SevenZipError, makeWasmSevenZip, type SevenZipPort } from './archive/sevenzip.ts';
147
+ export {
148
+ SevenZipError,
149
+ embeddedWasmBinary,
150
+ makeWasmSevenZip,
151
+ resolveEmbeddedWasmB64,
152
+ type SevenZipPort,
153
+ } from './archive/sevenzip.ts';
121
154
  export {
122
155
  ARCHIVE_DIR_REL,
123
156
  FREEZE_ARCHIVE_NAME,
@@ -132,6 +165,7 @@ export {
132
165
  } from './archive/freeze.ts';
133
166
  export {
134
167
  buildReview,
168
+ renderReviewHtml,
135
169
  type ReviewInput,
136
170
  type ReviewKind,
137
171
  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),