@jamesaphoenix/tx-core 0.11.1 → 0.12.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 (66) hide show
  1. package/README.md +105 -360
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/internal/doc-service-impl.d.ts +2 -2
  7. package/dist/internal/doc-service-impl.d.ts.map +1 -1
  8. package/dist/internal/doc-service-impl.js +340 -268
  9. package/dist/internal/doc-service-impl.js.map +1 -1
  10. package/dist/internal/spec-trace-service-impl.d.ts +7 -0
  11. package/dist/internal/spec-trace-service-impl.d.ts.map +1 -1
  12. package/dist/internal/spec-trace-service-impl.js +25 -0
  13. package/dist/internal/spec-trace-service-impl.js.map +1 -1
  14. package/dist/internal/sync/service-impl.d.ts.map +1 -1
  15. package/dist/internal/sync/service-impl.js +20 -1
  16. package/dist/internal/sync/service-impl.js.map +1 -1
  17. package/dist/mappers/task.js +4 -4
  18. package/dist/mappers/task.js.map +1 -1
  19. package/dist/migrations-embedded.d.ts.map +1 -1
  20. package/dist/migrations-embedded.js +11 -1
  21. package/dist/migrations-embedded.js.map +1 -1
  22. package/dist/repo/claim-repo.d.ts +6 -0
  23. package/dist/repo/claim-repo.d.ts.map +1 -1
  24. package/dist/repo/claim-repo.js +51 -0
  25. package/dist/repo/claim-repo.js.map +1 -1
  26. package/dist/repo/task-repo/read.d.ts.map +1 -1
  27. package/dist/repo/task-repo/read.js +6 -5
  28. package/dist/repo/task-repo/read.js.map +1 -1
  29. package/dist/schemas/sync.d.ts +10 -10
  30. package/dist/schemas/sync.d.ts.map +1 -1
  31. package/dist/services/claim-service.d.ts +5 -0
  32. package/dist/services/claim-service.d.ts.map +1 -1
  33. package/dist/services/claim-service.js +3 -0
  34. package/dist/services/claim-service.js.map +1 -1
  35. package/dist/services/ready-service.d.ts.map +1 -1
  36. package/dist/services/ready-service.js +48 -7
  37. package/dist/services/ready-service.js.map +1 -1
  38. package/dist/services/swarm-verification.d.ts +1 -1
  39. package/dist/services/swarm-verification.js +1 -1
  40. package/dist/services/task-service/internals.d.ts +18 -1
  41. package/dist/services/task-service/internals.d.ts.map +1 -1
  42. package/dist/services/task-service/internals.js +61 -2
  43. package/dist/services/task-service/internals.js.map +1 -1
  44. package/dist/services/task-service.d.ts.map +1 -1
  45. package/dist/services/task-service.js +12 -5
  46. package/dist/services/task-service.js.map +1 -1
  47. package/dist/sync/claude-task-writer.js +1 -1
  48. package/dist/sync/claude-task-writer.js.map +1 -1
  49. package/dist/utils/doc-renderer.d.ts.map +1 -1
  50. package/dist/utils/doc-renderer.js +306 -61
  51. package/dist/utils/doc-renderer.js.map +1 -1
  52. package/dist/utils/ears-validator.d.ts.map +1 -1
  53. package/dist/utils/ears-validator.js +128 -14
  54. package/dist/utils/ears-validator.js.map +1 -1
  55. package/dist/utils/md-doc-parser.d.ts +15 -0
  56. package/dist/utils/md-doc-parser.d.ts.map +1 -0
  57. package/dist/utils/md-doc-parser.js +275 -0
  58. package/dist/utils/md-doc-parser.js.map +1 -0
  59. package/dist/utils/toml-config.d.ts +34 -0
  60. package/dist/utils/toml-config.d.ts.map +1 -1
  61. package/dist/utils/toml-config.js +177 -6
  62. package/dist/utils/toml-config.js.map +1 -1
  63. package/migrations/001_initial.sql +2 -1
  64. package/migrations/039_needs_review_status.sql +108 -0
  65. package/migrations/040_cycles.sql +33 -0
  66. package/package.json +1 -1
@@ -1,26 +1,26 @@
1
1
  /**
2
2
  * DocService — business logic for docs-as-primitives (DD-023).
3
3
  *
4
- * Manages doc lifecycle (create/update/lock/version), rendering (YAML→MD),
4
+ * Manages doc lifecycle (create/update/lock/version), markdown validation,
5
5
  * linking (doc-doc, task-doc), invariant sync, drift detection, and graph data.
6
6
  *
7
- * YAML content lives on disk (.tx/docs/); DB stores metadata + links only.
7
+ * Markdown content lives on disk (specs/); DB stores metadata + links only.
8
8
  */
9
9
  import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, } from "node:fs";
10
10
  import { resolve, dirname, join } from "node:path";
11
- import { Cause, Context, Effect, Layer, Option } from "effect";
11
+ import { Cause, Context, Effect, Either, Layer, Option, Schema } from "effect";
12
12
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
13
13
  import { DocRepository } from "../repo/doc-repo.js";
14
14
  import { ValidationError, DocNotFoundError, DocLockedError, InvalidDocYamlError, InvariantNotFoundError, } from "../errors.js";
15
15
  import { computeDocHash } from "../utils/doc-hash.js";
16
- import { renderDocToMarkdown, renderIndexToMarkdown, } from "../utils/doc-renderer.js";
16
+ import { renderIndexToMarkdown } from "../utils/doc-renderer.js";
17
17
  import { formatEarsValidationErrors, validateEarsRequirements, } from "../utils/ears-validator.js";
