frontend-harness 0.8.0 → 0.8.2

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.
@@ -8,6 +8,8 @@ import { kindTypeMap, knowledgeKinds, knowledgeStabilities, knowledgeStatuses, k
8
8
  export const MAX_KNOWLEDGE_LIST_CARDS = 20;
9
9
  export const MAX_KNOWLEDGE_SEARCH_RESULTS = 8;
10
10
  export const MAX_KNOWLEDGE_DRAFT_UPDATE_CANDIDATES = 8;
11
+ export const MAX_KNOWLEDGE_OUTLINE_HEADINGS = 80;
12
+ export const MAX_KNOWLEDGE_DRAFT_EXTRACTION_ITEMS = 40;
11
13
  export const MAX_KNOWLEDGE_INDEX_CARDS = 20;
12
14
  export const MAX_KNOWLEDGE_INDEX_DETAIL_ITEMS = 20;
13
15
  const MAX_KNOWLEDGE_REFERENCE_SUGGESTIONS = 20;
@@ -37,6 +39,9 @@ export function promoteKnowledge(projectRoot, input) {
37
39
  createdAt,
38
40
  scope: splitList(input.scope),
39
41
  tags: splitList(input.tags),
42
+ verification: splitList(input.verification),
43
+ coverage: splitList(input.coverage),
44
+ prdAnchors: splitList(input.anchor),
40
45
  sourcePaths: parseKnowledgeSourcePaths(input.source, "knowledge promote --source")
41
46
  });
42
47
  writeText(path.join(projectRoot, relativePath), content);
@@ -159,6 +164,24 @@ export function addKnowledge(projectRoot, input) {
159
164
  createdAt
160
165
  };
161
166
  }
