@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.
- package/package.json +1 -1
- package/src/archive/freeze.ts +6 -4
- package/src/archive/sevenzip.ts +35 -6
- package/src/context/indexStore.ts +6 -12
- package/src/git/spawnGit.ts +0 -8
- package/src/index.ts +39 -5
- 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 +68 -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/freeze.ts
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
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`,
|
package/src/archive/sevenzip.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
@@ -117,7 +144,13 @@ export {
|
|
|
117
144
|
type SpecMorphology,
|
|
118
145
|
type SpecSummary,
|
|
119
146
|
} from './report/specs.ts';
|
|
120
|
-
export {
|
|
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
|
|
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),
|