@workflow-cannon/workspace-kit 0.7.0 → 0.9.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.
Files changed (74) hide show
  1. package/README.md +5 -4
  2. package/dist/cli/run-command.d.ts +11 -0
  3. package/dist/cli/run-command.js +138 -0
  4. package/dist/cli.js +18 -135
  5. package/dist/contracts/index.d.ts +1 -1
  6. package/dist/contracts/module-contract.d.ts +13 -0
  7. package/dist/core/config-cli.js +4 -4
  8. package/dist/core/config-metadata.js +199 -5
  9. package/dist/core/index.d.ts +6 -0
  10. package/dist/core/index.js +6 -0
  11. package/dist/core/instruction-template-mapper.d.ts +9 -0
  12. package/dist/core/instruction-template-mapper.js +35 -0
  13. package/dist/core/lineage-contract.d.ts +1 -1
  14. package/dist/core/lineage-contract.js +1 -1
  15. package/dist/core/policy.d.ts +13 -2
  16. package/dist/core/policy.js +42 -25
  17. package/dist/core/response-template-contract.d.ts +15 -0
  18. package/dist/core/response-template-contract.js +10 -0
  19. package/dist/core/response-template-registry.d.ts +4 -0
  20. package/dist/core/response-template-registry.js +44 -0
  21. package/dist/core/response-template-shaping.d.ts +6 -0
  22. package/dist/core/response-template-shaping.js +128 -0
  23. package/dist/core/session-policy.d.ts +18 -0
  24. package/dist/core/session-policy.js +57 -0
  25. package/dist/core/transcript-completion-hook.d.ts +7 -0
  26. package/dist/core/transcript-completion-hook.js +128 -0
  27. package/dist/core/workspace-kit-config.d.ts +2 -1
  28. package/dist/core/workspace-kit-config.js +19 -23
  29. package/dist/modules/approvals/index.js +2 -2
  30. package/dist/modules/documentation/runtime.js +413 -20
  31. package/dist/modules/improvement/generate-recommendations-runtime.d.ts +7 -0
  32. package/dist/modules/improvement/generate-recommendations-runtime.js +37 -4
  33. package/dist/modules/improvement/improvement-state.d.ts +10 -1
  34. package/dist/modules/improvement/improvement-state.js +36 -7
  35. package/dist/modules/improvement/index.js +70 -23
  36. package/dist/modules/improvement/ingest.js +2 -1
  37. package/dist/modules/improvement/transcript-redaction.d.ts +4 -0
  38. package/dist/modules/improvement/transcript-redaction.js +10 -0
  39. package/dist/modules/improvement/transcript-sync-runtime.d.ts +42 -1
  40. package/dist/modules/improvement/transcript-sync-runtime.js +215 -9
  41. package/dist/modules/index.d.ts +1 -1
  42. package/dist/modules/index.js +1 -1
  43. package/dist/modules/task-engine/index.d.ts +0 -2
  44. package/dist/modules/task-engine/index.js +4 -78
  45. package/package.json +6 -2
  46. package/src/modules/documentation/README.md +39 -0
  47. package/src/modules/documentation/RULES.md +70 -0
  48. package/src/modules/documentation/config.md +14 -0
  49. package/src/modules/documentation/index.ts +120 -0
  50. package/src/modules/documentation/instructions/document-project.md +44 -0
  51. package/src/modules/documentation/instructions/documentation-maintainer.md +81 -0
  52. package/src/modules/documentation/instructions/generate-document.md +44 -0
  53. package/src/modules/documentation/runtime.ts +870 -0
  54. package/src/modules/documentation/schemas/documentation-schema.md +54 -0
  55. package/src/modules/documentation/state.md +8 -0
  56. package/src/modules/documentation/templates/AGENTS.md +84 -0
  57. package/src/modules/documentation/templates/ARCHITECTURE.md +71 -0
  58. package/src/modules/documentation/templates/PRINCIPLES.md +122 -0
  59. package/src/modules/documentation/templates/RELEASING.md +96 -0
  60. package/src/modules/documentation/templates/ROADMAP.md +131 -0
  61. package/src/modules/documentation/templates/SECURITY.md +53 -0
  62. package/src/modules/documentation/templates/SUPPORT.md +40 -0
  63. package/src/modules/documentation/templates/TERMS.md +61 -0
  64. package/src/modules/documentation/templates/runbooks/consumer-cadence.md +55 -0
  65. package/src/modules/documentation/templates/runbooks/parity-validation-flow.md +68 -0
  66. package/src/modules/documentation/templates/runbooks/release-channels.md +30 -0
  67. package/src/modules/documentation/templates/workbooks/phase2-config-policy-workbook.md +42 -0
  68. package/src/modules/documentation/templates/workbooks/task-engine-workbook.md +42 -0
  69. package/src/modules/documentation/templates/workbooks/transcript-automation-baseline.md +68 -0
  70. package/src/modules/documentation/types.ts +51 -0
  71. package/dist/modules/task-engine/generator.d.ts +0 -3
  72. package/dist/modules/task-engine/generator.js +0 -118
  73. package/dist/modules/task-engine/importer.d.ts +0 -8
  74. package/dist/modules/task-engine/importer.js +0 -163
