cclaw-cli 0.51.26 → 0.51.28

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,7 @@ import { readConfig } from "./config.js";
6
6
  import { RUNTIME_ROOT, SHIP_FINALIZATION_MODES } from "./constants.js";
7
7
  import { exists } from "./fs-utils.js";
8
8
  import { stageSchema } from "./content/stage-schema.js";
9
+ import { CONFIDENCE_FINDING_REGEX_SOURCE, FORBIDDEN_PLACEHOLDER_TOKENS } from "./content/skills.js";
9
10
  import { FLOW_STAGES } from "./types.js";
10
11
  async function resolveNamedArtifactPath(projectRoot, fileName) {
11
12
  const relPath = path.join(RUNTIME_ROOT, "artifacts", fileName);
@@ -104,6 +105,24 @@ function sectionBodyByHeadingPrefix(sections, prefix) {
104
105
  }
105
106
  return null;
106
107
  }
108
+ /**
109
+ * Build a regex that matches `<field>: <value>` even when the field name
110
+ * and/or value are wrapped in markdown emphasis (`*`, `**`, `_`, `__`).
111
+ *
112
+ * The shipped templates render fields as `- **Field name:** value`, so any
113
+ * structural check that searches for `Field:\s*token` against the rendered
114
+ * artifact must tolerate the closing `**` between the colon and the value.
115
+ *
116
+ * `field` is treated as literal text (regex meta-characters are escaped).
117
+ * `value` is inserted verbatim so callers can pass alternation
118
+ * (`STARTUP|BUILDER|...`). `flags` defaults to case-insensitive Unicode.
119
+ */
120
+ function markdownFieldRegex(field, value, flags = "iu") {
121
+ const escapedField = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
122
+ const emph = "[*_]{0,2}";
123
+ const source = `(?:^|[\\s>])${emph}\\s*${escapedField}\\s*${emph}\\s*:\\s*${emph}\\s*(?:${value})\\b`;
124
+ return new RegExp(source, flags);
125
+ }
107
126
  export function extractMarkdownSectionBody(markdown, section) {
108
127
  return sectionBodyByName(extractH2Sections(markdown), section);
109
128
  }
@@ -210,7 +229,7 @@ function normalizeDesignDiagramTier(value) {
210
229
  if (!value)
211
230
  return null;
212
231
  const normalized = value.trim().toLowerCase();
213
- if (/^light(?:weight)?$/u.test(normalized))
232
+ if (/^(?:lite|light|lightweight)$/u.test(normalized))
214
233
  return "lightweight";
215
234
  if (/^standard$/u.test(normalized))
216
235
  return "standard";
@@ -223,12 +242,21 @@ function parseApproachTierSection(sectionBody) {
223
242
  return null;
224
243
  for (const line of sectionBody.split(/\r?\n/u)) {
225
244
  const cleaned = line.replace(/[*_`]/gu, "").trim();
226
- const directMatch = /(?:^|\b)tier\s*:\s*(lightweight|light|standard|deep)\b/iu.exec(cleaned);
245
+ const directMatch = /(?:^|\b)tier\s*:\s*(lite|lightweight|light|standard|deep)\b/iu.exec(cleaned);
227
246
  if (directMatch) {
228
- return normalizeDesignDiagramTier(directMatch[1] ?? null);
247
+ const captured = directMatch[1] ?? "";
248
+ const remainder = cleaned.slice(cleaned.toLowerCase().indexOf("tier") + 4);
249
+ const tierTokens = remainder.match(/\b(?:lite|lightweight|light|standard|deep)\b/giu) ?? [];
250
+ const distinct = new Set(tierTokens.map((token) => token.toLowerCase()));
251
+ if (distinct.size >= 2) {
252
+ // Multi-token line is the unfilled template placeholder
253
+ // (`Tier: lite | standard | deep`); treat as no decision.
254
+ continue;
255
+ }
256
+ return normalizeDesignDiagramTier(captured);
229
257
  }
230
258
  }
231
- const token = /\b(lightweight|light|standard|deep)\b/iu.exec(sectionBody)?.[1] ?? null;
259
+ const token = /\b(lite|lightweight|light|standard|deep)\b/iu.exec(sectionBody)?.[1] ?? null;
232
260
  return normalizeDesignDiagramTier(token);
233
261
  }
234
262
  async function resolveDesignDiagramTier(projectRoot, track, designRaw) {
@@ -1781,15 +1809,31 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
1781
1809
  // to a single row (defeating the "2-3 distinct approaches" gate).
1782
1810
  const tierBody = sectionBodyByName(sections, "Approach Tier");
1783
1811
  if (tierBody !== null) {
1784
- const hasTierToken = /\b(?:lightweight|standard|deep)\b/iu.test(tierBody);
1812
+ // Token vocabulary covers `lite`, `Lightweight`, `Standard`, and
1813
+ // `Deep` (case-insensitive). A line that lists ≥2 distinct tokens is
1814
+ // the unfilled template placeholder (`Tier: lite | standard | deep`)
1815
+ // and must not silently pass; we look for at least one decision line
1816
+ // with exactly one token, while ignoring placeholder lines.
1817
+ const cleanedLines = tierBody
1818
+ .split("\n")
1819
+ .map((line) => line.replace(/[*_`]/gu, ""));
1820
+ const lineTokenCounts = cleanedLines.map((line) => {
1821
+ const tokens = line.match(/\b(?:lite|lightweight|light|standard|deep)\b/giu) ?? [];
1822
+ return new Set(tokens.map((token) => token.toLowerCase())).size;
1823
+ });
1824
+ const hasDecisionLine = lineTokenCounts.some((count) => count === 1);
1825
+ const hasPlaceholderLine = lineTokenCounts.some((count) => count >= 2);
1826
+ const ok = hasDecisionLine;
1785
1827
  findings.push({
1786
1828
  section: "Approach Tier Classification",
1787
1829
  required: true,
1788
- rule: "Approach Tier must explicitly classify depth as Lightweight, Standard, or Deep.",
1789
- found: hasTierToken,
1790
- details: hasTierToken
1791
- ? "Approach Tier includes a recognized depth token."
1792
- : "Approach Tier is missing a recognized depth token (Lightweight/Standard/Deep)."
1830
+ rule: "Approach Tier must explicitly classify depth as one of `lite` (a.k.a. `Lightweight`), `Standard`, or `Deep`.",
1831
+ found: ok,
1832
+ details: ok
1833
+ ? "Approach Tier includes a single recognized depth token."
1834
+ : hasPlaceholderLine
1835
+ ? "Approach Tier still lists multiple tier tokens (template placeholder); pick exactly one of `lite`/`Lightweight`, `Standard`, or `Deep`."
1836
+ : "Approach Tier is missing a recognized depth token (`lite`/`Lightweight`, `Standard`, or `Deep`)."
1793
1837
  });
1794
1838
  }
1795
1839
  const approachesBody = sectionBodyByName(sections, "Approaches");
@@ -1922,6 +1966,161 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
1922
1966
  details: selfReview.details
1923
1967
  });
