@mstar-harness/engine 3.9.0 → 3.9.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.
@@ -6,6 +6,19 @@ export type AssignmentFields = {
6
6
  taskCategory?: string;
7
7
  workingBranch?: string;
8
8
  branchPolicy?: string;
9
+ /**
10
+ * `Budget (review / QC seats)` round cap. Required (present, non-empty,
11
+ * not `N/A`) on review-seat and audit rounds — see
12
+ * {@link REVIEW_SEAT_ROLES}. The value is prose (file opens / wall clock);
13
+ * it is never parsed numerically.
14
+ */
15
+ budget?: string;
16
+ /**
17
+ * `Return shape (review / QC seats)` — what the seat returns and how it
18
+ * stops. Same presence-only rule as {@link AssignmentFields.budget}, one
19
+ * violation code per field so the PM sees exactly which label is missing.
20
+ */
21
+ returnShape?: string;
9
22
  };
10
23
  export type ValidateAssignmentFieldsOptions = {
11
24
  /**
@@ -39,7 +52,15 @@ export type ExecutionModeToNResult = GateResult & {
39
52
  * are accepted so the engine parser is the SINGLE grammar for Assignment
40
53
  * header fields (the Slice-2 opencode presence parser tolerated bullets;
41
54
  * its acceptance is folded into this parser, not forked —).
42
- */
55
+ *
56
+ * The function scopes its OWN input: it reads the {@link
57
+ * assignmentHeaderRegion} of whatever text it is handed, so any caller (host
58
+ * gate, CLI, plugin) may pass the whole dispatch/prompt — a field label
59
+ * quoted in the task body can never be read as a header field. An
60
+ * already-sliced header may be passed as well: a slice holds no body marker,
61
+ * so re-slicing is a no-op (idempotent). Every Assignment field/branch read
62
+ * therefore shares ONE header rule by construction, rather than by each call
63
+ * site remembering to pre-slice. */
43
64
  export declare function parseAssignmentFields(assignmentText: string): AssignmentFields;
44
65
  /** Where the `Enforcement` flag was declared (roadmap §8.5 C4/D2). */
45
66
  export type EnforcementSource = "assignment" | "compass" | "mstarc" | "none";
@@ -51,9 +72,11 @@ export type EnforcementFlag = {
51
72
  /**
52
73
  * Slice an Assignment's header region — the text before the first body
53
74
  * marker (see {@link ASSIGNMENT_BODY_START_RE}). Returns the full text when
54
- * no marker is present. The Assignment enforcement flag is parsed against
55
- * THIS region only, so an example line `**Enforcement**: hard` quoted in the
56
- * task body cannot harden the dispatch. */
75
+ * no marker is present. This is the SINGLE header-scope rule: the Assignment
76
+ * field parsers ({@link parseAssignmentFields} plus the branch forms built
77
+ * on it) apply it to their own input, and the Assignment enforcement flag is
78
+ * parsed against THIS region, so an example line `**Enforcement**: hard`
79
+ * quoted in the task body cannot harden the dispatch. */
57
80
  export declare function assignmentHeaderRegion(assignmentText: string): string;
58
81
  /**
59
82
  * Parse the `Enforcement: hard` flag (roadmap §8.5 C4 + decision D2 — v2
@@ -110,8 +133,9 @@ export type AssignmentBranchForms = {
110
133
  * Parse an Assignment's branch forms via the engine's single parser
111
134
  * (`parseAssignmentFields` + {@link parseWorkingBranchValue}). Consumed by
112
135
  * the CLI `dispatch validate` gate-branch derivation and the opencode hook;
113
- * also the internal grammar behind `validateAssignmentFields`.
114
- */
136
+ * also the internal grammar behind `validateAssignmentFields`. Input may be
137
+ * the whole dispatch — the header scope is inherited from
138
+ * {@link parseAssignmentFields}. */
115
139
  export declare function parseAssignmentBranchForms(assignmentText: string): AssignmentBranchForms;
116
140
  /**
117
141
  * Parse the Assignment's `Branch policy: direct on <branch> — <reason>`
@@ -119,7 +143,8 @@ export declare function parseAssignmentBranchForms(assignmentText: string): Assi
119
143
  * form (branch + non-empty reason; separator set [—–]|--|-); undefined when
120
144
  * absent or malformed — the default-branch gate recognizes explicit
121
145
  * direct-on exceptions only. Single engine grammar shared by CLI + plugin
122
- *. */
146
+ * (the header scope is inherited from {@link parseAssignmentBranchForms}, so
147
+ * a whole dispatch may be passed). */
123
148
  export declare function parseBranchPolicyDirectOnBranch(assignmentText: string): string | undefined;
124
149
  /**
125
150
  * True when the Assignment's `Execute as` role is a read-only orientation
@@ -134,11 +159,23 @@ export declare function isReadOnlyAssignmentRole(roleId: string): boolean;
134
159
  *
135
160
  * Required: `Execute as` / `Delegation` / `Task category` present with
136
161
  * non-empty values (paste-only shells are caught here — every field missing).
137
- * Writable assignments must carry EXACTLY ONE branch form; `create <new>
162
+ * Review-seat and audit rounds must additionally declare both round-bounding
163
+ * fields, `Budget (review / QC seats)` and `Return shape (review / QC
164
+ * seats)` (one violation code per missing label). Writable assignments must
165
+ * carry EXACTLY ONE branch form; `create <new>`
138
166
  * from <base>` without `<base>` (incl. the dangling `create <new> from`
139
167
  * / `create from <base>` typos) and `Branch policy` without branch/reason
140
168
  * are flagged. The three core-field violations carry the legacy
141
- * `assignment.presence.*` codes as aliases. */
169
+ * `assignment.presence.*` codes as aliases.
170
+ *
171
+ * Every read is scoped to the Assignment HEADER region — the scope is
172
+ * applied by the field parsers themselves ({@link parseAssignmentFields} /
173
+ * {@link parseAssignmentBranchForms}), never the task body — the same rule
174
+ * {@link parseEnforcementFlag} follows at the compose gate. Dispatch text
175
+ * routinely quotes the field labels (template blocks, `## Task` bodies, the
176
+ * `**Task**:` line's own prose), and a quoted `**Budget (review / QC
177
+ * seats)**: …` line must not satisfy a gate the Assignment header left
178
+ * open. */
142
179
  export declare function validateAssignmentFields(assignmentText: string, opts?: ValidateAssignmentFieldsOptions): GateResult;
143
180
  /**
144
181
  * Flag writable work on a default protected branch (`main`/`master` per
package/dist/engine.js CHANGED
@@ -600,12 +600,18 @@ var REQUIRED_FIELDS = [
600
600
  { key: "delegation", label: "Delegation", code: "delegation" },
601
601
  { key: "taskCategory", label: "Task category", code: "task-category" }
602
602
  ];
603
+ var REVIEW_SEAT_ROLES = ["qc-specialist", "qc-specialist-2", "qc-specialist-3", "code-reviewer", "qa-engineer"];
604
+ var BUDGET_LABELS = ["Budget (review / QC seats)", "Budget"];
605
+ var RETURN_SHAPE_LABELS = ["Return shape (review / QC seats)", "Return shape"];
603
606
  function violation2(severity, code, message, fix) {
604
607
  return { ok: false, severity, code, message, fix };
605
608
  }
609
+ function describeAbsence(value) {
610
+ return value === undefined ? "the field is absent" : value === "" ? "the field is empty" : value.trim().toLowerCase() === "n/a" ? 'the value is "N/A"' : undefined;
611
+ }
606
612
  function parseAssignmentFields(assignmentText) {
607
613
  const fields = {};
608
- for (const line of assignmentText.split(/\r?\n/)) {
614
+ for (const line of assignmentHeaderRegion(assignmentText).split(/\r?\n/)) {
609
615
  const match = line.match(/^[ \t]*(?:[-*][ \t]+)?\*\*\s*([^*:]+?)\s*\*\*\s*:\s*(.*)$/) ?? line.match(/^[ \t]*(?:[-*][ \t]+)?([A-Za-z][A-Za-z -]*?)\s*:\s*(.*)$/);
610
616
  if (!match)
611
617
  continue;
@@ -620,6 +626,10 @@ function parseAssignmentFields(assignmentText) {
620
626
  fields.workingBranch = value;
621
627
  else if (label === "Branch policy")
622
628
  fields.branchPolicy = value;
629
+ else if (BUDGET_LABELS.includes(label))
630
+ fields.budget = value;
631
+ else if (RETURN_SHAPE_LABELS.includes(label))
632
+ fields.returnShape = value;
623
633
  }
624
634
  return fields;
625
635
  }
@@ -707,6 +717,33 @@ function validateAssignmentFields(assignmentText, opts = {}) {
707
717
  for (const { key, label, code } of REQUIRED_FIELDS) {
708
718
  requireField(violations, fields[key], label, code);
709
719
  }
720
+ const role = (fields.executeAs ?? "").trim().replace(/^@/, "").toLowerCase().split(/\s+/)[0] ?? "";
721
+ const reviewSeat = REVIEW_SEAT_ROLES.includes(role);
722
+ const category = (fields.taskCategory ?? "").replace(/^[*_`"'\s]+/, "").toLowerCase();
723
+ const auditRound = /^audit(?![A-Za-z0-9])/.test(category);
724
+ if (reviewSeat || auditRound) {
725
+ const subject = reviewSeat ? `review seat "${role}"` : `audit round (Task category: ${category})`;
726
+ const boundingFields = [
727
+ {
728
+ code: "assignment.field.budget-missing",
729
+ label: "Budget",
730
+ fix: `add "**Budget (review / QC seats)**: <cap> — may only tighten the default in mstar-harness-core § 定向执行与验证边界, never loosen it"`,
731
+ value: fields.budget
732
+ },
733
+ {
734
+ code: "assignment.field.return-shape-missing",
735
+ label: "Return shape",
736
+ fix: `add "**Return shape (review / QC seats)**: <what the seat returns and how it stops — verdict + findings shape; a clean round returns findings: [] explicitly>"`,
737
+ value: fields.returnShape
738
+ }
739
+ ];
740
+ for (const field of boundingFields) {
741
+ const missing = describeAbsence(field.value);
742
+ if (missing === undefined)
743
+ continue;
744
+ violations.push(violation2("high", field.code, `${subject} must declare a round ${field.label} — ${missing}`, field.fix));
745
+ }
746
+ }
710
747
  if (writable) {
711
748
  const workingPresent = fields.workingBranch !== undefined && fields.workingBranch !== "";
712
749
  const policyPresent = fields.branchPolicy !== undefined && fields.branchPolicy !== "";
@@ -2039,7 +2076,9 @@ function findingsCleanupGate(register, planId, opts) {
2039
2076
  const id = typeof entry.id === "string" ? entry.id : "<unnamed>";
2040
2077
  const label = `R#${id}`;
2041
2078
  if (mode === "zero-residual") {
2042
- if (entry.severity === "nit") {
2079
+ if (normalizeSeverity(entry.severity) === "critical") {
2080
+ violations.push(violation6("high", "findings.zero-residual-critical", `${label}: unresolved critical blocks approval under zero-residual — fix now or close via explicit risk acceptance`));
2081
+ } else if (entry.severity === "nit") {
2043
2082
  violations.push(violation6("medium", "findings.zero-residual-nit", `${label}: style-only nits must be fixed in-session or dropped — never left open under zero-residual`));
2044
2083
  } else if (entry.decision === "risk-accepted" || entry.lifecycle === "waived") {
2045
2084
  violations.push(violation6("medium", "findings.zero-residual-risk-accepted", `${label}: waived/risk-accepted findings must be closed/archived, not left open under zero-residual`));
@@ -8447,6 +8486,203 @@ function resolvePrReviewTier(input) {
8447
8486
  return "quick";
8448
8487
  return "default";
8449
8488
  }
8489
+ // src/qcreview.ts
8490
+ var QC_VERDICTS = ["Approve", "Request Changes", "Needs Discussion", "Unconfirmed"];
8491
+ var REPORT_FIELDS = ["report_kind", "reviewer", "reviewer_index", "plan_id", "verdict", "generated_at"];
8492
+ var REPORT_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
8493
+ var REPORT_SEVERITIES = ["Critical", "Warning", "Suggestion", "Unconfirmed"];
8494
+ var BODY_VERDICT_RE = /^[ \t]*(?:[-*+][ \t]+)?(?:#{1,6}[ \t]+)?[*_\s]*Verdict[*_\s]*:[ \t]*(.*)$/i;
8495
+ var VERDICT_DECORATION_RE = /^[*_`"'\s~]+/;
8496
+ var TRUNCATED_COVERAGE_RE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?Truncated coverage(?:\*\*)?[ \t]*:/;
8497
+ var EMPTY_SECTION_RE = /^(?:\(none\)|none(?![A-Za-z])|\uff08?\u65e0\uff09?)/i;
8498
+ var LIST_ITEM_RE = /^(?:[-*][ \t]+|\d+[.)][ \t]+)(.*)$/;
8499
+ function sectionRange(lines, heading) {
8500
+ const start = lines.findIndex((line) => {
8501
+ const m = /^(#{1,6})[ \t]+(.*?)[ \t]*$/.exec(line);
8502
+ return m !== null && m[1].length === 2 && m[2] === heading;
8503
+ });
8504
+ if (start < 0)
8505
+ return;
8506
+ let end = lines.length;
8507
+ for (let i = start + 1;i < lines.length; i++) {
8508
+ if (/^##[ \t]/.test(lines[i])) {
8509
+ end = i;
8510
+ break;
8511
+ }
8512
+ }
8513
+ return [start + 1, end];
8514
+ }
8515
+ function bodyLines(lines) {
8516
+ if (lines[0]?.trim() !== "---")
8517
+ return lines;
8518
+ for (let i = 1;i < lines.length; i++) {
8519
+ if (lines[i].trim() === "---")
8520
+ return lines.slice(i + 1);
8521
+ }
8522
+ return lines;
8523
+ }
8524
+ function linesUnclosedFence(text) {
8525
+ const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
8526
+ if (lines.length === 0 || lines[0].trim() !== "---")
8527
+ return false;
8528
+ return !lines.slice(1).some((line) => line.trim() === "---");
8529
+ }
8530
+ function tableCells(line) {
8531
+ const trimmed = line.trim();
8532
+ if (!trimmed.startsWith("|") || !trimmed.endsWith("|"))
8533
+ return;
8534
+ const cells = trimmed.slice(1, -1).split("|").map((cell) => cell.trim());
8535
+ return cells.length >= 2 ? cells : undefined;
8536
+ }
8537
+ function namesSeverity(text, severity) {
8538
+ const index = text.indexOf(severity);
8539
+ if (index < 0)
8540
+ return false;
8541
+ return !/[A-Za-z]/.test(text.charAt(index - 1)) && !/[A-Za-z]/.test(text.charAt(index + severity.length));
8542
+ }
8543
+ function summaryCounts(lines) {
8544
+ const counts = {};
8545
+ const range = sectionRange(lines, "Summary");
8546
+ if (range === undefined)
8547
+ return counts;
8548
+ for (let i = range[0];i < range[1]; i++) {
8549
+ const cells = tableCells(lines[i]);
8550
+ if (cells === undefined)
8551
+ continue;
8552
+ const values = cells.slice(1).map((cell) => /(\d+)/.exec(cell)?.[1]);
8553
+ const tail = values.filter((digits) => digits !== undefined).pop();
8554
+ if (tail === undefined)
8555
+ continue;
8556
+ for (const severity of REPORT_SEVERITIES) {
8557
+ if (!namesSeverity(cells[0], severity))
8558
+ continue;
8559
+ counts[severity] = Number(tail);
8560
+ }
8561
+ }
8562
+ return counts;
8563
+ }
8564
+ function findingsCounts(lines) {
8565
+ const counts = {};
8566
+ const range = sectionRange(lines, "Findings");
8567
+ if (range === undefined)
8568
+ return counts;
8569
+ let current;
8570
+ for (let i = range[0];i < range[1]; i++) {
8571
+ const line = lines[i];
8572
+ const heading = /^###[ \t]+(.*?)[ \t]*$/.exec(line);
8573
+ if (heading !== null) {
8574
+ current = REPORT_SEVERITIES.find((severity) => namesSeverity(heading[1], severity));
8575
+ if (current !== undefined && counts[current] === undefined)
8576
+ counts[current] = 0;
8577
+ continue;
8578
+ }
8579
+ if (current === undefined)
8580
+ continue;
8581
+ const item = LIST_ITEM_RE.exec(line);
8582
+ if (item === null || EMPTY_SECTION_RE.test(item[1].trim()))
8583
+ continue;
8584
+ counts[current] = (counts[current] ?? 0) + 1;
8585
+ }
8586
+ return counts;
8587
+ }
8588
+ function bodyVerdictPhrase(lines) {
8589
+ let phrase;
8590
+ for (const line of bodyLines(lines)) {
8591
+ const m = BODY_VERDICT_RE.exec(line);
8592
+ if (m === null)
8593
+ continue;
8594
+ const text = m[1].trim().replace(VERDICT_DECORATION_RE, "");
8595
+ phrase = text.split(/\s+/)[0] ?? "";
8596
+ for (const verdict of [...QC_VERDICTS].sort((a, b) => b.length - a.length)) {
8597
+ if (!text.startsWith(verdict))
8598
+ continue;
8599
+ const next = text.charAt(verdict.length);
8600
+ if (next === "" || !/[A-Za-z0-9]/.test(next)) {
8601
+ phrase = verdict;
8602
+ break;
8603
+ }
8604
+ }
8605
+ }
8606
+ return phrase;
8607
+ }
8608
+ function violation16(severity, code, message, fix) {
8609
+ return { ok: false, severity, code, message, fix };
8610
+ }
8611
+ function validateQcReport(text) {
8612
+ const violations = [];
8613
+ if (lines_missing_fence(text)) {
8614
+ return {
8615
+ ok: false,
8616
+ violations: [
8617
+ violation16("high", "qcreview.report.missing-frontmatter", "no `---` fenced frontmatter found - a QC seat report must open with the machine-readable frontmatter block (report-template.md § Frontmatter)", "open the report with `---` and the fields `report_kind: qc` / `reviewer` / `reviewer_index` / `plan_id` / `verdict` / `generated_at`")
8618
+ ]
8619
+ };
8620
+ }
8621
+ const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
8622
+ if (linesUnclosedFence(text)) {
8623
+ return {
8624
+ ok: false,
8625
+ violations: [
8626
+ violation16("high", "qcreview.report.unclosed-frontmatter", "the frontmatter `---` fence is never closed - every line after it is read as frontmatter, so the report body is not part of the document", "add a closing `---` line after the last frontmatter key (report-template.md § Frontmatter)")
8627
+ ]
8628
+ };
8629
+ }
8630
+ const { doc } = parseReportFrontmatter(text);
8631
+ for (const field of REPORT_FIELDS) {
8632
+ const value = doc[field];
8633
+ if (value === undefined || value.trim() === "") {
8634
+ violations.push(violation16("medium", `qcreview.report.missing-${field}`, `missing required frontmatter field: ${field}`, `add "${field}: <value>" to the frontmatter block (report-template.md § Frontmatter)`));
8635
+ }
8636
+ }
8637
+ const reportKind = doc.report_kind?.trim() ?? "";
8638
+ if (reportKind !== "" && reportKind !== "qc") {
8639
+ violations.push(violation16("medium", "qcreview.report.invalid-report-kind", `report_kind "${reportKind}" is not "qc"`, "use `report_kind: qc` for a QC seat report (a PM consolidated report is not a seat report)"));
8640
+ }
8641
+ const frontmatterVerdict = doc.verdict?.trim() ?? "";
8642
+ const frontmatterVerdictIsValid = QC_VERDICTS.includes(frontmatterVerdict);
8643
+ if (frontmatterVerdict !== "" && !frontmatterVerdictIsValid) {
8644
+ violations.push(violation16("medium", "qcreview.report.invalid-verdict", `frontmatter verdict "${frontmatterVerdict}" is not one of ${JSON.stringify(QC_VERDICTS)}`, `use one of: ${QC_VERDICTS.join(" | ")} - the vocabulary is verbatim (report-template.md § Report body template)`));
8645
+ }
8646
+ const generatedAt = doc.generated_at?.trim() ?? "";
8647
+ if (generatedAt !== "" && !REPORT_DATE_RE.test(generatedAt)) {
8648
+ violations.push(violation16("medium", "qcreview.report.invalid-generated-at", `generated_at "${generatedAt}" must be a calendar date (YYYY-MM-DD)`, 'write `generated_at: "YYYY-MM-DD"` (report-template.md § Frontmatter)'));
8649
+ }
8650
+ const bodyPhrase = bodyVerdictPhrase(lines);
8651
+ const bodyPhraseIsValid = bodyPhrase !== undefined && QC_VERDICTS.includes(bodyPhrase);
8652
+ if (bodyPhrase === undefined) {
8653
+ violations.push(violation16("high", "qcreview.report.missing-body-verdict", "the report body carries no verdict line (`**Verdict**: <verdict>` or `## Verdict: <verdict>`)", `add a body verdict line using one of: ${QC_VERDICTS.join(" | ")}`));
8654
+ } else if (!bodyPhraseIsValid) {
8655
+ violations.push(violation16("high", "qcreview.report.verdict-mismatch", bodyPhrase === "" ? `the body verdict line is empty - expected one of ${JSON.stringify(QC_VERDICTS)}` : `body verdict "${bodyPhrase}" is not one of ${JSON.stringify(QC_VERDICTS)}`, `start the verdict line with one of: ${QC_VERDICTS.join(" | ")}`));
8656
+ } else if (frontmatterVerdictIsValid && bodyPhrase !== frontmatterVerdict) {
8657
+ violations.push(violation16("high", "qcreview.report.verdict-mismatch", `body verdict "${bodyPhrase}" does not match the frontmatter verdict "${frontmatterVerdict}"`, "make the frontmatter verdict and the body verdict line agree on the same final verdict"));
8658
+ }
8659
+ const summary = summaryCounts(lines);
8660
+ const findings = findingsCounts(lines);
8661
+ for (const severity of REPORT_SEVERITIES) {
8662
+ const claimed = summary[severity];
8663
+ const counted = findings[severity];
8664
+ if (claimed === undefined || counted === undefined || claimed === counted)
8665
+ continue;
8666
+ violations.push(violation16("high", "qcreview.report.summary-count-mismatch", `## Summary ${severity} count ${claimed} does not match the ${counted} top-level entr${counted === 1 ? "y" : "ies"} under ## Findings (§ ${severity})`, `recount § ${severity} and update the ## Summary row - one top-level entry per finding, detail lines indented (report-template.md § Findings)`));
8667
+ }
8668
+ const effectiveVerdict = frontmatterVerdictIsValid ? frontmatterVerdict : bodyPhraseIsValid ? bodyPhrase : undefined;
8669
+ if (effectiveVerdict !== undefined) {
8670
+ const critical = summary.Critical;
8671
+ const warning = summary.Warning;
8672
+ const blocking = (critical ?? 0) + (warning ?? 0);
8673
+ if (effectiveVerdict === "Approve" && blocking > 0) {
8674
+ violations.push(violation16("high", "qcreview.report.verdict-contradicts-counts", `verdict "Approve" with ${blocking} Critical/Warning finding(s) in ## Summary (Critical ${critical ?? 0}, Warning ${warning ?? 0})`, 'do not Approve with open Critical/Warning findings - close them or use "Request Changes" / "Needs Discussion"'));
8675
+ }
8676
+ const unconfirmed = summary.Unconfirmed;
8677
+ if (unconfirmed !== undefined && unconfirmed > 0 && effectiveVerdict !== "Unconfirmed") {
8678
+ violations.push(violation16("high", "qcreview.report.verdict-contradicts-counts", `verdict "${effectiveVerdict}" with ${unconfirmed} Unconfirmed finding(s) in ## Summary - a failed evidence channel transmits as "Unconfirmed" (mstar-review-qc § 席位预算与截断)`, 'set the verdict to "Unconfirmed" or re-establish the evidence channel before converging'));
8679
+ }
8680
+ if (effectiveVerdict === "Unconfirmed" && lines.some((line) => TRUNCATED_COVERAGE_RE.test(line))) {
8681
+ violations.push(violation16("high", "qcreview.report.truncation-verdict", 'the report declares `Truncated coverage:` but carries the verdict "Unconfirmed" - a budget/scope cut is not a failed evidence channel', 'keep the verdict earned for the reviewed scope and reserve "Unconfirmed" for a failed evidence channel'));
8682
+ }
8683
+ }
8684
+ return { ok: violations.length === 0, violations };
8685
+ }
8450
8686
  export {
8451
8687
  ARCHIVED_STATUS_V1_FILE,
8452
8688
  AUDIT_CATEGORIES,
@@ -8481,6 +8717,7 @@ export {
8481
8717
  PR_REVIEW_TIER_BUDGETS,
8482
8718
  PR_VERDICTS,
8483
8719
  QC_REVIEWER_PARAMS,
8720
+ QC_VERDICTS,
8484
8721
  REVIEW_EMOJI,
8485
8722
  ROADMAP_STATUSES,
8486
8723
  ROLE_MAPPING,
@@ -8633,6 +8870,7 @@ export {
8633
8870
  validatePlanRow,
8634
8871
  validatePrReviewReport,
8635
8872
  validateProjectRegister,
8873
+ validateQcReport,
8636
8874
  validateResidual,
8637
8875
  validateRoadmap,
8638
8876
  validateRoleMapping,
package/dist/index.d.ts CHANGED
@@ -17,7 +17,10 @@
17
17
  * compound-refresh scope. `roles` validates the role reference mapping +
18
18
  * parameter tables and the load-order contract, `prreview` implements the
19
19
  * PR-review tally/score/verdict arithmetic and the merge-class/verdict
20
- * constants (mstar-audit pr-review.md § Tally and derived score), `host`
20
+ * constants (mstar-audit pr-review.md § Tally and derived score), `qcreview`
21
+ * is the QC seat-report contract (frontmatter fields + verbatim verdict
22
+ * vocabulary + body-verdict agreement + Summary/Findings count parity +
23
+ * truncation/verdict coherence, `mstar-review-qc` SKILL.md § 席位预算与截断), `host`
21
24
  * detects the active
22
25
  * host from tool shapes, resolves skill roots and defines the type-only
23
26
  * `HostAdapter` contract, `gates` is the host-neutral coordination-write
@@ -77,6 +80,8 @@ export type { FiveQuestionMode, FiveQuestionSection, SkillLintKind, SkillLintPro
77
80
  export { classifySkillLint, FIVE_QUESTION_SECTIONS, RUNTIME_HEADING_ALIASES, lintFiveQuestion, lintFrontmatter, resolveAssetPath, stripFrontmatter, } from "./skill-authoring.js";
78
81
  export type { MergeClass, MstarReviewFinding, MstarReviewV1, PrReportTarget, PrReviewSeatPromptOptions, PrReviewSizing, PrReviewTier, PrTierKeyword, PrSizeBand, PrTallyInput, PrTallyResult, PrVerdict, ResolvePrReviewTierInput, ReviewChangesetMode, ReviewInlineComment, ReviewPostPlan, ValidateFindingDocOptions, } from "./prreview.js";
79
82
  export { MERGE_CLASSES, PR_REVIEW_TIER_BUDGETS, PR_VERDICTS, REVIEW_EMOJI, computePrTally, pickReviewBranchName, planReviewPost, preflightChangeset, prReviewReportPath, prReviewSeatPrompt, prReviewSizing, resolvePrReviewTier, synthesizeReview, validateFindingDoc, validateMstarReviewV1, validatePrReviewReport, } from "./prreview.js";
83
+ export type { QcVerdict } from "./qcreview.js";
84
+ export { QC_VERDICTS, validateQcReport } from "./qcreview.js";
80
85
  export type { ArtifactDoc, ArtifactKind, ArtifactRef, ArtifactStore } from "./store.js";
81
86
  export { assertFsStorePath, createFsStore, getArtifactStore, loadStoreModule, resolveArtifactPath, setArtifactStore } from "./store.js";
82
87
  export { collectActiveLifecycleBranches, scanActiveLifecycleBranches, type ActiveLifecycleScan } from "./lifecycle-branches.js";
package/dist/project.d.ts CHANGED
@@ -170,7 +170,9 @@ export declare function closeProjectRegisterEntry(opts: CloseProjectRegisterEntr
170
170
  * snapshot's plan row). Every OPEN entry of the plan is checked.
171
171
  * `zero-residual`: only true blocker-defers (`decision: defer` + non-empty
172
172
  * `target`) may stay open — fixable findings, `nit`s, and waived/
173
- * risk-accepted entries are violations. `allow-residual` (default): open
173
+ * risk-accepted entries are violations, and an unresolved Critical is a
174
+ * violation for EVERY decision (it must be fixed or closed by explicit
175
+ * risk acceptance, not carried as a defer). `allow-residual` (default): open
174
176
  * residuals are fine unless an unresolved Critical remains. Mode resolution:
175
177
  * explicit `opts.mode` → `allow-residual` (the v1
176
178
  * `plans[].metadata.findings_cleanup` mirror is deleted — no dual-track).
@@ -166,6 +166,16 @@ export declare function prReviewReportPath(opts: {
166
166
  stage?: 1 | 2;
167
167
  slug?: string;
168
168
  }): string;
169
+ /**
170
+ * Parse the narrow frontmatter subset reports actually carry: scalar
171
+ * `key: value` lines inside a leading `---` fence (inline `# comment`
172
+ * tails stripped, surrounding quotes trimmed). Returns null when the fence
173
+ * is missing. Unreadable lines surface as violations, never throws.
174
+ */
175
+ export declare function parseReportFrontmatter(text: string): {
176
+ doc: Record<string, string>;
177
+ unreadable: number;
178
+ };
169
179
  /**
170
180
  * Validate a saved local PR-review report against the machine-readable
171
181
  * contract (pr-review.md § Local report archive Frontmatter + § Output
@@ -191,6 +201,8 @@ export declare function prReviewReportPath(opts: {
191
201
  * (legacy reports without elapsed still pass).
192
202
  */
193
203
  export declare function validatePrReviewReport(text: string): GateResult;
204
+ /** True when `text` opens with a `---` fenced frontmatter block. */
205
+ export declare function lines_missing_fence(text: string): boolean;
194
206
  /** One inline review comment: `path` + `line` in the three-dot diff, RIGHT
195
207
  * side only (§ Comment posting step 2 — comments[] entry shape). */
196
208
  export type ReviewInlineComment = {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * qcreview — QC 席位报告契约(review-seat return side)。
3
+ *
4
+ * spec: `mstar-review-qc` SKILL.md § 席位预算与截断(该节 callout 声明的
5
+ * `qc validate-report`)、`report-template.md` § Frontmatter / § Report body
6
+ * template / § Findings(verdict 词表与报告形状逐字来源)、
7
+ * `qc-specialist-shared.md` § Budget and stopping / § Targeted re-review
8
+ * (就地 revalidation 刷新当前状态,报告只有一份计数)。
9
+ *
10
+ * 机器可判定的十一条规则(violation code 前缀一律 `qcreview.report.`):
11
+ * 1. `missing-frontmatter` — 报告必须以 `---` 围栏 frontmatter 开头。
12
+ * 2. `unclosed-frontmatter` — 起始围栏必须有闭合 `---` 行。缺闭合行时其后的
13
+ * 全部内容都被读作 frontmatter(`parseReportFrontmatter` 直到文末收集
14
+ * `key: value`),正文与 frontmatter 的边界不存在,后续规则无从判定。
15
+ * 3. `missing-<field>` — `report_kind` / `reviewer` / `reviewer_index` /
16
+ * `plan_id` / `verdict` / `generated_at` 缺失或空值。
17
+ * 4. `invalid-report-kind` — `report_kind` 必须是 `qc`。
18
+ * 5. `invalid-verdict` — frontmatter `verdict` 必须逐字属于 `QC_VERDICTS`。
19
+ * 6. `invalid-generated-at` — `generated_at` 必须是日历日期 `YYYY-MM-DD`。
20
+ * 7. `missing-body-verdict` — 正文必须有 verdict 行。
21
+ * 8. `verdict-mismatch` — 正文 verdict 取词必须属于 `QC_VERDICTS` 且与
22
+ * frontmatter verdict 一致;允许尾随散文(真实报告写
23
+ * `**Verdict**: Approve. The diff keeps ...`,历史上还出现
24
+ * `## Verdict: **Approve with residuals**`)。
25
+ * 9. `summary-count-mismatch` — `## Summary` 计数必须等于 `## Findings`
26
+ * 对应 severity 分区的顶层条目数(`None.` = 0)。`## Summary` 是报告
27
+ * 唯一的当前计数:就地 revalidation 刷新它与 `## Findings`,
28
+ * `## Revalidation` 只记过程与逐 finding 处置,不另立计数。
29
+ * 10. `verdict-contradicts-counts` — Critical / Warning 计数 > 0 不允许
30
+ * `Approve`;Unconfirmed 计数 > 0 要求 verdict 为 `Unconfirmed`。
31
+ * 11. `truncation-verdict` — 已声明 `Truncated coverage:` 行时 verdict 不得为
32
+ * `Unconfirmed`(截断是范围收缩,不是证据通道失败)。
33
+ *
34
+ * 不可判定即静默:形状未文档化(分区或计数行缺失、计数单元格非数字、
35
+ * frontmatter verdict 本身非法)时不出 violation,绝不猜测;每条 violation
36
+ * 都带可执行的 `fix`。
37
+ */
38
+ import type { GateResult } from "./core.js";
39
+ /**
40
+ * Verdict 词表 —— 逐字取自 `report-template.md` § Report body template。
41
+ * `Unconfirmed` 是证据通道失败态(不是"未审"),预算截断不得用它。
42
+ */
43
+ export declare const QC_VERDICTS: readonly ["Approve", "Request Changes", "Needs Discussion", "Unconfirmed"];
44
+ /** 席位报告 verdict 取值(`QC_VERDICTS` 成员)。 */
45
+ export type QcVerdict = (typeof QC_VERDICTS)[number];
46
+ /**
47
+ * Validate a QC 席位报告 against the rules in the module header.
48
+ * Structural checks only — 报告形状与 frontmatter 一致性,不重审审查内容。
49
+ */
50
+ export declare function validateQcReport(text: string): GateResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mstar-harness/engine",
3
- "version": "3.9.0",
3
+ "version": "3.9.2",
4
4
  "description": "Morning Star Harness Workflow Engine — deterministic workflow enforcement library (path, status, lease, dispatch, sdd, iteration, lint gates).",
5
5
  "license": "MIT",
6
6
  "repository": {