@namewta/speculo 1.0.13 → 1.0.14

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 (34) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/template/canonical/canonical-specdev-goal-plan.md +8 -5
  4. package/template/canonical/canonical-specdev-grill-with-docs.md +5 -4
  5. package/template/canonical/canonical-specdev-spec.md +7 -4
  6. package/template/canonical/canonical-specdev-tickets.md +19 -4
  7. package/template/commands/handoff.md +1 -1
  8. package/template/commands/status.md +1 -1
  9. package/template/workflows/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md +3 -3
  10. package/template/workflows/specdev/I-implement/references/implementation-procedure.md +1 -1
  11. package/template/workflows/specdev/I-init-setup/tracking-template.md +1 -1
  12. package/template/workflows/specdev/INDEX.md +1 -1
  13. package/template/workflows/specdev/README.md +4 -4
  14. package/template/workflows/specdev/T-tickets/ticket-template.md +1 -0
  15. package/template/workflows/specdev/T-triage/T-triage.md +44 -13
  16. package/template/workflows/specdev/T-triage/close-comment-template.md +42 -0
  17. package/template/workflows/specdev/T-triage/intake-protocol.md +6 -2
  18. package/template/workflows/specdev/T-triage/issue-body-template.md +34 -0
  19. package/template/workflows/specdev/T-triage/publish-protocol.md +92 -0
  20. package/template/workflows/specdev/T-triage/publish-template.md +49 -0
  21. package/template/workflows/specdev/T-triage/reconcile-protocol.md +3 -3
  22. package/template/workflows/specdev/T-triage/references/classification-map.md +53 -0
  23. package/template/workflows/specdev/T-triage/references/public-projection.md +66 -0
  24. package/template/workflows/specdev/T-triage/tools/publish-status.mjs +148 -0
  25. package/template/workflows/specdev/T-triage/triage-template.md +12 -1
  26. package/template/workflows/specdev/common/README.md +1 -0
  27. package/template/workflows/specdev/common/rules/artifact-contract.md +5 -4
  28. package/template/workflows/specdev/common/rules/change-completion.md +1 -1
  29. package/template/workflows/specdev/common/rules/evidence-and-verification.md +2 -0
  30. package/template/workflows/specdev/common/rules/workflow-routing.md +2 -1
  31. package/template/workflows/specdev/common/schemas/publish.schema.json +21 -0
  32. package/template/workflows/specdev/common/schemas/ticket.schema.json +11 -0
  33. package/template/workflows/specdev/common/schemas/triage.schema.json +3 -1
  34. package/template/workflows/specdev/common/tools/validate-specdev.mjs +173 -3
@@ -66,6 +66,43 @@ const VALID_TICKET_STATUS = new Set([
66
66
  "deviated",
67
67
  "cancelled",
68
68
  ]);
69
+ const VALID_TICKET_KIND = new Set([
70
+ "bug",
71
+ "feature",
72
+ "refactor",
73
+ "investigation",
74
+ "operations",
75
+ "documentation",
76
+ "review",
77
+ ]);
78
+ const VALID_TRIAGE_MODE = new Set(["intake", "reconcile", "publish"]);
79
+ const VALID_PUBLISH_ACTION = new Set([
80
+ "not-requested",
81
+ "pending",
82
+ "published",
83
+ "publish-failed",
84
+ "waived",
85
+ ]);
86
+ const VALID_PUBLISH_ROW_STATE = new Set([
87
+ "planned",
88
+ "created",
89
+ "commented",
90
+ "closed",
91
+ "skipped:cancelled",
92
+ "skipped:excluded",
93
+ "failed",
94
+ ]);
95
+ const FORBIDDEN_PUBLISH_LABELS = new Set([
96
+ "needs-triage",
97
+ "needs-info",
98
+ "ready-for-agent",
99
+ "ready-for-human",
100
+ "wontfix",
101
+ "duplicate",
102
+ "invalid",
103
+ "stale",
104
+ ]);
105
+ const COUNTED_PUBLISH_STATES = new Set(["closed", "created", "commented"]);
69
106
  const VALID_DEPTH = new Set(["lite", "standard", "deep"]);