1924
1968
  }
1969
+ // Universal structural checks (Layer 2.1). Each fires only when the
1970
+ // matching section is present so legacy fixtures keep their current
1971
+ // shape, while artifacts emitted from the v3 template have to satisfy
1972
+ // them. Content is never inspected — only the shape required by the
1973
+ // reference patterns (gstack mode, forcing questions, premise list,
1974
+ // approach detail cards, anti-sycophancy stamp).
1975
+ const modeBody = sectionBodyByName(sections, "Mode Block");
1976
+ if (modeBody !== null) {
1977
+ const modeTokens = ["STARTUP", "BUILDER", "ENGINEERING", "OPS", "RESEARCH"];
1978
+ const modeRegex = markdownFieldRegex("Mode", modeTokens.join("|"), "u");
1979
+ const tokenMatches = new Set();
1980
+ const lineRegex = new RegExp(modeRegex.source, "gu");
1981
+ for (const match of modeBody.matchAll(lineRegex)) {
1982
+ const token = (match[0].match(/STARTUP|BUILDER|ENGINEERING|OPS|RESEARCH/u) ?? [""])[0];
1983
+ if (token)
1984
+ tokenMatches.add(token);
1985
+ }
1986
+ const placeholderLine = modeBody
1987
+ .split("\n")
1988
+ .find((line) => /\bMode\b\s*[*_]{0,2}\s*:/iu.test(line) && (line.match(/STARTUP|BUILDER|ENGINEERING|OPS|RESEARCH/giu) ?? []).length >= 2);
1989
+ const isPlaceholder = Boolean(placeholderLine);
1990
+ const ok = tokenMatches.size === 1 && !isPlaceholder;
1991
+ findings.push({
1992
+ section: "Mode Block Token",
1993
+ required: true,
1994
+ rule: "Mode Block must declare exactly one mode token: STARTUP, BUILDER, ENGINEERING, OPS, or RESEARCH.",
1995
+ found: ok,
1996
+ details: ok
1997
+ ? `Recognized mode token detected: ${[...tokenMatches][0] ?? ""}.`
1998
+ : isPlaceholder
1999
+ ? "Mode Block still lists multiple mode tokens (template placeholder); pick exactly one of STARTUP/BUILDER/ENGINEERING/OPS/RESEARCH."
2000
+ : "Mode Block is missing a recognized mode token (STARTUP/BUILDER/ENGINEERING/OPS/RESEARCH)."
2001
+ });
2002
+ }
2003
+ const forcingBody = sectionBodyByName(sections, "Forcing Questions");
2004
+ if (forcingBody !== null) {
2005
+ const tableRows = forcingBody
2006
+ .split("\n")
2007
+ .filter((line) => /^\|\s*\d+\s*\|/u.test(line));
2008
+ const enoughRows = tableRows.length >= 3;
2009
+ findings.push({
2010
+ section: "Forcing Questions Count",
2011
+ required: true,
2012
+ rule: "Forcing Questions must include at least 3 numbered rows.",
2013
+ found: enoughRows,
2014
+ details: enoughRows
2015
+ ? `Detected ${tableRows.length} forcing-question row(s).`
2016
+ : `Detected ${tableRows.length} forcing-question row(s); at least 3 required.`
2017
+ });
2018
+ // A "specific" answer is signalled by at least one of: numeric token,
2019
+ // backticked path/identifier, http(s) link, @mention/role, or quoted
2020
+ // verbatim string. We check structural shape, not content.
2021
+ const specificTokenRegex = /(\d|`[^`]+`|https?:\/\/|@[A-Za-z][\w-]*|"[^"]+"|'[^']+')/u;
2022
+ const allRowsSpecific = tableRows.every((row) => {
2023
+ const cells = row.split("|").map((cell) => cell.trim());
2024
+ // cells: ["", "#", "Question", "Answer", "Decision impact", "Q<n> decision", ""]
2025
+ const answer = cells[3] ?? "";
2026
+ return answer.length > 0 && specificTokenRegex.test(answer);
2027
+ });
2028
+ findings.push({
2029
+ section: "Forcing Questions Specific Answers",
2030
+ required: true,
2031
+ rule: "Each Forcing Questions row must include a specific token in the answer column (number, backticked path, link, @mention, or quoted string).",
2032
+ found: tableRows.length === 0 ? false : allRowsSpecific,
2033
+ details: tableRows.length === 0
2034
+ ? "No rows to evaluate."
2035
+ : allRowsSpecific
2036
+ ? "All rows include a specific-answer token."
2037
+ : "At least one row's answer is missing a specific-answer token (number, `path`, https link, @mention, or quoted string)."
2038
+ });
2039
+ const decisionRows = (forcingBody.match(/decision\s*:/giu) ?? []).length;
2040
+ findings.push({
2041
+ section: "Forcing Questions STOP-per-issue",
2042
+ required: true,
2043
+ rule: "Each forcing-question row must record a `decision:` marker (STOP-per-issue protocol).",
2044
+ found: decisionRows >= tableRows.length && tableRows.length > 0,
2045
+ details: tableRows.length === 0
2046
+ ? "No rows to evaluate."
2047
+ : `Detected ${decisionRows} decision marker(s) for ${tableRows.length} forcing-question row(s).`
2048
+ });
2049
+ }
2050
+ const premiseBody = sectionBodyByName(sections, "Premise List");
2051
+ if (premiseBody !== null) {
2052
+ const premiseRowRegex = /^[-*]\s*P\d+:\s+.+\s+—\s+(agreed|disagreed|revised)\b/imu;
2053
+ const allRows = premiseBody
2054
+ .split("\n")
2055
+ .filter((line) => /^[-*]\s*P\d+:/u.test(line));
2056
+ const validRows = allRows.filter((row) => premiseRowRegex.test(row.trim() + "\n"));
2057
+ const enoughPremises = validRows.length >= 2;
2058
+ findings.push({
2059
+ section: "Premise List Shape",
2060
+ required: true,
2061
+ rule: "Premise List must contain at least 2 rows in the form `P<n>: <statement> — agreed|disagreed|revised`.",
2062
+ found: enoughPremises,
2063
+ details: enoughPremises
2064
+ ? `Detected ${validRows.length} valid premise row(s).`
2065
+ : `Detected ${validRows.length} valid premise row(s); at least 2 required (form: \`P<n>: ... — agreed|disagreed|revised\`).`
2066
+ });
2067
+ }
2068
+ // Approach Detail Cards: structural sub-section under Approaches, one
2069
+ // bullet block per approach with the canonical fields.
2070
+ const approachCardsRegex = /####\s+APPROACH\s+[A-Z]\b[\s\S]*?(?:^-\s*Summary:[\s\S]*?^-\s*Effort:[\s\S]*?^-\s*Risk:[\s\S]*?^-\s*Pros:[\s\S]*?^-\s*Cons:[\s\S]*?^-\s*Reuses:)/gimu;
2071
+ const matches = raw.match(approachCardsRegex);
2072
+ const cardCount = matches ? matches.length : 0;
2073
+ if (/####\s+APPROACH\s+[A-Z]\b/iu.test(raw) ||
2074
+ /^RECOMMENDATION:/imu.test(raw)) {
2075
+ findings.push({
2076
+ section: "Approach Detail Cards",
2077
+ required: true,
2078
+ rule: "Approach Detail Cards must include ≥2 `#### APPROACH <letter>` blocks each with Summary/Effort/Risk/Pros/Cons/Reuses.",
2079
+ found: cardCount >= 2,
2080
+ details: cardCount >= 2
2081
+ ? `Detected ${cardCount} valid approach detail card(s).`
2082
+ : `Detected ${cardCount} valid approach detail card(s); at least 2 required with all fields present.`
2083
+ });
2084
+ const recommendationLine = raw.match(/^RECOMMENDATION:\s*(.+)$/imu);
2085
+ const hasRecommendation = recommendationLine !== null && recommendationLine[1] !== undefined && recommendationLine[1].trim().length > 0;
2086
+ findings.push({
2087
+ section: "Approach Recommendation Marker",
2088
+ required: true,
2089
+ rule: "Approach Detail Cards must conclude with a single `RECOMMENDATION:` line citing the chosen letter and rationale.",
2090
+ found: hasRecommendation,
2091
+ details: hasRecommendation
2092
+ ? "Recommendation marker present."
2093
+ : "Missing or empty `RECOMMENDATION:` line after approach detail cards."
2094
+ });
2095
+ }
2096
+ const stampBody = sectionBodyByName(sections, "Anti-Sycophancy Stamp");
2097
+ if (stampBody !== null) {
2098
+ const acknowledged = markdownFieldRegex("Forbidden response openers acknowledged", "yes|true|y").test(stampBody);
2099
+ findings.push({
2100
+ section: "Anti-Sycophancy Acknowledgement",
2101
+ required: true,
2102
+ rule: "Anti-Sycophancy Stamp must affirm `Forbidden response openers acknowledged: yes`.",
2103
+ found: acknowledged,
2104
+ details: acknowledged
2105
+ ? "Anti-sycophancy commitment is acknowledged."
2106
+ : "Anti-Sycophancy Stamp is missing the explicit `Forbidden response openers acknowledged: yes` marker."
2107
+ });
2108
+ }
2109
+ const outsideVoiceBody = sectionBodyByName(sections, "Outside Voice");
2110
+ if (outsideVoiceBody !== null) {
2111
+ const required = ["source:", "prompt:", "tension:", "resolution:"];
2112
+ const missing = required.filter((key) => !new RegExp(`(?:^|\\n)\\s*-?\\s*${key.replace(":", "\\s*:")}`, "iu").test(outsideVoiceBody));
2113
+ const optedOut = /\bnot used\b|\bn\/a\b|\bnone\b/iu.test(outsideVoiceBody);
2114
+ findings.push({
2115
+ section: "Outside Voice Slot Shape",
2116
+ required: true,
2117
+ rule: "Outside Voice section must either declare opt-out (`not used`/`none`) or include `source:`, `prompt:`, `tension:`, `resolution:`.",
2118
+ found: optedOut || missing.length === 0,
2119
+ details: optedOut || missing.length === 0
2120
+ ? "Outside Voice slot is well-formed."
2121
+ : `Outside Voice section is missing field(s): ${missing.join(", ")}.`
2122
+ });
2123
+ }
1925
2124
  }
