@llman-sdd/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Pageindex tree (context-index capability, r26): specs IR serialized as a
3
+ * tree — building is LLM-free. Serialization keys are snake_case, matching
4
+ * v1's tree.json so retrieval IDs stay comparable.
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ import { join } from 'node:path';
8
+
9
+ import type { SpecEntry } from '../validation/validate.ts';
10
+
11
+ export const TREE_VERSION = 1;
12
+
13
+ export interface SerializedReqNode {
14
+ req_id: string;
15
+ title: string;
16
+ statement: string;
17
+ }
18
+
19
+ export interface SerializedScenarioNode {
20
+ req_id: string;
21
+ id: string;
22
+ given: string;
23
+ when: string;
24
+ then: string;
25
+ }
26
+
27
+ export interface SerializedDocNode {
28
+ spec_id: string;
29
+ purpose: string;
30
+ reqs: SerializedReqNode[];
31
+ scenarios: SerializedScenarioNode[];
32
+ }
33
+
34
+ export interface SerializedTreeIndex {
35
+ version: number;
36
+ spec_hash: string;
37
+ build_timestamp: string;
38
+ chat_model: string;
39
+ docs: SerializedDocNode[];
40
+ }
41
+
42
+ function joinSteps(
43
+ steps: { kind: string; text: string }[],
44
+ kind: 'given' | 'when' | 'then',
45
+ ): string {
46
+ return steps
47
+ .filter((s) => s.kind === kind)
48
+ .map((s) => s.text)
49
+ .join('\n');
50
+ }
51
+
52
+ export function buildDocs(entries: readonly SpecEntry[]): SerializedDocNode[] {
53
+ return entries
54
+ .map((entry) => {
55
+ const cap = entry.doc.header.capability ?? entry.fileName;
56
+ const reqs = entry.doc.scenarios
57
+ .filter((s) => s.classification === 'human')
58
+ .map((s) => ({
59
+ req_id: s.reqIds[0] ?? '',
60
+ title: s.name,
61
+ statement: s.statement
62
+ .split('\n')
63
+ .map((l) => l.replace(/^-\s+/u, ''))
64
+ .join('\n'),
65
+ }));
66
+ const scenarios = entry.doc.scenarios
67
+ .filter((s) => s.classification === 'executable')
68
+ .map((s) => ({
69
+ req_id: s.reqIds[0] ?? '',
70
+ id: s.name,
71
+ given: joinSteps(s.steps, 'given'),
72
+ when: joinSteps(s.steps, 'when'),
73
+ // `then` is the tree.json contract key, not a thenable
74
+ // oxlint-disable-next-line unicorn/no-thenable
75
+ then: joinSteps(s.steps, 'then'),
76
+ }));
77
+ return { spec_id: cap, purpose: entry.doc.header.purpose ?? '', reqs, scenarios };
78
+ })
79
+ .toSorted((a, b) => a.spec_id.localeCompare(b.spec_id));
80
+ }
81
+
82
+ export function buildTreeIndex(
83
+ entries: readonly SpecEntry[],
84
+ meta: { specHash: string; buildTimestamp: string; chatModel: string },
85
+ ): SerializedTreeIndex {
86
+ return {
87
+ version: TREE_VERSION,
88
+ spec_hash: meta.specHash,
89
+ build_timestamp: meta.buildTimestamp,
90
+ chat_model: meta.chatModel,
91
+ docs: buildDocs(entries),
92
+ };
93
+ }
94
+
95
+ export interface HashIo {
96
+ exists(p: string): boolean;
97
+ isDirectory(p: string): boolean;
98
+ listDir(p: string): string[];
99
+ readText(p: string): string;
100
+ }
101
+
102
+ /** sha256 over all .feature files, sorted by path (r26 freshness anchor). */
103
+ export function computeSpecHash(specsDir: string, io: HashIo): string {
104
+ const hash = createHash('sha256');
105
+ const walk = (dir: string): void => {
106
+ if (!io.exists(dir) || !io.isDirectory(dir)) return;
107
+ for (const name of io.listDir(dir).toSorted()) {
108
+ const full = join(dir, name);
109
+ if (io.isDirectory(full)) walk(full);
110
+ else if (name.endsWith('.feature')) {
111
+ hash.update(full);
112
+ hash.update(io.readText(full));
113
+ }
114
+ }
115
+ };
116
+ walk(specsDir);
117
+ return hash.digest('hex');
118
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Git subprocess adapter (change-lifecycle capability). Domain logic depends
3
+ * on the GitLike port; this module is the concrete spawn-based implementation
4
+ * (node:child_process — dual-runtime compliant per AGENTS.md).
5
+ */
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ export class GitError extends Error {
9
+ readonly args: string[];
10
+ readonly stderr: string;
11
+
12
+ constructor(args: string[], stderr: string) {
13
+ super(`git ${args.join(' ')} failed: ${stderr.trim()}`);
14
+ this.name = 'GitError';
15
+ this.args = args;
16
+ this.stderr = stderr;
17
+ }
18
+ }
19
+
20
+ export interface GitLike {
21
+ /** Run git, return stdout; throw GitError on non-zero exit. */
22
+ run(args: string[]): string;
23
+ /** Run git, return stdout or null on non-zero exit (probes). */
24
+ runOpt(args: string[]): string | null;
25
+ }
26
+
27
+ export function makeSpawnGit(cwd: string): GitLike {
28
+ const spawn = (args: string[]): { code: number; stdout: string; stderr: string } => {
29
+ const proc = spawnSync('git', args, { cwd, encoding: 'utf8' });
30
+ return {
31
+ code: proc.status ?? 1,
32
+ stdout: proc.stdout ?? '',
33
+ stderr: proc.stderr ?? '',
34
+ };
35
+ };
36
+ return {
37
+ run(args: string[]): string {
38
+ const r = spawn(args);
39
+ if (r.code !== 0) throw new GitError(args, r.stderr);
40
+ return r.stdout.trim();
41
+ },
42
+ runOpt(args: string[]): string | null {
43
+ const r = spawn(args);
44
+ return r.code === 0 ? r.stdout.trim() : null;
45
+ },
46
+ };
47
+ }
48
+
49
+ export function currentBranch(git: GitLike): string | null {
50
+ return git.runOpt(['branch', '--show-current']);
51
+ }
52
+
53
+ export function isCleanTree(git: GitLike): boolean {
54
+ return git.run(['status', '--porcelain']) === '';
55
+ }
56
+
57
+ /** Local-first default branch resolution: main → master → origin/HEAD → origin/*. */
58
+ export function defaultBranch(git: GitLike): string {
59
+ for (const candidate of ['main', 'master']) {
60
+ if (git.runOpt(['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`]) !== null) {
61
+ return candidate;
62
+ }
63
+ }
64
+ const originHead = git.runOpt(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
65
+ if (originHead !== null) {
66
+ const branch = originHead.replace(/^origin\//u, '');
67
+ if (branch !== originHead && branch !== '') return branch;
68
+ }
69
+ for (const ref of git.runOpt(['branch', '--remote', '--format=%(refname:short)'])?.split('\n') ??
70
+ []) {
71
+ if (ref.startsWith('origin/')) return ref.slice('origin/'.length);
72
+ }
73
+ throw new GitError(
74
+ ['default-branch-resolution'],
75
+ 'no local main/master or origin/* branch found',
76
+ );
77
+ }
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
+ export function revParseHead(git: GitLike): string {
88
+ return git.run(['rev-parse', 'HEAD']);
89
+ }
package/src/index.ts ADDED
@@ -0,0 +1,176 @@
1
+ export const VERSION = '0.1.0';
2
+
3
+ export * from './ports.ts';
4
+
5
+ export {
6
+ EXTRA_SKILLS,
7
+ archiveSchema,
8
+ bddSchema,
9
+ bindingSchema,
10
+ changeIdSchema,
11
+ scenarioAttrsBindingSchema,
12
+ sddConfigSchema,
13
+ sddSchema,
14
+ tagsBindingSchema,
15
+ type SddConfig,
16
+ type SddConfigInput,
17
+ } from './config/schema.ts';
18
+ export { ConfigValidationError, MAX_REPORTED_ISSUES, loadConfig } from './config/load.ts';
19
+
20
+ export type {
21
+ CapabilityDoc,
22
+ CapabilityHeader,
23
+ ScenarioClassification,
24
+ ScenarioIR,
25
+ SpecStructuralError,
26
+ } from './spec/ir.ts';
27
+ export {
28
+ SpecParseError,
29
+ localeToGherkinLang,
30
+ parseCapability,
31
+ parseFeatureSource,
32
+ } from './spec/parser.ts';
33
+ export { buildReqRegistry, type ReqRegistry, type RegistryDuplicate } from './spec/reqRegistry.ts';
34
+ export type {
35
+ SpecEntry,
36
+ SpecIo,
37
+ SpecVerdict,
38
+ ValidationItem,
39
+ ValidationLevel,
40
+ ValidationReport,
41
+ } from './validation/validate.ts';
42
+ export { validateAllSpecs, validateCapability } from './validation/validate.ts';
43
+ export { discoverSpecs, type DiscoveryIo } from './validation/discover.ts';
44
+ export type { ChangeBinding } from './change/frontmatter.ts';
45
+ export { readBinding, writeBinding } from './change/frontmatter.ts';
46
+ export {
47
+ GitError,
48
+ defaultBranch,
49
+ isCleanTree,
50
+ makeSpawnGit,
51
+ mergeBase,
52
+ currentBranch,
53
+ branchExists,
54
+ revParseHead,
55
+ type GitLike,
56
+ } from './git/spawnGit.ts';
57
+ export { DRAFT_PROPOSAL_TEMPLATE, deriveChangeId, isLegalChangeId } from './change/id.ts';
58
+ export {
59
+ CHANGES_DIR,
60
+ LifecycleError,
61
+ attachChange,
62
+ changeDiff,
63
+ finalizeChange,
64
+ newChange,
65
+ startChange,
66
+ type FinalizeResult,
67
+ type FsIo,
68
+ } from './change/lifecycle.ts';
69
+ export { normalizeLocale, localeFallbacks } from './templates/locale.ts';
70
+ export {
71
+ MAX_UNIT_NESTING_DEPTH,
72
+ MissingUnitError,
73
+ renderTemplate,
74
+ renderWithUnits,
75
+ type UnitRegistry,
76
+ } from './templates/engine.ts';
77
+ export {
78
+ DEFAULT_SKILL_FILES,
79
+ ETHICS_KEYS,
80
+ OPTIONAL_SKILL_FILES,
81
+ UNIT_FILES,
82
+ buildTemplateVars,
83
+ effectiveRunCommand,
84
+ enforceEthicsGovernance,
85
+ loadLocaleResource,
86
+ loadSkillTemplates,
87
+ loadUnitRegistry,
88
+ skillCandidates,
89
+ type SkillTemplate,
90
+ type TemplateIo,
91
+ } from './templates/skills.ts';
92
+ export { runInit, updateFileWithMarkers, type InitResult } from './init/init.ts';
93
+ export {
94
+ collectChanges,
95
+ renderChangesList,
96
+ renderChangesJson,
97
+ statusFor,
98
+ stageFor,
99
+ relativeTime,
100
+ statusHuman,
101
+ type ChangeFsIo,
102
+ type ChangeStatus,
103
+ type ChangeSummary,
104
+ } from './report/collect.ts';
105
+ export { graphMermaid, type GraphFsIo } from './report/graph.ts';
106
+ export { showChangeJson, type ShowDeps, type ShowFsIo } from './report/show.ts';
107
+ export {
108
+ nextReqId,
109
+ scaffoldSpec,
110
+ skeletonContent,
111
+ type SpecHelperIo,
112
+ } from './report/specHelpers.ts';
113
+ export {
114
+ collectSpecs,
115
+ renderSpecsJson,
116
+ renderSpecsList,
117
+ type SpecMorphology,
118
+ type SpecSummary,
119
+ } from './report/specs.ts';
120
+ export { SevenZipError, makeWasmSevenZip, type SevenZipPort } from './archive/sevenzip.ts';
121
+ export {
122
+ ARCHIVE_DIR_REL,
123
+ FREEZE_ARCHIVE_NAME,
124
+ freezeCandidates,
125
+ runFreeze,
126
+ runList,
127
+ runThaw,
128
+ type FreezeIo,
129
+ type FreezeOpts,
130
+ type FreezeRunResult,
131
+ type ThawResult,
132
+ } from './archive/freeze.ts';
133
+ export {
134
+ buildReview,
135
+ type ReviewInput,
136
+ type ReviewKind,
137
+ type ReviewResult,
138
+ type ReviewSignal,
139
+ type TagBinding,
140
+ } from './review/review.ts';
141
+ export {
142
+ TREE_VERSION,
143
+ buildDocs,
144
+ buildTreeIndex,
145
+ computeSpecHash,
146
+ type HashIo,
147
+ type SerializedDocNode,
148
+ type SerializedScenarioNode,
149
+ type SerializedReqNode,
150
+ type SerializedTreeIndex,
151
+ } from './context/tree.ts';
152
+ export {
153
+ CONTEXT_DIR_REL,
154
+ PAGEINDEX_DIR_REL,
155
+ REBUILD_LOCK_REL,
156
+ TREE_JSON_REL,
157
+ checkIndexFreshness,
158
+ loadTree,
159
+ parseLock,
160
+ rebuildIndex,
161
+ type FreshnessResult,
162
+ type IndexIo,
163
+ type RebuildOpts,
164
+ type RebuildResult,
165
+ } from './context/indexStore.ts';
166
+ export {
167
+ MAX_TOOL_ROUNDS,
168
+ resolveChatConfig,
169
+ runContextRetrieval,
170
+ unavailableResult,
171
+ type ChatConfig,
172
+ type ContextResult,
173
+ type RetrieveDeps,
174
+ type TierEntry,
175
+ type TreeToolDeps,
176
+ } from './context/retrieve.ts';
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Default llmanspec/config.yaml templates, extracted verbatim from v1
3
+ * (crates/llman-sdd/src/sdd/project/config.rs DEFAULT_CONFIG_EN/ZH_HANS).
4
+ */
5
+ export const LLMANSPEC_SCHEMA_URL =
6
+ 'https://raw.githubusercontent.com/StrayDragon/llman/main/artifacts/schema/configs/en/llmanspec-config.schema.json';
7
+
8
+ export const DEFAULT_CONFIG_EN = `schema: spec-driven
9
+ locale: en
10
+
11
+ # Optional extra skills (disabled by default, uncomment to enable).
12
+ # On \`llman sdd init --update\`, managed candidates are:
13
+ # default workflow skills + entries listed here (extra_skills extend).
14
+ # Then \`.agents/skills/llman-sdd-*\` is scanned: anything not in that candidate
15
+ # set is removed first, then candidates are written/updated. Only the
16
+ # \`llman-sdd-\` prefix is touched (custom skills without that prefix are kept).
17
+ # Deprecated shipped skills (e.g. removed from defaults) are cleaned this way.
18
+ # extra_skills:
19
+ # - llman-sdd-continue
20
+ # - llman-sdd-ff
21
+ # - llman-sdd-validate
22
+ # - llman-sdd-arch-review
23
+ # - llman-sdd-wayfinder
24
+ # - llman-sdd-research
25
+
26
+ # BDD integration (optional, uncomment to enable)
27
+ # bdd:
28
+ # framework: pytest-bdd
29
+ # feature_dir: tests/features/
30
+ # # default_language: en
31
+ # # Filtered runners: include {feature_*} so validate --all/--specs runs per capability.
32
+ # # run_command: "pytest {feature_dir} -k {feature_name} -v"
33
+ # # Project-wide runners (no placeholders): validate --all/--specs runs the suite once (batch-once).
34
+ # # run_command: "cargo test --features bdd"
35
+ # # verify_prompt: |
36
+ # # Map test failures to requirement IDs.
37
+ `;
38
+
39
+ export const DEFAULT_CONFIG_ZH_HANS = `schema: spec-driven
40
+ locale: zh-Hans
41
+
42
+ # 可选额外技能(默认禁用,取消注释以启用)。
43
+ # 运行 \`llman sdd init --update\` 时,管理候选集为:
44
+ # 默认 workflow 技能 + 本列表(extra_skills 扩展)。
45
+ # 然后扫描 \`.agents/skills/llman-sdd-*\`:不在候选集中的先删除,再写入/更新候选。
46
+ # 仅处理 \`llman-sdd-\` 前缀(无此前缀的自定义技能不会被删)。
47
+ # 已废弃的内置技能(从默认集移除后)会因此被正确清理。
48
+ # extra_skills:
49
+ # - llman-sdd-continue
50
+ # - llman-sdd-ff
51
+ # - llman-sdd-validate
52
+ # - llman-sdd-arch-review
53
+ # - llman-sdd-wayfinder
54
+ # - llman-sdd-research
55
+
56
+ # BDD 集成(可选,取消注释以启用)
57
+ # bdd:
58
+ # framework: pytest-bdd
59
+ # feature_dir: tests/features/
60
+ # # default_language: zh-CN
61
+ # # 过滤型 runner:写 {feature_*},validate --all/--specs 按 capability 分别执行。
62
+ # # run_command: "pytest {feature_dir} -k {feature_name} -v"
63
+ # # 项目级 runner(无占位符):validate --all/--specs 整批只跑一次(batch-once)。
64
+ # # run_command: "cargo test --features bdd"
65
+ # # verify_prompt: |
66
+ # # 将测试失败映射到对应的 requirement ID。
67
+ `;
68
+
69
+ export function prependSchemaHeader(content: string, schemaUrl: string): string {
70
+ const header = `# yaml-language-server: $schema=${schemaUrl}`;
71
+ if (content === '') return `${header}\n`;
72
+ return `${header}\n${content}`;
73
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * init / --update orchestration (init-generators capability, r19).
3
+ * Scaffold llmanspec/, write managed AGENTS.md blocks, render skills into
4
+ * the .agents/skills namespace (SKILL.md per skill dir), and clean the
5
+ * managed namespace.
6
+ */
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ import { loadConfig } from '../config/load.ts';
11
+ import { renderTemplate } from '../templates/engine.ts';
12
+ import { localeFallbacks } from '../templates/locale.ts';
13
+ import {
14
+ buildTemplateVars,
15
+ enforceEthicsGovernance,
16
+ loadLocaleResource,
17
+ loadSkillTemplates,
18
+ skillCandidates,
19
+ } from '../templates/skills.ts';
20
+ import {
21
+ DEFAULT_CONFIG_EN,
22
+ DEFAULT_CONFIG_ZH_HANS,
23
+ LLMANSPEC_SCHEMA_URL,
24
+ prependSchemaHeader,
25
+ } from './defaultConfig.ts';
26
+
27
+ export { effectiveRunCommand } from '../templates/skills.ts';
28
+
29
+ const MARKER_START = '<!-- LLMANSPEC:START -->';
30
+ const MARKER_END = '<!-- LLMANSPEC:END -->';
31
+
32
+ /** Marker-block update: replace block body if present, else prepend the block. */
33
+ export function updateFileWithMarkers(content: string, body: string): string {
34
+ if (content === '') {
35
+ return `${MARKER_START}\n${body}\n${MARKER_END}\n`;
36
+ }
37
+ const start = content.indexOf(MARKER_START);
38
+ const end = content.indexOf(MARKER_END);
39
+ if (start !== -1 && end !== -1) {
40
+ if (end < start)
41
+ throw new Error('Invalid marker state: end marker appears before start marker.');
42
+ return `${content.slice(0, start)}${MARKER_START}\n${body}\n${MARKER_END}${content.slice(end + MARKER_END.length)}`;
43
+ }
44
+ return `${MARKER_START}\n${body}\n${MARKER_END}\n${content}`;
45
+ }
46
+
47
+ export const TEMPLATES_ROOT = join(import.meta.dirname, '..', '..', 'templates');
48
+
49
+ function writeDefaultConfig(root: string, locale: string): void {
50
+ 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));
54
+ }
55
+
56
+ export interface InitResult {
57
+ /** Skill dirs written, in render order. */
58
+ skills: string[];
59
+ /** llman-sdd-* directories removed by --update namespace cleanup. */
60
+ removed: string[];
61
+ configPath: string;
62
+ }
63
+
64
+ export function runInit(
65
+ root: string,
66
+ opts: { update: boolean; locale?: string; version: string },
67
+ ): InitResult {
68
+ const llmanspecDir = join(root, 'llmanspec');
69
+ mkdirSync(llmanspecDir, { recursive: true });
70
+
71
+ // 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'));
75
+
76
+ // 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, '');
83
+
84
+ // 3) AGENTS.md managed blocks (root + llmanspec), preserving content.
85
+ const vars = buildTemplateVars(config, opts.version);
86
+ 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
+ );
94
+ enforceEthicsGovernance(skillTemplates);
95
+
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));
119
+ }
120
+
121
+ // 4) skills namespace cleanup (--update only): remove llman-sdd-* dirs
122
+ // outside the candidate set; un-prefixed custom skills stay untouched.
123
+ const removed: string[] = [];
124
+ const skillsBase = join(root, '.agents', 'skills');
125
+ if (opts.update && existsSync(skillsBase)) {
126
+ const candidates = new Set(skillCandidates(config).map((f) => f.replace(/\.md$/u, '')));
127
+ for (const entry of readdirSync(skillsBase)) {
128
+ if (entry.startsWith('llman-sdd-') && !candidates.has(entry)) {
129
+ rmSync(join(skillsBase, entry), { recursive: true, force: true });
130
+ removed.push(entry);
131
+ }
132
+ }
133
+ }
134
+
135
+ // 5) write candidates: rendered product trimmed + single trailing newline.
136
+ const skillsBase2 = join(root, '.agents', 'skills');
137
+ for (const t of skillTemplates) {
138
+ 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`);
142
+ }
143
+
144
+ return { skills: skillTemplates.map((t) => t.name.replace(/\.md$/u, '')), removed, configPath };
145
+ }
package/src/ports.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Ports (spec monorepo-structure r3): domain logic must not touch the
3
+ * filesystem, git subprocesses, or the terminal directly — side effects are
4
+ * injected through these interfaces. Nothing under packages/core may import
5
+ * `node:fs`, `Bun.$`, or prompt libraries; runtimes wire adapters in.
6
+ */
7
+
8
+ /** Interaction port. v1 ships an @inquirer/prompts adapter; a future ink TUI
9
+ * ships its own adapter (the two must never run in the same process). */
10
+ export interface PromptDriver {
11
+ select<T extends string>(message: string, choices: readonly T[]): Promise<T>;
12
+ multiselect<T extends string>(message: string, choices: readonly T[]): Promise<T[]>;
13
+ confirm(message: string): Promise<boolean>;
14
+ }
15
+
16
+ /** Template rendering port (nunjucks adapter planned: autoescape off,
17
+ * minijinja-compatible trim semantics, `unit()` registered via addGlobal). */
18
+ export interface TemplateEngine {
19
+ render(template: string, context: Record<string, unknown>): string;
20
+ }