167
+ export function outlineKnowledgeFromIngest(projectRoot, input) {
168
+ if (!input.ref?.trim()) {
169
+ throw new Error("knowledge outline requires --ref ingest://<kind>/<source-id>");
170
+ }
171
+ const record = showIngestRecord(projectRoot, input.ref);
172
+ const outlineSelection = buildKnowledgeOutline(projectRoot, record);
173
+ return {
174
+ contractVersion: 1,
175
+ ingestRef: record.ingestRef,
176
+ ingestKind: record.kind,
177
+ source: record.source.value,
178
+ sourceType: record.source.type,
179
+ outline: outlineSelection.headings,
180
+ limits: {
181
+ headings: outlineSelection.limits
182
+ }
183
+ };
184
+ }
162
185
  export function draftKnowledgeFromIngest(projectRoot, input) {
163
186
  if (!input.ref?.trim()) {
164
187
  throw new Error("knowledge draft requires --ref ingest://<kind>/<source-id>");
@@ -182,6 +205,10 @@ export function draftKnowledgeFromIngest(projectRoot, input) {
182
205
  const contentFlag = contentFlagForKind(recommendedKind);
183
206
  const updateCandidateSelection = draftUpdateCandidates(projectRoot, record, defaults);
184
207
  const updateCandidates = updateCandidateSelection.candidates;
208
+ const outlineSelection = buildKnowledgeOutline(projectRoot, record);
209
+ const extractionItemSelection = draftExtractionItems(record, outlineSelection.headings, updateCandidates);
210
+ const outlineLimits = outlineSelection.limits.total > 0 ? outlineSelection.limits : undefined;
211
+ const extractionLimits = extractionItemSelection.limits.total > 0 ? extractionItemSelection.limits : undefined;
185
212
  const args = [
186
213
  "knowledge",
187
214
  "add",
@@ -219,13 +246,20 @@ export function draftKnowledgeFromIngest(projectRoot, input) {
219
246
  command: "frontend-harness knowledge add --json",
220
247
  args,
221
248
  updateCandidates,
249
+ ...(outlineSelection.headings.length ? { outline: outlineSelection.headings } : {}),
250
+ ...(extractionItemSelection.items.length ? { extractionItems: extractionItemSelection.items } : {}),
222
251
  limits: {
223
- updateCandidates: updateCandidateSelection.limits
252
+ updateCandidates: updateCandidateSelection.limits,
253
+ ...(outlineLimits ? { outlineHeadings: outlineLimits } : {}),
254
+ ...(extractionLimits ? { extractionItems: extractionLimits } : {})
224
255
  },
225
256
  requiredEdits: [
226
257
  ...(updateCandidateSelection.limits.total > 0
227
258
  ? ["Prefer a matching update candidate before adding a new card for the same source."]
228
259
  : []),
260
+ ...(extractionItemSelection.items.length
261
+ ? ["Use extractionItems as the source checklist; add, update, or explicitly skip each required source block."]
262
+ : []),
229
263
  `Replace --${contentFlag} with one atomic durable fact; create separate cards for separate facts.`,
230
264
  "Replace --coverage with the precise source section, endpoint, screen, or acceptance block.",
231
265
  "Replace --verification with the concrete check or evidence that proves the fact still holds.",
@@ -278,6 +312,228 @@ function draftUpdateCandidates(projectRoot, record, defaults) {
278
312
  }
279
313
  };
280
314
  }
315
+ function buildKnowledgeOutline(projectRoot, record) {
316
+ if (record.kind !== "prd" || record.source.type !== "project-file" || !/\.md$/i.test(record.source.value)) {
317
+ return {
318
+ headings: [],
319
+ limits: { selected: 0, total: 0, omitted: 0 }
320
+ };
321
+ }
322
+ const fullPath = path.join(projectRoot, record.source.value);
323
+ const content = safeRead(fullPath);
324
+ if (content === null) {
325
+ return {
326
+ headings: [],
327
+ limits: { selected: 0, total: 0, omitted: 0 }
328
+ };
329
+ }
330
+ const headings = parseKnowledgeOutlineHeadings(content);
331
+ const selected = headings.slice(0, MAX_KNOWLEDGE_OUTLINE_HEADINGS);
332
+ return {
333
+ headings: selected,
334
+ limits: {
335
+ selected: selected.length,
336
+ total: headings.length,
337
+ omitted: Math.max(0, headings.length - selected.length)
338
+ }
339
+ };
340
+ }
341
+ function parseKnowledgeOutlineHeadings(content) {
342
+ const lines = content.split(/\r?\n/);
343
+ const rawHeadings = [];
344
+ const stack = [];
345
+ const idCounts = new Map();
346
+ lines.forEach((line, index) => {
347
+ const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
348
+ if (!match?.[1] || !match[2]) {
349
+ return;
350
+ }
351
+ const level = match[1].length;
352
+ const title = match[2].replace(/\s+#+$/, "").trim();
353
+ if (!title) {
354
+ return;
355
+ }
356
+ while (stack.length && stack[stack.length - 1].level >= level) {
357
+ stack.pop();
358
+ }
359
+ const baseId = slugify(title) || `heading-${index + 1}`;
360
+ const count = idCounts.get(baseId) ?? 0;
361
+ idCounts.set(baseId, count + 1);
362
+ const id = count === 0 ? baseId : `${baseId}-${count + 1}`;
363
+ const parent = stack[stack.length - 1] ?? null;
364
+ const headingPath = [...(parent?.path ?? []), title];
365
+ rawHeadings.push({
366
+ id,
367
+ title,
368
+ level,
369
+ lineStart: index + 1,
370
+ parentId: parent?.id ?? null,
371
+ path: headingPath
372
+ });
373
+ stack.push({ id, level, title, path: headingPath });
374
+ });
375
+ return rawHeadings.map((heading, index) => {
376
+ const nextHeading = rawHeadings.slice(index + 1).find((candidate) => candidate.level <= heading.level);
377
+ const classification = classifyOutlineHeading(heading.title, heading.path);
378
+ return {
379
+ ...heading,
380
+ lineEnd: nextHeading ? nextHeading.lineStart - 1 : lines.length,
381
+ extractionRequired: classification.extractionRequired,
382
+ suggestedKind: classification.suggestedKind,
383
+ reason: classification.reason,
384
+ coverage: heading.path.join(" > ")
385
+ };
386
+ });
387
+ }
388
+ function classifyOutlineHeading(title, headingPath) {
389
+ const text = normalize([...headingPath, title].join(" "));
390
+ if (/(?:不做|不扩展|边界|风险|异常|歧义|pitfall|risk|out of scope|non goal|not doing)/i.test(text)) {
391
+ return {
392
+ extractionRequired: true,
393
+ suggestedKind: "pitfall",
394
+ reason: "source block describes boundary, exception, risk, ambiguity, or non-goal information"
395
+ };
396
+ }
397
+ if (/(?:权限|按钮|操作|可见|允许|禁止|permission|button|action|visibility)/i.test(text)) {
398
+ return {
399
+ extractionRequired: true,
400
+ suggestedKind: "permission",
401
+ reason: "source block describes action availability, visibility, or permission behavior"
402
+ };
403
+ }
404
+ if (/(?:流程|链路|流转|workflow|process|journey|flow|lifecycle|状态机)/i.test(text)) {
405
+ return {
406
+ extractionRequired: true,
407
+ suggestedKind: "workflow",
408
+ reason: "source block describes workflow, lifecycle, or state transition semantics"
409
+ };
410
+ }
411
+ if (/(?:字段|枚举|状态|术语|定义|field|enum|status|term|definition)/i.test(text)) {
412
+ return {
413
+ extractionRequired: true,
414
+ suggestedKind: "term",
415
+ reason: "source block defines fields, states, terms, or enumerations"
416
+ };
417
+ }
418
+ if (/(?:模块|页面结构|区域|信息层级|布局|module|section|layout|region)/i.test(text)) {
419
+ return {
420
+ extractionRequired: true,
421
+ suggestedKind: "module_summary",
422
+ reason: "source block describes module, page region, layout, or structure boundaries"
423
+ };
424
+ }
425
+ if (/(?:规则|校验|验收|条件|策略|计算|rule|validation|acceptance|criteria|condition|policy)/i.test(text)) {
426
+ return {
427
+ extractionRequired: true,
428
+ suggestedKind: "rule",
429
+ reason: "source block describes durable rule, validation, acceptance, condition, or policy"
430
+ };
431
+ }
432
+ return {
433
+ extractionRequired: false,
434
+ suggestedKind: "none",
435
+ reason: "source block appears descriptive; mark skip if it has no durable implementation knowledge"
436
+ };
437
+ }
438
+ function draftExtractionItems(record, headings, sourceUpdateCandidates) {
439
+ const requiredHeadings = headings.filter((heading) => heading.extractionRequired && heading.suggestedKind !== "none");
440
+ const items = requiredHeadings
441
+ .slice(0, MAX_KNOWLEDGE_DRAFT_EXTRACTION_ITEMS)
442
+ .map((heading) => draftExtractionItem(record, heading, sourceUpdateCandidates));
443
+ return {
444
+ items,
445
+ limits: {
446
+ selected: items.length,
447
+ total: requiredHeadings.length,
448
+ omitted: Math.max(0, requiredHeadings.length - items.length)
449
+ }
450
+ };
451
+ }
452
+ function draftExtractionItem(record, heading, sourceUpdateCandidates) {
453
+ if (heading.suggestedKind === "none") {
454
+ throw new Error(`cannot draft extraction item for non-required heading: ${heading.id}`);
455
+ }
456
+ const suggestedKind = heading.suggestedKind === "module_summary" ? "module_summary" : heading.suggestedKind;
457
+ const subject = titleCase(heading.title.replace(/^\d+(?:\.\d+)*\s*/, ""));
458
+ const scope = draftScope(record);
459
+ const tags = suggestedKind === "module_summary" ? `prd,module,${scope}` : `prd,${suggestedKind},${scope}`;
460
+ const itemId = slugify(`${record.id}-${heading.title}`) || `${record.id}-${heading.id}`;
461
+ const updateCandidates = sourceUpdateCandidates.filter((candidate) => candidate.title.includes(heading.title) || candidate.summary.includes(heading.title) || candidate.id.includes(slugify(heading.title)));
462
+ if (suggestedKind === "module_summary") {
463
+ return {
464
+ id: itemId,
465
+ headingId: heading.id,
466
+ title: heading.title,
467
+ coverage: heading.coverage,
468
+ suggestedKind,
469
+ extractionRequired: true,
470
+ reason: heading.reason,
471
+ command: "frontend-harness knowledge module --json",
472
+ args: [
473
+ "knowledge",
474
+ "module",
475
+ "--json",
476
+ "--id",
477
+ itemId,
478
+ "--title",
479
+ subject,
480
+ "--summary",
481
+ `<module summary distilled from ${heading.coverage}>`,
482
+ "--body",
483
+ `<structured module facts from ${heading.coverage}>`,
484
+ "--source",
485
+ record.ingestRef,
486
+ "--coverage",
487
+ heading.coverage,
488
+ "--verification",
489
+ "<specific check or evidence required>",
490
+ "--scope",
491
+ scope,
492
+ "--tags",
493
+ tags
494
+ ],
495
+ updateCandidates
496
+ };
497
+ }
498
+ const kind = suggestedKind;
499
+ const contentFlag = contentFlagForKind(kind);
500
+ return {
501
+ id: itemId,
502
+ headingId: heading.id,
503
+ title: heading.title,
504
+ coverage: heading.coverage,
505
+ suggestedKind: kind,
506
+ extractionRequired: true,
507
+ reason: heading.reason,
508
+ command: "frontend-harness knowledge add --json",
509
+ args: [
510
+ "knowledge",
511
+ "add",
512
+ "--json",
513
+ "--id",
514
+ itemId,
515
+ "--kind",
516
+ kind,
517
+ "--subject",
518
+ subject,
519
+ "--summary",
520
+ `<summary distilled from ${heading.coverage}>`,
521
+ `--${contentFlag}`,
522
+ `<atomic ${contentFlag} distilled from ${heading.coverage}>`,
523
+ "--source",
524
+ record.ingestRef,
525
+ "--coverage",
526
+ heading.coverage,
527
+ "--verification",
528
+ "<specific check or evidence required>",
529
+ "--scope",
530
+ scope,
531
+ "--tags",
532
+ tags
533
+ ],
534
+ updateCandidates
535
+ };
536
+ }
281
537
  function recommendedKnowledgeKind(kind) {
282
538
  if (kind === "openapi") {
283
539
  return "workflow";
@@ -635,6 +891,9 @@ export function checkKnowledge(projectRoot) {
635
891
  if ((parsed.metadata.summary?.length ?? 0) > 180) {
636
892
  warnings.push(`${relativePath} summary should stay concise for context display.`);
637
893
  }
894
+ if (parsed.metadata.status === "active" && parsed.metadata.type === "prd_semantics") {
895
+ errors.push(...prdSemanticMetadataErrors(relativePath, parsed.metadata));
896
+ }
638
897
  if (parsed.metadata.kind) {
639
898
  const contentValue = primaryAtomicValue(parsed.metadata);
640
899
  if (contentValue && normalize(contentValue) === normalize(parsed.metadata.summary ?? "")) {
@@ -1026,6 +1285,7 @@ function renderAtomicBody(kind, subject, summary, contentFields, coverage, prdAn
1026
1285
  }
1027
1286
  function renderKnowledge(input) {
1028
1287
  const summary = firstBodyLine(input.body) ?? input.title;
1288
+ const body = renderBodyWithTraceability(input.body, input.coverage, input.prdAnchors, input.verification);
1029
1289
  return `---
1030
1290
  id: ${yamlScalar(input.id)}
1031
1291
  type: ${yamlScalar(input.type)}
@@ -1035,6 +1295,12 @@ scope:
1035
1295
  ${renderList(input.scope)}
1036
1296
  tags:
1037
1297
  ${renderList(input.tags)}
1298
+ verification:
1299
+ ${renderList(input.verification)}
1300
+ coverage:
1301
+ ${renderList(input.coverage)}
1302
+ prd_anchors:
1303
+ ${renderList(input.prdAnchors)}
1038
1304
  status: ${yamlScalar(input.status)}
1039
1305
  stability: ${yamlScalar(input.stability)}
1040
1306
  updated_at: ${yamlScalar(input.createdAt)}
@@ -1045,7 +1311,7 @@ related: []
1045
1311
 
1046
1312
  # ${input.title}
1047
1313
 
1048
- ${input.body}
1314
+ ${body}
1049
1315
  `;
1050
1316
  }
1051
1317
  function renderList(items) {
@@ -1550,6 +1816,25 @@ function isPrdSourcePath(sourcePath) {
1550
1816
  function isPrdTraceableCard(card) {
1551
1817
  return card.type === "prd_semantics" || card.type === "module_summary" || card.type === "pitfall" || card.type === "glossary";
1552
1818
  }
1819
+ function prdSemanticMetadataErrors(relativePath, metadata) {
1820
+ const errors = [];
1821
+ if (metadata.sourcePaths.length === 0) {
1822
+ errors.push(`${relativePath} active PRD semantics must include source_paths.`);
1823
+ }
1824
+ if (metadata.coverage.length === 0) {
1825
+ errors.push(`${relativePath} active PRD semantics must include coverage.`);
1826
+ }
1827
+ if (metadata.scope.length === 0) {
1828
+ errors.push(`${relativePath} active PRD semantics must include scope.`);
1829
+ }
1830
+ if (metadata.tags.length === 0) {
1831
+ errors.push(`${relativePath} active PRD semantics must include tags.`);
1832
+ }
1833
+ if (metadata.verification.length === 0) {
1834
+ errors.push(`${relativePath} active PRD semantics must include verification.`);
1835
+ }
1836
+ return errors;
1837
+ }
1553
1838
  function countByType(cards) {
1554
1839
  const counts = Object.fromEntries([...knowledgeTypes].map((type) => [type, 0]));
1555
1840
  for (const card of cards) {