1926
2125
  if (stage === "design") {
1927
2126
  const tierResolution = await resolveDesignDiagramTier(projectRoot, track, raw);
@@ -1975,6 +2174,58 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
1975
2174
  });
1976
2175
  }
1977
2176
  }
2177
+ // Universal Layer 2.3 structural checks (gstack plan-eng-review). All
2178
+ // present-only. Validates ASCII coverage diagram tokens, regression iron
2179
+ // rule acknowledgment, and confidence-calibrated finding format.
2180
+ const coverageBody = sectionBodyByName(sections, "ASCII Coverage Diagram");
2181
+ if (coverageBody !== null) {
2182
+ const tokens = ["[★★★]", "[★★]", "[★]", "[GAP]", "[→E2E]", "[→EVAL]"];
2183
+ const presentTokens = tokens.filter((token) => coverageBody.includes(token));
2184
+ const ok = presentTokens.length >= 3;
2185
+ findings.push({
2186
+ section: "ASCII Coverage Diagram Tokens",
2187
+ required: true,
2188
+ rule: "ASCII Coverage Diagram must use the canonical marker tokens (at least 3 of `[★★★]` / `[★★]` / `[★]` / `[GAP]` / `[→E2E]` / `[→EVAL]`).",
2189
+ found: ok,
2190
+ details: ok
2191
+ ? `Detected ${presentTokens.length} canonical marker token(s).`
2192
+ : `Detected ${presentTokens.length} canonical marker token(s); at least 3 required.`
2193
+ });
2194
+ }
2195
+ const regressionBody = sectionBodyByName(sections, "Regression Iron Rule");
2196
+ if (regressionBody !== null) {
2197
+ const ack = markdownFieldRegex("Iron rule acknowledged", "yes|true|y").test(regressionBody);
2198
+ findings.push({
2199
+ section: "Regression Iron Rule Acknowledgement",
2200
+ required: true,
2201
+ rule: "Regression Iron Rule section must affirm `Iron rule acknowledged: yes`.",
2202
+ found: ack,
2203
+ details: ack
2204
+ ? "Regression iron rule acknowledged."
2205
+ : "Regression Iron Rule is missing explicit `Iron rule acknowledged: yes`."
2206
+ });
2207
+ }
2208
+ const findingsBody = sectionBodyByName(sections, "Calibrated Findings");
2209
+ if (findingsBody !== null) {
2210
+ const isEmpty = /(^|\n)\s*-\s*None this stage\b/iu.test(findingsBody);
2211
+ const findingRegex = new RegExp(CONFIDENCE_FINDING_REGEX_SOURCE, "u");
2212
+ const validRows = findingsBody
2213
+ .split("\n")
2214
+ .filter((line) => /^[-*]\s+\[/u.test(line.trim()))
2215
+ .filter((line) => findingRegex.test(line));
2216
+ const ok = isEmpty || validRows.length >= 1;
2217
+ findings.push({
2218
+ section: "Calibrated Finding Format",
2219
+ required: true,
2220
+ rule: "Calibrated Findings must either declare `None this stage` or contain at least one finding in the form `[P1|P2|P3] (confidence: <n>/10) <path>[:<line>] — <description>`.",
2221
+ found: ok,
2222
+ details: isEmpty
2223
+ ? "No findings recorded for this stage."
2224
+ : ok
2225
+ ? `Detected ${validRows.length} calibrated finding(s).`
2226
+ : "No calibrated findings detected. Use `[P1|P2|P3] (confidence: <n>/10) <repo-path>[:<line>] — <description>`."
2227
+ });
2228
+ }
1978
2229
  }