18
+ import { parseMdDocSync, MdDocParseError } from "../utils/md-doc-parser.js";
18
19
  import { readTxConfig } from "../utils/toml-config.js";
19
20
  import { resolvePathWithin } from "../utils/file-path.js";
20
- import { DOC_KINDS, INVARIANT_ENFORCEMENT_TYPES, EARS_PATTERNS, renderEarsRule, } from "@jamesaphoenix/tx-types";
21
+ import { DOC_KINDS, DOC_CONTENT_SCHEMAS, EARS_PATTERNS, renderEarsRule, } from "@jamesaphoenix/tx-types";
21
22
  // Local string arrays for .includes() (avoids readonly cast)
22
23
  const docKindStrings = DOC_KINDS;
23
- const enforcementStrings = INVARIANT_ENFORCEMENT_TYPES;
24
24
  /** Infer link type from doc kinds (from → to). */
25
25
  const inferLinkType = (fromKind, toKind) => {
26
26
  if (fromKind === "overview" && toKind === "prd")
@@ -44,15 +44,15 @@ const kindSubdir = (kind) => {
44
44
  if (kind === "overview")
45
45
  return "";
46
46
  if (kind === "requirement")
47
- return "requirement";
47
+ return "requirements";
48
48
  if (kind === "system_design")
49
- return "system_design";
49
+ return "system-design";
50
50
  return kind;
51
51
  };
52
- /** Resolve the YAML file path for a doc. */
53
- const resolveYamlPath = (docsPath, kind, name) => {
52
+ /** Resolve the markdown file path for a doc. */
53
+ const resolveDocPath = (docsPath, kind, name) => {
54
54
  const sub = kindSubdir(kind);
55
- const relativeDocPath = sub ? join(sub, `${name}.yml`) : `${name}.yml`;
55
+ const relativeDocPath = sub ? join(sub, `${name}.md`) : `${name}.md`;
56
56
  const resolvedDocPath = resolvePathWithin(docsPath, relativeDocPath, {
57
57
  useRealpath: true,
58
58
  });
@@ -63,19 +63,14 @@ const resolveYamlPath = (docsPath, kind, name) => {
63
63
  }
64
64
  return resolvedDocPath;
65
65
  };
66
- /** Resolve the MD file path for a doc. */
67
- const resolveMdPath = (docsPath, kind, name) => {
68
- const sub = kindSubdir(kind);
69
- const relativeDocPath = sub ? join(sub, `${name}.md`) : `${name}.md`;
70
- const resolvedDocPath = resolvePathWithin(docsPath, relativeDocPath, {
71
- useRealpath: true,
72
- });
73
- if (!resolvedDocPath) {
74
- throw new ValidationError({
75
- reason: `Invalid doc path for name '${name}'`,
66
+ const parseSpecTypeAsDocKind = (name, specType) => {
67
+ if (!docKindStrings.includes(specType)) {
68
+ throw new InvalidDocYamlError({
69
+ name,
70
+ reason: `Unsupported spec_type '${specType}' for docs service.`,
76
71
  });
77
72
  }
78
- return resolvedDocPath;
73
+ return specType;
79
74
  };
80
75
  const collectLegacyRequirements = (value) => {
81
76
  const normalize = (item) => {
@@ -112,8 +107,123 @@ const collectLegacyRequirements = (value) => {
112
107
  };
113
108
  /** EARS is a hard requirement for PRDs — not configurable. */
114
109
  const isEarsRequiredForLegacyPrds = () => true;
110
+ const nullableYamlStringKeys = new Set([
111
+ "name",
112
+ "title",
113
+ "problem_definition",
114
+ "subsystems",
115
+ "object_model",
116
+ "user_specific_content",
117
+ "problem",
118
+ "solution",
119
+ "overview",
120
+ "scope",
121
+ "design",
122
+ "architecture",
123
+ "data_model",
124
+ "open_questions",
125
+ "functional_requirements",
126
+ "id",
127
+ "statement",
128
+ "category",
129
+ "rationale",
130
+ "condition",
131
+ "impact",
132
+ "handling",
133
+ "expected_behavior",
134
+ "description",
135
+ "actor",
136
+ "trigger",
137
+ "outcome",
138
+ "target",
139
+ "reason",
140
+ "decision",
141
+ "consequence",
142
+ "requirement_id",
143
+ "verification",
144
+ "success_criteria",
145
+ "system",
146
+ "response",
147
+ "feature",
148
+ "state",
149
+ "test_hint",
150
+ "constraints",
151
+ "acceptance_criteria",
152
+ "non_goals",
153
+ "requirements",
154
+ "out_of_scope",
155
+ "goals",
156
+ "non_functional_requirements",
157
+ ]);
158
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
159
+ const normalizeNullYamlStrings = (value, currentKey) => {
160
+ if (value === null) {
161
+ return currentKey && nullableYamlStringKeys.has(currentKey) ? "" : value;
162
+ }
163
+ if (Array.isArray(value)) {
164
+ return value.map((item) => normalizeNullYamlStrings(item, currentKey));
165
+ }
166
+ if (!isRecord(value)) {
167
+ return value;
168
+ }
169
+ const normalized = {};
170
+ for (const [key, entry] of Object.entries(value)) {
171
+ normalized[key] = normalizeNullYamlStrings(entry, key);
172
+ }
173
+ return normalized;
174
+ };
175
+ const normalizeLegacyEarsRequirements = (parsed) => {
176
+ const raw = parsed.ears_requirements;
177
+ if (!Array.isArray(raw))
178
+ return;
179
+ for (const entry of raw) {
180
+ if (!isRecord(entry))
181
+ continue;
182
+ if (typeof entry.statement === "string" && entry.statement.trim().length > 0) {
183
+ continue;
184
+ }
185
+ const patternValue = entry.pattern;
186
+ const systemValue = entry.system;
187
+ const responseValue = entry.response;
188
+ if (typeof patternValue !== "string" ||
189
+ !EARS_PATTERNS.includes(patternValue) ||
190
+ typeof systemValue !== "string" ||
191
+ typeof responseValue !== "string") {
192
+ continue;
193
+ }
194
+ entry.statement = renderEarsRule({
195
+ pattern: patternValue,
196
+ system: systemValue,
197
+ response: responseValue,
198
+ trigger: typeof entry.trigger === "string" ? entry.trigger : undefined,
199
+ state: typeof entry.state === "string" ? entry.state : undefined,
200
+ condition: typeof entry.condition === "string" ? entry.condition : undefined,
201
+ feature: typeof entry.feature === "string" ? entry.feature : undefined,
202
+ });
203
+ }
204
+ };
205
+ const normalizeForSchemaValidation = (kind, parsed) => {
206
+ const normalized = normalizeNullYamlStrings(parsed);
207
+ if (kind === "prd" || kind === "requirement") {
208
+ normalizeLegacyEarsRequirements(normalized);
209
+ }
210
+ return normalized;
211
+ };
212
+ const collectSchemaWarnings = (kind, parsed) => {
213
+ if (kind !== "prd")
214
+ return [];
215
+ const warnings = [];
216
+ if (parsed.requirements !== undefined) {
217
+ warnings.push("Deprecated field 'requirements' detected in PRD YAML; use 'ears_requirements' instead.");
218
+ }
219
+ if (parsed.out_of_scope !== undefined) {
220
+ warnings.push("Deprecated field 'out_of_scope' detected in PRD YAML; use 'non_goals' instead.");
221
+ }
222
+ return warnings;
223
+ };
115
224
  /** Validate YAML content and return parsed object. */
116
- const validateYaml = (name, content, expectedKind) => {
225
+ const _validateYaml = (name, content, expectedKind, options) => {
226
+ const enforceContentSchema = options?.enforceContentSchema === true;
117
227
  let parsed;
118
228
  try {
119
229
  parsed = parseYaml(content);
@@ -135,6 +245,24 @@ const validateYaml = (name, content, expectedKind) => {
135
245
  ? parsedObject.kind
136
246
  : null;
137
247
  const effectiveKind = expectedKind ?? yamlKind;
248
+ const warnings = collectSchemaWarnings(effectiveKind, parsedObject);
249
+ if (enforceContentSchema && effectiveKind && effectiveKind in DOC_CONTENT_SCHEMAS) {
250
+ const contentSchema = DOC_CONTENT_SCHEMAS[effectiveKind];
251
+ const normalizedForDecode = normalizeForSchemaValidation(effectiveKind, parsedObject);
252
+ const decoded = Schema.decodeUnknownEither(contentSchema)(normalizedForDecode);
253
+ if (Either.isLeft(decoded)) {
254
+ const detail = typeof decoded.left === "object" &&
255
+ decoded.left !== null &&
256
+ "message" in decoded.left &&
257
+ typeof decoded.left.message === "string"
258
+ ? decoded.left.message
259
+ : String(decoded.left);
260
+ throw new InvalidDocYamlError({
261
+ name,
262
+ reason: `YAML schema validation failed for kind '${effectiveKind}': ${detail}`,
263
+ });
264
+ }
265
+ }
138
266
  if (effectiveKind === "prd" && parsedObject.ears_requirements !== undefined) {
139
267
  if (!Array.isArray(parsedObject.ears_requirements)) {
140
268
  throw new InvalidDocYamlError({
@@ -162,10 +290,10 @@ const validateYaml = (name, content, expectedKind) => {
162
290
  "'ears_requirements' array. EARS-structured requirements are mandatory for all PRDs.",
163
291
  });
164
292
  }
165
- return parsedObject;
293
+ return { parsed: parsedObject, warnings };
166
294
  };
167
295
  /** Validate doc kind from YAML. */
168
- const validateKind = (name, parsed, expectedKind) => {
296
+ const _validateKind = (name, parsed, expectedKind) => {
169
297
  const yamlKind = parsed.kind;
170
298
  if (yamlKind && typeof yamlKind === "string" && yamlKind !== expectedKind) {
171
299
  throw new InvalidDocYamlError({
@@ -174,151 +302,54 @@ const validateKind = (name, parsed, expectedKind) => {
174
302
  });
175
303
  }
176
304
  };
177
- const normalizeInvariantSegment = (value) => {
178
- const normalized = value
179
- .toUpperCase()
180
- .replace(/[^A-Z0-9]+/g, "-")
181
- .replace(/^-+|-+$/g, "")
182
- .replace(/-+/g, "-");
183
- return normalized.length > 0 ? normalized : "DOC";
184
- };
185
- const collectStringList = (value) => {
186
- return collectLegacyRequirements(value);
187
- };
188
- const extractSubsystemFromEarsId = (id) => {
189
- const match = /^EARS-([A-Z0-9]+)-\d{3}$/.exec(id);
190
- return match?.[1]?.toLowerCase();
191
- };
192
- const extractExplicitInvariants = (raw) => {
193
- if (!Array.isArray(raw))
194
- return [];
195
- const candidates = [];
196
- for (const item of raw) {
197
- if (typeof item !== "object" || item === null)
198
- continue;
199
- const inv = item;
200
- const id = typeof inv.id === "string" ? inv.id : null;
201
- const rule = typeof inv.rule === "string" ? inv.rule : null;
202
- const enforcement = typeof inv.enforcement === "string" ? inv.enforcement : null;
203
- if (!id || !rule || !enforcement)
204
- continue;
205
- if (!enforcementStrings.includes(enforcement))
206
- continue;
207
- candidates.push({
208
- id,
209
- rule,
210
- enforcement,
211
- subsystem: typeof inv.subsystem === "string"
212
- ? inv.subsystem
213
- : inv.subsystem === null
214
- ? null
215
- : undefined,
216
- testRef: typeof inv.test_ref === "string" ? inv.test_ref : undefined,
217
- lintRule: typeof inv.lint_rule === "string" ? inv.lint_rule : undefined,
218
- promptRef: typeof inv.prompt_ref === "string" ? inv.prompt_ref : undefined,
219
- // Provenance
220
- source: typeof inv.source === "string" ? inv.source : undefined,
221
- sourceRef: typeof inv.source_ref === "string" ? inv.source_ref : undefined,
222
- // EARS fields (explicit invariants may include these)
223
- pattern: typeof inv.pattern === "string" ? inv.pattern : undefined,
224
- triggerText: typeof inv.trigger === "string" ? inv.trigger : undefined,
225
- stateText: typeof inv.state === "string" ? inv.state : undefined,
226
- conditionText: typeof inv.condition === "string" ? inv.condition : undefined,
227
- feature: typeof inv.feature === "string" ? inv.feature : undefined,
228
- systemName: typeof inv.system === "string" ? inv.system : undefined,
229
- response: typeof inv.response === "string" ? inv.response : undefined,
230
- rationale: typeof inv.rationale === "string" ? inv.rationale : undefined,
231
- testHint: typeof inv.test_hint === "string" ? inv.test_hint : undefined,
232
- });
233
- }
234
- return candidates;
305
+ const mapInvariantSeverityToEnforcement = (_severity) => {
306
+ return "integration_test";
235
307
  };
236
- const deriveEarsInvariants = (doc, parsed) => {
237
- if (doc.kind !== "prd" && doc.kind !== "requirement")
238
- return [];
239
- const raw = parsed.ears_requirements;
240
- if (!Array.isArray(raw))
241
- return [];
242
- const candidates = [];
243
- for (const item of raw) {
244
- if (typeof item !== "object" || item === null)
245
- continue;
246
- const req = item;
247
- const earsId = typeof req.id === "string" ? req.id : null;
248
- const system = typeof req.system === "string" ? req.system.trim() : "";
249
- const response = typeof req.response === "string" ? req.response.trim() : "";
250
- if (!earsId || !system || !response)
251
- continue;
252
- const pattern = typeof req.pattern === "string" ? req.pattern : "ubiquitous";
253
- const trigger = typeof req.trigger === "string" ? req.trigger : undefined;
254
- const state = typeof req.state === "string" ? req.state : undefined;
255
- const condition = typeof req.condition === "string" ? req.condition : undefined;
256
- const feature = typeof req.feature === "string" ? req.feature : undefined;
257
- const rationale = typeof req.rationale === "string" ? req.rationale : undefined;
258
- const testHint = typeof req.test_hint === "string" ? req.test_hint : undefined;
259
- // Validate pattern against known EARS patterns
260
- const earsPatternStrings = EARS_PATTERNS;
261
- const validPattern = earsPatternStrings.includes(pattern)
262
- ? pattern
263
- : "ubiquitous";
264
- const rule = renderEarsRule({
265
- pattern: validPattern,
266
- system,
267
- response,
268
- trigger,
269
- state,
270
- condition,
271
- feature,
272
- });
273
- candidates.push({
274
- id: `INV-${earsId}`,
275
- rule,
276
- enforcement: "integration_test",
277
- subsystem: extractSubsystemFromEarsId(earsId),
278
- testRef: testHint ?? null,
279
- source: "explicit",
280
- // EARS fields
281
- pattern: validPattern,
282
- triggerText: trigger ?? null,
283
- stateText: state ?? null,
284
- conditionText: condition ?? null,
285
- feature: feature ?? null,
286
- systemName: system,
287
- response,
288
- rationale: rationale ?? null,
289
- testHint: testHint ?? null,
290
- });
308
+ const mapMdEarsKindToLegacyPattern = (kind) => {
309
+ switch (kind) {
310
+ case "event-driven":
311
+ return "event_driven";
312
+ case "state-driven":
313
+ return "state_driven";
314
+ case "unwanted":
315
+ return "unwanted";
316
+ case "optional":
317
+ return "optional";
318
+ case "complex":
319
+ return "complex";
320
+ default:
321
+ return "ubiquitous";
291
322
  }
292
- return candidates;
293
323
  };
294
- const derivePrdRequirementInvariants = (doc, parsed) => {
295
- if (doc.kind !== "prd")
324
+ const deriveEmbeddedInvariantCandidates = (doc, rawInvariants) => {
325
+ if (!rawInvariants || rawInvariants.length === 0)
296
326
  return [];
297
- const requirements = collectStringList(parsed.requirements);
298
- if (requirements.length === 0)
299
- return [];
300
- const docSegment = normalizeInvariantSegment(doc.name);
301
- return requirements.map((rule, index) => ({
302
- id: `INV-PRD-${docSegment}-REQ-${String(index + 1).padStart(3, "0")}`,
303
- rule,
304
- enforcement: "integration_test",
305
- subsystem: "prd",
327
+ return rawInvariants.map((inv) => ({
328
+ id: inv.id,
329
+ rule: inv.statement,
330
+ enforcement: mapInvariantSeverityToEnforcement(inv.severity),
331
+ subsystem: doc.kind,
332
+ testRef: inv.verified_by[0] ?? null,
306
333
  source: "explicit",
334
+ sourceRef: "blocks.invariants",
307
335
  }));
308
336
  };
309
- const deriveDesignGoalInvariants = (doc, parsed) => {
310
- if (doc.kind !== "design")
337
+ const deriveEmbeddedEarsInvariantCandidates = (doc, rawRequirements) => {
338
+ if (!rawRequirements || rawRequirements.length === 0)
311
339
  return [];
312
- const goals = collectStringList(parsed.goals);
313
- if (goals.length === 0)
314
- return [];
315
- const docSegment = normalizeInvariantSegment(doc.name);
316
- return goals.map((rule, index) => ({
317
- id: `INV-DESIGN-${docSegment}-GOAL-${String(index + 1).padStart(3, "0")}`,
318
- rule,
340
+ return rawRequirements.map((req) => ({
341
+ id: `INV-${req.id}`,
342
+ rule: req.statement,
319
343
  enforcement: "integration_test",
320
- subsystem: "design",
321
- source: "goals",
344
+ subsystem: doc.kind,
345
+ source: "explicit",
346
+ sourceRef: "blocks.ears_requirements",
347
+ pattern: mapMdEarsKindToLegacyPattern(req.kind),
348
+ triggerText: req.when ?? null,
349
+ stateText: req.while ?? null,
350
+ conditionText: req.if ?? null,
351
+ feature: req.where ?? null,
352
+ rationale: req.rationale ?? null,
322
353
  }));
323
354
  };
324
355
  export class DocService extends Context.Tag("DocService")() {
@@ -335,19 +366,47 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
335
366
  mkdirSync(dir, { recursive: true });
336
367
  }
337
368
  };
338
- /** Render a single doc and return its MD file path. */
339
- const renderSingleDoc = (doc, docsPath) => {
340
- const yamlPath = resolveYamlPath(docsPath, doc.kind, doc.name);
341
- if (!existsSync(yamlPath)) {
369
+ const parseMarkdownSpecDocContent = (name, content) => {
370
+ const parsedResult = parseMdDocSync(content);
371
+ if (Either.isLeft(parsedResult)) {
372
+ const reason = parsedResult.left instanceof MdDocParseError
373
+ ? parsedResult.left.reason
374
+ : String(parsedResult.left);
375
+ throw new InvalidDocYamlError({
376
+ name,
377
+ reason: `Markdown validation failed: ${reason}`,
378
+ });
379
+ }
380
+ if (parsedResult.right.kind !== "spec") {
381
+ throw new InvalidDocYamlError({
382
+ name,
383
+ reason: "Markdown docs managed by DocService must have frontmatter kind: spec.",
384
+ });
385
+ }
386
+ return parsedResult.right;
387
+ };
388
+ /** Validate a single markdown doc and return its canonical file path. */
389
+ const validateDocFile = (doc, docsPath) => {
390
+ const docPath = resolveDocPath(docsPath, doc.kind, doc.name);
391
+ if (!existsSync(docPath)) {
342
392
  throw new DocNotFoundError({ name: doc.name });
343
393
  }
344
- const yamlContent = readFileSync(yamlPath, "utf8");
345
- const parsed = validateYaml(doc.name, yamlContent, doc.kind);
346
- const md = renderDocToMarkdown(parsed, doc.kind);
347
- const mdPath = resolveMdPath(docsPath, doc.kind, doc.name);
348
- ensureDir(mdPath);
349
- writeFileSync(mdPath, md, "utf8");
350
- return mdPath;
394
+ const content = readFileSync(docPath, "utf8");
395
+ const parsed = parseMarkdownSpecDocContent(doc.name, content);
396
+ const parsedKind = parseSpecTypeAsDocKind(doc.name, parsed.frontmatter.spec_type);
397
+ if (parsed.frontmatter.name !== doc.name) {
398
+ throw new InvalidDocYamlError({
399
+ name: doc.name,
400
+ reason: `Frontmatter name '${parsed.frontmatter.name}' does not match doc name '${doc.name}'.`,
401
+ });
402
+ }
403
+ if (parsedKind !== doc.kind) {
404
+ throw new InvalidDocYamlError({
405
+ name: doc.name,
406
+ reason: `Frontmatter spec_type '${parsed.frontmatter.spec_type}' does not match doc kind '${doc.kind}'.`,
407
+ });
408
+ }
409
+ return docPath;
351
410
  };
352
411
  /** Generate index.yml and index.md from all docs in DB. */
353
412
  function generateIndexEffect(docsPath) {
@@ -463,22 +522,31 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
463
522
  writeFileSync(indexMdPath, indexMd, "utf8");
464
523
  });
465
524
  }
466
- /** Sync invariants from a single doc's YAML into DB. */
525
+ /** Sync invariants from a single markdown doc into DB. */
467
526
  function syncInvariantsForDoc(doc) {
468
527
  return Effect.gen(function* () {
469
528
  const docsPath = getDocsPath();
470
- const yamlPath = resolveYamlPath(docsPath, doc.kind, doc.name);
471
- if (!existsSync(yamlPath)) {
529
+ const docPath = resolveDocPath(docsPath, doc.kind, doc.name);
530
+ if (!existsSync(docPath)) {
472
531
  return [];
473
532
  }
474
- const yamlContent = readFileSync(yamlPath, "utf8");
475
- const parsed = validateYaml(doc.name, yamlContent, doc.kind);
476
- const explicit = extractExplicitInvariants(parsed.invariants);
477
- const derived = [
478
- ...deriveEarsInvariants(doc, parsed),
479
- ...derivePrdRequirementInvariants(doc, parsed),
480
- ...deriveDesignGoalInvariants(doc, parsed),
481
- ];
533
+ const content = readFileSync(docPath, "utf8");
534
+ const parsed = parseMarkdownSpecDocContent(doc.name, content);
535
+ const parsedKind = parseSpecTypeAsDocKind(doc.name, parsed.frontmatter.spec_type);
536
+ if (parsed.frontmatter.name !== doc.name) {
537
+ throw new InvalidDocYamlError({
538
+ name: doc.name,
539
+ reason: `Frontmatter name '${parsed.frontmatter.name}' does not match doc name '${doc.name}'.`,
540
+ });
541
+ }
542
+ if (parsedKind !== doc.kind) {
543
+ throw new InvalidDocYamlError({
544
+ name: doc.name,
545
+ reason: `Frontmatter spec_type '${parsed.frontmatter.spec_type}' does not match doc kind '${doc.kind}'.`,
546
+ });
547
+ }
548
+ const explicit = deriveEmbeddedInvariantCandidates(doc, parsed.blocks.invariants);
549
+ const derived = deriveEmbeddedEarsInvariantCandidates(doc, parsed.blocks.ears_requirements);
482
550
  const candidates = [];
483
551
  const seen = new Set();
484
552
  for (const candidate of [...explicit, ...derived]) {
@@ -526,7 +594,7 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
526
594
  }
527
595
  return {
528
596
  create: (input) => Effect.gen(function* () {
529
- const { kind, name, title, yamlContent, metadata } = input;
597
+ const { kind, name, title, content, metadata } = input;
530
598
  if (!docKindStrings.includes(kind)) {
531
599
  return yield* Effect.fail(new ValidationError({ reason: `Invalid doc kind: ${kind}` }));
532
600
  }
@@ -535,37 +603,49 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
535
603
  reason: `Invalid doc name: ${name}. Use alphanumeric with dashes/dots.`,
536
604
  }));
537
605
  }
538
- const parsed = validateYaml(name, yamlContent, kind);
539
- validateKind(name, parsed, kind);
540
- const existing = yield* docRepo.findByName(name);
606
+ const parsedDoc = parseMarkdownSpecDocContent(name, content);
607
+ const frontmatter = parsedDoc.frontmatter;
608
+ const parsedKind = parseSpecTypeAsDocKind(name, frontmatter.spec_type);
609
+ if (frontmatter.name !== name) {
610
+ return yield* Effect.fail(new ValidationError({
611
+ reason: `Frontmatter name '${frontmatter.name}' does not match input name '${name}'.`,
612
+ }));
613
+ }
614
+ if (frontmatter.title !== title) {
615
+ return yield* Effect.fail(new ValidationError({
616
+ reason: `Frontmatter title '${frontmatter.title}' does not match input title '${title}'.`,
617
+ }));
618
+ }
619
+ if (parsedKind !== kind) {
620
+ return yield* Effect.fail(new ValidationError({
621
+ reason: `Frontmatter spec_type '${frontmatter.spec_type}' does not match input kind '${kind}'.`,
622
+ }));
623
+ }
624
+ const existing = yield* docRepo.findByName(frontmatter.name);
541
625
  if (existing) {
542
626
  return yield* Effect.fail(new ValidationError({
543
- reason: `Doc '${name}' already exists (v${existing.version})`,
627
+ reason: `Doc '${frontmatter.name}' already exists (v${existing.version})`,
544
628
  }));
545
629
  }
546
- const hash = computeDocHash(yamlContent);
630
+ const hash = computeDocHash(content);
547
631
  const docsPath = getDocsPath();
548
- const filePath = resolveYamlPath(docsPath, kind, name);
632
+ const filePath = resolveDocPath(docsPath, parsedKind, frontmatter.name);
549
633
  ensureDir(filePath);
550
- writeFileSync(filePath, yamlContent, "utf8");
551
- const sub = kindSubdir(kind);
552
- const relPath = sub ? join(sub, `${name}.yml`) : `${name}.yml`;
634
+ writeFileSync(filePath, content, "utf8");
635
+ const sub = kindSubdir(parsedKind);
636
+ const relPath = sub
637
+ ? join(sub, `${frontmatter.name}.md`)
638
+ : `${frontmatter.name}.md`;
553
639
  const doc = yield* docRepo.insert({
554
640
  hash,
555
- kind,
556
- name,
557
- title,
641
+ kind: parsedKind,
642
+ name: frontmatter.name,
643
+ title: frontmatter.title,
558
644
  version: 1,
559
645
  filePath: relPath,
560
646
  parentDocId: null,
561
647
  metadata: metadata ? JSON.stringify(metadata) : undefined,
562
648
  });
563
- try {
564
- renderSingleDoc(doc, docsPath);
565
- }
566
- catch {
567
- /* non-fatal */
568
- }
569
649
  yield* generateIndexEffect(docsPath);
570
650
  return doc;
571
651
  }),
@@ -576,7 +656,7 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
576
656
  }
577
657
  return doc;
578
658
  }),
579
- update: (name, yamlContent) => Effect.gen(function* () {
659
+ update: (name, content) => Effect.gen(function* () {
580
660
  const doc = yield* docRepo.findByName(name);
581
661
  if (!doc) {
582
662
  return yield* Effect.fail(new DocNotFoundError({ name }));
@@ -584,25 +664,32 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
584
664
  if (doc.status === "locked") {
585
665
  return yield* Effect.fail(new DocLockedError({ name, version: doc.version }));
586
666
  }
587
- const parsed = validateYaml(name, yamlContent, doc.kind);
588
- validateKind(name, parsed, doc.kind);
589
- const hash = computeDocHash(yamlContent);
667
+ const parsedDoc = parseMarkdownSpecDocContent(name, content);
668
+ const frontmatter = parsedDoc.frontmatter;
669
+ const parsedKind = parseSpecTypeAsDocKind(name, frontmatter.spec_type);
670
+ if (frontmatter.name !== name) {
671
+ return yield* Effect.fail(new InvalidDocYamlError({
672
+ name,
673
+ reason: `Frontmatter name '${frontmatter.name}' does not match doc '${name}'.`,
674
+ }));
675
+ }
676
+ if (parsedKind !== doc.kind) {
677
+ return yield* Effect.fail(new InvalidDocYamlError({
678
+ name,
679
+ reason: `Frontmatter spec_type '${frontmatter.spec_type}' does not match existing kind '${doc.kind}'.`,
680
+ }));
681
+ }
682
+ const hash = computeDocHash(content);
590
683
  const docsPath = getDocsPath();
591
- const filePath = resolveYamlPath(docsPath, doc.kind, name);
684
+ const filePath = resolveDocPath(docsPath, doc.kind, name);
592
685
  ensureDir(filePath);
593
- writeFileSync(filePath, yamlContent, "utf8");
594
- const title = typeof parsed.title === "string" ? parsed.title : doc.title;
686
+ writeFileSync(filePath, content, "utf8");
687
+ const title = frontmatter.title;
595
688
  yield* docRepo.update(doc.id, { hash, title });
596
689
  const updated = yield* docRepo.findById(doc.id);
597
690
  if (!updated) {
598
691
  return yield* Effect.fail(new DocNotFoundError({ name }));
599
692
  }
600
- try {
601
- renderSingleDoc(updated, docsPath);
602
- }
603
- catch {
604
- /* non-fatal */
605
- }
606
693
  yield* generateIndexEffect(docsPath);
607
694
  return updated;
608
695
  }),
@@ -620,14 +707,7 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
620
707
  if (!locked) {
621
708
  return yield* Effect.fail(new DocNotFoundError({ name }));
622
709
  }
623
- const docsPath = getDocsPath();
624
- try {
625
- renderSingleDoc(locked, docsPath);
626
- }
627
- catch {
628
- /* non-fatal */
629
- }
630
- yield* generateIndexEffect(docsPath);
710
+ yield* generateIndexEffect(getDocsPath());
631
711
  return locked;
632
712
  }),
633
713
  list: (filter) => docRepo.findAll(filter),
@@ -641,18 +721,10 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
641
721
  }
642
722
  yield* docRepo.remove(doc.id);
643
723
  const docsPath = getDocsPath();
644
- const yamlPath = resolveYamlPath(docsPath, doc.kind, name);
645
- const mdPath = resolveMdPath(docsPath, doc.kind, name);
724
+ const docPath = resolveDocPath(docsPath, doc.kind, name);
646
725
  try {
647
- if (existsSync(yamlPath))
648
- unlinkSync(yamlPath);
649
- }
650
- catch {
651
- /* non-fatal */
652
- }
653
- try {
654
- if (existsSync(mdPath))
655
- unlinkSync(mdPath);
726
+ if (existsSync(docPath))
727
+ unlinkSync(docPath);
656
728
  }
657
729
  catch {
658
730
  /* non-fatal */
@@ -667,16 +739,16 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
667
739
  if (!doc) {
668
740
  return yield* Effect.fail(new DocNotFoundError({ name }));
669
741
  }
670
- rendered.push(renderSingleDoc(doc, docsPath));
742
+ rendered.push(validateDocFile(doc, docsPath));
671
743
  }
672
744
  else {
673
745
  const allDocs = yield* docRepo.findAll();
674
746
  for (const doc of allDocs) {
675
747
  try {
676
- rendered.push(renderSingleDoc(doc, docsPath));
748
+ rendered.push(validateDocFile(doc, docsPath));
677
749
  }
678
750
  catch {
679
- /* skip docs with missing YAML */
751
+ /* skip docs with invalid or missing markdown */
680
752
  }
681
753
  }
682
754
  }
@@ -694,17 +766,19 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
694
766
  }));
695
767
  }
696
768
  const docsPath = getDocsPath();
697
- const yamlPath = resolveYamlPath(docsPath, doc.kind, name);
698
- if (!existsSync(yamlPath)) {
769
+ const docPath = resolveDocPath(docsPath, doc.kind, name);
770
+ if (!existsSync(docPath)) {
699
771
  return yield* Effect.fail(new ValidationError({
700
- reason: `YAML file not found for '${name}'`,
772
+ reason: `Markdown file not found for '${name}'`,
701
773
  }));
702
774
  }
703
- const yamlContent = readFileSync(yamlPath, "utf8");
704
- const hash = computeDocHash(yamlContent);
775
+ const content = readFileSync(docPath, "utf8");
776
+ const hash = computeDocHash(content);
705
777
  const newVersion = doc.version + 1;
706
778
  const versionSub = kindSubdir(doc.kind);
707
- const relPath = versionSub ? join(versionSub, `${name}.yml`) : `${name}.yml`;
779
+ const relPath = versionSub
780
+ ? join(versionSub, `${name}.md`)
781
+ : `${name}.md`;
708
782
  const newDoc = yield* docRepo.insert({
709
783
  hash,
710
784
  kind: doc.kind,
@@ -714,12 +788,6 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
714
788
  filePath: relPath,
715
789
  parentDocId: doc.id,
716
790
  });
717
- try {
718
- renderSingleDoc(newDoc, docsPath);
719
- }
720
- catch {
721
- /* non-fatal */
722
- }
723
791
  yield* generateIndexEffect(docsPath);
724
792
  return newDoc;
725
793
  }),
@@ -757,21 +825,31 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
757
825
  reason: `Patches can only be created on design docs, got '${parentDoc.kind}'`,
758
826
  }));
759
827
  }
760
- const patchYaml = stringifyYaml({
761
- kind: "design",
828
+ const today = new Date().toISOString().slice(0, 10);
829
+ const patchFrontmatter = stringifyYaml({
830
+ kind: "spec",
831
+ spec_type: "design",
762
832
  name: patchName,
763
833
  title: patchTitle,
764
- status: "changing",
834
+ status: "draft",
765
835
  version: 1,
836
+ owners: ["docs-team"],
837
+ summary: `Patch for ${parentDoc.name}: ${patchTitle}`,
838
+ domain: "docs",
839
+ tags: ["patch"],
840
+ depends_on: [parentDoc.name],
841
+ supersedes: [],
766
842
  implements: parentDoc.name,
767
- problem_definition: `Patch for ${parentDoc.name}: ${patchTitle}`,
843
+ last_reviewed_at: today,
768
844
  });
769
- const hash = computeDocHash(patchYaml);
845
+ const patchContent = `---\n${patchFrontmatter}---\n\n# Summary\nPatch for ${parentDoc.name}: ${patchTitle}\n\n# Architecture\nPatch architecture details.\n\n# Interfaces\n\`\`\`yaml\ninterfaces: []\n\`\`\`\n\n# Data Model\nNo data model changes.\n\n# Invariants\n\`\`\`yaml\ninvariants: []\n\`\`\`\n\n# Failure Modes\n\`\`\`yaml\nfailure_modes: []\n\`\`\`\n\n# Verification\n\`\`\`yaml\nverification: []\n\`\`\`\n`;
846
+ parseMarkdownSpecDocContent(patchName, patchContent);
847
+ const hash = computeDocHash(patchContent);
770
848
  const docsPath = getDocsPath();
771
- const filePath = resolveYamlPath(docsPath, "design", patchName);
849
+ const filePath = resolveDocPath(docsPath, "design", patchName);
772
850
  ensureDir(filePath);
773
- writeFileSync(filePath, patchYaml, "utf8");
774
- const relPath = join("design", `${patchName}.yml`);
851
+ writeFileSync(filePath, patchContent, "utf8");
852
+ const relPath = join("design", `${patchName}.md`);
775
853
  const patchDoc = yield* docRepo.insert({
776
854
  hash,
777
855
  kind: "design",
@@ -782,12 +860,6 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
782
860
  parentDocId: null,
783
861
  });
784
862
  yield* docRepo.createLink(patchDoc.id, parentDoc.id, "design_patch");
785
- try {
786
- renderSingleDoc(patchDoc, docsPath);
787
- }
788
- catch {
789
- /* non-fatal */
790
- }
791
863
  yield* generateIndexEffect(docsPath);
792
864
  return patchDoc;
793
865
  }),
@@ -806,16 +878,16 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
806
878
  }
807
879
  const warnings = [];
808
880
  const docsPath = getDocsPath();
809
- const yamlPath = resolveYamlPath(docsPath, doc.kind, name);
810
- if (existsSync(yamlPath)) {
811
- const content = readFileSync(yamlPath, "utf8");
881
+ const docPath = resolveDocPath(docsPath, doc.kind, name);
882
+ if (existsSync(docPath)) {
883
+ const content = readFileSync(docPath, "utf8");
812
884
  const currentHash = computeDocHash(content);
813
885
  if (currentHash !== doc.hash) {
814
886
  warnings.push(`Content hash mismatch: DB has ${doc.hash.slice(0, 8)}..., file has ${currentHash.slice(0, 8)}...`);
815
887
  }
816
888
  }
817
889
  else {
818
- warnings.push(`YAML file missing: ${yamlPath}`);
890
+ warnings.push(`Markdown file missing: ${docPath}`);
819
891
  }
820
892
  const taskLinks = yield* docRepo.getTaskLinksForDoc(doc.id);
821
893
  if (taskLinks.length === 0 && doc.kind === "design") {
@@ -837,7 +909,7 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
837
909
  else {
838
910
  const allDocs = yield* docRepo.findAll();
839
911
  for (const doc of allDocs) {
840
- // Keep whole-repo sync resilient: one malformed YAML file should not
912
+ // Keep whole-repo sync resilient: one malformed markdown file should not
841
913
  // prevent invariants from being refreshed for every other doc.
842
914
  const result = yield* syncInvariantsForDoc(doc).pipe(Effect.catchAllCause((cause) => {
843
915
  const defect = Cause.dieOption(cause);