ddduck 0.1.0 → 0.2.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.
- package/README.md +11 -6
- package/docs/architecture.md +5 -2
- package/docs/cli.md +135 -53
- package/docs/definition-workflow.md +130 -0
- package/docs/getting-started.md +21 -48
- package/docs/model-reference.md +4 -0
- package/docs/model.md +1 -0
- package/docs/templates/change-brief.md +48 -0
- package/package.json +8 -5
- package/schemas/model-diff.schema.json +107 -0
- package/scripts/audit-fr-to-code.mjs +25 -0
- package/scripts/check-generated-docs.mjs +24 -5
- package/scripts/check-generated-graph-svg.mjs +26 -8
- package/scripts/check-generated-graph.mjs +25 -5
- package/scripts/check-model.mjs +95 -5
- package/scripts/ddduck.mjs +216 -22
- package/scripts/generate-agent-readiness-report.mjs +8 -0
- package/scripts/generate-docs.mjs +29 -3
- package/scripts/generate-graph-svg.mjs +53 -16
- package/scripts/generate-graph.mjs +26 -1
- package/scripts/lib/agent-readiness-evals.mjs +32 -0
- package/scripts/lib/agent-readiness-report.mjs +14 -0
- package/scripts/lib/cli-contract.mjs +65 -15
- package/scripts/lib/context-pack.mjs +49 -1
- package/scripts/lib/ddduck-config.mjs +31 -1
- package/scripts/lib/fr-to-code-audit.mjs +30 -0
- package/scripts/lib/product-authoring.mjs +52 -0
- package/scripts/lib/product-diff.mjs +155 -0
- package/scripts/lib/product-layout.mjs +42 -1
- package/scripts/lib/product-operation.mjs +140 -22
- package/scripts/lib/product-paths.mjs +16 -0
- package/scripts/lib/product-query.mjs +102 -20
- package/scripts/lib/product-root-resolver.mjs +40 -0
- package/scripts/lib/scan-ignore.mjs +14 -3
- package/scripts/lib/skill-installer.mjs +227 -39
- package/scripts/query-model.mjs +18 -7
- package/scripts/run-agent-readiness-evals.mjs +18 -2
- package/skills/update-ddduck-specs/SKILL.md +25 -83
- package/skills/update-ddduck-specs/references/authoring-and-verification.md +60 -0
- package/skills/update-ddduck-specs/references/modeling-and-evidence.md +56 -0
- package/skills/update-ddduck-specs/references/reviewing-changes.md +45 -0
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installer for the bundled update-ddduck-specs agent skill, behind `ddduck
|
|
3
|
+
* install skill`. Selects a host topology from the repository state (codex
|
|
4
|
+
* .agents/, claude-code .claude/, or shared via symlink), plans create,
|
|
5
|
+
* upgrade, or no-op against the canonical skill bundle and the
|
|
6
|
+
* .ddduck/agent-skills.lock.json lock, and applies the plan with atomic
|
|
7
|
+
* writes plus rollback of everything touched on failure. Conflicting host
|
|
8
|
+
* state or a locally modified managed bundle refuses with a nextAction
|
|
9
|
+
* naming the exact path to resolve.
|
|
10
|
+
*/
|
|
11
|
+
|
|
1
12
|
import {
|
|
2
13
|
lstatSync,
|
|
3
14
|
mkdirSync,
|
|
@@ -12,7 +23,7 @@ import {
|
|
|
12
23
|
import { createHash, randomUUID } from "node:crypto";
|
|
13
24
|
import path from "node:path";
|
|
14
25
|
|
|
15
|
-
const lockSchemaVersion =
|
|
26
|
+
const lockSchemaVersion = 2;
|
|
16
27
|
const lockRelativePath = path.join(".ddduck", "agent-skills.lock.json");
|
|
17
28
|
|
|
18
29
|
const defaultOperations = {
|
|
@@ -112,21 +123,48 @@ const installTopologies = [
|
|
|
112
123
|
},
|
|
113
124
|
];
|
|
114
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Install (or upgrade) the bundled skill into a repository: load, plan, apply.
|
|
128
|
+
* @param {{repository: string, skillName: string, skillPath: string, packageVersion: string, operations?: object}} options - Repository root, skill identity, bundled asset path, and ddduck version for the lock.
|
|
129
|
+
* @returns {{action: "create"|"upgrade"|"no-op", skillSha256: string, bundleSha256: string, canonicalPath: string, lockPath: string}} The installation result.
|
|
130
|
+
*/
|
|
115
131
|
export function installSkill(options) {
|
|
116
132
|
const bundle = loadSkillBundle(options);
|
|
117
133
|
const plan = planSkillInstall({ ...options, bundle });
|
|
118
134
|
return applySkillInstall({ ...options, bundle, plan });
|
|
119
135
|
}
|
|
120
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Read the bundled skill directory and compute per-file and bundle digests.
|
|
139
|
+
* @param {{skillName: string, skillPath: string, operations?: object}} options - Skill name, SKILL.md path, and fs overrides for tests.
|
|
140
|
+
* @returns {{name: string, files: {path: string, bytes: Buffer, sha256: string}[], skillSha256: string, bundleSha256: string, sha256: string}} The loaded bundle.
|
|
141
|
+
*/
|
|
121
142
|
export function loadSkillBundle({ skillName, skillPath, operations = {} }) {
|
|
122
143
|
const resolvedOperations = { ...defaultOperations, ...operations };
|
|
123
144
|
if (pathState(skillPath, resolvedOperations).type !== "file") {
|
|
124
145
|
throw new Error(`Missing bundled skill asset: ${skillPath}`);
|
|
125
146
|
}
|
|
126
|
-
const
|
|
127
|
-
|
|
147
|
+
const files = readBundleFiles(path.dirname(skillPath), resolvedOperations);
|
|
148
|
+
const skill = files.find(({ path: relativePath }) => relativePath === skillFileName);
|
|
149
|
+
if (!skill) throw new Error(`Missing bundled skill asset: ${skillPath}`);
|
|
150
|
+
const bundleSha256 = files.length === 1 ? skill.sha256 : digestBundle(files);
|
|
151
|
+
return {
|
|
152
|
+
name: skillName,
|
|
153
|
+
files,
|
|
154
|
+
skillSha256: skill.sha256,
|
|
155
|
+
bundleSha256,
|
|
156
|
+
// Preserve the internal single-file field while older callers migrate.
|
|
157
|
+
sha256: skill.sha256,
|
|
158
|
+
};
|
|
128
159
|
}
|
|
129
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Inspect the repository's lock, canonical file, and host adapters and decide
|
|
163
|
+
* the action: create, upgrade, or no-op — or throw on conflicting host state,
|
|
164
|
+
* an incomplete lock, or a locally modified canonical skill.
|
|
165
|
+
* @param {{repository: string, skillName: string, bundle: object, operations?: object}} options - Repository root, skill name, loaded bundle, and fs overrides.
|
|
166
|
+
* @returns {{action: string, paths: object, topology: object, adaptersToMaterialize: object[], writeBundle: boolean, staleFiles: string[]}} The install plan for applySkillInstall.
|
|
167
|
+
*/
|
|
130
168
|
export function planSkillInstall({ repository, skillName, bundle, operations = {} }) {
|
|
131
169
|
const resolvedOperations = { ...defaultOperations, ...operations };
|
|
132
170
|
const root = path.resolve(repository);
|
|
@@ -149,51 +187,77 @@ export function planSkillInstall({ repository, skillName, bundle, operations = {
|
|
|
149
187
|
canonical.type === "absent" &&
|
|
150
188
|
resolvedOperations.readdirSync(paths.canonicalDirectory).length > 0
|
|
151
189
|
) {
|
|
152
|
-
throw
|
|
190
|
+
throw conflictingHostState(
|
|
191
|
+
`Conflicting canonical skill directory: ${paths.canonicalDirectory}`,
|
|
192
|
+
paths.canonicalDirectory,
|
|
193
|
+
);
|
|
153
194
|
}
|
|
154
195
|
if (canonical.type === "absent") {
|
|
155
|
-
return createPlan({ paths, adapters,
|
|
196
|
+
return createPlan({ paths, adapters, writeBundle: true });
|
|
156
197
|
}
|
|
157
|
-
if (
|
|
158
|
-
|
|
198
|
+
if (
|
|
199
|
+
canonical.type !== "file" ||
|
|
200
|
+
sha256(resolvedOperations.readFileSync(paths.canonical)) !== bundle.skillSha256 ||
|
|
201
|
+
bundle.files.length !== 1
|
|
202
|
+
) {
|
|
203
|
+
throw conflictingHostState(`Conflicting canonical skill destination: ${paths.canonical}`, paths.canonical);
|
|
159
204
|
}
|
|
160
|
-
return createPlan({ paths, adapters,
|
|
205
|
+
return createPlan({ paths, adapters, writeBundle: false });
|
|
161
206
|
}
|
|
162
207
|
|
|
163
208
|
const lock = lockState.value;
|
|
164
209
|
if (!isValidLock(lock, skillName, topology)) throw incompleteLock(paths.lock);
|
|
165
|
-
if (canonical.type === "absent")
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
throw locallyModifiedCanonical(paths.canonical);
|
|
210
|
+
if (canonical.type === "absent") {
|
|
211
|
+
assertRemainingBundleMatchesLock(paths, lock, resolvedOperations);
|
|
212
|
+
return createPlan({ paths, adapters, writeBundle: true });
|
|
169
213
|
}
|
|
214
|
+
if (canonical.type !== "file") throw locallyModifiedCanonical(paths.canonical);
|
|
215
|
+
assertInstalledBundleMatchesLock(paths, lock, resolvedOperations);
|
|
170
216
|
if (adapters.some(({ state }) => state !== "valid")) throw incompleteLock(paths.lock);
|
|
171
217
|
|
|
218
|
+
const installedDigest = lock.bundleSha256 ?? lock.skillSha256;
|
|
219
|
+
const staleFiles =
|
|
220
|
+
lock.files
|
|
221
|
+
?.map(({ path: relativePath }) => relativePath)
|
|
222
|
+
.filter((relativePath) => !bundle.files.some((file) => file.path === relativePath)) ?? [];
|
|
223
|
+
|
|
172
224
|
return {
|
|
173
|
-
action:
|
|
225
|
+
action: installedDigest === bundle.bundleSha256 ? "no-op" : "upgrade",
|
|
174
226
|
paths,
|
|
175
227
|
topology,
|
|
176
228
|
adaptersToMaterialize: [],
|
|
177
|
-
|
|
229
|
+
writeBundle: installedDigest !== bundle.bundleSha256,
|
|
230
|
+
staleFiles,
|
|
178
231
|
};
|
|
179
232
|
}
|
|
180
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Execute an install plan: atomically write each bundled file, materialize
|
|
236
|
+
* host adapters, and write the lock; on failure roll back everything touched
|
|
237
|
+
* and report any recovery failures in the thrown error.
|
|
238
|
+
* @param {{packageVersion: string, bundle: object, plan: object, operations?: object}} options - ddduck version for the lock, loaded bundle, plan from planSkillInstall, and fs overrides.
|
|
239
|
+
* @returns {{action: string, skillSha256: string, bundleSha256: string, canonicalPath: string, lockPath: string}} The installation result.
|
|
240
|
+
*/
|
|
181
241
|
export function applySkillInstall({ packageVersion, bundle, plan, operations = {} }) {
|
|
182
242
|
const resolvedOperations = { ...defaultOperations, ...operations };
|
|
183
243
|
if (plan.action === "no-op") return installResult("no-op", bundle, plan);
|
|
184
244
|
|
|
185
245
|
const touched = [];
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
? resolvedOperations.readFileSync(plan.paths.canonical)
|
|
189
|
-
: null;
|
|
190
|
-
let canonicalWritten = false;
|
|
246
|
+
const originalBundle = plan.writeBundle ? snapshotDirectory(plan.paths.canonicalDirectory, resolvedOperations) : null;
|
|
247
|
+
let bundleWritten = false;
|
|
191
248
|
const materializedAdapters = [];
|
|
192
249
|
|
|
193
250
|
try {
|
|
194
|
-
if (plan.
|
|
195
|
-
|
|
196
|
-
|
|
251
|
+
if (plan.writeBundle) {
|
|
252
|
+
bundleWritten = true;
|
|
253
|
+
for (const file of bundle.files) {
|
|
254
|
+
atomicWrite(path.join(plan.paths.canonicalDirectory, file.path), file.bytes, resolvedOperations, touched);
|
|
255
|
+
}
|
|
256
|
+
for (const relativePath of plan.staleFiles ?? []) {
|
|
257
|
+
const stalePath = path.join(plan.paths.canonicalDirectory, relativePath);
|
|
258
|
+
touched.push(stalePath);
|
|
259
|
+
resolvedOperations.rmSync(stalePath, { force: true });
|
|
260
|
+
}
|
|
197
261
|
}
|
|
198
262
|
for (const adapter of plan.adaptersToMaterialize) {
|
|
199
263
|
adapter.materialize({
|
|
@@ -214,8 +278,8 @@ export function applySkillInstall({ packageVersion, bundle, plan, operations = {
|
|
|
214
278
|
} catch (error) {
|
|
215
279
|
const recoveryFailures = restoreAfterFailure({
|
|
216
280
|
plan,
|
|
217
|
-
|
|
218
|
-
|
|
281
|
+
originalBundle,
|
|
282
|
+
bundleWritten,
|
|
219
283
|
materializedAdapters,
|
|
220
284
|
operations: resolvedOperations,
|
|
221
285
|
touched,
|
|
@@ -229,19 +293,21 @@ export function applySkillInstall({ packageVersion, bundle, plan, operations = {
|
|
|
229
293
|
function installResult(action, bundle, plan) {
|
|
230
294
|
return {
|
|
231
295
|
action,
|
|
232
|
-
skillSha256: bundle.
|
|
296
|
+
skillSha256: bundle.skillSha256,
|
|
297
|
+
bundleSha256: bundle.bundleSha256,
|
|
233
298
|
canonicalPath: toPosixPath(plan.topology.canonicalRelativePath),
|
|
234
299
|
lockPath: toPosixPath(lockRelativePath),
|
|
235
300
|
};
|
|
236
301
|
}
|
|
237
302
|
|
|
238
|
-
function createPlan({ paths, adapters,
|
|
303
|
+
function createPlan({ paths, adapters, writeBundle }) {
|
|
239
304
|
return {
|
|
240
305
|
action: "create",
|
|
241
306
|
paths,
|
|
242
307
|
topology: paths.topology,
|
|
243
308
|
adaptersToMaterialize: adapters.filter(({ state }) => state === "absent").map(({ adapter }) => adapter),
|
|
244
|
-
|
|
309
|
+
writeBundle,
|
|
310
|
+
staleFiles: [],
|
|
245
311
|
};
|
|
246
312
|
}
|
|
247
313
|
|
|
@@ -320,23 +386,39 @@ function selectTopology({ root, lockState, operations }) {
|
|
|
320
386
|
function isValidLock(lock, skillName, topology) {
|
|
321
387
|
return (
|
|
322
388
|
lock &&
|
|
323
|
-
lock.schemaVersion
|
|
389
|
+
[1, lockSchemaVersion].includes(lock.schemaVersion) &&
|
|
324
390
|
lock.skill === skillName &&
|
|
325
391
|
typeof lock.ddduckVersion === "string" &&
|
|
326
392
|
lock.ddduckVersion.length > 0 &&
|
|
327
393
|
toPosixPath(lock.canonicalPath) === toPosixPath(topology.canonicalRelativePath) &&
|
|
328
394
|
/^[a-f0-9]{64}$/.test(lock.skillSha256) &&
|
|
395
|
+
(lock.schemaVersion === 1 || isValidBundleLock(lock)) &&
|
|
329
396
|
JSON.stringify(lock.adapters) === JSON.stringify(expectedAdapters(topology))
|
|
330
397
|
);
|
|
331
398
|
}
|
|
332
399
|
|
|
333
400
|
function createLock({ packageVersion, bundle, topology }) {
|
|
401
|
+
if (bundle.files.length === 1) {
|
|
402
|
+
return {
|
|
403
|
+
schemaVersion: 1,
|
|
404
|
+
skill: bundle.name,
|
|
405
|
+
ddduckVersion: packageVersion,
|
|
406
|
+
canonicalPath: toPosixPath(topology.canonicalRelativePath),
|
|
407
|
+
skillSha256: bundle.skillSha256,
|
|
408
|
+
adapters: expectedAdapters(topology),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
334
411
|
return {
|
|
335
412
|
schemaVersion: lockSchemaVersion,
|
|
336
413
|
skill: bundle.name,
|
|
337
414
|
ddduckVersion: packageVersion,
|
|
338
415
|
canonicalPath: toPosixPath(topology.canonicalRelativePath),
|
|
339
|
-
skillSha256: bundle.
|
|
416
|
+
skillSha256: bundle.skillSha256,
|
|
417
|
+
bundleSha256: bundle.bundleSha256,
|
|
418
|
+
files: bundle.files.map(({ path: relativePath, sha256: fileSha256 }) => ({
|
|
419
|
+
path: relativePath,
|
|
420
|
+
sha256: fileSha256,
|
|
421
|
+
})),
|
|
340
422
|
adapters: expectedAdapters(topology),
|
|
341
423
|
};
|
|
342
424
|
}
|
|
@@ -347,7 +429,18 @@ function expectedAdapters(topology) {
|
|
|
347
429
|
|
|
348
430
|
function conflictingHostAdapter({ adapter }, paths) {
|
|
349
431
|
const label = adapter.host === "claude-code" ? "Claude Code" : adapter.host;
|
|
350
|
-
return
|
|
432
|
+
return conflictingHostState(
|
|
433
|
+
`Conflicting ${label} adapter: ${paths.adapters[adapter.host]}`,
|
|
434
|
+
paths.adapters[adapter.host],
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Host-state conflicts are environment failures, not input failures: name the
|
|
439
|
+
// pre-existing path the user must resolve instead of the usage hint.
|
|
440
|
+
function conflictingHostState(message, conflictingPath) {
|
|
441
|
+
const error = new Error(message);
|
|
442
|
+
error.nextAction = `Move ${conflictingPath} aside or remove it, then re-run ddduck install skill update-ddduck-specs.`;
|
|
443
|
+
return error;
|
|
351
444
|
}
|
|
352
445
|
|
|
353
446
|
function atomicWrite(destination, content, operations, touched) {
|
|
@@ -363,7 +456,7 @@ function atomicWrite(destination, content, operations, touched) {
|
|
|
363
456
|
}
|
|
364
457
|
}
|
|
365
458
|
|
|
366
|
-
function restoreAfterFailure({ plan,
|
|
459
|
+
function restoreAfterFailure({ plan, originalBundle, bundleWritten, materializedAdapters, operations, touched }) {
|
|
367
460
|
const failures = [];
|
|
368
461
|
for (const adapter of materializedAdapters.toReversed()) {
|
|
369
462
|
try {
|
|
@@ -376,23 +469,26 @@ function restoreAfterFailure({ plan, originalCanonical, canonicalWritten, materi
|
|
|
376
469
|
failures.push(`remove ${adapter.host} adapter: ${error.message}`);
|
|
377
470
|
}
|
|
378
471
|
}
|
|
379
|
-
if (!
|
|
472
|
+
if (!bundleWritten && originalBundle === null) return failures;
|
|
380
473
|
|
|
381
474
|
try {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
operations.rmSync(plan.paths.canonical, { force: true });
|
|
475
|
+
touched.push(plan.paths.canonicalDirectory);
|
|
476
|
+
operations.rmSync(plan.paths.canonicalDirectory, { recursive: true, force: true });
|
|
477
|
+
for (const file of originalBundle ?? []) {
|
|
478
|
+
atomicWrite(path.join(plan.paths.canonicalDirectory, file.path), file.bytes, operations, touched);
|
|
387
479
|
}
|
|
388
480
|
} catch (error) {
|
|
389
|
-
failures.push(`restore canonical skill: ${error.message}`);
|
|
481
|
+
failures.push(`restore canonical skill bundle: ${error.message}`);
|
|
390
482
|
}
|
|
391
483
|
return failures;
|
|
392
484
|
}
|
|
393
485
|
|
|
394
486
|
function incompleteLock(lockPath) {
|
|
395
|
-
|
|
487
|
+
const error = new Error(`Incomplete or inconsistent skill lock: ${lockPath}`);
|
|
488
|
+
// Deleting the lock is safe: the next install rebuilds it from the repository
|
|
489
|
+
// state, and any canonical mismatch then surfaces as its own conflict.
|
|
490
|
+
error.nextAction = `Delete ${lockPath}, then re-run ddduck install skill update-ddduck-specs to rebuild it.`;
|
|
491
|
+
return error;
|
|
396
492
|
}
|
|
397
493
|
|
|
398
494
|
function locallyModifiedCanonical(canonicalPath) {
|
|
@@ -401,6 +497,98 @@ function locallyModifiedCanonical(canonicalPath) {
|
|
|
401
497
|
return error;
|
|
402
498
|
}
|
|
403
499
|
|
|
500
|
+
function locallyModifiedBundle(bundlePath) {
|
|
501
|
+
const error = new Error(`Locally modified canonical skill bundle: ${bundlePath}`);
|
|
502
|
+
error.nextAction = `Revert or remove ${bundlePath}, then re-run ddduck install skill update-ddduck-specs.`;
|
|
503
|
+
return error;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function readBundleFiles(directory, operations, relativeDirectory = "") {
|
|
507
|
+
const current = path.join(directory, relativeDirectory);
|
|
508
|
+
const files = [];
|
|
509
|
+
const entries = operations
|
|
510
|
+
.readdirSync(current, { withFileTypes: true })
|
|
511
|
+
.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
512
|
+
for (const entry of entries) {
|
|
513
|
+
const relativePath = path.join(relativeDirectory, entry.name);
|
|
514
|
+
if (entry.isDirectory()) {
|
|
515
|
+
files.push(...readBundleFiles(directory, operations, relativePath));
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (!entry.isFile()) throw new Error(`Unsupported bundled skill entry: ${path.join(directory, relativePath)}`);
|
|
519
|
+
const bytes = operations.readFileSync(path.join(directory, relativePath));
|
|
520
|
+
files.push({ path: toPosixPath(relativePath), bytes, sha256: sha256(bytes) });
|
|
521
|
+
}
|
|
522
|
+
return files;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function digestBundle(files) {
|
|
526
|
+
const digest = createHash("sha256");
|
|
527
|
+
for (const file of files) {
|
|
528
|
+
digest.update(`${file.path.length}:${file.path}:${file.bytes.length}:`);
|
|
529
|
+
digest.update(file.bytes);
|
|
530
|
+
}
|
|
531
|
+
return digest.digest("hex");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function isValidBundleLock(lock) {
|
|
535
|
+
return (
|
|
536
|
+
/^[a-f0-9]{64}$/.test(lock.bundleSha256) &&
|
|
537
|
+
Array.isArray(lock.files) &&
|
|
538
|
+
lock.files.length > 0 &&
|
|
539
|
+
lock.files.every(
|
|
540
|
+
(file) =>
|
|
541
|
+
file &&
|
|
542
|
+
typeof file.path === "string" &&
|
|
543
|
+
file.path.length > 0 &&
|
|
544
|
+
!path.isAbsolute(file.path) &&
|
|
545
|
+
!file.path.split("/").includes("..") &&
|
|
546
|
+
/^[a-f0-9]{64}$/.test(file.sha256),
|
|
547
|
+
)
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function assertInstalledBundleMatchesLock(paths, lock, operations) {
|
|
552
|
+
if (lock.schemaVersion === 1) {
|
|
553
|
+
if (sha256(operations.readFileSync(paths.canonical)) !== lock.skillSha256) {
|
|
554
|
+
throw locallyModifiedCanonical(paths.canonical);
|
|
555
|
+
}
|
|
556
|
+
const entries = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
|
|
557
|
+
if (entries.some(({ path: relativePath }) => relativePath !== skillFileName)) {
|
|
558
|
+
throw locallyModifiedBundle(paths.canonicalDirectory);
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const installed = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
|
|
564
|
+
const expectedPaths = lock.files.map(({ path: relativePath }) => relativePath);
|
|
565
|
+
if (
|
|
566
|
+
JSON.stringify(installed.map(({ path: relativePath }) => relativePath)) !== JSON.stringify(expectedPaths) ||
|
|
567
|
+
installed.some((file, index) => file.sha256 !== lock.files[index].sha256)
|
|
568
|
+
) {
|
|
569
|
+
throw locallyModifiedBundle(paths.canonicalDirectory);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function assertRemainingBundleMatchesLock(paths, lock, operations) {
|
|
574
|
+
const installed = snapshotDirectory(paths.canonicalDirectory, operations) ?? [];
|
|
575
|
+
const expected = new Map(
|
|
576
|
+
lock.schemaVersion === 1
|
|
577
|
+
? [[skillFileName, lock.skillSha256]]
|
|
578
|
+
: lock.files.map(({ path: relativePath, sha256: fileSha256 }) => [relativePath, fileSha256]),
|
|
579
|
+
);
|
|
580
|
+
if (installed.some((file) => expected.get(file.path) !== file.sha256)) {
|
|
581
|
+
throw locallyModifiedBundle(paths.canonicalDirectory);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function snapshotDirectory(directory, operations) {
|
|
586
|
+
const state = pathState(directory, operations);
|
|
587
|
+
if (state.type === "absent") return null;
|
|
588
|
+
if (state.type !== "directory") throw locallyModifiedBundle(directory);
|
|
589
|
+
return readBundleFiles(directory, operations);
|
|
590
|
+
}
|
|
591
|
+
|
|
404
592
|
function sha256(bytes) {
|
|
405
593
|
return createHash("sha256").update(bytes).digest("hex");
|
|
406
594
|
}
|
package/scripts/query-model.mjs
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Implements `ddduck query`, the read-only query surface: dispatches the
|
|
5
|
+
* operations node, neighbors, impact, anchors, spec, and context to
|
|
6
|
+
* lib/product-query.mjs and lib/context-pack.mjs, emitting exactly one JSON
|
|
7
|
+
* document on stdout. The product root auto-resolves (enclosing directory,
|
|
8
|
+
* config, or unique discovery) unless --root is passed; busy or interrupted
|
|
9
|
+
* roots are refused before any answer is served. Also imported by ddduck.mjs
|
|
10
|
+
* and the agent-readiness evals as the runQuery entry point.
|
|
11
|
+
*/
|
|
12
|
+
|
|
4
13
|
import { fileURLToPath } from "node:url";
|
|
5
14
|
import { resolveContextPack } from "./lib/context-pack.mjs";
|
|
6
15
|
import { resolveProductRoot } from "./lib/product-root-resolver.mjs";
|
|
@@ -14,6 +23,12 @@ import {
|
|
|
14
23
|
} from "./lib/product-query.mjs";
|
|
15
24
|
import { CliUsageError, parseCommandArgs, renderHelp, writeCliError } from "./lib/cli-contract.mjs";
|
|
16
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Parse and run one query invocation, writing the JSON document to stdout.
|
|
28
|
+
* @param {string[]} args - Arguments after the `query` command word.
|
|
29
|
+
* @param {{cwd?: string, stdout?: {write: (chunk: string) => unknown}}} [io] - Working directory for root resolution and the output stream (used by the evals harness to capture output).
|
|
30
|
+
* @returns {void}
|
|
31
|
+
*/
|
|
17
32
|
export function runQuery(args, { cwd = process.cwd(), stdout = process.stdout } = {}) {
|
|
18
33
|
if (args.includes("--help")) {
|
|
19
34
|
stdout.write(renderHelp("query"));
|
|
@@ -30,14 +45,10 @@ export function runQuery(args, { cwd = process.cwd(), stdout = process.stdout }
|
|
|
30
45
|
const ids = options.id;
|
|
31
46
|
const id = ids[0];
|
|
32
47
|
if (operation !== "context" && ids.length > 1) throw new CliUsageError(`query ${operation} accepts exactly one --id`);
|
|
33
|
-
if (operation === "context")
|
|
34
|
-
if (options.history) throw new CliUsageError("query context does not support --history");
|
|
35
|
-
if (!options.root) throw new CliUsageError("query context requires --root <product-root>");
|
|
36
|
-
}
|
|
48
|
+
if (operation === "context" && options.history) throw new CliUsageError("query context does not support --history");
|
|
37
49
|
if (operation !== "spec" && !id) throw new CliUsageError(`query ${operation} requires --id <model-node-id>`);
|
|
38
50
|
|
|
39
|
-
const root =
|
|
40
|
-
operation === "context" ? path.resolve(cwd, options.root) : resolveProductRoot({ cwd, explicitRoot: options.root });
|
|
51
|
+
const root = resolveProductRoot({ cwd, explicitRoot: options.root });
|
|
41
52
|
const product = loadQueryProduct(root, { history: options.history });
|
|
42
53
|
if (operation === "spec" && id && id !== product.rootModelId) {
|
|
43
54
|
throw new CliUsageError(`query spec --id must be ${product.rootModelId}`);
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* CLI wrapper for the agent-readiness eval harness: reads a JSONL file of eval
|
|
5
|
+
* records (--input) that each name a product root below --repo-root, a query
|
|
6
|
+
* to run, and candidate evidence to verify, then delegates to
|
|
7
|
+
* lib/agent-readiness-evals.mjs. Emits one JSON result document on stdout,
|
|
8
|
+
* optionally mirrors it to --output (contained below the repo root, never
|
|
9
|
+
* .git), and exits 1 unless every case passed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
3
12
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
13
|
import path from "node:path";
|
|
5
|
-
import { format } from "prettier";
|
|
6
14
|
import { runAgentReadinessEvals } from "./lib/agent-readiness-evals.mjs";
|
|
7
15
|
import { resolveContainedOutput } from "./lib/product-paths.mjs";
|
|
8
16
|
|
|
@@ -10,7 +18,9 @@ try {
|
|
|
10
18
|
const options = parseArgs(process.argv.slice(2));
|
|
11
19
|
const outputTarget = options.output ? resolveOutputPath(options.repoRoot, options.output) : null;
|
|
12
20
|
const result = runAgentReadinessEvals(options);
|
|
13
|
-
|
|
21
|
+
// Deterministic serialization with no runtime formatter dependency: the
|
|
22
|
+
// published package must run without devDependencies.
|
|
23
|
+
const output = `${JSON.stringify(result, null, 2)}\n`;
|
|
14
24
|
process.stdout.write(output);
|
|
15
25
|
if (outputTarget) writeOutput(outputTarget, output);
|
|
16
26
|
process.exitCode = result.passed ? 0 : 1;
|
|
@@ -19,6 +29,12 @@ try {
|
|
|
19
29
|
process.exitCode = 1;
|
|
20
30
|
}
|
|
21
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Parse the --input/--repo-root/--output arguments, rejecting duplicates and
|
|
34
|
+
* missing values.
|
|
35
|
+
* @param {string[]} args - Raw CLI arguments.
|
|
36
|
+
* @returns {{inputPath: string, repoRoot: string, output: string|undefined}} Resolved option paths.
|
|
37
|
+
*/
|
|
22
38
|
function parseArgs(args) {
|
|
23
39
|
const options = {};
|
|
24
40
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -1,98 +1,40 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: update-ddduck-specs
|
|
3
|
-
description: Use when a repository's ddduck product model needs to be created, audited against current code, tests, and documentation, or reconciled after product changes.
|
|
3
|
+
description: Use when a repository's ddduck product model needs to be created, audited against current code, tests, and documentation, compared across revisions, or reconciled after product changes.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Update ddduck Specs
|
|
7
7
|
|
|
8
|
-
Maintain or bootstrap a
|
|
8
|
+
Maintain or bootstrap a ddduck product model from evidence in the current working tree. Preserve the difference between observed implementation, accepted intent, canonical product meaning, generated views, and runtime proof.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Operating contract
|
|
11
11
|
|
|
12
|
-
- Resolve the repository root, read
|
|
13
|
-
- Use
|
|
14
|
-
- Default to plan-only. Mutate files only when the current
|
|
15
|
-
- Preserve unrelated
|
|
16
|
-
- Write only `<root>/product.yaml`, `<root>/model/**`, `<root>/decisions/**`, and regenerated `<root>/generated/**`.
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
- Never create an ADR merely to satisfy validation or justify an inferred change.
|
|
21
|
-
- Keep unresolved questions out of canonical model facts.
|
|
22
|
-
- Prefer the smallest coherent product-model change. Avoid ornamental DDD vocabulary and speculative structure.
|
|
23
|
-
- Do not commit or push consumer changes unless the user separately requests it.
|
|
12
|
+
- Resolve the repository root, read every applicable instruction file, and inspect Git status before analysis.
|
|
13
|
+
- Use the repository-compatible ddduck executable. Do not install dependencies or substitute an unrelated global version.
|
|
14
|
+
- Default to plan-only. Mutate model files only when the current request explicitly authorizes applying the evidence-backed proposal.
|
|
15
|
+
- Preserve unrelated work. Stop when intended model edits overlap user changes inseparably or the analyzed tree changes before application.
|
|
16
|
+
- Write only `<root>/product.yaml`, `<root>/model/**`, `<root>/decisions/**`, and regenerated `<root>/generated/**`. Regenerate derived views; never edit them directly.
|
|
17
|
+
- Preserve stable IDs and Guarantee history. Use lifecycle commands for Guarantee transitions and the supported creation commands for Domain, Concept, and UseCase.
|
|
18
|
+
- Keep unresolved questions in prose. Canonicalize only meaning that is unambiguous and supported by inspected evidence or accepted authority.
|
|
19
|
+
- Commit, push, consumer migration, and external effects require separate authorization.
|
|
24
20
|
|
|
25
|
-
|
|
21
|
+
## Load the relevant reference
|
|
26
22
|
|
|
27
|
-
|
|
23
|
+
Read each selected reference completely before acting. All references are one level below this file.
|
|
28
24
|
|
|
29
|
-
|
|
25
|
+
- For every bootstrap, audit, or reconciliation, read [modeling and evidence](references/modeling-and-evidence.md).
|
|
26
|
+
- When comparing revisions, reviewing moves/removals, or selecting affected context, read [reviewing changes](references/reviewing-changes.md). Start with `ddduck diff --base <before-root> --root <after-root> --json` when two valid roots exist.
|
|
27
|
+
- Before proposing or applying canonical changes, read [authoring and verification](references/authoring-and-verification.md). Use `ddduck create domain`, `ddduck create concept`, and `ddduck create use-case` for the kinds they support.
|
|
30
28
|
|
|
31
|
-
|
|
32
|
-
- `absent`: the root is missing or empty. Inspect the repository before proposing initialization.
|
|
33
|
-
- `path-collision`: the root is non-empty but not a recognizable ddduck product. Report the collision and never initialize over it.
|
|
29
|
+
## Workflow
|
|
34
30
|
|
|
35
|
-
|
|
31
|
+
1. Resolve and classify the product root. Record `query spec` and `check` independently; an invalid existing model is not an absent model.
|
|
32
|
+
2. Gather current code, tests, interfaces, documentation, configuration, schemas, and accepted decisions. Record contradictions, exclusions, and coverage gaps.
|
|
33
|
+
3. Separate observed behavior, accepted intent, open questions, and rejected alternatives. Classify every material model difference using the modeling reference.
|
|
34
|
+
4. Produce the plan-only report defined in the authoring reference. A partial or zero-model-change result is valid when it is the evidence-backed outcome.
|
|
35
|
+
5. If application is explicitly authorized, recheck Git status, decisive evidence, and target files; then apply only the approved unambiguous changes.
|
|
36
|
+
6. Run the complete verification sequence from the authoring reference. Any failed command makes the result `incomplete`.
|
|
36
37
|
|
|
37
|
-
##
|
|
38
|
+
## Completion criteria
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
For every candidate fact, record:
|
|
42
|
-
|
|
43
|
-
- proposed model assertion;
|
|
44
|
-
- repository-relative path plus line, symbol, heading, or test name;
|
|
45
|
-
- evidence role: implementation, verification, documentation, decision, or configuration;
|
|
46
|
-
- contradictory evidence;
|
|
47
|
-
- inspected scope and remaining unknowns.
|
|
48
|
-
|
|
49
|
-
Executable behavior and passing tests establish observed behavior. Accepted requirements and decisions establish intended behavior. Treat conflicts between them as inconsistencies; do not silently encode either a possible bug or an unimplemented requirement as product truth.
|
|
50
|
-
|
|
51
|
-
The current schema restricts persisted interface evidence anchors to paths inside the product root. Cite repository-wide evidence in the plan and final report, but persist only schema-supported product-root anchors. Do not copy source evidence into the product root, invent unsupported metadata, or create bridge documents merely to manufacture provenance.
|
|
52
|
-
|
|
53
|
-
For a greenfield repository, use runtime-supported subagents only when no model exists and the relevant corpus spans several substantial, independent packages, applications, or domain areas that cannot be covered reliably in the coordinating context. Repository file count alone is not sufficient. Subagents are read-only evidence adapters: assign non-overlapping scopes, provide applicable repository instructions, forbid writes and canonical model synthesis, require candidate facts with exact evidence locations, conflicts, unknowns, and coverage, then re-read decisive evidence before adopting it. The coordinator is the sole writer. If subagents are unavailable, inspect the same scopes sequentially and disclose the coverage limitations.
|
|
54
|
-
|
|
55
|
-
## Compare and classify
|
|
56
|
-
|
|
57
|
-
Classify every material difference as exactly one of:
|
|
58
|
-
|
|
59
|
-
- verified omission;
|
|
60
|
-
- stale modeled fact;
|
|
61
|
-
- structural inconsistency with an unambiguous repair;
|
|
62
|
-
- contradiction or uncertainty requiring a human decision;
|
|
63
|
-
- irrelevant implementation detail;
|
|
64
|
-
- insufficiently covered.
|
|
65
|
-
|
|
66
|
-
For existing models, preserve identity and history.
|
|
67
|
-
|
|
68
|
-
## Plan-only workflow
|
|
69
|
-
|
|
70
|
-
Before any mutation, report in this order:
|
|
71
|
-
|
|
72
|
-
1. Mode, resolved root, and model state.
|
|
73
|
-
2. Baseline query and validation status.
|
|
74
|
-
3. Inspected coverage, exclusions, and gaps.
|
|
75
|
-
4. Proposed changes with classification, concrete evidence, and exact target files.
|
|
76
|
-
5. Contradictions, uncertainties, and required decisions.
|
|
77
|
-
6. Exact generation and verification commands.
|
|
78
|
-
|
|
79
|
-
Without explicit application authorization, stop before all writes. Plan mode performs no writes, including initialization and generation.
|
|
80
|
-
|
|
81
|
-
## Apply workflow
|
|
82
|
-
|
|
83
|
-
When application is explicitly authorized:
|
|
84
|
-
|
|
85
|
-
1. Recheck Git status, intended target files, and decisive evidence.
|
|
86
|
-
2. Initialize only an absent or empty root whose model identity, purpose, and initial domain seams are explicit or unambiguously grounded. Otherwise request the missing decision.
|
|
87
|
-
3. Apply only planned, evidence-backed changes whose meaning is unambiguous.
|
|
88
|
-
4. Leave unresolved findings unchanged.
|
|
89
|
-
5. Use ddduck lifecycle commands for Guarantee transitions. Author other canonical YAML against installed schemas and existing model conventions.
|
|
90
|
-
6. Run `ddduck check --root <root> --source-only` before generation; hand-authored canonical edits legitimately leave generated views stale until step 7.
|
|
91
|
-
7. Run `ddduck generate --root <root>`.
|
|
92
|
-
8. Run `ddduck check --root <root>` again.
|
|
93
|
-
9. Run `ddduck query spec --root <root> --json` and require every generated view to be fresh.
|
|
94
|
-
10. Inspect the final diff for scope.
|
|
95
|
-
|
|
96
|
-
Any command failure makes the result incomplete. Inspect and report the resulting working tree; never destructively roll back unrelated user work.
|
|
97
|
-
|
|
98
|
-
Report changed canonical, decision, and generated files; evidence supporting each material change; skipped and unresolved findings; exact command outcomes and diagnostics; final generated-view freshness; and remaining coverage gaps. Explicitly report `incomplete` when any verification or scope check fails. A verified no-op is a valid result; do not create model content merely to demonstrate activity.
|
|
40
|
+
Report the resolved root and mode, baseline outcomes, inspected coverage, changed or proposed IDs and files, supporting evidence, contradictions and open decisions, exact command results, generated-view freshness, and remaining gaps. For comparisons, distinguish structural differences from semantic approval. For applications, inspect the final scoped diff and report every skipped or unresolved finding.
|