1979
2230
  if (stage === "plan") {
1980
2231
  const strictPlanGuards = parsedFrontmatter.hasFrontmatter ||
@@ -2025,6 +2276,79 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
2025
2276
  ? "No scope-reduction phrases detected in Task List."
2026
2277
  : `Detected scope-reduction phrase(s) in Task List: ${reductionHits.join(", ")}.`
2027
2278
  });
2279
+ // Universal Layer 2.5 structural checks (superpowers writing-plans + ce-plan).
2280
+ // Plan-wide placeholder scan (broader than Task List) using the
2281
+ // FORBIDDEN_PLACEHOLDER_TOKENS list shared with the cross-cutting block.
2282
+ const planHeaderBody = sectionBodyByName(sections, "Plan Header");
2283
+ if (planHeaderBody !== null) {
2284
+ const required = ["Goal:", "Architecture:", "Tech Stack:"];
2285
+ const missing = required.filter((token) => !new RegExp(token.replace(":", "\\s*:"), "iu").test(planHeaderBody));
2286
+ findings.push({
2287
+ section: "Plan Header Coverage",
2288
+ required: true,
2289
+ rule: "Plan Header must include Goal, Architecture, and Tech Stack lines.",
2290
+ found: missing.length === 0,
2291
+ details: missing.length === 0
2292
+ ? "Plan Header covers Goal/Architecture/Tech Stack."
2293
+ : `Plan Header is missing field(s): ${missing.join(", ")}.`
2294
+ });
2295
+ }
2296
+ const unitBlocks = raw.match(/###\s+Implementation Unit\s+U-\d+/giu) ?? [];
2297
+ if (unitBlocks.length > 0) {
2298
+ const requiredKeys = ["Goal:", "Files", "Approach:", "Test scenarios:", "Verification:"];
2299
+ const blockBodies = raw.split(/(?=###\s+Implementation Unit\s+U-\d+)/iu).slice(1);
2300
+ const validBlocks = blockBodies.filter((block) => requiredKeys.every((key) => new RegExp(key.replace(":", "\\s*:"), "iu").test(block)));
2301
+ findings.push({
2302
+ section: "Implementation Unit Shape",
2303
+ required: true,
2304
+ rule: "Each `### Implementation Unit U-<n>` must include Goal, Files, Approach, Test scenarios, Verification.",
2305
+ found: validBlocks.length === unitBlocks.length,
2306
+ details: validBlocks.length === unitBlocks.length
2307
+ ? `All ${unitBlocks.length} implementation unit(s) include the required fields.`
2308
+ : `${unitBlocks.length - validBlocks.length} implementation unit(s) are missing required fields.`
2309
+ });
2310
+ }
2311
+ const allPlaceholderTokens = FORBIDDEN_PLACEHOLDER_TOKENS.map((token) => token.toLowerCase());
2312
+ const lowerRaw = raw.toLowerCase();
2313
+ const planWidePlaceholderHits = allPlaceholderTokens.filter((token) => lowerRaw.includes(token));
2314
+ // Strip the "## NO PLACEHOLDERS Rule" section (which lists tokens) and
2315
+ // any acknowledgement text from the scan to avoid false positives where
2316
+ // the plan deliberately references the rule by name.
2317
+ const placeholderRuleSection = sectionBodyByName(sections, "NO PLACEHOLDERS Rule");
2318
+ const ruleScanBody = (placeholderRuleSection ?? "").toLowerCase();
2319
+ const ruleAcceptedHits = ruleScanBody.length > 0
2320
+ ? allPlaceholderTokens.filter((token) => ruleScanBody.includes(token))
2321
+ : [];
2322
+ const filteredPlanHits = planWidePlaceholderHits.filter((token) => {
2323
+ // If the only occurrence is in the rule section, ignore it.
2324
+ if (!ruleAcceptedHits.includes(token))
2325
+ return true;
2326
+ const occurrencesElsewhere = lowerRaw.split(token).length - 1
2327
+ - (ruleScanBody.split(token).length - 1);
2328
+ return occurrencesElsewhere > 0;
2329
+ });
2330
+ findings.push({
2331
+ section: "Plan-wide Placeholder Scan",
2332
+ required: false,
2333
+ rule: "Plan should not contain forbidden placeholder tokens outside the NO PLACEHOLDERS rule section.",
2334
+ found: filteredPlanHits.length === 0,
2335
+ details: filteredPlanHits.length === 0
2336
+ ? "No forbidden placeholder tokens detected outside the rule section."
2337
+ : `Detected forbidden token(s) elsewhere in plan: ${filteredPlanHits.join(", ")}.`
2338
+ });
2339
+ const handoffBody = sectionBodyByName(sections, "Execution Handoff");
2340
+ if (handoffBody !== null) {
2341
+ const ok = /(subagent-driven|inline executor)/iu.test(handoffBody);
2342
+ findings.push({
2343
+ section: "Execution Handoff Posture",
2344
+ required: true,
2345
+ rule: "Execution Handoff must declare a posture (Subagent-Driven or Inline executor).",
2346
+ found: ok,
2347
+ details: ok
2348
+ ? "Execution Handoff posture declared."
2349
+ : "Execution Handoff is missing a posture declaration (Subagent-Driven or Inline executor)."
2350
+ });
2351
+ }
2028
2352
  }
2029
2353
  if (stage === "scope") {
2030
2354
  const lockedDecisionsBody = sectionBodyByHeadingPrefix(sections, "Locked Decisions") ?? "";
@@ -2099,6 +2423,313 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
2099
2423
  : issues.join("; ")
2100
2424
  });
2101
2425
  }
