silvermoon 0.0.1
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/README.md +236 -0
- package/README.zh-CN.md +199 -0
- package/assets/silvermoon.svg +175 -0
- package/bin/silvermoon.js +5 -0
- package/package.json +65 -0
- package/schema/v1/config.schema.json +24 -0
- package/schema/v1/definitions.schema.json +25 -0
- package/schema/v1/idea-status.schema.json +41 -0
- package/skills/silvermoon/SKILL.md +209 -0
- package/skills/silvermoon/references/adoption.md +122 -0
- package/src/adoption.js +313 -0
- package/src/cli.js +197 -0
- package/src/config.js +176 -0
- package/src/create-idea.js +235 -0
- package/src/git.js +284 -0
- package/src/idea-layout.js +439 -0
- package/src/idea-templates.js +109 -0
- package/src/ideas.js +132 -0
- package/src/index.js +260 -0
- package/src/layout.js +21 -0
- package/src/repository.js +88 -0
- package/src/whatsnext.js +379 -0
- package/src/yaml.js +66 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { deriveIdeaState, isValidUlid, parseIdeaStatus } from "./ideas.js";
|
|
5
|
+
import { gitObjectIdLength, runGit, worktreePathTree } from "./git.js";
|
|
6
|
+
import { IDEAS_ROOT, ideaPaths } from "./layout.js";
|
|
7
|
+
|
|
8
|
+
const REVISION_BINDINGS = {
|
|
9
|
+
approvedRevision: "idealRevision",
|
|
10
|
+
implementationAcceptedRevision: "implementationRevision",
|
|
11
|
+
deploymentAcceptedRevision: "deploymentRevision",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function displayPath(root, path) {
|
|
15
|
+
return relative(root, path).replaceAll("\\", "/");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function error(code, path, message, remediation) {
|
|
19
|
+
return { code, level: "error", path, message, remediation };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function metadata(path) {
|
|
23
|
+
try {
|
|
24
|
+
return await lstat(path);
|
|
25
|
+
} catch (caught) {
|
|
26
|
+
if (caught.code === "ENOENT") return null;
|
|
27
|
+
throw caught;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateRevisionObjects(root, statusPath, status, diagnostics) {
|
|
32
|
+
for (const key of Object.keys(REVISION_BINDINGS)) {
|
|
33
|
+
if (!status[key]) continue;
|
|
34
|
+
const type = runGit(root, ["cat-file", "-t", status[key]]);
|
|
35
|
+
if (!type.ok || type.stdout !== "tree") {
|
|
36
|
+
diagnostics.push(error(
|
|
37
|
+
"idea.revision.invalid-object",
|
|
38
|
+
`${statusPath}#${key}`,
|
|
39
|
+
`${key} must resolve to a Git tree object: ${status[key]}`,
|
|
40
|
+
"Use the current corresponding world tree object ID from Git.",
|
|
41
|
+
));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateRevisionHistory(root, commit, idea, diagnostics) {
|
|
47
|
+
for (const [statusKey, revisionKey] of Object.entries(REVISION_BINDINGS)) {
|
|
48
|
+
const revision = idea.status[statusKey];
|
|
49
|
+
if (!revision) continue;
|
|
50
|
+
const history = runGit(root, [
|
|
51
|
+
"log",
|
|
52
|
+
"--reverse",
|
|
53
|
+
"--format=%H",
|
|
54
|
+
"-S",
|
|
55
|
+
`${statusKey}: ${revision}`,
|
|
56
|
+
commit,
|
|
57
|
+
"--",
|
|
58
|
+
idea.statusPath,
|
|
59
|
+
]);
|
|
60
|
+
const evidenceCommit = history.ok
|
|
61
|
+
? history.stdout.split(/\r?\n/).filter(Boolean)[0]
|
|
62
|
+
: null;
|
|
63
|
+
if (!evidenceCommit) {
|
|
64
|
+
const shallow = runGit(root, ["rev-parse", "--is-shallow-repository"]);
|
|
65
|
+
diagnostics.push(error(
|
|
66
|
+
shallow.ok && shallow.stdout === "true"
|
|
67
|
+
? "history.incomplete"
|
|
68
|
+
: "idea.revision.history-missing",
|
|
69
|
+
`${idea.statusPath}#${statusKey}`,
|
|
70
|
+
`Primary history does not prove when ${statusKey} recorded ${revision}.`,
|
|
71
|
+
"Fetch complete primary history or repair the decision through a forward commit.",
|
|
72
|
+
));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const tree = runGit(root, ["rev-parse", `${evidenceCommit}:${idea.worlds[revisionKey].path}`]);
|
|
76
|
+
if (!tree.ok || tree.stdout !== revision) {
|
|
77
|
+
diagnostics.push(error(
|
|
78
|
+
"idea.revision.history-mismatch",
|
|
79
|
+
`${idea.statusPath}#${statusKey}`,
|
|
80
|
+
`${statusKey} was not bound to its world tree in commit ${evidenceCommit}.`,
|
|
81
|
+
"Record the decision only for the corresponding world tree in the same candidate commit.",
|
|
82
|
+
));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateCandidateRevisions(root, baseRevision, idea, diagnostics, objectIdLength) {
|
|
88
|
+
let previous = {};
|
|
89
|
+
if (baseRevision) {
|
|
90
|
+
const previousSource = runGit(root, ["show", `${baseRevision}:${idea.statusPath}`]);
|
|
91
|
+
if (previousSource.ok) {
|
|
92
|
+
try {
|
|
93
|
+
previous = parseIdeaStatus(previousSource.stdout + "\n", { objectIdLength });
|
|
94
|
+
} catch {
|
|
95
|
+
previous = {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const [statusKey, revisionKey] of Object.entries(REVISION_BINDINGS)) {
|
|
100
|
+
if (
|
|
101
|
+
idea.status[statusKey] === previous[statusKey] ||
|
|
102
|
+
idea.status[statusKey] === undefined
|
|
103
|
+
) continue;
|
|
104
|
+
const expected = idea.revisions[revisionKey];
|
|
105
|
+
if (idea.status[statusKey] !== expected) {
|
|
106
|
+
diagnostics.push(error(
|
|
107
|
+
"idea.revision.candidate-mismatch",
|
|
108
|
+
`${idea.statusPath}#${statusKey}`,
|
|
109
|
+
`${statusKey} must equal the corresponding world tree in the same candidate snapshot.`,
|
|
110
|
+
`Use ${expected} for ${revisionKey} or omit the stale mutation.`,
|
|
111
|
+
));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function requireDirectory(root, path, diagnostics) {
|
|
117
|
+
const absolute = resolve(root, path);
|
|
118
|
+
const value = await metadata(absolute);
|
|
119
|
+
if (!value || !value.isDirectory() || value.isSymbolicLink()) {
|
|
120
|
+
diagnostics.push(error(
|
|
121
|
+
"idea.world.invalid-directory",
|
|
122
|
+
path,
|
|
123
|
+
`Required world path must be a repository-owned regular directory: ${path}`,
|
|
124
|
+
`Create ${path} as a regular directory without symlinks.`,
|
|
125
|
+
));
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function requireDocument(root, path, diagnostics) {
|
|
132
|
+
const absolute = resolve(root, path);
|
|
133
|
+
const value = await metadata(absolute);
|
|
134
|
+
if (!value || !value.isFile() || value.isSymbolicLink()) {
|
|
135
|
+
diagnostics.push(error(
|
|
136
|
+
"idea.world.missing-document",
|
|
137
|
+
path,
|
|
138
|
+
`Required world entry must be a repository-owned regular file: ${path}`,
|
|
139
|
+
`Create ${path} as a regular file without symlinks.`,
|
|
140
|
+
));
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function requireLedger(root, path, diagnostics) {
|
|
147
|
+
const value = await metadata(resolve(root, path));
|
|
148
|
+
if (!value) {
|
|
149
|
+
diagnostics.push(error(
|
|
150
|
+
"idea.ledger.missing-file",
|
|
151
|
+
path,
|
|
152
|
+
`Required idea ledger does not exist: ${path}`,
|
|
153
|
+
`Create ${path} as a repository-owned regular file.`,
|
|
154
|
+
));
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (!value.isFile() || value.isSymbolicLink()) {
|
|
158
|
+
diagnostics.push(error(
|
|
159
|
+
"idea.ledger.invalid-file",
|
|
160
|
+
path,
|
|
161
|
+
`Required idea ledger must be a repository-owned regular file: ${path}`,
|
|
162
|
+
`Replace ${path} with a regular file.`,
|
|
163
|
+
));
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function rejectSymlinks(root, path, diagnostics) {
|
|
170
|
+
const absolute = resolve(root, path);
|
|
171
|
+
for (const entry of await readdir(absolute, { withFileTypes: true })) {
|
|
172
|
+
const entryPath = `${path}/${entry.name}`;
|
|
173
|
+
const value = await lstat(resolve(root, entryPath));
|
|
174
|
+
if (value.isSymbolicLink()) {
|
|
175
|
+
diagnostics.push(error(
|
|
176
|
+
"idea.world.symlink",
|
|
177
|
+
entryPath,
|
|
178
|
+
`World content must not use symlinks: ${entryPath}`,
|
|
179
|
+
"Replace the symlink with repository-owned regular content.",
|
|
180
|
+
));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (value.isDirectory()) await rejectSymlinks(root, entryPath, diagnostics);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveTree({ gitRoot, root, snapshotTree }, path) {
|
|
188
|
+
if (snapshotTree) {
|
|
189
|
+
const resolved = runGit(gitRoot, ["rev-parse", `${snapshotTree}:${path}`]);
|
|
190
|
+
if (!resolved.ok) throw new Error(resolved.stderr || `Cannot resolve ${path}`);
|
|
191
|
+
return resolved.stdout;
|
|
192
|
+
}
|
|
193
|
+
return worktreePathTree(root, path);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function inspectIdeaLayout({
|
|
197
|
+
baseRevision,
|
|
198
|
+
config: _config,
|
|
199
|
+
historyCommit,
|
|
200
|
+
root,
|
|
201
|
+
gitRoot = root,
|
|
202
|
+
snapshotTree,
|
|
203
|
+
validateCandidate = false,
|
|
204
|
+
}) {
|
|
205
|
+
const diagnostics = [];
|
|
206
|
+
const ideasRoot = resolve(root, IDEAS_ROOT);
|
|
207
|
+
const ideasRootMetadata = await metadata(ideasRoot);
|
|
208
|
+
if (!ideasRootMetadata) {
|
|
209
|
+
return {
|
|
210
|
+
diagnostics: [error(
|
|
211
|
+
"layout.ideas.missing",
|
|
212
|
+
IDEAS_ROOT,
|
|
213
|
+
`Silvermoon ideas directory does not exist: ${IDEAS_ROOT}`,
|
|
214
|
+
`Create ${IDEAS_ROOT} as a repository-owned directory.`,
|
|
215
|
+
)],
|
|
216
|
+
ideas: [],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (!ideasRootMetadata.isDirectory() || ideasRootMetadata.isSymbolicLink()) {
|
|
220
|
+
return {
|
|
221
|
+
diagnostics: [error(
|
|
222
|
+
"layout.ideas.invalid",
|
|
223
|
+
IDEAS_ROOT,
|
|
224
|
+
"The Silvermoon ideas path must be a repository-owned regular directory.",
|
|
225
|
+
`Replace ${IDEAS_ROOT} with a regular directory.`,
|
|
226
|
+
)],
|
|
227
|
+
ideas: [],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let objectIdLength;
|
|
232
|
+
try {
|
|
233
|
+
objectIdLength = gitObjectIdLength(gitRoot);
|
|
234
|
+
} catch (caught) {
|
|
235
|
+
return {
|
|
236
|
+
diagnostics: [error(
|
|
237
|
+
"git.object-format.unavailable",
|
|
238
|
+
IDEAS_ROOT,
|
|
239
|
+
caught.message,
|
|
240
|
+
"Run Silvermoon inside a Git repository with a supported object format.",
|
|
241
|
+
)],
|
|
242
|
+
ideas: [],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const aliases = new Map();
|
|
247
|
+
const ideas = [];
|
|
248
|
+
const caseNames = new Map();
|
|
249
|
+
for (const entry of await readdir(ideasRoot, { withFileTypes: true })) {
|
|
250
|
+
const folderPath = resolve(ideasRoot, entry.name);
|
|
251
|
+
const folderMetadata = await lstat(folderPath);
|
|
252
|
+
const relativePath = displayPath(root, folderPath);
|
|
253
|
+
const folded = entry.name.toUpperCase();
|
|
254
|
+
const existingCase = caseNames.get(folded);
|
|
255
|
+
if (existingCase && existingCase !== entry.name) {
|
|
256
|
+
diagnostics.push(error(
|
|
257
|
+
"idea.id.case-collision",
|
|
258
|
+
relativePath,
|
|
259
|
+
`Idea identities collide by case: ${existingCase} and ${entry.name}.`,
|
|
260
|
+
"Keep exactly one canonical uppercase ULID identity.",
|
|
261
|
+
));
|
|
262
|
+
} else {
|
|
263
|
+
caseNames.set(folded, entry.name);
|
|
264
|
+
}
|
|
265
|
+
if (!isValidUlid(entry.name)) {
|
|
266
|
+
diagnostics.push(error(
|
|
267
|
+
"idea.id.invalid",
|
|
268
|
+
relativePath,
|
|
269
|
+
`Idea folders must use canonical ULID identities: ${entry.name}`,
|
|
270
|
+
"Rename the folder to one canonical uppercase ULID.",
|
|
271
|
+
));
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (!folderMetadata.isDirectory() || folderMetadata.isSymbolicLink()) {
|
|
275
|
+
diagnostics.push(error(
|
|
276
|
+
"idea.folder.invalid",
|
|
277
|
+
relativePath,
|
|
278
|
+
"Idea must be a repository-owned regular directory.",
|
|
279
|
+
"Replace it with a regular directory.",
|
|
280
|
+
));
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const paths = ideaPaths(entry.name);
|
|
285
|
+
for (const child of await readdir(folderPath, { withFileTypes: true })) {
|
|
286
|
+
if (
|
|
287
|
+
child.name === "status.yaml" ||
|
|
288
|
+
child.name === "ledger.md" ||
|
|
289
|
+
child.name === "outer"
|
|
290
|
+
) continue;
|
|
291
|
+
diagnostics.push(error(
|
|
292
|
+
child.name.toLowerCase().includes("status")
|
|
293
|
+
? "idea.status.unexpected-file"
|
|
294
|
+
: "idea.entry.unexpected",
|
|
295
|
+
`${paths.ideaPath}/${child.name}`,
|
|
296
|
+
`Unexpected entry at the idea root: ${child.name}`,
|
|
297
|
+
"Keep only status.yaml, ledger.md, and outer/ at the idea root; put supporting files in their world.",
|
|
298
|
+
));
|
|
299
|
+
}
|
|
300
|
+
await requireLedger(root, paths.ledgerPath, diagnostics);
|
|
301
|
+
const requiredDirectories = [
|
|
302
|
+
paths.outerPath,
|
|
303
|
+
paths.innerPath,
|
|
304
|
+
paths.idealPath,
|
|
305
|
+
];
|
|
306
|
+
const requiredDocuments = [
|
|
307
|
+
paths.deploymentDocumentPath,
|
|
308
|
+
paths.implementationDocumentPath,
|
|
309
|
+
paths.ideaDocumentPath,
|
|
310
|
+
];
|
|
311
|
+
const validDirectories = (await Promise.all(
|
|
312
|
+
requiredDirectories.map((path) => requireDirectory(root, path, diagnostics)),
|
|
313
|
+
)).every(Boolean);
|
|
314
|
+
const validDocuments = (await Promise.all(
|
|
315
|
+
requiredDocuments.map((path) => requireDocument(root, path, diagnostics)),
|
|
316
|
+
)).every(Boolean);
|
|
317
|
+
if (validDirectories) await rejectSymlinks(root, paths.outerPath, diagnostics);
|
|
318
|
+
|
|
319
|
+
const statusMetadata = await metadata(resolve(root, paths.statusPath));
|
|
320
|
+
if (!statusMetadata || !statusMetadata.isFile() || statusMetadata.isSymbolicLink()) {
|
|
321
|
+
diagnostics.push(error(
|
|
322
|
+
"idea.status.invalid-file",
|
|
323
|
+
paths.statusPath,
|
|
324
|
+
"Idea status must be a repository-owned regular file named status.yaml.",
|
|
325
|
+
`Create ${paths.statusPath} as canonical YAML.`,
|
|
326
|
+
));
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let status;
|
|
331
|
+
try {
|
|
332
|
+
status = parseIdeaStatus(
|
|
333
|
+
await readFile(resolve(root, paths.statusPath), "utf8"),
|
|
334
|
+
{ objectIdLength },
|
|
335
|
+
);
|
|
336
|
+
} catch (caught) {
|
|
337
|
+
diagnostics.push(error(
|
|
338
|
+
"idea.status.invalid",
|
|
339
|
+
paths.statusPath,
|
|
340
|
+
caught.message,
|
|
341
|
+
"Rewrite status.yaml in canonical form.",
|
|
342
|
+
));
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (status.id !== entry.name) {
|
|
346
|
+
diagnostics.push(error(
|
|
347
|
+
"idea.status.id-mismatch",
|
|
348
|
+
`${paths.statusPath}#id`,
|
|
349
|
+
`Status id ${status.id} does not match ${entry.name}.`,
|
|
350
|
+
"Use the same canonical ULID in the folder and status id.",
|
|
351
|
+
));
|
|
352
|
+
}
|
|
353
|
+
if (status.alias !== undefined) {
|
|
354
|
+
const aliasOwner = aliases.get(status.alias);
|
|
355
|
+
if (aliasOwner) {
|
|
356
|
+
diagnostics.push(error(
|
|
357
|
+
"idea.alias.duplicate",
|
|
358
|
+
`${paths.statusPath}#alias`,
|
|
359
|
+
`Alias ${status.alias} is shared by ${aliasOwner} and ${entry.name}.`,
|
|
360
|
+
"Assign a unique exact case-sensitive alias.",
|
|
361
|
+
));
|
|
362
|
+
} else {
|
|
363
|
+
aliases.set(status.alias, entry.name);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (
|
|
367
|
+
!validDirectories ||
|
|
368
|
+
!validDocuments ||
|
|
369
|
+
diagnostics.some(({ code, path }) =>
|
|
370
|
+
code === "idea.world.symlink" && path.startsWith(`${paths.outerPath}/`)
|
|
371
|
+
)
|
|
372
|
+
) continue;
|
|
373
|
+
|
|
374
|
+
let revisions;
|
|
375
|
+
try {
|
|
376
|
+
revisions = {
|
|
377
|
+
idealRevision: resolveTree({ gitRoot, root, snapshotTree }, paths.idealPath),
|
|
378
|
+
implementationRevision: resolveTree(
|
|
379
|
+
{ gitRoot, root, snapshotTree },
|
|
380
|
+
paths.innerPath,
|
|
381
|
+
),
|
|
382
|
+
deploymentRevision: resolveTree(
|
|
383
|
+
{ gitRoot, root, snapshotTree },
|
|
384
|
+
paths.outerPath,
|
|
385
|
+
),
|
|
386
|
+
};
|
|
387
|
+
} catch (caught) {
|
|
388
|
+
diagnostics.push(error(
|
|
389
|
+
"idea.revision.unavailable",
|
|
390
|
+
paths.ideaPath,
|
|
391
|
+
caught.message,
|
|
392
|
+
"Ensure every world can be represented as a Git tree.",
|
|
393
|
+
));
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
validateRevisionObjects(gitRoot, paths.statusPath, status, diagnostics);
|
|
398
|
+
const worlds = {
|
|
399
|
+
idealRevision: {
|
|
400
|
+
name: "Ideal World",
|
|
401
|
+
displayName: "道心",
|
|
402
|
+
path: paths.idealPath,
|
|
403
|
+
documentPath: paths.ideaDocumentPath,
|
|
404
|
+
},
|
|
405
|
+
implementationRevision: {
|
|
406
|
+
name: "Inner World",
|
|
407
|
+
displayName: "内景",
|
|
408
|
+
path: paths.innerPath,
|
|
409
|
+
documentPath: paths.implementationDocumentPath,
|
|
410
|
+
},
|
|
411
|
+
deploymentRevision: {
|
|
412
|
+
name: "Outer World",
|
|
413
|
+
displayName: "现世",
|
|
414
|
+
path: paths.outerPath,
|
|
415
|
+
documentPath: paths.deploymentDocumentPath,
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
const idea = {
|
|
419
|
+
id: entry.name,
|
|
420
|
+
path: folderPath,
|
|
421
|
+
relativePath: paths.ideaPath,
|
|
422
|
+
revisions,
|
|
423
|
+
...revisions,
|
|
424
|
+
state: deriveIdeaState(revisions, status),
|
|
425
|
+
status,
|
|
426
|
+
statusPath: paths.statusPath,
|
|
427
|
+
ledgerPath: paths.ledgerPath,
|
|
428
|
+
worlds,
|
|
429
|
+
};
|
|
430
|
+
if (status.alias !== undefined) idea.alias = status.alias;
|
|
431
|
+
if (validateCandidate) {
|
|
432
|
+
validateCandidateRevisions(gitRoot, baseRevision, idea, diagnostics, objectIdLength);
|
|
433
|
+
}
|
|
434
|
+
if (historyCommit) validateRevisionHistory(gitRoot, historyCommit, idea, diagnostics);
|
|
435
|
+
ideas.push(idea);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return { diagnostics, ideas };
|
|
439
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export const IDEA_TEMPLATE = `# Idea
|
|
2
|
+
|
|
3
|
+
## Intent
|
|
4
|
+
|
|
5
|
+
<!-- State the desired outcome in one or two sentences. -->
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
<!-- Describe the current problem, situation, or opportunity. -->
|
|
10
|
+
|
|
11
|
+
## Desired outcome
|
|
12
|
+
|
|
13
|
+
<!-- Describe the externally meaningful state that should become true. -->
|
|
14
|
+
|
|
15
|
+
## Scope
|
|
16
|
+
|
|
17
|
+
### In scope
|
|
18
|
+
|
|
19
|
+
<!-- Describe what this idea includes. -->
|
|
20
|
+
|
|
21
|
+
### Out of scope
|
|
22
|
+
|
|
23
|
+
<!-- Describe adjacent work this idea intentionally excludes. -->
|
|
24
|
+
|
|
25
|
+
## Constraints
|
|
26
|
+
|
|
27
|
+
<!-- Record material product, repository, compatibility, or operational constraints. -->
|
|
28
|
+
|
|
29
|
+
## Open questions
|
|
30
|
+
|
|
31
|
+
<!-- Record unresolved decisions. Remove this section when none remain. -->
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
export const IMPLEMENTATION_TEMPLATE = `# Implementation
|
|
35
|
+
|
|
36
|
+
## Steps
|
|
37
|
+
|
|
38
|
+
<!--
|
|
39
|
+
Give every step a stable I-Sxx identifier and a level-three heading.
|
|
40
|
+
Describe what will change, its boundaries, and important design details.
|
|
41
|
+
Do not use task-list checkboxes in this document.
|
|
42
|
+
-->
|
|
43
|
+
|
|
44
|
+
### I-S01: Step title
|
|
45
|
+
|
|
46
|
+
<!-- Describe this implementation step. -->
|
|
47
|
+
|
|
48
|
+
## Acceptance criteria
|
|
49
|
+
|
|
50
|
+
<!--
|
|
51
|
+
Give every criterion a stable I-ACxx identifier and a level-three heading.
|
|
52
|
+
Describe both the observable outcome and the method that proves it.
|
|
53
|
+
Do not create a separate validation section or use task-list checkboxes.
|
|
54
|
+
-->
|
|
55
|
+
|
|
56
|
+
### I-AC01: Criterion title
|
|
57
|
+
|
|
58
|
+
<!-- Describe the required outcome and how an Agent can prove it. -->
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
export const DEPLOYMENT_TEMPLATE = `# Deployment
|
|
62
|
+
|
|
63
|
+
## Steps
|
|
64
|
+
|
|
65
|
+
<!--
|
|
66
|
+
Give every step a stable D-Sxx identifier and a level-three heading.
|
|
67
|
+
Describe deployment or external-world verification work.
|
|
68
|
+
Do not use task-list checkboxes in this document.
|
|
69
|
+
-->
|
|
70
|
+
|
|
71
|
+
### D-S01: Step title
|
|
72
|
+
|
|
73
|
+
<!-- Describe this deployment or external-verification step. -->
|
|
74
|
+
|
|
75
|
+
## Acceptance criteria
|
|
76
|
+
|
|
77
|
+
<!--
|
|
78
|
+
Give every criterion a stable D-ACxx identifier and a level-three heading.
|
|
79
|
+
Describe both the observable external outcome and the method that proves it.
|
|
80
|
+
Do not create a separate validation section or use task-list checkboxes.
|
|
81
|
+
-->
|
|
82
|
+
|
|
83
|
+
### D-AC01: Criterion title
|
|
84
|
+
|
|
85
|
+
<!-- Describe the required external outcome and how an Agent can prove it. -->
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
export const LEDGER_TEMPLATE = `# Ledger
|
|
89
|
+
|
|
90
|
+
## Implementation
|
|
91
|
+
|
|
92
|
+
### Steps
|
|
93
|
+
|
|
94
|
+
- [ ] **I-S01:** Step title
|
|
95
|
+
|
|
96
|
+
### Acceptance criteria
|
|
97
|
+
|
|
98
|
+
- [ ] **I-AC01:** Criterion title
|
|
99
|
+
|
|
100
|
+
## Deployment
|
|
101
|
+
|
|
102
|
+
### Steps
|
|
103
|
+
|
|
104
|
+
- [ ] **D-S01:** Step title
|
|
105
|
+
|
|
106
|
+
### Acceptance criteria
|
|
107
|
+
|
|
108
|
+
- [ ] **D-AC01:** Criterion title
|
|
109
|
+
`;
|
package/src/ideas.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { parseStrictYaml, stringifyCanonicalYaml } from "./yaml.js";
|
|
2
|
+
|
|
3
|
+
export const IDEA_STATES = [
|
|
4
|
+
"preparing",
|
|
5
|
+
"implementing",
|
|
6
|
+
"deploying",
|
|
7
|
+
"completed",
|
|
8
|
+
"abandoned",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
12
|
+
const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
13
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
14
|
+
const STATUS_KEYS = new Set([
|
|
15
|
+
"version",
|
|
16
|
+
"id",
|
|
17
|
+
"alias",
|
|
18
|
+
"abandoned",
|
|
19
|
+
"approvedRevision",
|
|
20
|
+
"implementationAcceptedRevision",
|
|
21
|
+
"deploymentAcceptedRevision",
|
|
22
|
+
]);
|
|
23
|
+
const REVISION_KEYS = [
|
|
24
|
+
"approvedRevision",
|
|
25
|
+
"implementationAcceptedRevision",
|
|
26
|
+
"deploymentAcceptedRevision",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function ideaStatusError(message) {
|
|
30
|
+
return new Error(`Invalid idea status: ${message}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isMapping(value) {
|
|
34
|
+
return value !== null && !Array.isArray(value) && typeof value === "object";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isValidAlias(value) {
|
|
38
|
+
return (
|
|
39
|
+
typeof value === "string" &&
|
|
40
|
+
value.length > 0 &&
|
|
41
|
+
value.trim() === value &&
|
|
42
|
+
[...value].length <= 120 &&
|
|
43
|
+
!CONTROL_CHARACTER.test(value)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isValidUlid(value) {
|
|
48
|
+
return typeof value === "string" && ULID.test(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validateIdeaStatus(value, { objectIdLength } = {}) {
|
|
52
|
+
if (!isMapping(value)) throw ideaStatusError("document must be a mapping");
|
|
53
|
+
for (const key of Object.keys(value)) {
|
|
54
|
+
if (!STATUS_KEYS.has(key)) throw ideaStatusError(`unknown field: ${key}`);
|
|
55
|
+
}
|
|
56
|
+
for (const key of ["version", "id"]) {
|
|
57
|
+
if (!Object.hasOwn(value, key)) throw ideaStatusError(`missing ${key}`);
|
|
58
|
+
}
|
|
59
|
+
if (value.version !== 1) throw ideaStatusError("version must be 1");
|
|
60
|
+
if (!isValidUlid(value.id)) throw ideaStatusError("id must be a canonical ULID");
|
|
61
|
+
if (Object.hasOwn(value, "alias") && !isValidAlias(value.alias)) {
|
|
62
|
+
throw ideaStatusError("alias must be 1 to 120 trimmed characters without controls");
|
|
63
|
+
}
|
|
64
|
+
if (Object.hasOwn(value, "abandoned") && value.abandoned !== true) {
|
|
65
|
+
throw ideaStatusError("abandoned must be omitted or true");
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
objectIdLength !== undefined &&
|
|
69
|
+
(!Number.isInteger(objectIdLength) || objectIdLength <= 0)
|
|
70
|
+
) {
|
|
71
|
+
throw ideaStatusError("objectIdLength must be a positive integer");
|
|
72
|
+
}
|
|
73
|
+
for (const key of REVISION_KEYS) {
|
|
74
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
75
|
+
if (
|
|
76
|
+
typeof value[key] !== "string" ||
|
|
77
|
+
!OBJECT_ID.test(value[key]) ||
|
|
78
|
+
(objectIdLength !== undefined && value[key].length !== objectIdLength)
|
|
79
|
+
) {
|
|
80
|
+
throw ideaStatusError(`${key} must be a lowercase hexadecimal Git object ID`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function serializeIdeaStatus(value, options) {
|
|
87
|
+
validateIdeaStatus(value, options);
|
|
88
|
+
const canonical = {
|
|
89
|
+
version: 1,
|
|
90
|
+
id: value.id,
|
|
91
|
+
};
|
|
92
|
+
if (Object.hasOwn(value, "alias")) canonical.alias = value.alias;
|
|
93
|
+
if (value.abandoned === true) canonical.abandoned = true;
|
|
94
|
+
for (const key of REVISION_KEYS) {
|
|
95
|
+
if (Object.hasOwn(value, key)) canonical[key] = value[key];
|
|
96
|
+
}
|
|
97
|
+
return stringifyCanonicalYaml(canonical);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseIdeaStatus(source, options) {
|
|
101
|
+
const normalizedSource = source.replaceAll("\r\n", "\n");
|
|
102
|
+
let value;
|
|
103
|
+
try {
|
|
104
|
+
value = parseStrictYaml(normalizedSource);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw ideaStatusError(error.message);
|
|
107
|
+
}
|
|
108
|
+
validateIdeaStatus(value, options);
|
|
109
|
+
if (serializeIdeaStatus(value, options) !== normalizedSource) {
|
|
110
|
+
throw ideaStatusError("status is not canonical");
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function deriveIdeaState(revisions, status) {
|
|
116
|
+
if (revisions === null || Array.isArray(revisions) || typeof revisions !== "object") {
|
|
117
|
+
throw ideaStatusError("revisions must be a mapping");
|
|
118
|
+
}
|
|
119
|
+
for (const key of ["idealRevision", "implementationRevision", "deploymentRevision"]) {
|
|
120
|
+
if (typeof revisions[key] !== "string" || !OBJECT_ID.test(revisions[key])) {
|
|
121
|
+
throw ideaStatusError(`${key} must be a lowercase hexadecimal Git object ID`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
validateIdeaStatus(status);
|
|
125
|
+
if (status.abandoned) return "abandoned";
|
|
126
|
+
if (status.approvedRevision !== revisions.idealRevision) return "preparing";
|
|
127
|
+
if (
|
|
128
|
+
status.implementationAcceptedRevision !== revisions.implementationRevision
|
|
129
|
+
) return "implementing";
|
|
130
|
+
if (status.deploymentAcceptedRevision !== revisions.deploymentRevision) return "deploying";
|
|
131
|
+
return "completed";
|
|
132
|
+
}
|