@@ -0,0 +1,870 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, resolve, sep } from "node:path";
4
+ import { readdir } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import type {
7
+ DocumentationBatchResult,
8
+ DocumentationConflict,
9
+ DocumentationGenerateOptions,
10
+ DocumentationGenerateResult,
11
+ DocumentationValidationIssue
12
+ } from "./types.js";
13
+ import type { ModuleLifecycleContext } from "../../contracts/module-contract.js";
14
+
15
+ type DocumentationRuntimeConfig = {
16
+ aiRoot: string;
17
+ humanRoot: string;
18
+ templatesRoot: string;
19
+ instructionsRoot: string;
20
+ schemasRoot: string;
21
+ maxValidationAttempts: number;
22
+ sourceRoot: string;
23
+ };
24
+
25
+ function isPathWithinRoot(path: string, root: string): boolean {
26
+ return path === root || path.startsWith(`${root}${sep}`);
27
+ }
28
+
29
+ function parseDefaultValue(fileContent: string, key: string, fallback: string): string {
30
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
+ const regex = new RegExp(`\\\`${escaped}\\\`[^\\n]*default:\\s*\\\`([^\\\`]+)\\\``);
32
+ const match = fileContent.match(regex);
33
+ return match?.[1] ?? fallback;
34
+ }
35
+
36
+ async function loadRuntimeConfig(workspacePath: string): Promise<DocumentationRuntimeConfig> {
37
+ const runtimeSourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
38
+ const sourceRoots = [workspacePath, runtimeSourceRoot];
39
+ let sourceRoot = workspacePath;
40
+ let configContent: string | undefined;
41
+ for (const candidateRoot of sourceRoots) {
42
+ const candidate = resolve(candidateRoot, "src/modules/documentation/config.md");
43
+ if (!existsSync(candidate)) {
44
+ continue;
45
+ }
46
+ configContent = await readFile(candidate, "utf8");
47
+ sourceRoot = candidateRoot;
48
+ break;
49
+ }
50
+
51
+ if (!configContent) {
52
+ return {
53
+ aiRoot: "/.ai",
54
+ humanRoot: "docs/maintainers",
55
+ templatesRoot: "src/modules/documentation/templates",
56
+ instructionsRoot: "src/modules/documentation/instructions",
57
+ schemasRoot: "src/modules/documentation/schemas",
58
+ maxValidationAttempts: 3,
59
+ sourceRoot
60
+ };
61
+ }
62
+
63
+ const aiRoot = parseDefaultValue(configContent, "sources.aiRoot", "/.ai");
64
+ const humanRoot = parseDefaultValue(configContent, "sources.humanRoot", "docs/maintainers");
65
+ const templatesRoot = parseDefaultValue(
66
+ configContent,
67
+ "sources.templatesRoot",
68
+ "src/modules/documentation/templates"
69
+ );
70
+ const instructionsRoot = parseDefaultValue(
71
+ configContent,
72
+ "sources.instructionsRoot",
73
+ "src/modules/documentation/instructions"
74
+ );
75
+ const schemasRoot = parseDefaultValue(
76
+ configContent,
77
+ "sources.schemasRoot",
78
+ "src/modules/documentation/schemas"
79
+ );
80
+ const maxValidationAttemptsRaw = parseDefaultValue(configContent, "generation.maxValidationAttempts", "3");
81
+ const maxValidationAttempts = Number.parseInt(maxValidationAttemptsRaw, 10);
82
+
83
+ return {
84
+ aiRoot,
85
+ humanRoot,
86
+ templatesRoot,
87
+ instructionsRoot,
88
+ schemasRoot,
89
+ maxValidationAttempts: Number.isFinite(maxValidationAttempts) ? maxValidationAttempts : 3,
90
+ sourceRoot
91
+ };
92
+ }
93
+
94
+ type AiValidationContext = {
95
+ strict: boolean;
96
+ workspacePath: string;
97
+ expectedDoc?: "rules" | "runbook" | "workbook";
98
+ };
99
+
100
+ type AiRecord = {
101
+ type: string;
102
+ positional: string[];
103
+ kv: Record<string, string>;
104
+ raw: string;
105
+ };
106
+
107
+ function parseAiRecordLine(line: string): AiRecord | null {
108
+ const trimmed = line.trim();
109
+ if (!trimmed || trimmed.startsWith("#")) return null;
110
+ const parts = trimmed.split("|");
111
+ // Record format is `type|token|token...`. Ignore non-record markdown lines.
112
+ if (parts.length < 2) return null;
113
+ const type = parts[0] ?? "";
114
+ if (!type) return null;
115
+ const positional: string[] = [];
116
+ const kv: Record<string, string> = {};
117
+ for (const token of parts.slice(1)) {
118
+ if (!token) continue;
119
+ const idx = token.indexOf("=");
120
+ if (idx >= 0) {
121
+ const k = token.slice(0, idx).trim();
122
+ const v = token.slice(idx + 1).trim();
123
+ if (!k) continue;
124
+ kv[k] = v;
125
+ } else {
126
+ positional.push(token);
127
+ }
128
+ }
129
+ return { type, positional, kv, raw: line };
130
+ }
131
+
132
+ function isAllowedMetaDoc(doc: string): boolean {
133
+ return (
134
+ doc === "rules" ||
135
+ doc === "runbook" ||
136
+ doc === "workbook" ||
137
+ doc === "generator" ||
138
+ doc === "map" ||
139
+ doc === "workflows" ||
140
+ doc === "commands" ||
141
+ doc === "decisions" ||
142
+ doc === "glossary" ||
143
+ doc === "observed" ||
144
+ doc === "planned" ||
145
+ doc === "checks" ||
146
+ doc === "manifest"
147
+ );
148
+ }
149
+
150
+ function validateAiSchema(aiOutput: string, ctx: AiValidationContext): DocumentationValidationIssue[] {
151
+ const issues: DocumentationValidationIssue[] = [];
152
+ const lines = aiOutput.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
153
+ if (lines.length === 0) {
154
+ return [
155
+ {
156
+ check: "schema",
157
+ message: "AI output is empty",
158
+ resolved: false,
159
+ }
160
+ ];
161
+ }
162
+
163
+ const metaLine = lines[0];
164
+ const meta = parseAiRecordLine(metaLine);
165
+ if (!meta || meta.type !== "meta") {
166
+ return [
167
+ {
168
+ check: "schema",
169
+ message: "AI output must start with a meta record",
170
+ resolved: false,
171
+ }
172
+ ];
173
+ }
174
+
175
+ const v = meta.kv["v"];
176
+ const doc = meta.kv["doc"];
177
+ const truth = meta.kv["truth"];
178
+ const st = meta.kv["st"];
179
+
180
+ if (v !== "1") {
181
+ issues.push({
182
+ check: "schema",
183
+ message: "AI meta schemaVersion must be v=1",
184
+ resolved: false,
185
+ });
186
+ }
187
+
188
+ if (!doc || !isAllowedMetaDoc(doc)) {
189
+ issues.push({
190
+ check: "schema",
191
+ message: `Unsupported meta.doc '${doc ?? ""}'`,
192
+ resolved: false,
193
+ });
194
+ }
195
+
196
+ if (!truth || truth.length === 0) {
197
+ issues.push({
198
+ check: "schema",
199
+ message: "AI meta.truth is required",
200
+ resolved: false,
201
+ });
202
+ }
203
+
204
+ if (!st || st.length === 0) {
205
+ issues.push({
206
+ check: "schema",
207
+ message: "AI meta.st is required",
208
+ resolved: false,
209
+ });
210
+ }
211
+
212
+ if (ctx.expectedDoc && doc && ctx.expectedDoc !== doc) {
213
+ issues.push({
214
+ check: "schema",
215
+ message: `meta.doc '${doc}' does not match expected doc family for '${ctx.expectedDoc}'`,
216
+ resolved: !ctx.strict,
217
+ });
218
+ }
219
+
220
+ const requireActiveRecords = st === "active";
221
+
222
+ const allowedTypes = new Set([
223
+ // Global ai record families used across .ai/*.
224
+ "project",
225
+ "stack",
226
+ "prio",
227
+ "ref",
228
+ "rule",
229
+ "check",
230
+ "path",
231
+ "role",
232
+ "has",
233
+ "xhas",
234
+ "deps",
235
+ "xdeps",
236
+ "module",
237
+ "wf",
238
+ "cmd",
239
+ "decision",
240
+ "term",
241
+ "observed",
242
+ "planned",
243
+ "map",
244
+ // Runbooks
245
+ "runbook",
246
+ "intent",
247
+ "chain",
248
+ "artifact",
249
+ "state",
250
+ "transition",
251
+ "promotion",
252
+ "rollback",
253
+ // Workbooks
254
+ "workbook",
255
+ "scope",
256
+ "command",
257
+ "config",
258
+ "cadence",
259
+ "guardrail",
260
+ ]);
261
+
262
+ const presentByType: Record<string, boolean> = {};
263
+ const missingRequired: string[] = [];
264
+
265
+ for (const line of lines.slice(1)) {
266
+ const rec = parseAiRecordLine(line);
267
+ if (!rec) continue;
268
+ presentByType[rec.type] = true;
269
+
270
+ if (!allowedTypes.has(rec.type)) {
271
+ issues.push({
272
+ check: "schema",
273
+ message: `Unknown AI record type '${rec.type}'`,
274
+ resolved: !ctx.strict,
275
+ });
276
+ continue;
277
+ }
278
+
279
+ // Minimal record-level validation for current runbook/workbook families.
280
+ if (rec.type === "ref") {
281
+ const p = rec.kv["path"];
282
+ const n = rec.kv["name"];
283
+ if (!p || !n) {
284
+ issues.push({
285
+ check: "schema",
286
+ message: "ref records require both 'name' and 'path'",
287
+ resolved: !ctx.strict,
288
+ });
289
+ } else {
290
+ const abs = resolve(ctx.workspacePath, p);
291
+ const ok = existsSync(abs);
292
+ if (!ok) {
293
+ issues.push({
294
+ check: "schema",
295
+ message: `ref.path does not exist: '${p}'`,
296
+ resolved: !ctx.strict,
297
+ });
298
+ }
299
+ }
300
+ continue;
301
+ }
302
+
303
+ if (rec.type === "rule") {
304
+ const rid = rec.positional[0];
305
+ const lvl = rec.positional[1] ?? rec.kv["lvl"];
306
+ const directive = (() => {
307
+ // rule lines can be either:
308
+ // rule|RID|lvl|scope|directive|...
309
+ // or the scope can be omitted:
310
+ // rule|RID|lvl|directive|...
311
+ const nonKey = rec.positional.slice(2);
312
+ return nonKey[nonKey.length - 1];
313
+ })();
314
+
315
+ if (!rid || !/^R\d{3,}$/.test(rid)) {
316
+ issues.push({
317
+ check: "schema",
318
+ message: "rule records require RID formatted like R### or R####",
319
+ resolved: !ctx.strict,
320
+ });
321
+ }
322
+ if (!lvl || !["must", "must_not", "should", "may"].includes(lvl)) {
323
+ issues.push({
324
+ check: "schema",
325
+ message: `rule lvl is invalid: '${lvl ?? ""}'`,
326
+ resolved: !ctx.strict,
327
+ });
328
+ }
329
+ if (!directive || directive.length < 2) {
330
+ issues.push({
331
+ check: "schema",
332
+ message: "rule directive cannot be empty",
333
+ resolved: !ctx.strict,
334
+ });
335
+ }
336
+ continue;
337
+ }
338
+
339
+ if (rec.type === "runbook") {
340
+ if (!rec.kv["name"] || !rec.kv["scope"]) {
341
+ issues.push({
342
+ check: "schema",
343
+ message: "runbook records require at least name and scope",
344
+ resolved: !ctx.strict,
345
+ });
346
+ }
347
+ continue;
348
+ }
349
+
350
+ if (rec.type === "workbook") {
351
+ if (!rec.kv["name"]) {
352
+ issues.push({
353
+ check: "schema",
354
+ message: "workbook records require 'name'",
355
+ resolved: !ctx.strict,
356
+ });
357
+ }
358
+ continue;
359
+ }
360
+
361
+ if (rec.type === "chain") {
362
+ const step = rec.kv["step"];
363
+ const command = rec.kv["command"];
364
+ const expect = rec.kv["expect_exit"];
365
+ if (!step || !command || expect === undefined) {
366
+ issues.push({
367
+ check: "schema",
368
+ message: "chain records require step, command, and expect_exit",
369
+ resolved: !ctx.strict,
370
+ });
371
+ }
372
+ continue;
373
+ }
374
+
375
+ if (rec.type === "transition") {
376
+ if (!rec.kv["from"] || !rec.kv["to"] || !rec.kv["requires"]) {
377
+ issues.push({
378
+ check: "schema",
379
+ message: "transition records require from, to, requires",
380
+ resolved: !ctx.strict,
381
+ });
382
+ }
383
+ continue;
384
+ }
385
+
386
+ if (rec.type === "state") {
387
+ if (!rec.kv["name"]) {
388
+ issues.push({
389
+ check: "schema",
390
+ message: "state records require name",
391
+ resolved: !ctx.strict,
392
+ });
393
+ }
394
+ continue;
395
+ }
396
+
397
+ if (rec.type === "artifact") {
398
+ if (!rec.kv["path"] || !rec.kv["schema"]) {
399
+ issues.push({
400
+ check: "schema",
401
+ message: "artifact records require path and schema",
402
+ resolved: !ctx.strict,
403
+ });
404
+ }
405
+ continue;
406
+ }
407
+
408
+ if (rec.type === "command") {
409
+ if (!rec.kv["name"]) {
410
+ issues.push({
411
+ check: "schema",
412
+ message: "command records require name",
413
+ resolved: !ctx.strict,
414
+ });
415
+ }
416
+ continue;
417
+ }
418
+
419
+ if (rec.type === "config") {
420
+ if (!rec.kv["key"]) {
421
+ issues.push({
422
+ check: "schema",
423
+ message: "config records require key",
424
+ resolved: !ctx.strict,
425
+ });
426
+ }
427
+ continue;
428
+ }
429
+ }
430
+
431
+ // Per-doc required record sets.
432
+ if (requireActiveRecords) {
433
+ if (doc === "runbook") {
434
+ if (!presentByType["runbook"]) missingRequired.push("runbook| record");
435
+ if (!presentByType["rule"] && !presentByType["chain"]) missingRequired.push("at least one rule| or chain| record");
436
+ }
437
+ if (doc === "workbook") {
438
+ if (!presentByType["workbook"]) missingRequired.push("workbook| record");
439
+ if (!presentByType["command"]) missingRequired.push("at least one command| record");
440
+ if (!presentByType["config"]) missingRequired.push("at least one config| record");
441
+ }
442
+ if (doc === "rules") {
443
+ if (!presentByType["rule"] && !presentByType["check"]) missingRequired.push("at least one rule| or check| record");
444
+ }
445
+ }
446
+
447
+ if (missingRequired.length > 0) {
448
+ issues.push({
449
+ check: "schema",
450
+ message: `Missing required AI records for doc family '${doc}': ${missingRequired.join(", ")}`,
451
+ resolved: !ctx.strict,
452
+ });
453
+ }
454
+
455
+ return issues;
456
+ }
457
+
458
+ function autoResolveAiSchema(aiOutput: string): string {
459
+ if (aiOutput.startsWith("meta|v=")) {
460
+ return aiOutput;
461
+ }
462
+ return `meta|v=1|doc=rules|truth=canonical|st=draft\n\n${aiOutput}`;
463
+ }
464
+
465
+ function renderTemplate(templateContent: string): { output: string; unresolvedBlocks: boolean } {
466
+ const output = templateContent.replace(/\{\{\{([\s\S]*?)\}\}\}/g, (_match, instructionText: string) => {
467
+ const normalized = instructionText.trim().split("\n")[0] ?? "template instructions";
468
+ return `Generated content based on instruction: ${normalized}`;
469
+ });
470
+ return {
471
+ output,
472
+ unresolvedBlocks: output.includes("{{{")
473
+ };
474
+ }
475
+
476
+ function validateSectionCoverage(templateContent: string, output: string): DocumentationValidationIssue[] {
477
+ const issues: DocumentationValidationIssue[] = [];
478
+ const sectionRegex = /^##\s+(.+)$/gm;
479
+ const expectedSections = [...templateContent.matchAll(sectionRegex)].map((match) => match[1]);
480
+ for (const section of expectedSections) {
481
+ if (!output.includes(`## ${section}`)) {
482
+ issues.push({
483
+ check: "section-coverage",
484
+ message: `Missing required section: ${section}`,
485
+ resolved: false
486
+ });
487
+ }
488
+ }
489
+ return issues;
490
+ }
491
+
492
+ function detectConflicts(aiOutput: string, humanOutput: string): DocumentationConflict[] {
493
+ const conflicts: DocumentationConflict[] = [];
494
+ const combined = `${aiOutput}\n${humanOutput}`;
495
+ if (combined.includes("CONFLICT:")) {
496
+ conflicts.push({
497
+ source: "generated-output",
498
+ reason: "Generated output flagged a conflict marker",
499
+ severity: "stop"
500
+ });
501
+ }
502
+ return conflicts;
503
+ }
504
+
505
+ type GenerateDocumentArgs = {
506
+ documentType?: string;
507
+ options?: DocumentationGenerateOptions;
508
+ };
509
+
510
+ export async function generateDocument(
511
+ args: GenerateDocumentArgs,
512
+ ctx: ModuleLifecycleContext
513
+ ): Promise<DocumentationGenerateResult> {
514
+ const documentType = args.documentType;
515
+ if (!documentType) {
516
+ return {
517
+ ok: false,
518
+ evidence: {
519
+ documentType: "unknown",
520
+ filesRead: [],
521
+ filesWritten: [],
522
+ filesSkipped: [],
523
+ validationIssues: [
524
+ {
525
+ check: "template-resolution",
526
+ message: "Missing required argument 'documentType'",
527
+ resolved: false
528
+ }
529
+ ],
530
+ conflicts: [],
531
+ attemptsUsed: 0,
532
+ timestamp: new Date().toISOString()
533
+ }
534
+ };
535
+ }
536
+
537
+ const options = args.options ?? {};
538
+ const canOverwriteAi = options.overwriteAi ?? options.overwrite ?? true;
539
+ const canOverwriteHuman = options.overwriteHuman ?? options.overwrite ?? true;
540
+ const config = await loadRuntimeConfig(ctx.workspacePath);
541
+ const filesRead: string[] = [];
542
+ const filesWritten: string[] = [];
543
+ const filesSkipped: string[] = [];
544
+ const validationIssues: DocumentationValidationIssue[] = [];
545
+ const conflicts: DocumentationConflict[] = [];
546
+
547
+ const aiRoot = resolve(ctx.workspacePath, config.aiRoot.replace(/^\//, ""));
548
+ const humanRoot = resolve(ctx.workspacePath, config.humanRoot.replace(/^\//, ""));
549
+ const templatePath = resolve(config.sourceRoot, config.templatesRoot, documentType);
550
+ const aiOutputPath = resolve(aiRoot, documentType);
551
+ const humanOutputPath = resolve(humanRoot, documentType);
552
+
553
+ if (!isPathWithinRoot(aiOutputPath, aiRoot) || !isPathWithinRoot(humanOutputPath, humanRoot)) {
554
+ return {
555
+ ok: false,
556
+ evidence: {
557
+ documentType,
558
+ filesRead,
559
+ filesWritten,
560
+ filesSkipped,
561
+ validationIssues: [
562
+ {
563
+ check: "write-boundary",
564
+ message: "Resolved output path escapes configured output roots",
565
+ resolved: false
566
+ }
567
+ ],
568
+ conflicts,
569
+ attemptsUsed: 0,
570
+ timestamp: new Date().toISOString()
571
+ }
572
+ };
573
+ }
574
+
575
+ let templateContent = "";
576
+ let templateFound = existsSync(templatePath);
577
+ if (templateFound) {
578
+ templateContent = await readFile(templatePath, "utf8");
579
+ filesRead.push(templatePath);
580
+ } else {
581
+ validationIssues.push({
582
+ check: "template-resolution",
583
+ message: `Template not found for '${documentType}'`,
584
+ resolved: Boolean(options.allowWithoutTemplate)
585
+ });
586
+ if (!options.allowWithoutTemplate) {
587
+ return {
588
+ ok: false,
589
+ evidence: {
590
+ documentType,
591
+ filesRead,
592
+ filesWritten,
593
+ filesSkipped,
594
+ validationIssues,
595
+ conflicts,
596
+ attemptsUsed: 0,
597
+ timestamp: new Date().toISOString()
598
+ }
599
+ };
600
+ }
601
+ }
602
+
603
+ const schemaPath = resolve(config.sourceRoot, config.schemasRoot, "documentation-schema.md");
604
+ if (existsSync(schemaPath)) {
605
+ filesRead.push(schemaPath);
606
+ await readFile(schemaPath, "utf8");
607
+ }
608
+
609
+ function resolveExpectedDocFamily(docType: string): "rules" | "runbook" | "workbook" {
610
+ if (docType.includes("runbooks/") || docType.startsWith("runbooks/")) return "runbook";
611
+ if (docType.includes("workbooks/") || docType.startsWith("workbooks/")) return "workbook";
612
+ return "rules";
613
+ }
614
+
615
+ const expectedDoc = resolveExpectedDocFamily(documentType);
616
+
617
+ // Default AI output for draft generation. When AI files already exist and overwriteAi is false,
618
+ // we validate and preserve the existing AI surface content instead of using this stub.
619
+ let aiOutput = `meta|v=1|doc=${expectedDoc}|truth=canonical|st=draft\nproject|name=workflow-cannon|type=generated_doc|scope=${documentType}`;
620
+ let attemptsUsed = 0;
621
+ const maxAttempts = options.maxValidationAttempts ?? config.maxValidationAttempts;
622
+
623
+ const strict = options.strict !== false;
624
+
625
+ if (existsSync(aiOutputPath) && !canOverwriteAi) {
626
+ // Preserve existing AI docs: validate them instead of validating the stub.
627
+ // This avoids schema regressions from breaking doc regeneration when AI docs are already curated.
628
+ aiOutput = await readFile(aiOutputPath, "utf8");
629
+ }
630
+
631
+ while (attemptsUsed < maxAttempts) {
632
+ attemptsUsed += 1;
633
+ const schemaIssues = validateAiSchema(aiOutput, {
634
+ strict,
635
+ workspacePath: ctx.workspacePath,
636
+ expectedDoc,
637
+ });
638
+ if (schemaIssues.length === 0) {
639
+ break;
640
+ }
641
+ const hasUnresolved = schemaIssues.some((i) => !i.resolved);
642
+ validationIssues.push(...schemaIssues);
643
+ if (!hasUnresolved) {
644
+ // In advisory mode, schema warnings should not block generation.
645
+ break;
646
+ }
647
+ aiOutput = autoResolveAiSchema(aiOutput);
648
+ }
649
+
650
+ const aiFinalIssues = validateAiSchema(aiOutput, {
651
+ strict,
652
+ workspacePath: ctx.workspacePath,
653
+ expectedDoc,
654
+ });
655
+ if (aiFinalIssues.some((i) => !i.resolved)) {
656
+ validationIssues.push(...aiFinalIssues);
657
+ return {
658
+ ok: false,
659
+ evidence: {
660
+ documentType,
661
+ filesRead,
662
+ filesWritten,
663
+ filesSkipped,
664
+ validationIssues,
665
+ conflicts,
666
+ attemptsUsed,
667
+ timestamp: new Date().toISOString()
668
+ }
669
+ };
670
+ }
671
+
672
+ let humanOutput = `# ${documentType}\n\nGenerated without template.`;
673
+ if (templateFound) {
674
+ const rendered = renderTemplate(templateContent);
675
+ humanOutput = rendered.output;
676
+ if (rendered.unresolvedBlocks) {
677
+ validationIssues.push({
678
+ check: "section-coverage",
679
+ message: "Template output still contains unresolved {{{ }}} blocks",
680
+ resolved: false
681
+ });
682
+ }
683
+ validationIssues.push(...validateSectionCoverage(templateContent, humanOutput));
684
+ }
685
+
686
+ conflicts.push(...detectConflicts(aiOutput, humanOutput));
687
+ if (conflicts.some((conflict) => conflict.severity === "stop")) {
688
+ return {
689
+ ok: false,
690
+ evidence: {
691
+ documentType,
692
+ filesRead,
693
+ filesWritten,
694
+ filesSkipped,
695
+ validationIssues,
696
+ conflicts,
697
+ attemptsUsed,
698
+ timestamp: new Date().toISOString()
699
+ }
700
+ };
701
+ }
702
+
703
+ const hasUnresolvedValidation = validationIssues.some((issue) => !issue.resolved);
704
+ if (options.strict !== false && hasUnresolvedValidation) {
705
+ return {
706
+ ok: false,
707
+ evidence: {
708
+ documentType,
709
+ filesRead,
710
+ filesWritten,
711
+ filesSkipped,
712
+ validationIssues,
713
+ conflicts,
714
+ attemptsUsed,
715
+ timestamp: new Date().toISOString()
716
+ }
717
+ };
718
+ }
719
+
720
+ if (!options.dryRun) {
721
+ const aiExists = existsSync(aiOutputPath);
722
+ const humanExists = existsSync(humanOutputPath);
723
+
724
+ if ((!canOverwriteAi && aiExists) && (!canOverwriteHuman && humanExists)) {
725
+ return {
726
+ ok: false,
727
+ evidence: {
728
+ documentType,
729
+ filesRead,
730
+ filesWritten,
731
+ filesSkipped: [aiOutputPath, humanOutputPath],
732
+ validationIssues: [
733
+ ...validationIssues,
734
+ {
735
+ check: "write-boundary",
736
+ message: "Output exists and overwrite=false",
737
+ resolved: false
738
+ }
739
+ ],
740
+ conflicts,
741
+ attemptsUsed,
742
+ timestamp: new Date().toISOString()
743
+ }
744
+ };
745
+ }
746
+
747
+ await mkdir(aiRoot, { recursive: true });
748
+ await mkdir(humanRoot, { recursive: true });
749
+ await mkdir(dirname(aiOutputPath), { recursive: true });
750
+ await mkdir(dirname(humanOutputPath), { recursive: true });
751
+
752
+ if (canOverwriteAi || !aiExists) {
753
+ await writeFile(aiOutputPath, `${aiOutput}\n`, "utf8");
754
+ filesWritten.push(aiOutputPath);
755
+ } else {
756
+ filesSkipped.push(aiOutputPath);
757
+ }
758
+ if (canOverwriteHuman || !humanExists) {
759
+ await writeFile(humanOutputPath, `${humanOutput}\n`, "utf8");
760
+ filesWritten.push(humanOutputPath);
761
+ } else {
762
+ filesSkipped.push(humanOutputPath);
763
+ }
764
+ }
765
+
766
+ return {
767
+ ok: true,
768
+ aiOutputPath,
769
+ humanOutputPath,
770
+ evidence: {
771
+ documentType,
772
+ filesRead,
773
+ filesWritten,
774
+ filesSkipped,
775
+ validationIssues,
776
+ conflicts,
777
+ attemptsUsed,
778
+ timestamp: new Date().toISOString()
779
+ }
780
+ };
781
+ }
782
+
783
+ type GenerateAllDocumentsArgs = {
784
+ options?: DocumentationGenerateOptions;
785
+ };
786
+
787
+ export async function generateAllDocuments(
788
+ args: GenerateAllDocumentsArgs,
789
+ ctx: ModuleLifecycleContext
790
+ ): Promise<DocumentationBatchResult> {
791
+ const config = await loadRuntimeConfig(ctx.workspacePath);
792
+ const templatesDir = resolve(config.sourceRoot, config.templatesRoot);
793
+
794
+ async function listTemplateFiles(dir: string, baseDir: string): Promise<string[]> {
795
+ const entries = await readdir(dir, { withFileTypes: true });
796
+ const files: string[] = [];
797
+ for (const entry of entries) {
798
+ const absPath = resolve(dir, entry.name);
799
+ if (entry.isDirectory()) {
800
+ files.push(...(await listTemplateFiles(absPath, baseDir)));
801
+ continue;
802
+ }
803
+ if (!entry.isFile() || !entry.name.endsWith(".md")) {
804
+ continue;
805
+ }
806
+ const relPath = absPath.slice(baseDir.length + 1).split("\\").join("/");
807
+ files.push(relPath);
808
+ }
809
+ return files;
810
+ }
811
+
812
+ let templateFiles: string[] = [];
813
+ try {
814
+ templateFiles = (await listTemplateFiles(templatesDir, templatesDir)).sort();
815
+ } catch {
816
+ return {
817
+ ok: false,
818
+ results: [],
819
+ summary: {
820
+ total: 0,
821
+ succeeded: 0,
822
+ failed: 1,
823
+ skipped: 0,
824
+ timestamp: new Date().toISOString()
825
+ }
826
+ };
827
+ }
828
+
829
+ const results: DocumentationGenerateResult[] = [];
830
+ let succeeded = 0;
831
+ let failed = 0;
832
+ let skipped = 0;
833
+
834
+ const batchOptions: DocumentationGenerateOptions = {
835
+ ...args.options,
836
+ overwriteAi: args.options?.overwriteAi ?? false,
837
+ overwriteHuman: args.options?.overwriteHuman ?? true,
838
+ strict: args.options?.strict ?? false,
839
+ };
840
+
841
+ for (const templateFile of templateFiles) {
842
+ const result = await generateDocument(
843
+ { documentType: templateFile, options: batchOptions },
844
+ ctx
845
+ );
846
+ results.push(result);
847
+
848
+ if (result.ok) {
849
+ if (result.evidence.filesWritten.length > 0) {
850
+ succeeded++;
851
+ } else {
852
+ skipped++;
853
+ }
854
+ } else {
855
+ failed++;
856
+ }
857
+ }
858
+
859
+ return {
860
+ ok: failed === 0,
861
+ results,
862
+ summary: {
863
+ total: templateFiles.length,
864
+ succeeded,
865
+ failed,
866
+ skipped,
867
+ timestamp: new Date().toISOString()
868
+ }
869
+ };
870
+ }