70
107
  const VALID_RISK = new Set(["low", "medium", "high", "critical"]);
71
108
  const VALID_PLAN_MODES = new Set([
@@ -175,6 +212,7 @@ const STATE_ARTIFACT_BASENAMES = new Set([
175
212
  "status.json",
176
213
  ".status.json",
177
214
  "triage.md",
215
+ "publish.md",
178
216
  "diagnosis.md",
179
217
  "source.md",
180
218
  "architecture-review.md",
@@ -973,7 +1011,7 @@ function capabilityChecks(root) {
973
1011
  "triage",
974
1012
  [
975
1013
  join(root, "T-triage", "T-triage.md"),
976
- ["source.md", "intake", "reconcile", "唯一权威", "远程写入为零"],
1014
+ ["source.md", "intake", "reconcile", "publish", "publish.md", "specdev:published", "唯一权威", "远程写入为零"],
977
1015
  ],
978
1016
  ],
979
1017
  [
@@ -1061,7 +1099,7 @@ function capabilityChecks(root) {
1061
1099
  "archive",
1062
1100
  [
1063
1101
  join(root, "A-archive-and-consolidate", "A-archive-and-consolidate.md"),
1064
- ["archive-single", "dry-run", "external_action", "consolidate-from-code", "archive-and-consolidate"],
1102
+ ["archive-single", "dry-run", "external_action", "publish_action", "consolidate-from-code", "archive-and-consolidate"],
1065
1103
  ],
1066
1104
  ],
1067
1105
  [
@@ -1327,7 +1365,7 @@ function validateTriage(path, expectedChange, errors) {
1327
1365
  errors.push("triage.md: artifact/schema_version must be triage/1");
1328
1366
  }
1329
1367
  if (meta.change !== expectedChange) errors.push("triage.md: change must equal directory name");
1330
- if (!new Set(["intake", "reconcile"]).has(meta.mode)) errors.push(`triage.md: invalid mode ${meta.mode}`);
1368
+ if (!VALID_TRIAGE_MODE.has(meta.mode)) errors.push(`triage.md: invalid mode ${meta.mode}`);
1331
1369
  if (!VALID_RISK.has(meta.risk)) errors.push(`triage.md: invalid risk ${meta.risk}`);
1332
1370
  if (typeof meta.route !== "string" || !meta.route.startsWith("specdev/")) {
1333
1371
  errors.push("triage.md: route must be a specdev work id");
@@ -1338,15 +1376,130 @@ function validateTriage(path, expectedChange, errors) {
1338
1376
  if (!new Set(["not-applicable", "pending-close", "closed", "close-failed", "waived"]).has(meta.external_action)) {
1339
1377
  errors.push(`triage.md: invalid external_action ${meta.external_action}`);
1340
1378
  }
1379
+ const publishAction = meta.publish_action == null || meta.publish_action === ""
1380
+ ? "not-requested"
1381
+ : meta.publish_action;
1382
+ if (!VALID_PUBLISH_ACTION.has(publishAction)) {
1383
+ errors.push(`triage.md: invalid publish_action ${meta.publish_action}`);
1384
+ }
1385
+ meta.publish_action = publishAction;
1341
1386
  if (!String(meta.source ?? "").includes("/source.md</Path>")) {
1342
1387
  errors.push("triage.md: source must reference the local source.md artifact");
1343
1388
  }
1344
1389
  for (const heading of ["## 当前判定", "## 未知项", "## 路由", "## 外部动作"]) {
1345
1390
  if (!body.includes(heading)) errors.push(`triage.md: missing '${heading}'`);
1346
1391
  }
1392
+ if (publishAction !== "not-requested" && !body.includes("## 发布投影")) {
1393
+ errors.push("triage.md: missing '## 发布投影'");
1394
+ }
1347
1395
  return { path, meta, body };
1348
1396
  }
1349
1397
 
1398
+ function parsePublishLedger(body) {
1399
+ const rows = [];
1400
+ for (const line of body.split(/\r?\n/)) {
1401
+ if (!/^\|/.test(line) || /^\|\s*-+/.test(line) || /^\|\s*ticket\s*\|/i.test(line)) continue;
1402
+ const cells = line.split("|").map((cell) => cell.trim());
1403
+ const inner = cells.slice(1, cells.length - 1);
1404
+ if (inner.length < 8) continue;
1405
+ rows.push({
1406
+ ticket: inner[0],
1407
+ kind: inner[1],
1408
+ labels: inner[2],
1409
+ number: inner[3],
1410
+ url: inner[4],
1411
+ marker: inner[5],
1412
+ sha256: inner[6],
1413
+ state: inner[7],
1414
+ });
1415
+ }
1416
+ return rows;
1417
+ }
1418
+
1419
+ function validatePublish(path, expectedChange, triage, errors) {
1420
+ if (!isFile(path)) {
1421
+ errors.push("missing publish.md ledger");
1422
+ return null;
1423
+ }
1424
+ const { meta, body } = parseFrontmatter(path);
1425
+ const required = [
1426
+ "schema_version",
1427
+ "artifact",
1428
+ "change",
1429
+ "mode",
1430
+ "repo",
1431
+ "publish_action",
1432
+ "include_cancelled",
1433
+ "origin",
1434
+ "updated_at",
1435
+ ];
1436
+ const missing = required.filter((key) => !(key in meta));
1437
+ if (missing.length) errors.push(`publish.md: missing keys ${JSON.stringify(missing)}`);
1438
+ if (meta.schema_version !== 1 || meta.artifact !== "publish") {
1439
+ errors.push("publish.md: artifact/schema_version must be publish/1");
1440
+ }
1441
+ if (meta.change !== expectedChange) errors.push("publish.md: change must equal directory name");
1442
+ if (meta.mode !== "publish") errors.push(`publish.md: invalid mode ${meta.mode}`);
1443
+ if (!new Set(["pending", "published", "publish-failed", "waived"]).has(meta.publish_action)) {
1444
+ errors.push(`publish.md: invalid publish_action ${meta.publish_action}`);
1445
+ }
1446
+ if (!new Set(["local", "intake"]).has(meta.origin)) {
1447
+ errors.push(`publish.md: invalid origin ${meta.origin}`);
1448
+ }
1449
+ if (typeof meta.repo !== "string" || !/.+\/.+/.test(meta.repo)) {
1450
+ errors.push("publish.md: repo must be owner/repo");
1451
+ }
1452
+ for (const heading of ["## 发布计划", "## 账本", "## 计数", "## 重试"]) {
1453
+ if (!body.includes(heading)) errors.push(`publish.md: missing '${heading}'`);
1454
+ }
1455
+ const rows = parsePublishLedger(body);
1456
+ if (!rows.length) errors.push("publish.md: ledger table has no ticket rows");
1457
+ const mixed = triage && triage.meta.classification === "mixed";
1458
+ for (const row of rows) {
1459
+ if (!/^T-\d{2,}$/.test(row.ticket)) {
1460
+ errors.push(`publish.md: invalid ticket id ${row.ticket}`);
1461
+ }
1462
+ if (!VALID_PUBLISH_ROW_STATE.has(row.state)) {
1463
+ errors.push(`publish.md: ${row.ticket}: invalid state ${row.state}`);
1464
+ }
1465
+ const labels = row.labels.split(",").map((item) => item.trim()).filter(Boolean);
1466
+ for (const label of labels) {
1467
+ if (FORBIDDEN_PUBLISH_LABELS.has(label)) {
1468
+ errors.push(`publish.md: ${row.ticket}: forbidden label ${label}`);
1469
+ }
1470
+ }
1471
+ const skipped = row.state.startsWith("skipped:");
1472
+ if (!skipped && !VALID_TICKET_KIND.has(row.kind)) {
1473
+ errors.push(`publish.md: ${row.ticket}: invalid kind ${row.kind}`);
1474
+ }
1475
+ if (mixed && !skipped && !VALID_TICKET_KIND.has(row.kind)) {
1476
+ errors.push(`publish.md: mixed change requires kind on ${row.ticket}`);
1477
+ }
1478
+ if (COUNTED_PUBLISH_STATES.has(row.state) || row.state === "closed") {
1479
+ if (!/^\d+$/.test(row.number)) {
1480
+ errors.push(`publish.md: ${row.ticket}: ${row.state} row requires issue number`);
1481
+ }
1482
+ if (!String(row.url).startsWith("https://github.com/")) {
1483
+ errors.push(`publish.md: ${row.ticket}: ${row.state} row requires GitHub url`);
1484
+ }
1485
+ const expectedMarker = `specdev:${expectedChange}:${row.ticket}:published`;
1486
+ if (row.marker !== expectedMarker) {
1487
+ errors.push(`publish.md: ${row.ticket}: marker must be ${expectedMarker}`);
1488
+ }
1489
+ }
1490
+ if (row.state === "closed" || row.state === "created" || row.state === "commented") {
1491
+ if (!labels.includes("specdev:published")) {
1492
+ errors.push(`publish.md: ${row.ticket}: missing specdev:published`);
1493
+ }
1494
+ const originLabel = meta.origin === "intake" ? "origin:intake" : "origin:local";
1495
+ if (!labels.includes(originLabel)) {
1496
+ errors.push(`publish.md: ${row.ticket}: missing ${originLabel}`);
1497
+ }
1498
+ }
1499
+ }
1500
+ return { path, meta, body, rows };
1501
+ }
1502
+
1350
1503
  function validateDiagnosis(path, expectedChange, errors) {
1351
1504
  if (!isFile(path)) {
1352
1505
  errors.push("missing diagnosis artifact");
@@ -2048,6 +2201,9 @@ function validateTicket(path, errors) {
2048
2201
  const ticketId = String(meta.id);
2049
2202
  if (!/^T-\d{2,}$/.test(ticketId)) errors.push(`${basename(path)}: invalid Ticket id ${ticketId}`);
2050
2203
  if (!VALID_TICKET_STATUS.has(meta.status)) errors.push(`${basename(path)}: invalid status ${meta.status}`);
2204
+ if (meta.kind != null && meta.kind !== "" && !VALID_TICKET_KIND.has(meta.kind)) {
2205
+ errors.push(`${basename(path)}: invalid kind ${meta.kind}`);
2206
+ }
2051
2207
  if (!VALID_DEPTH.has(meta.planning_depth)) {
2052
2208
  errors.push(`${basename(path)}: invalid planning_depth ${meta.planning_depth}`);
2053
2209
  }
@@ -3010,6 +3166,13 @@ function validateChange(change, stage = null, repoRoot = null) {
3010
3166
  const triage = isFile(triagePath) || sourceRequired
3011
3167
  ? validateTriage(triagePath, basename(change), errors)
3012
3168
  : null;
3169
+ const publishPath = join(change, "publish.md");
3170
+ const publishRequired = Boolean(
3171
+ triage && new Set(["pending", "published", "publish-failed", "waived"]).has(triage.meta.publish_action),
3172
+ );
3173
+ if (isFile(publishPath) || publishRequired) {
3174
+ validatePublish(publishPath, basename(change), triage, errors);
3175
+ }
3013
3176
  const diagnosisPath = join(change, "diagnosis.md");
3014
3177
  if (isFile(diagnosisPath) || stage === "diagnosis") {
3015
3178
  validateDiagnosis(diagnosisPath, basename(change), errors);
@@ -3298,6 +3461,13 @@ function validateChange(change, stage = null, repoRoot = null) {
3298
3461
  ) {
3299
3462
  errors.push(`complete stage cannot archive external_action=${triage.meta.external_action}`);
3300
3463
  }
3464
+ if (
3465
+ stage === "complete" &&
3466
+ triage &&
3467
+ new Set(["pending", "publish-failed"]).has(triage.meta.publish_action)
3468
+ ) {
3469
+ errors.push(`complete stage cannot archive publish_action=${triage.meta.publish_action}`);
3470
+ }
3301
3471
 
3302
3472
  for (const path of walk(change).filter((item) => isFile(item) && extname(item) === ".md")) {
3303
3473
  const text = readText(path);