2426
+ // Universal Layer 2.2 structural checks (gstack plan-ceo-review). All
2427
+ // present-only — they validate shape when the section exists.
2428
+ const altsBody = sectionBodyByName(sections, "Implementation Alternatives");
2429
+ if (altsBody !== null) {
2430
+ const recommendation = /^RECOMMENDATION:\s*(.+)$/imu.test(altsBody);
2431
+ findings.push({
2432
+ section: "Implementation Alternatives Recommendation",
2433
+ required: true,
2434
+ rule: "Implementation Alternatives must conclude with a `RECOMMENDATION:` line citing the chosen option and rationale.",
2435
+ found: recommendation,
2436
+ details: recommendation
2437
+ ? "Recommendation marker present."
2438
+ : "Missing or empty `RECOMMENDATION:` line under Implementation Alternatives."
2439
+ });
2440
+ }
2441
+ const failureModesBody = sectionBodyByName(sections, "Failure Modes Registry");
2442
+ if (failureModesBody !== null) {
2443
+ const required = ["Codepath", "Failure mode", "Rescued?", "Test?", "User sees?", "Logged?"];
2444
+ const headerOk = required.every((column) => failureModesBody.includes(column));
2445
+ const rows = failureModesBody.split("\n").filter((line) => /^\|/u.test(line));
2446
+ const hasDataRow = rows.length >= 3 && rows.slice(2).some((row) => row
2447
+ .split("|")
2448
+ .slice(1, -1)
2449
+ .some((cell) => cell.trim().length > 0));
2450
+ findings.push({
2451
+ section: "Failure Modes Registry Shape",
2452
+ required: true,
2453
+ rule: "Failure Modes Registry must include columns Codepath / Failure mode / Rescued? / Test? / User sees? / Logged? and at least one populated data row.",
2454
+ found: headerOk && hasDataRow,
2455
+ details: !headerOk
2456
+ ? "Failure Modes Registry header is missing one or more required columns."
2457
+ : hasDataRow
2458
+ ? "Failure Modes Registry header and at least one data row present."
2459
+ : "Failure Modes Registry has the canonical header but no populated data row."
2460
+ });
2461
+ const decisionMarkers = (failureModesBody.match(/decision\s*:/giu) ?? []).length;
2462
+ findings.push({
2463
+ section: "Failure Modes STOP-per-issue",
2464
+ required: true,
2465
+ rule: "Each Failure Modes Registry data row must record a `decision:` marker (STOP-per-issue protocol).",
2466
+ found: decisionMarkers >= 1,
2467
+ details: decisionMarkers >= 1
2468
+ ? `Detected ${decisionMarkers} decision marker(s).`
2469
+ : "Failure Modes Registry has no `decision:` markers; STOP-per-issue requires at least one."
2470
+ });
2471
+ }
2472
+ const reversibilityBody = sectionBodyByName(sections, "Reversibility Rating");
2473
+ if (reversibilityBody !== null) {
2474
+ const scoreMatch = reversibilityBody.match(/score\s*\(.*?\)\s*:\s*([1-5])\b/iu);
2475
+ const ok = scoreMatch !== null;
2476
+ findings.push({
2477
+ section: "Reversibility Rating Score",
2478
+ required: true,
2479
+ rule: "Reversibility Rating must declare a score in the range 1-5.",
2480
+ found: ok,
2481
+ details: ok
2482
+ ? `Reversibility score ${scoreMatch[1]} declared.`
2483
+ : "Reversibility Rating is missing a numeric score 1-5."
2484
+ });
2485
+ }
2486
+ }
2487
+ if (stage === "spec") {
2488
+ // Universal Layer 2.4 structural checks (evanflow-prd + superpowers).
2489
+ // All checks fire only when the matching section is present so legacy
2490
+ // fixtures keep working while v3-template artifacts are validated.
2491
+ const synthesisBody = sectionBodyByName(sections, "Synthesis Sources");
2492
+ if (synthesisBody !== null) {
2493
+ const tableRows = synthesisBody
2494
+ .split("\n")
2495
+ .filter((line) => /^\|/u.test(line));
2496
+ const dataRows = tableRows.length >= 3 ? tableRows.slice(2) : [];
2497
+ const populatedRows = dataRows.filter((row) => row
2498
+ .split("|")
2499
+ .slice(1, -1)
2500
+ .some((cell) => cell.trim().length > 0));
2501
+ const hasRow = populatedRows.length >= 1;
2502
+ findings.push({
2503
+ section: "Synthesis Sources Coverage",
2504
+ required: true,
2505
+ rule: "Synthesis Sources must cite at least one source artifact (synthesize-not-interview).",
2506
+ found: hasRow,
2507
+ details: hasRow
2508
+ ? `Detected ${populatedRows.length} populated source row(s).`
2509
+ : "Synthesis Sources is empty; spec must cite at least one upstream artifact or context file."
2510
+ });
2511
+ }
2512
+ const behaviorBody = sectionBodyByName(sections, "Behavior Contract");
2513
+ if (behaviorBody !== null) {
2514
+ const optedOut = /(^|\n)\s*-\s*None\b/iu.test(behaviorBody);
2515
+ const userStoryRegex = /(^|\n)\s*-\s*as\s+a\b[\s\S]*?,\s*i\s+can\b[\s\S]*?,\s*so that\b/imu;
2516
+ const givenWhenThenRegex = /(^|\n)\s*-\s*given\b[\s\S]*?,\s*when\b[\s\S]*?,\s*then\b/imu;
2517
+ const matches = [
2518
+ ...behaviorBody.matchAll(/(^|\n)\s*-\s*as\s+a\b[\s\S]*?,\s*i\s+can\b[\s\S]*?,\s*so that\b/gimu),
2519
+ ...behaviorBody.matchAll(/(^|\n)\s*-\s*given\b[\s\S]*?,\s*when\b[\s\S]*?,\s*then\b/gimu)
2520
+ ];
2521
+ const ok = optedOut || matches.length >= 3;
2522
+ findings.push({
2523
+ section: "Behavior Contract Shape",
2524
+ required: true,
2525
+ rule: "Behavior Contract must list ≥3 behaviors in user-story (As a/I can/so that) or Given/When/Then form, or declare `- None.` for single-step specs.",
2526
+ found: ok,
2527
+ details: optedOut
2528
+ ? "Single-step spec; behaviors opted out via `- None.`."
2529
+ : ok
2530
+ ? `Detected ${matches.length} behavior(s) in canonical form.`
2531
+ : `Detected ${matches.length} behavior(s) in canonical form; need ≥3 (or `
2532
+ + "`- None.`).",
2533
+ });
2534
+ // Bonus: detect if at least one user-story OR given/when/then form is present
2535
+ // (mirrors existing helpers).
2536
+ void userStoryRegex;
2537
+ void givenWhenThenRegex;
2538
+ }
2539
+ const archModulesBody = sectionBodyByName(sections, "Architecture Modules");
2540
+ if (archModulesBody !== null) {
2541
+ const codeFenceCount = (archModulesBody.match(/```/gu) ?? []).length;
2542
+ const fnSignatureRegex = /\b(function|class|def|fn|method)\b\s+[A-Za-z_]/u;
2543
+ const noCode = codeFenceCount === 0 && !fnSignatureRegex.test(archModulesBody);
2544
+ findings.push({
2545
+ section: "Architecture Modules No-Code",
2546
+ required: true,
2547
+ rule: "Architecture Modules must not contain code blocks, function signatures, or class definitions — modules listed by responsibility only.",
2548
+ found: noCode,
2549
+ details: noCode
2550
+ ? "Architecture Modules is free of code blocks and function/class signatures."
2551
+ : "Architecture Modules contains a code fence or function/class signature; remove code-level details."
2552
+ });
2553
+ }
2554
+ const selfReviewBody = sectionBodyByName(sections, "Spec Self-Review");
2555
+ if (selfReviewBody !== null) {
2556
+ const required = ["placeholder", "consistency", "scope", "ambiguity"];
2557
+ const missing = required.filter((token) => !new RegExp(token, "iu").test(selfReviewBody));
2558
+ findings.push({
2559
+ section: "Spec Self-Review Coverage",
2560
+ required: true,
2561
+ rule: "Spec Self-Review must cover placeholder/consistency/scope/ambiguity checks.",
2562
+ found: missing.length === 0,
2563
+ details: missing.length === 0
2564
+ ? "Spec Self-Review covers all required checks."
2565
+ : `Spec Self-Review is missing check(s): ${missing.join(", ")}.`
2566
+ });
2567
+ }
2568
+ }
2569
+ if (stage === "tdd") {
2570
+ // Universal Layer 2.6 structural checks (superpowers TDD + evanflow vertical slices).
2571
+ const ironLawBody = sectionBodyByName(sections, "Iron Law Acknowledgement");
2572
+ if (ironLawBody !== null) {
2573
+ const ack = /acknowledged:\s*(yes|true|y)\b/iu.test(ironLawBody);
2574
+ findings.push({
2575
+ section: "TDD Iron Law Acknowledgement",
2576
+ required: true,
2577
+ rule: "Iron Law Acknowledgement must affirm `Acknowledged: yes`.",
2578
+ found: ack,
2579
+ details: ack
2580
+ ? "TDD Iron Law acknowledged."
2581
+ : "Iron Law Acknowledgement is missing explicit `Acknowledged: yes`."
2582
+ });
2583
+ }
2584
+ const watchedRedBody = sectionBodyByName(sections, "Watched-RED Proof");
2585
+ if (watchedRedBody !== null) {
2586
+ const rows = watchedRedBody.split("\n").filter((line) => /^\|/u.test(line));
2587
+ const dataRows = rows.length >= 3 ? rows.slice(2) : [];
2588
+ const populatedRows = dataRows.filter((row) => row
2589
+ .split("|")
2590
+ .slice(1, -1)
2591
+ .filter((_, idx) => idx !== 0) // skip slice column
2592
+ .some((cell) => cell.trim().length > 0));
2593
+ // Each populated row must include an ISO timestamp in column 3.
2594
+ const isoRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u;
2595
+ const validProofRows = populatedRows.filter((row) => isoRegex.test(row));
2596
+ findings.push({
2597
+ section: "Watched-RED Proof Shape",
2598
+ required: true,
2599
+ rule: "Watched-RED Proof rows must include an ISO timestamp showing when the test was observed failing.",
2600
+ found: populatedRows.length === 0 || validProofRows.length === populatedRows.length,
2601
+ details: populatedRows.length === 0
2602
+ ? "Watched-RED Proof has no populated rows."
2603
+ : validProofRows.length === populatedRows.length
2604
+ ? `All ${populatedRows.length} watched-RED proof row(s) include an ISO timestamp.`
2605
+ : `${populatedRows.length - validProofRows.length} watched-RED proof row(s) lack an ISO timestamp.`
2606
+ });
2607
+ }
2608
+ const sliceCycleBody = sectionBodyByName(sections, "Vertical Slice Cycle");
2609
+ if (sliceCycleBody !== null) {
2610
+ const required = ["RED", "GREEN", "REFACTOR"];
2611
+ const missing = required.filter((token) => !new RegExp(token, "u").test(sliceCycleBody));
2612
+ findings.push({
2613
+ section: "Vertical Slice Cycle Coverage",
2614
+ required: true,
2615
+ rule: "Vertical Slice Cycle must include RED, GREEN, and REFACTOR per slice (refactor may be deferred with rationale).",
2616
+ found: missing.length === 0,
2617
+ details: missing.length === 0
2618
+ ? "Vertical Slice Cycle references RED/GREEN/REFACTOR."
2619
+ : `Vertical Slice Cycle is missing phase token(s): ${missing.join(", ")}.`
2620
+ });
2621
+ }
2622
+ const assertionBody = sectionBodyByName(sections, "Assertion Correctness Notes");
2623
+ if (assertionBody !== null) {
2624
+ const tableRows = assertionBody.split("\n").filter((line) => /^\|/u.test(line));
2625
+ const dataRows = tableRows.length >= 3 ? tableRows.slice(2) : [];
2626
+ const ok = dataRows.length === 0 || dataRows.some((row) => row
2627
+ .split("|")
2628
+ .slice(1, -1)
2629
+ .some((cell) => cell.trim().length > 0));
2630
+ findings.push({
2631
+ section: "Assertion Correctness Notes Shape",
2632
+ required: true,
2633
+ rule: "Assertion Correctness Notes must include at least one populated row when the slice has new assertions.",
2634
+ found: ok,
2635
+ details: ok
2636
+ ? "Assertion Correctness Notes is populated or absent (single-step slice)."
2637
+ : "Assertion Correctness Notes table has no populated rows."
2638
+ });
2639
+ }
2640
+ }
2641
+ if (stage === "review") {
2642
+ // Universal Layer 2.7 structural checks (superpowers requesting + receiving).
2643
+ const frameBody = sectionBodyByName(sections, "Frame the Review Request");
2644
+ if (frameBody !== null) {
2645
+ const required = ["Goal:", "Approach:", "Risk areas:", "Verification done:", "Open questions"];
2646
+ const missing = required.filter((token) => !new RegExp(token.replace(":", "\\s*:"), "iu").test(frameBody));
2647
+ findings.push({
2648
+ section: "Review Frame Coverage",
2649
+ required: true,
2650
+ rule: "Frame the Review Request must include Goal, Approach, Risk areas, Verification done, Open questions.",
2651
+ found: missing.length === 0,
2652
+ details: missing.length === 0
2653
+ ? "Review request frame covers all required fields."
2654
+ : `Frame is missing field(s): ${missing.join(", ")}.`
2655
+ });
2656
+ }
2657
+ const criticBody = sectionBodyByName(sections, "Critic Subagent Dispatch");
2658
+ if (criticBody !== null) {
2659
+ const required = [
2660
+ "Critic agent definition path",
2661
+ "Dispatch surface",
2662
+ "Frame sent",
2663
+ "Critic returned"
2664
+ ];
2665
+ const missing = required.filter((token) => !criticBody.includes(token));
2666
+ findings.push({
2667
+ section: "Critic Subagent Dispatch Shape",
2668
+ required: true,
2669
+ rule: "Critic Subagent Dispatch must declare agent definition path, dispatch surface, frame sent, and critic-returned summary.",
2670
+ found: missing.length === 0,
2671
+ details: missing.length === 0
2672
+ ? "Critic dispatch metadata complete."
2673
+ : `Critic Subagent Dispatch is missing field(s): ${missing.join(", ")}.`
2674
+ });
2675
+ }
2676
+ const receivingBody = sectionBodyByName(sections, "Receiving Posture");
2677
+ if (receivingBody !== null) {
2678
+ const ack = /no performative agreement/iu.test(receivingBody);
2679
+ findings.push({
2680
+ section: "Receiving Posture Anti-Sycophancy",
2681
+ required: true,
2682
+ rule: "Receiving Posture must affirm `No performative agreement (forbidden openers acknowledged)`.",
2683
+ found: ack,
2684
+ details: ack
2685
+ ? "Receiving posture acknowledged anti-sycophancy."
2686
+ : "Receiving Posture is missing the anti-sycophancy acknowledgement line."
2687
+ });
2688
+ }
2689
+ }
2690
+ if (stage === "ship") {
2691
+ // Universal Layer 2.8 structural checks (superpowers finishing-a-development-branch).
2692
+ const optionsBody = sectionBodyByName(sections, "Finalization Options");
2693
+ if (optionsBody !== null) {
2694
+ const required = ["MERGE_LOCAL", "OPEN_PR", "KEEP_BRANCH", "DISCARD"];
2695
+ const missing = required.filter((token) => !optionsBody.includes(token));
2696
+ findings.push({
2697
+ section: "Finalization Options Coverage",
2698
+ required: true,
2699
+ rule: "Finalization Options must surface all four canonical options (MERGE_LOCAL, OPEN_PR, KEEP_BRANCH, DISCARD).",
2700
+ found: missing.length === 0,
2701
+ details: missing.length === 0
2702
+ ? "All four finalization options surfaced."
2703
+ : `Finalization Options is missing token(s): ${missing.join(", ")}.`
2704
+ });
2705
+ }
2706
+ const prBody = sectionBodyByName(sections, "Structured PR Body");
2707
+ if (prBody !== null) {
2708
+ const required = ["## Summary", "## Test Plan", "## Commits Included"];
2709
+ const missing = required.filter((token) => !prBody.includes(token));
2710
+ findings.push({
2711
+ section: "Structured PR Body Shape",
2712
+ required: true,
2713
+ rule: "Structured PR Body must include `## Summary`, `## Test Plan`, and `## Commits Included` subsections.",
2714
+ found: missing.length === 0,
2715
+ details: missing.length === 0
2716
+ ? "Structured PR Body covers all required subsections."
2717
+ : `Structured PR Body is missing subsection(s): ${missing.join(", ")}.`
2718
+ });
2719
+ }
2720
+ const verifyBody = sectionBodyByName(sections, "Verify Tests Gate");
2721
+ if (verifyBody !== null) {
2722
+ const ok = /\bResult:\s*(PASS|FAIL)\b/iu.test(verifyBody);
2723
+ findings.push({
2724
+ section: "Verify Tests Gate Result",
2725
+ required: true,
2726
+ rule: "Verify Tests Gate must declare a Result of PASS or FAIL.",
2727
+ found: ok,
2728
+ details: ok
2729
+ ? "Verify Tests Gate result declared."
2730
+ : "Verify Tests Gate is missing a `Result: PASS|FAIL` line."
2731
+ });
2732
+ }
2102
2733
  }
2103
2734
  if (["design", "spec", "plan", "review"].includes(stage)) {
2104
2735
  const scopeArtifact = await resolveStageArtifactPath("scope", {