@pathmode/cli 2.1.4 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +757 -50
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -16416,6 +16416,44 @@ const gray_matter_1 = __importDefault(__nccwpck_require__(9599));
16416
16416
  const ajv_1 = __importDefault(__nccwpck_require__(2463));
16417
16417
  const api_client_1 = __nccwpck_require__(7475);
16418
16418
  const program = new commander_1.Command();
16419
+ /**
16420
+ * The open IntentSpec schema historically described YAML frontmatter, while the
16421
+ * local MCP writer deliberately keeps the editable specification in Markdown
16422
+ * sections. Validate either representation through the same schema: explicit
16423
+ * frontmatter remains authoritative (including invalid values), and parsed body
16424
+ * sections only fill fields that were omitted from frontmatter.
16425
+ *
16426
+ * A body section fills a field only when it actually carried one. The parser is
16427
+ * total — a missing `## Objective` reads back as `''` and missing `## Outcomes`
16428
+ * as `[]` — so filling unconditionally handed the schema a present-but-empty
16429
+ * value and `required` was satisfied by nothing. A spec with no outcomes in
16430
+ * either representation validated clean. Absent in both must stay absent, so the
16431
+ * schema can name the field, which is the same rule constraints, edge cases, and
16432
+ * health metrics already follow below.
16433
+ */
16434
+ function buildValidationSubject(content) {
16435
+ const { data } = (0, gray_matter_1.default)(content);
16436
+ const parsed = (0, local_reader_1.parseIntentMarkdown)(content);
16437
+ const outcomes = parsed.outcomes.map(outcome => typeof outcome === 'string' ? outcome : outcome.text);
16438
+ return {
16439
+ ...data,
16440
+ ...(!Object.prototype.hasOwnProperty.call(data, 'objective') && parsed.objective
16441
+ ? { objective: parsed.objective }
16442
+ : {}),
16443
+ ...(!Object.prototype.hasOwnProperty.call(data, 'outcomes') && outcomes.length > 0
16444
+ ? { outcomes }
16445
+ : {}),
16446
+ ...(!Object.prototype.hasOwnProperty.call(data, 'constraints') && parsed.constraints.length > 0
16447
+ ? { constraints: parsed.constraints }
16448
+ : {}),
16449
+ ...(!Object.prototype.hasOwnProperty.call(data, 'edgeCases') && parsed.edgeCases.length > 0
16450
+ ? { edgeCases: parsed.edgeCases }
16451
+ : {}),
16452
+ ...(!Object.prototype.hasOwnProperty.call(data, 'healthMetrics') && parsed.healthMetrics.length > 0
16453
+ ? { healthMetrics: parsed.healthMetrics }
16454
+ : {}),
16455
+ };
16456
+ }
16419
16457
  // Detect the GitHub ACTION invocation specifically: action.yml runs dist/index.js with no
16420
16458
  // argv and passes INPUT_FILE through the environment. The old check keyed on GITHUB_ACTIONS
16421
16459
  // alone, which is set for EVERY step of every workflow, so `pathmode preflight <path>` inside
@@ -16442,7 +16480,7 @@ const validateIntent = async (file) => {
16442
16480
  }
16443
16481
  try {
16444
16482
  const content = fs_1.default.readFileSync(filePath, 'utf-8');
16445
- const { data } = (0, gray_matter_1.default)(content);
16483
+ const data = buildValidationSubject(content);
16446
16484
  const schema = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__dirname, 'schema.json'), 'utf-8'));
16447
16485
  const ajv = new ajv_1.default({ allErrors: true });
16448
16486
  const validate = ajv.compile(schema);
@@ -16906,6 +16944,463 @@ else {
16906
16944
  }
16907
16945
 
16908
16946
 
16947
+ /***/ }),
16948
+
16949
+ /***/ 229:
16950
+ /***/ ((__unused_webpack_module, exports) => {
16951
+
16952
+ "use strict";
16953
+
16954
+ /**
16955
+ * serializeIntentMd — the one implementation of the intent.md file format.
16956
+ *
16957
+ * Two writers used to exist: `lib/agentPromptGenerator.ts` (cloud exports, browser downloads) and
16958
+ * `packages/mcp-server/src/intent-compiler.ts` (every local, keyless write). They diverged in BOTH
16959
+ * directions, and the divergence cost more than tidiness:
16960
+ *
16961
+ * - the cloud writer emitted no `## Confirmations`, so a human's authenticated confirmation died
16962
+ * at the boundary and never reached the repo;
16963
+ * - the local writer emitted no authorization gate, so an agent proposal still pending human
16964
+ * judgment lost its DO-NOT-IMPLEMENT banner on the next local save.
16965
+ *
16966
+ * Both are the same failure: judgment recorded in one place not travelling to the other. This
16967
+ * module emits the union, and `serializer-parity.test.ts` holds the two callers to it.
16968
+ *
16969
+ * ZERO IMPORTS, deliberately. The local writer is bundled by ncc into a published npm package that
16970
+ * must run keyless and offline, and the cloud writer is reached from `'use client'` components — so
16971
+ * a single node builtin here (`crypto`, say) would break the browser bundle. Callers compute their
16972
+ * own environment-specific values (id minting, readiness verdicts, evidence resolution) and hand
16973
+ * the results in as plain data.
16974
+ */
16975
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
16976
+ exports.GENERATED_CAPTIONS = exports.VERIFICATION_NOT_DEFINED_CAPTION = exports.VERIFICATION_FEEDBACK_CAPTION = void 0;
16977
+ exports.renderOutcomeMeasurementDefinition = renderOutcomeMeasurementDefinition;
16978
+ exports.toSerializableChecks = toSerializableChecks;
16979
+ exports.renderAuthorizationGateText = renderAuthorizationGateText;
16980
+ exports.renderConfirmationsBody = renderConfirmationsBody;
16981
+ exports.normalizeCaption = normalizeCaption;
16982
+ exports.isGeneratedCaption = isGeneratedCaption;
16983
+ exports.serializeIntentMd = serializeIntentMd;
16984
+ // ── Small helpers ───────────────────────────────────────────────────────────
16985
+ const VERIFICATION_KIND_LABELS = {
16986
+ fastest: 'Fastest check',
16987
+ 'shipped-signal': 'Shipped signal',
16988
+ 'regression-guard': 'Regression guard',
16989
+ manual: 'Manual check',
16990
+ test: 'Automated test',
16991
+ };
16992
+ const VERIFICATION_KIND_ORDER = ['fastest', 'shipped-signal', 'regression-guard', 'manual', 'test'];
16993
+ const VERIFICATION_KIND_SET = new Set(VERIFICATION_KIND_ORDER);
16994
+ function nonEmpty(v) {
16995
+ return typeof v === 'string' && v.trim().length > 0;
16996
+ }
16997
+ /** YAML double-quoted scalars. Both writers used to interpolate raw, so a product named `The "Real"
16998
+ * One` produced a file gray-matter could not parse. */
16999
+ function yamlScalar(value) {
17000
+ if (typeof value === 'number')
17001
+ return String(value);
17002
+ return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
17003
+ }
17004
+ function outcomeOf(o) {
17005
+ return typeof o === 'string' ? { text: o } : o;
17006
+ }
17007
+ /** Compact, provider-neutral wording shared by every agent-facing export. */
17008
+ function renderOutcomeMeasurementDefinition(measurement) {
17009
+ if (!measurement)
17010
+ return '';
17011
+ const { source, expectation, window } = measurement;
17012
+ const target = `${expectation.operator} ${expectation.target}${expectation.unit ? ` ${expectation.unit}` : ''}`;
17013
+ const windowValue = window.kind === 'rolling'
17014
+ ? `${window.kind} ${window.duration}`
17015
+ : window.kind === 'fixed'
17016
+ ? `${window.kind} ${window.start}..${window.end}`
17017
+ : window.kind;
17018
+ return [
17019
+ `measure via ${source.provider} ${source.queryRef.kind}:${source.queryRef.id}`,
17020
+ measurement.resultSelector ? `select ${measurement.resultSelector}` : '',
17021
+ `target ${target}`,
17022
+ `window ${windowValue}`,
17023
+ ].filter(Boolean).join('; ');
17024
+ }
17025
+ function constraintOf(c) {
17026
+ return typeof c === 'string' ? { text: c } : c;
17027
+ }
17028
+ /** `backed by:` suffix on its own indented bullet. Empty when nothing resolved, so callers append
17029
+ * unconditionally. */
17030
+ function backedBySuffix(refs, indent = ' ') {
17031
+ const clean = (refs || []).filter(nonEmpty);
17032
+ if (!clean.length)
17033
+ return '';
17034
+ return `\n${indent}- backed by: ${clean.join('; ')}`;
17035
+ }
17036
+ /** Read verification uniformly as a check collection: canonical `checks[]` plus the legacy
17037
+ * {manual,unit,e2e} buckets, which are adapted rather than dropped. */
17038
+ function toSerializableChecks(v) {
17039
+ if (!v || typeof v !== 'object')
17040
+ return [];
17041
+ const out = [];
17042
+ for (const c of Array.isArray(v.checks) ? v.checks : []) {
17043
+ if (!nonEmpty(c?.description))
17044
+ continue;
17045
+ out.push({
17046
+ kind: VERIFICATION_KIND_SET.has(String(c.kind)) ? String(c.kind) : 'test',
17047
+ description: c.description.trim(),
17048
+ status: c.status,
17049
+ verifies: c.verifies,
17050
+ });
17051
+ }
17052
+ for (const d of Array.isArray(v.manualChecks) ? v.manualChecks : []) {
17053
+ if (nonEmpty(d))
17054
+ out.push({ kind: 'manual', description: d.trim() });
17055
+ }
17056
+ for (const d of Array.isArray(v.unitTests) ? v.unitTests : []) {
17057
+ if (nonEmpty(d))
17058
+ out.push({ kind: 'test', description: d.trim() });
17059
+ }
17060
+ for (const d of Array.isArray(v.e2eTests) ? v.e2eTests : []) {
17061
+ if (nonEmpty(d))
17062
+ out.push({ kind: 'test', description: d.trim() });
17063
+ }
17064
+ return out;
17065
+ }
17066
+ function groupChecks(v) {
17067
+ const checks = toSerializableChecks(v);
17068
+ return VERIFICATION_KIND_ORDER
17069
+ .map(kind => ({ kind, label: VERIFICATION_KIND_LABELS[kind], checks: checks.filter(c => c.kind === kind) }))
17070
+ .filter(g => g.checks.length > 0);
17071
+ }
17072
+ function checkLine(c) {
17073
+ const verifies = nonEmpty(c.verifies) ? ` (verifies: ${c.verifies})` : '';
17074
+ const status = nonEmpty(c.status) && c.status !== 'unknown' ? ` [${c.status}]` : '';
17075
+ return `${c.description}${verifies}${status}`;
17076
+ }
17077
+ // ── The authorization gate ──────────────────────────────────────────────────
17078
+ /**
17079
+ * The DO-NOT-IMPLEMENT banner for an agent proposal a human has not authorized.
17080
+ *
17081
+ * Lives here, not in either caller, because the local writer used to omit it entirely: a pending
17082
+ * proposal pulled into a repo and re-saved came back without its gate, which is the one thing in
17083
+ * the file an agent must not be able to lose.
17084
+ *
17085
+ * Trailing blank line included, so callers can prepend unconditionally.
17086
+ */
17087
+ function renderAuthorizationGateText(opts) {
17088
+ if (opts.origin !== 'agent' || opts.authorization === 'authorized')
17089
+ return '';
17090
+ if (opts.authorization === 'rejected') {
17091
+ const note = nonEmpty(opts.authorizationNote) ? ` The human's note: "${opts.authorizationNote}".` : '';
17092
+ return `> **REJECTED BY HUMAN REVIEW — DO NOT IMPLEMENT.** This spec was proposed by an agent and a human rejected it.${note} Revise the proposal per the note, or ask your operator before proceeding.\n\n`;
17093
+ }
17094
+ return `> **PENDING HUMAN AUTHORIZATION — DO NOT IMPLEMENT YET.** This spec was proposed by an agent and no human has authorized it. Ask your operator to review it in Pathmode before building against it.\n\n`;
17095
+ }
17096
+ // ── Confirmations ───────────────────────────────────────────────────────────
17097
+ /**
17098
+ * Render confirmation records as the `## Confirmations` section body.
17099
+ *
17100
+ * Derived fields (`assurance`, `source`) are NOT written: anything read back out of a file is
17101
+ * local-unverified by definition, so persisting a trust level would let a record vouch for itself.
17102
+ * Records outside the dimensions the readiness gate can resolve are dropped rather than emitted,
17103
+ * so an unwaivable dimension never leaves an empty section behind.
17104
+ */
17105
+ function renderConfirmationsBody(records) {
17106
+ const emittable = (records || []).filter(c => {
17107
+ const dim = String(c?.dimension ?? '').toLowerCase();
17108
+ const kind = String(c?.kind ?? '').toLowerCase();
17109
+ const by = String(c?.by ?? '').toLowerCase();
17110
+ return ['objective', 'outcomes'].includes(dim)
17111
+ && ['confirmed', 'waived'].includes(kind)
17112
+ && ['agent', 'human'].includes(by);
17113
+ });
17114
+ if (!emittable.length)
17115
+ return '';
17116
+ const out = ['## Confirmations'];
17117
+ for (const c of emittable) {
17118
+ out.push('');
17119
+ out.push(`**${String(c.dimension).toLowerCase()}** — ${String(c.kind).toLowerCase()} by ${String(c.by).toLowerCase()}`);
17120
+ for (const key of ['actor', 'problem', 'outcome', 'observable', 'reason', 'anchor', 'at']) {
17121
+ const v = c[key];
17122
+ if (!nonEmpty(v))
17123
+ continue;
17124
+ out.push(`- ${key}: ${v.replace(/\s+/g, ' ').trim()}`);
17125
+ }
17126
+ }
17127
+ return out.join('\n');
17128
+ }
17129
+ // ── The serializer ──────────────────────────────────────────────────────────
17130
+ /**
17131
+ * Frontmatter key order. Fixed so a file rewritten by the other implementation produces a clean
17132
+ * diff instead of a reordering churn that hides the real change.
17133
+ */
17134
+ const FRONTMATTER_ORDER = [
17135
+ 'id', 'version', 'status', 'readiness', 'source', 'specVersion',
17136
+ 'origin', 'authorization', 'authorizationNote', 'outcomeMeasurements',
17137
+ 'evidence', 'space', 'severity', 'created', 'updated',
17138
+ ];
17139
+ /**
17140
+ * Captions this codebase writes into an intent.md, as opposed to anything an author wrote.
17141
+ *
17142
+ * A caption is prose under a heading that is NOT content: it explains the section rather than
17143
+ * recording a decision. The readers exclude exactly these strings, which is why they live here,
17144
+ * next to the code that emits them, and are imported rather than retyped. Excluding "all italic
17145
+ * prose" instead was wrong: an author writing `*Must not slow down page load.*` under
17146
+ * Constraints means it, and reporting that section as absent is the accusation the four-state
17147
+ * gate exists to stop making.
17148
+ *
17149
+ * Adding a caption anywhere means adding it here, or the reader will grade it as content.
17150
+ */
17151
+ exports.VERIFICATION_FEEDBACK_CAPTION = '_A feedback loop, not just a test list._';
17152
+ /** Written by lib/scan/draftIntentMd.ts when a pre-account draft has no verification yet. */
17153
+ exports.VERIFICATION_NOT_DEFINED_CAPTION = '_Not defined yet. Nothing in this spec has been checked against a running system._';
17154
+ exports.GENERATED_CAPTIONS = [
17155
+ exports.VERIFICATION_FEEDBACK_CAPTION,
17156
+ exports.VERIFICATION_NOT_DEFINED_CAPTION,
17157
+ ];
17158
+ /** Trim, drop one layer of surrounding `_` or `*`, casefold. Both emphasis spellings match. */
17159
+ function normalizeCaption(line) {
17160
+ return line
17161
+ .trim()
17162
+ .replace(/^([_*])(.*)\1$/s, '$2')
17163
+ .trim()
17164
+ .toLowerCase();
17165
+ }
17166
+ const NORMALIZED_CAPTIONS = new Set(exports.GENERATED_CAPTIONS.map(normalizeCaption));
17167
+ /** True only for a caption this codebase generated. Author prose, italic or not, is content. */
17168
+ function isGeneratedCaption(line) {
17169
+ return NORMALIZED_CAPTIONS.has(normalizeCaption(line));
17170
+ }
17171
+ function serializeIntentMd(spec, opts = {}) {
17172
+ const outcomeMeasurements = (spec.outcomes || [])
17173
+ .map(outcomeOf)
17174
+ .map((outcome, index) => outcome.measurement ? { outcome: index, ...outcome.measurement } : null)
17175
+ .filter((entry) => entry !== null);
17176
+ const values = {
17177
+ id: spec.id,
17178
+ version: opts.version && opts.version >= 1 ? opts.version : 1,
17179
+ status: opts.status || 'draft',
17180
+ readiness: opts.readiness,
17181
+ source: opts.source,
17182
+ specVersion: opts.specVersion,
17183
+ origin: opts.origin,
17184
+ // Only meaningful for agent-originated specs; human-authored ones are authorized by
17185
+ // authorship and carrying a key here would imply a gate that does not exist.
17186
+ authorization: opts.origin === 'agent' ? (opts.authorization ?? 'pending') : undefined,
17187
+ // The banner renders the note as prose, but prose is not recoverable: a reader cannot tell
17188
+ // the reviewer's words from the sentence around them. Persisting it here is what stops a
17189
+ // local rewrite from keeping the REJECTED verdict while erasing the correction it asked
17190
+ // for. Single-line by contract, like a confirmation value; flattened rather than escaped.
17191
+ authorizationNote: opts.origin === 'agent' && nonEmpty(opts.authorizationNote)
17192
+ ? opts.authorizationNote.replace(/\s+/g, ' ').trim()
17193
+ : undefined,
17194
+ // Body outcomes stay plain, interoperable list items. The optional recipe is a generic
17195
+ // extension in frontmatter, keyed by outcome position so old readers simply ignore it.
17196
+ outcomeMeasurements: outcomeMeasurements.length ? outcomeMeasurements : undefined,
17197
+ evidence: opts.evidence?.length || undefined,
17198
+ space: opts.space?.name,
17199
+ severity: opts.severity,
17200
+ created: opts.created,
17201
+ updated: opts.updated,
17202
+ };
17203
+ // null, not just undefined: the cloud mapper reads absent columns as null (`problemSeverity`,
17204
+ // `currentState`, a product with no name), and an unguarded null reached the quoter as a
17205
+ // crash rather than an omitted key.
17206
+ const yamlLines = FRONTMATTER_ORDER
17207
+ .filter(key => values[key] !== undefined && values[key] !== null && values[key] !== '')
17208
+ .map(key => {
17209
+ const value = values[key];
17210
+ return `${key}: ${typeof value === 'object' ? JSON.stringify(value) : yamlScalar(value)}`;
17211
+ })
17212
+ .join('\n');
17213
+ const sections = ['---', yamlLines, '---', ''];
17214
+ // Directly under the frontmatter: the `authorization` key is machine-readable, but the body
17215
+ // must also say DO NOT IMPLEMENT before any instruction an agent might act on.
17216
+ const gate = renderAuthorizationGateText(opts).trim();
17217
+ if (gate) {
17218
+ sections.push(gate);
17219
+ sections.push('');
17220
+ }
17221
+ sections.push(`# ${spec.title || 'Untitled Intent'}`);
17222
+ const space = opts.space;
17223
+ if (space && (nonEmpty(space.productVision) || nonEmpty(space.northStar) || nonEmpty(space.targetAudience)
17224
+ || space.constraints?.length || space.principles?.length)) {
17225
+ sections.push('');
17226
+ sections.push('## Space Context');
17227
+ if (nonEmpty(space.productVision))
17228
+ sections.push(`**Product Vision**: ${space.productVision}`);
17229
+ if (nonEmpty(space.northStar))
17230
+ sections.push(`**North Star**: ${space.northStar}`);
17231
+ if (nonEmpty(space.targetAudience))
17232
+ sections.push(`**Target Audience**: ${space.targetAudience}`);
17233
+ if (space.constraints?.length)
17234
+ sections.push('**Constraints**: ' + space.constraints.join(', '));
17235
+ if (space.principles?.length)
17236
+ sections.push('**Principles**: ' + space.principles.join(', '));
17237
+ }
17238
+ if (nonEmpty(spec.objective)) {
17239
+ sections.push('');
17240
+ sections.push('## Objective');
17241
+ sections.push(spec.objective);
17242
+ }
17243
+ // Heading text is the round-trip key (the reader keys sections by exact heading) — keep it
17244
+ // plain, exactly like Objective above.
17245
+ if (nonEmpty(spec.currentState)) {
17246
+ sections.push('');
17247
+ sections.push('## Current State');
17248
+ sections.push(spec.currentState.trim());
17249
+ }
17250
+ // Sits next to Current State on purpose: one is what the author says is true today, the other
17251
+ // is what the repo says. Same heading in both modes, so a handoff reads identically either way.
17252
+ const icBody = implementationContextBody(spec);
17253
+ if (icBody.length) {
17254
+ sections.push('');
17255
+ sections.push('## Implementation Context');
17256
+ sections.push(...icBody);
17257
+ }
17258
+ const decisions = (spec.decisions || []).filter(d => d && nonEmpty(d.choice) && typeof d.reason === 'string');
17259
+ if (decisions.length) {
17260
+ sections.push('');
17261
+ sections.push('## Decisions & Ruled-Out Alternatives');
17262
+ for (const d of decisions) {
17263
+ sections.push(`- **${d.choice}**${nonEmpty(d.ruledOut) ? ` (instead of: ${d.ruledOut})` : ''} — ${d.reason}`);
17264
+ if (nonEmpty(d.reopenTrigger))
17265
+ sections.push(` - reopen if: ${d.reopenTrigger.trim()}`);
17266
+ }
17267
+ }
17268
+ const outcomes = (spec.outcomes || []).map(outcomeOf).filter(o => nonEmpty(o.text));
17269
+ if (outcomes.length) {
17270
+ sections.push('');
17271
+ sections.push('## Outcomes');
17272
+ // No priority prefix. The writer used to emit `[MUST] `, which is in neither SPEC.md nor the
17273
+ // normalization corpus, and neither parser strips it — so it read back as part of the
17274
+ // outcome text. Emitting a label the format cannot read back is worse than omitting it.
17275
+ for (const o of outcomes) {
17276
+ sections.push(`- [ ] ${o.text}${backedBySuffix(o.backedBy)}`);
17277
+ }
17278
+ }
17279
+ if (spec.scope?.inScope?.length || spec.scope?.outOfScope?.length) {
17280
+ sections.push('');
17281
+ sections.push('## Scope');
17282
+ if (spec.scope.inScope?.length) {
17283
+ sections.push('**In scope:**');
17284
+ for (const s of spec.scope.inScope)
17285
+ sections.push(`- ${s}`);
17286
+ }
17287
+ if (spec.scope.outOfScope?.length) {
17288
+ sections.push('**Out of scope:**');
17289
+ for (const s of spec.scope.outOfScope)
17290
+ sections.push(`- ${s}`);
17291
+ }
17292
+ }
17293
+ const constraints = (spec.constraints || []).map(constraintOf).filter(c => nonEmpty(c.text));
17294
+ if (constraints.length) {
17295
+ sections.push('');
17296
+ sections.push('## Constraints');
17297
+ for (const c of constraints)
17298
+ sections.push(`- ${c.text}${backedBySuffix(c.backedBy)}`);
17299
+ }
17300
+ const edgeCases = (spec.edgeCases || []).filter(ec => ec && nonEmpty(ec.scenario));
17301
+ if (edgeCases.length) {
17302
+ sections.push('');
17303
+ sections.push('## Edge Cases');
17304
+ for (const ec of edgeCases) {
17305
+ sections.push(`- **${ec.scenario}**: ${ec.expectedBehavior}${backedBySuffix(ec.backedBy)}`);
17306
+ }
17307
+ }
17308
+ const evidenceBody = renderCompactEvidence(opts.evidence);
17309
+ if (evidenceBody.length) {
17310
+ sections.push('');
17311
+ sections.push('## Supporting Evidence');
17312
+ sections.push(...evidenceBody);
17313
+ }
17314
+ if (spec.healthMetrics?.length) {
17315
+ sections.push('');
17316
+ sections.push('## Health Metrics');
17317
+ for (const metric of spec.healthMetrics)
17318
+ sections.push(`- ${metric}`);
17319
+ }
17320
+ const checkGroups = groupChecks(spec.verification);
17321
+ if (checkGroups.length) {
17322
+ sections.push('');
17323
+ sections.push('## Verification');
17324
+ sections.push(exports.VERIFICATION_FEEDBACK_CAPTION);
17325
+ for (const g of checkGroups) {
17326
+ sections.push(`**${g.label}**:`);
17327
+ for (const c of g.checks)
17328
+ sections.push(`- [ ] ${checkLine(c)}`);
17329
+ }
17330
+ }
17331
+ // `## Confirmations` is emitted LAST so it never sits between the fields a reader is comparing,
17332
+ // and so appending one cannot shift any other section's parse.
17333
+ const confirmationsBody = renderConfirmationsBody(spec.confirmations);
17334
+ if (confirmationsBody) {
17335
+ sections.push('');
17336
+ sections.push(confirmationsBody);
17337
+ }
17338
+ return sections.join('\n');
17339
+ }
17340
+ /**
17341
+ * The body of `## Implementation Context`.
17342
+ *
17343
+ * Two sources, one section. Local mode carries prose the agent gathered; cloud intents carry the
17344
+ * analyzer's structured object. Rendering both under the same heading is what lets a spec move
17345
+ * between modes without the section disappearing on the way through.
17346
+ */
17347
+ function implementationContextBody(spec) {
17348
+ if (nonEmpty(spec.implementationContextText))
17349
+ return [spec.implementationContextText.trim()];
17350
+ const ic = spec.implementationContext;
17351
+ if (!ic)
17352
+ return [];
17353
+ const lines = [];
17354
+ if (ic.relevantAreas?.length) {
17355
+ lines.push('### Relevant areas');
17356
+ for (const a of ic.relevantAreas) {
17357
+ if (nonEmpty(a?.path))
17358
+ lines.push(`- \`${a.path}\`${nonEmpty(a.reason) ? ` — ${a.reason}` : ''}`);
17359
+ }
17360
+ }
17361
+ if (nonEmpty(ic.currentBehavior)) {
17362
+ if (lines.length)
17363
+ lines.push('');
17364
+ lines.push('### Current behavior');
17365
+ lines.push(ic.currentBehavior.trim());
17366
+ }
17367
+ if (ic.risks?.length) {
17368
+ if (lines.length)
17369
+ lines.push('');
17370
+ lines.push('### Risks');
17371
+ for (const r of ic.risks)
17372
+ if (nonEmpty(r))
17373
+ lines.push(`- ${r.trim()}`);
17374
+ }
17375
+ if (ic.verificationSuggestions?.length) {
17376
+ if (lines.length)
17377
+ lines.push('');
17378
+ lines.push('### Verification suggestions');
17379
+ for (const v of ic.verificationSuggestions)
17380
+ if (nonEmpty(v))
17381
+ lines.push(`- ${v.trim()}`);
17382
+ }
17383
+ return lines;
17384
+ }
17385
+ /** Compact evidence list — one truncated line per item, quotes as blockquotes. Lighter than the
17386
+ * execution prompt's full rendering so a committed intent.md stays readable. */
17387
+ function renderCompactEvidence(evidence) {
17388
+ const items = (evidence || []).filter(e => nonEmpty(e?.content));
17389
+ if (!items.length)
17390
+ return [];
17391
+ const lines = [];
17392
+ const truncate = (s) => (s.length > 160 ? `${s.slice(0, 160)}…` : s);
17393
+ for (const q of items.filter(e => e.type === 'quote')) {
17394
+ lines.push(`> "${truncate(q.content)}"${nonEmpty(q.source) ? ` — ${q.source}` : ''}`);
17395
+ }
17396
+ for (const e of items.filter(e => e.type !== 'quote')) {
17397
+ const severity = nonEmpty(e.severity) ? ` (${e.severity})` : '';
17398
+ lines.push(`- [${e.type}]${severity} ${truncate(e.content)}${nonEmpty(e.source) ? ` — ${e.source}` : ''}`);
17399
+ }
17400
+ return lines;
17401
+ }
17402
+
17403
+
16909
17404
  /***/ }),
16910
17405
 
16911
17406
  /***/ 8963:
@@ -16936,6 +17431,7 @@ const fs_1 = __importDefault(__nccwpck_require__(9896));
16936
17431
  const path_1 = __importDefault(__nccwpck_require__(6928));
16937
17432
  const gray_matter_1 = __importDefault(__nccwpck_require__(2702));
16938
17433
  const measurement_schema_1 = __nccwpck_require__(2642);
17434
+ const serializeIntentMd_1 = __nccwpck_require__(229);
16939
17435
  const CONFIRMATION_HEADER_RE = /^\*\*(objective|outcomes)\*\*\s*[—–-]\s*(confirmed|waived)\s+by\s+(agent|human)\s*$/i;
16940
17436
  const CONFIRMATION_FIELD_RE = /^\s*[-*]\s+(actor|problem|outcome|observable|reason|anchor|at)\s*:\s*(.+)$/i;
16941
17437
  /**
@@ -17109,7 +17605,6 @@ function parseIntentMarkdown(content, fallbackId = 'intent') {
17109
17605
  const { data, content: rawBody } = (0, gray_matter_1.default)(content);
17110
17606
  const body = stripHtmlComments(stripFencedBlocks(rawBody));
17111
17607
  const sections = splitSections(body);
17112
- const parsedVerification = extractVerification(sections);
17113
17608
  // The public schema allows verification as a flat string array; read it as manual checks
17114
17609
  // rather than silently producing {} (the same adapter normalizeSpecForReadiness applies).
17115
17610
  const frontmatterVerification = Array.isArray(data.verification)
@@ -17117,9 +17612,16 @@ function parseIntentMarkdown(content, fallbackId = 'intent') {
17117
17612
  : data.verification && typeof data.verification === 'object'
17118
17613
  ? data.verification
17119
17614
  : null;
17120
- const verification = hasVerificationContent(parsedVerification)
17121
- ? parsedVerification
17122
- : (frontmatterVerification || {});
17615
+ // Same precedence as every other section: body list, then frontmatter, then body prose.
17616
+ const verificationAll = extractVerification(sections);
17617
+ const verificationHasList = hasVerificationContent(extractVerification(sections, { includeProse: false }));
17618
+ const verification = verificationHasList
17619
+ ? verificationAll
17620
+ : frontmatterVerification && hasVerificationContent(frontmatterVerification)
17621
+ ? frontmatterVerification
17622
+ : hasVerificationContent(verificationAll)
17623
+ ? verificationAll
17624
+ : (frontmatterVerification || {});
17123
17625
  const version = Number(data.version);
17124
17626
  return {
17125
17627
  id: data.id || fallbackId,
@@ -17131,11 +17633,19 @@ function parseIntentMarkdown(content, fallbackId = 'intent') {
17131
17633
  title: data.title || data.userGoal || extractTitle(body) || 'Untitled Intent',
17132
17634
  stageName: data.stage || undefined,
17133
17635
  severity: data.severity || undefined,
17134
- outcomes: attachOutcomeMeasurements(preferSection(extractListSection(sections, 'Outcomes'), data.outcomes), data.outcomeMeasurements),
17636
+ outcomes: attachOutcomeMeasurements(preferListThenFrontmatter(sections, 'Outcomes', data.outcomes), data.outcomeMeasurements),
17135
17637
  decisions: extractDecisions(sections),
17136
- constraints: preferSection(extractListSection(sections, 'Constraints'), data.constraints),
17137
- edgeCases: (() => { const fromBody = extractEdgeCases(sections); return fromBody.length ? fromBody : frontmatterEdgeCases(data.edgeCases); })(),
17138
- healthMetrics: preferSection(extractListSection(sections, 'Health Metrics'), data.healthMetrics),
17638
+ constraints: preferListThenFrontmatter(sections, 'Constraints', data.constraints),
17639
+ edgeCases: (() => {
17640
+ const all = extractEdgeCases(sections);
17641
+ if (extractEdgeCases(sections, { includeProse: false }).length)
17642
+ return all;
17643
+ const fromFrontmatter = frontmatterEdgeCases(data.edgeCases);
17644
+ if (fromFrontmatter.length)
17645
+ return fromFrontmatter;
17646
+ return all;
17647
+ })(),
17648
+ healthMetrics: preferListThenFrontmatter(sections, 'Health Metrics', data.healthMetrics),
17139
17649
  scope: extractScope(sections) || undefined,
17140
17650
  verification,
17141
17651
  confirmations: extractConfirmations(sections),
@@ -17249,17 +17759,115 @@ function stripListMarker(line) {
17249
17759
  function isListItem(line) {
17250
17760
  return /^\s*[-*]\s/.test(line);
17251
17761
  }
17252
- function extractListSection(sections, heading) {
17253
- return sectionLines(sections, heading)
17254
- .filter(isListItem)
17255
- .map(stripListMarker)
17256
- .filter(Boolean);
17762
+ /**
17763
+ * Structure and generated captions are not content; everything else under a heading is.
17764
+ *
17765
+ * Two distinct exclusions, and they are not the same rule:
17766
+ *
17767
+ * A heading line is structure. `splitSections` only opens a section on `^## `, so a `### Regression
17768
+ * checks` subheading falls through as a body line, and treating it as prose made the HEADING an
17769
+ * executable check: a `## Verification` containing nothing but subheadings passed the gate.
17770
+ *
17771
+ * A generated caption is ours, not the author's, and the registry in serializeIntentMd.ts names
17772
+ * exactly which strings those are. This deliberately does NOT exclude all italic prose: an author
17773
+ * writing `*Must not slow down page load.*` under Constraints means it, and reporting that section
17774
+ * as absent is the accusation the four-state gate exists to stop making.
17775
+ */
17776
+ function isNotContent(line) {
17777
+ return /^#{1,6}\s/.test(line.trim()) || (0, serializeIntentMd_1.isGeneratedCaption)(line);
17778
+ }
17779
+ /**
17780
+ * Items in a section, preserving what the author actually wrote.
17781
+ *
17782
+ * List items keep their existing boundaries: one item per marker, with an indented
17783
+ * continuation line folded into the item it wraps. A run of unindented non-list lines is one
17784
+ * paragraph, and one paragraph is one item; blank lines separate paragraphs. Prose is never
17785
+ * split into sentences, so an author who writes two independently checkable outcomes as two
17786
+ * paragraphs gets two items and one who writes them as a single block gets one.
17787
+ *
17788
+ * Before this, a `.filter(isListItem)` dropped every prose line here and the gates then
17789
+ * reported the section as `absent`, which told an author who had written a constraint in prose
17790
+ * that they had written nothing. Preserving the text is what lets readiness say `unconfirmed`
17791
+ * and quote it back instead. The gates themselves are unchanged: nothing about the parse
17792
+ * relaxes a threshold, and a formatting choice never earns a pass it would not otherwise get.
17793
+ */
17794
+ function extractItemsSection(sections, heading, { includeProse = true } = {}) {
17795
+ const items = [];
17796
+ let paragraph = [];
17797
+ let lastWasListItem = false;
17798
+ const flushParagraph = () => {
17799
+ const text = paragraph.join(' ').replace(/\s+/g, ' ').trim();
17800
+ if (text)
17801
+ items.push(text);
17802
+ paragraph = [];
17803
+ };
17804
+ for (const line of sectionLines(sections, heading)) {
17805
+ if (!line.trim()) {
17806
+ flushParagraph();
17807
+ lastWasListItem = false;
17808
+ continue;
17809
+ }
17810
+ // Structure and generated captions are checked FIRST, so neither can be folded into the
17811
+ // item above it or accumulated into a paragraph. A subheading is not a wrapped bullet.
17812
+ if (isNotContent(line)) {
17813
+ flushParagraph();
17814
+ lastWasListItem = false;
17815
+ continue;
17816
+ }
17817
+ if (isListItem(line)) {
17818
+ flushParagraph();
17819
+ const text = stripListMarker(line);
17820
+ lastWasListItem = Boolean(text);
17821
+ if (text)
17822
+ items.push(text);
17823
+ continue;
17824
+ }
17825
+ // A non-blank line straight after a list item is that item's wrapped text, not a new
17826
+ // item. Markdown lazy continuation does not require indentation, and requiring it let a
17827
+ // hard-wrapped bullet split into two items: wrapping must never manufacture an outcome
17828
+ // that clears the two-outcome gate. A blank line is what ends an item. This runs
17829
+ // whatever `includeProse` says, because wrapped text belongs to the list item, not to
17830
+ // the prose the flag governs.
17831
+ if (lastWasListItem && items.length) {
17832
+ items[items.length - 1] = `${items[items.length - 1]} ${line.trim()}`.replace(/\s+/g, ' ');
17833
+ continue;
17834
+ }
17835
+ if (!includeProse) {
17836
+ flushParagraph();
17837
+ lastWasListItem = false;
17838
+ continue;
17839
+ }
17840
+ lastWasListItem = false;
17841
+ paragraph.push(line.trim());
17842
+ }
17843
+ flushParagraph();
17844
+ return items;
17257
17845
  }
17258
17846
  /**
17259
17847
  * Parse `## Decisions & Ruled-Out Alternatives` entries written by `decisionLines()`:
17260
17848
  * - **choice** (instead of: ruledOut) — reason
17261
17849
  * The separator is an em dash when written by us; a plain hyphen is accepted for hand-edited files.
17262
17850
  */
17851
+ /**
17852
+ * A section that has list items owns the field, prose included. A section that is prose only
17853
+ * yields to frontmatter, and is read only when there is no frontmatter to yield to.
17854
+ *
17855
+ * The middle step is the published normalization rule, conformance case "list fallback: a
17856
+ * section with no list items does not override frontmatter": prose under a heading must not
17857
+ * erase a structured frontmatter value. It is a rule about OVERRIDE, not about ignoring prose,
17858
+ * so a section mixing a bullet and a paragraph keeps both. The last step is the defect this
17859
+ * change exists for: with nothing in frontmatter either, prose used to vanish and the gate
17860
+ * reported the section as `absent`, telling the author they had written nothing.
17861
+ */
17862
+ function preferListThenFrontmatter(sections, heading, fromFrontmatter) {
17863
+ const all = extractItemsSection(sections, heading);
17864
+ if (extractItemsSection(sections, heading, { includeProse: false }).length)
17865
+ return all;
17866
+ const frontmatter = preferSection([], fromFrontmatter);
17867
+ if (frontmatter.length)
17868
+ return frontmatter;
17869
+ return all;
17870
+ }
17263
17871
  function extractDecisions(sections) {
17264
17872
  const out = [];
17265
17873
  for (const line of sectionLines(sections, DECISIONS_HEADING)) {
@@ -17278,12 +17886,11 @@ function extractDecisions(sections) {
17278
17886
  }
17279
17887
  return out;
17280
17888
  }
17281
- function extractEdgeCases(sections) {
17889
+ function extractEdgeCases(sections, opts = {}) {
17282
17890
  const cases = [];
17283
- for (const line of sectionLines(sections, 'Edge Cases')) {
17284
- if (!isListItem(line))
17285
- continue;
17286
- const clean = stripListMarker(line);
17891
+ // Items come from the shared reader, so a prose paragraph is an item exactly as a bullet is
17892
+ // and the pair patterns below are applied to both. Nothing here depends on the formatting.
17893
+ for (const clean of extractItemsSection(sections, 'Edge Cases', opts)) {
17287
17894
  // Pattern: **scenario**: expected behavior
17288
17895
  const match = clean.match(/^\*\*(.+?)\*\*:\s*(.+)$/);
17289
17896
  if (match) {
@@ -17297,7 +17904,13 @@ function extractEdgeCases(sections) {
17297
17904
  const arrowMatch = clean.match(/^(.+?)\s*(?:->|→|:)\s*(.+)$/);
17298
17905
  if (arrowMatch) {
17299
17906
  cases.push({ scenario: arrowMatch[1].trim(), expectedBehavior: arrowMatch[2].trim() });
17907
+ continue;
17300
17908
  }
17909
+ // No pair in it. Keep the scenario so readiness can quote it back and ask for the
17910
+ // expected behavior, and leave `expectedBehavior` empty: the gate requires both, so an
17911
+ // unpaired item cannot pass, and no behavior is invented for the author. Dropping it,
17912
+ // which is what happened before, reported the section as absent instead.
17913
+ cases.push({ scenario: clean, expectedBehavior: '' });
17301
17914
  }
17302
17915
  return cases;
17303
17916
  }
@@ -17381,7 +17994,7 @@ function parseVerificationLabel(line) {
17381
17994
  * ("E2E Tests", "Unit Tests", "Manual Checks") keep their string-array buckets, which
17382
17995
  * `toVerificationChecks()` in intent-compiler.ts adapts on read.
17383
17996
  */
17384
- function extractVerification(sections) {
17997
+ function extractVerification(sections, { includeProse = true } = {}) {
17385
17998
  const lines = sectionLines(sections, 'Verification');
17386
17999
  if (lines.length === 0)
17387
18000
  return {};
@@ -17389,9 +18002,55 @@ function extractVerification(sections) {
17389
18002
  let currentKind = null;
17390
18003
  let currentLegacy = null;
17391
18004
  let sawLabel = false;
18005
+ // Prose is preserved here on the same terms as everywhere else: a run of unindented
18006
+ // non-list lines is one paragraph and one check, attributed to the bucket it was written
18007
+ // under, and an indented wrap folds into the check above it rather than becoming a check of
18008
+ // its own. A bullet parses exactly as it did before.
18009
+ const paragraph = [];
18010
+ // A holder, not a bare `let`: TypeScript narrows a variable assigned only inside
18011
+ // closures down to its initializer, and the call below then reads as `never`.
18012
+ const last = { append: null };
18013
+ const pushText = (text) => {
18014
+ if (currentKind) {
18015
+ const { description, status, verifies } = parseCheckLine(text);
18016
+ if (!description) {
18017
+ last.append = null;
18018
+ return;
18019
+ }
18020
+ const check = { kind: currentKind, description };
18021
+ if (status)
18022
+ check.status = status;
18023
+ if (verifies)
18024
+ check.verifies = verifies;
18025
+ (result.checks ||= []).push(check);
18026
+ last.append = (extra) => { check.description = `${check.description} ${extra}`.replace(/\s+/g, ' '); };
18027
+ return;
18028
+ }
18029
+ const bucket = currentLegacy
18030
+ ? (result[currentLegacy] ||= [])
18031
+ // IntentSpec labels are optional. Treat an ungrouped item as a manual check so a
18032
+ // valid flat `## Verification` section survives adoption instead of disappearing.
18033
+ : sawLabel ? null : (result.manualChecks ||= []);
18034
+ if (!bucket) {
18035
+ last.append = null;
18036
+ return;
18037
+ }
18038
+ bucket.push(text);
18039
+ last.append = (extra) => { bucket[bucket.length - 1] = `${bucket[bucket.length - 1]} ${extra}`.replace(/\s+/g, ' '); };
18040
+ };
18041
+ const flushParagraph = () => {
18042
+ const text = paragraph.join(' ').replace(/\s+/g, ' ').trim();
18043
+ paragraph.length = 0;
18044
+ if (text)
18045
+ pushText(text);
18046
+ };
17392
18047
  for (const line of lines) {
17393
18048
  const label = parseVerificationLabel(line);
17394
18049
  if (label) {
18050
+ // Flush before switching buckets so the paragraph lands under the label it was
18051
+ // written beneath, not the next one.
18052
+ flushParagraph();
18053
+ last.append = null;
17395
18054
  sawLabel = true;
17396
18055
  const kind = VERIFICATION_LABEL_TO_KIND[label.toLowerCase()];
17397
18056
  if (kind) {
@@ -17412,31 +18071,41 @@ function extractVerification(sections) {
17412
18071
  currentLegacy = null;
17413
18072
  continue;
17414
18073
  }
17415
- if (!isListItem(line))
18074
+ if (!line.trim()) {
18075
+ flushParagraph();
18076
+ last.append = null;
17416
18077
  continue;
17417
- const text = stripListMarker(line);
17418
- if (!text)
18078
+ }
18079
+ // Structure first, so a `### Regression checks` subheading under `## Verification` can
18080
+ // neither become a check of its own nor be folded into the check above it. It used to
18081
+ // become one, and a Verification section holding nothing but subheadings passed.
18082
+ if (isNotContent(line)) {
18083
+ flushParagraph();
18084
+ last.append = null;
17419
18085
  continue;
17420
- if (currentKind) {
17421
- const { description, status, verifies } = parseCheckLine(text);
17422
- if (!description)
18086
+ }
18087
+ if (isListItem(line)) {
18088
+ flushParagraph();
18089
+ const text = stripListMarker(line);
18090
+ if (!text) {
18091
+ last.append = null;
17423
18092
  continue;
17424
- const check = { kind: currentKind, description };
17425
- if (status)
17426
- check.status = status;
17427
- if (verifies)
17428
- check.verifies = verifies;
17429
- (result.checks ||= []).push(check);
18093
+ }
18094
+ pushText(text);
18095
+ continue;
17430
18096
  }
17431
- else if (currentLegacy) {
17432
- result[currentLegacy].push(text);
18097
+ // Lazy continuation, as above: a wrapped check is one check.
18098
+ if (last.append) {
18099
+ last.append(line.trim());
18100
+ continue;
17433
18101
  }
17434
- else if (!sawLabel) {
17435
- // IntentSpec labels are optional. Treat an ungrouped list as manual checks so a valid
17436
- // flat `## Verification` section survives adoption instead of silently disappearing.
17437
- (result.manualChecks ||= []).push(text);
18102
+ if (!includeProse) {
18103
+ flushParagraph();
18104
+ continue;
17438
18105
  }
18106
+ paragraph.push(line.trim());
17439
18107
  }
18108
+ flushParagraph();
17440
18109
  return result;
17441
18110
  }
17442
18111
  function hasVerificationContent(v) {
@@ -17753,7 +18422,7 @@ function readOpenSpecChangeForPathmode(ref, io = {}) {
17753
18422
  * https://preflight.pathmode.io, so keep it true.
17754
18423
  */
17755
18424
  Object.defineProperty(exports, "__esModule", ({ value: true }));
17756
- exports.READINESS_GATE_ORDER = exports.READINESS_BLOCKER_DESCRIPTIONS = void 0;
18425
+ exports.READINESS_GATE_ORDER = exports.READINESS_OUTCOMES_TOO_FEW_BLOCKER = exports.READINESS_BLOCKER_DESCRIPTIONS = void 0;
17757
18426
  exports.coerceText = coerceText;
17758
18427
  exports.asArray = asArray;
17759
18428
  exports.isTitleMeaningful = isTitleMeaningful;
@@ -17761,6 +18430,7 @@ exports.isConstraintConcrete = isConstraintConcrete;
17761
18430
  exports.isVerificationCheckSubstantive = isVerificationCheckSubstantive;
17762
18431
  exports.isObjectiveSpecific = isObjectiveSpecific;
17763
18432
  exports.isOutcomeMeasurable = isOutcomeMeasurable;
18433
+ exports.readinessBlockerFor = readinessBlockerFor;
17764
18434
  exports.normalizeAnchor = normalizeAnchor;
17765
18435
  exports.fieldDigest = fieldDigest;
17766
18436
  exports.computeReadinessVerdict = computeReadinessVerdict;
@@ -17880,6 +18550,18 @@ exports.READINESS_BLOCKER_DESCRIPTIONS = {
17880
18550
  edgeCases: 'No edge case with a defined expected behavior.',
17881
18551
  verification: 'No concrete verification — describe at least one check specific enough to run.',
17882
18552
  };
18553
+ /**
18554
+ * MIRROR of `OUTCOMES_TOO_FEW_BLOCKER` in lib/intentReadiness.ts; see its comment for why the
18555
+ * outcomes gate needs two messages. The threshold is unchanged.
18556
+ */
18557
+ exports.READINESS_OUTCOMES_TOO_FEW_BLOCKER = 'Fewer than two outcomes. Name at least two, one per line or paragraph, so each can be checked on its own.';
18558
+ function readinessBlockerFor(key, spec) {
18559
+ if (key !== 'outcomes')
18560
+ return exports.READINESS_BLOCKER_DESCRIPTIONS[key];
18561
+ return asArray(spec.outcomes).map(o => outcomeText(o).trim()).filter(Boolean).length < 2
18562
+ ? exports.READINESS_OUTCOMES_TOO_FEW_BLOCKER
18563
+ : exports.READINESS_BLOCKER_DESCRIPTIONS.outcomes;
18564
+ }
17883
18565
  /** Display order for the gate strip (title first, matching the Preflight page). */
17884
18566
  // ── Confirmation anchoring ──────────────────────────────────────────────────
17885
18567
  /**
@@ -18059,7 +18741,7 @@ function computeReadinessVerdict(spec) {
18059
18741
  };
18060
18742
  const failingBlockers = BLOCKER_ORDER
18061
18743
  .filter(k => !signals[k])
18062
- .map(k => exports.READINESS_BLOCKER_DESCRIPTIONS[k]);
18744
+ .map(k => readinessBlockerFor(k, spec));
18063
18745
  // What the reader read, per dimension, independent of whether the heuristic accepted it.
18064
18746
  // Placeholder text is deliberately not extracted: no path from a blank spec to confirmable.
18065
18747
  const objectiveText = coerceText(spec.objective).trim();
@@ -18077,11 +18759,14 @@ function computeReadinessVerdict(spec) {
18077
18759
  const detail = {};
18078
18760
  for (const key of exports.READINESS_GATE_ORDER) {
18079
18761
  const text = extractedBy[key] ?? '';
18762
+ // Only when it OVERRIDES the static message, so the common shape is untouched.
18763
+ const resolved = readinessBlockerFor(key, spec);
18764
+ const override = resolved && resolved !== exports.READINESS_BLOCKER_DESCRIPTIONS[key] ? { blocker: resolved } : {};
18080
18765
  detail[key] = signals[key]
18081
18766
  ? { state: 'pass', ...(text ? { extracted: text } : {}) }
18082
18767
  : text
18083
- ? { state: 'unconfirmed', extracted: text }
18084
- : { state: 'absent' };
18768
+ ? { state: 'unconfirmed', extracted: text, ...override }
18769
+ : { state: 'absent', ...override };
18085
18770
  }
18086
18771
  // Confirmations only ever move a dimension the heuristics already failed; `signals` is never
18087
18772
  // mutated, so the lexical verdict stays visible underneath.
@@ -18139,15 +18824,36 @@ function formatReadinessFrontmatter(verdict) {
18139
18824
  * The extra line an `unconfirmed` dimension earns: what was read, and an honest statement of
18140
18825
  * what the check could not find in it.
18141
18826
  *
18142
- * Only objective and outcomes appear here, and that scoping is the finding, not caution. The
18143
- * audit measured that most failures on those two are false negatives from a fixed English
18144
- * vocabulary. Verification's measured misses were all extraction failures, so quoting text
18145
- * back there would imply a confidence the evidence does not support.
18827
+ * Objective and outcomes get the SOFTENED copy, and that scoping is the finding, not caution:
18828
+ * the audit measured that most failures on those two are false negatives from a fixed English
18829
+ * vocabulary, so telling those authors the text may be fine is honest.
18830
+ *
18831
+ * Constraints, edge cases and verification get a hint too, but a plainer one. Their measured
18832
+ * misses were extraction failures, and the audit never measured their lexical recall, so the
18833
+ * copy states what was read and what is missing from it and stops there. It does not say the
18834
+ * check is probably wrong, because for these three there is no evidence that it is. They earn
18835
+ * a hint at all only because the parser now preserves prose: before that, an unread section
18836
+ * reached this code as `absent`, there was nothing extracted to quote, and the reader was told
18837
+ * it had written nothing.
18146
18838
  */
18147
18839
  const UNCONFIRMED_HINT = {
18148
18840
  objective: 'Read this, but could not confirm an affected actor and a concrete problem in it. If it already names them, this check reads a fixed vocabulary and may be missing valid phrasing:',
18149
18841
  outcomes: 'Read these, but could not confirm an observable threshold in enough of them. If they are already observable, this check reads a fixed vocabulary and may be missing valid phrasing:',
18842
+ constraints: 'Read this, but could not confirm a hard limit in it. A constraint names something that must never happen:',
18843
+ edgeCases: 'Read this, but no expected behavior was paired with it. Write the case as "situation: what should happen", one per line or paragraph:',
18844
+ verification: 'Read this, but could not confirm a check specific enough to run. A check names a command or a stated expected result:',
18150
18845
  };
18846
+ /**
18847
+ * The hint has to follow the blocker that was actually chosen. When outcomes failed on COUNT,
18848
+ * the measurability hint contradicted the line directly above it and accused wording the
18849
+ * detector had accepted.
18850
+ */
18851
+ function unconfirmedHintFor(key, d) {
18852
+ if (key === 'outcomes' && d?.blocker === exports.READINESS_OUTCOMES_TOO_FEW_BLOCKER) {
18853
+ return 'Read this. If it holds more than one outcome, separate them so each can be checked on its own:';
18854
+ }
18855
+ return UNCONFIRMED_HINT[key];
18856
+ }
18151
18857
  /** Keep a quoted extraction to one readable line; the full text is in verdict.detail. */
18152
18858
  function truncateForQuote(text, max = 160) {
18153
18859
  const flat = text.replace(/\s+/g, ' ').trim();
@@ -18199,10 +18905,11 @@ repairHint) {
18199
18905
  for (const key of BLOCKER_ORDER) {
18200
18906
  if (verdict.signals[key] || resolved(key) === 'pass' || resolved(key) === 'not_applicable')
18201
18907
  continue;
18202
- lines.push(` ✗ ${exports.READINESS_BLOCKER_DESCRIPTIONS[key]}`);
18908
+ lines.push(` ✗ ${verdict.detail?.[key]?.blocker ?? exports.READINESS_BLOCKER_DESCRIPTIONS[key]}`);
18203
18909
  const d = verdict.detail?.[key];
18204
- if (d?.state === 'unconfirmed' && d.extracted && UNCONFIRMED_HINT[key]) {
18205
- lines.push(` ↳ ${UNCONFIRMED_HINT[key]}`);
18910
+ const hint = unconfirmedHintFor(key, d);
18911
+ if (d?.state === 'unconfirmed' && d.extracted && hint) {
18912
+ lines.push(` ↳ ${hint}`);
18206
18913
  lines.push(` "${truncateForQuote(d.extracted)}"`);
18207
18914
  }
18208
18915
  }
@@ -29964,7 +30671,7 @@ module.exports = /*#__PURE__*/JSON.parse('{"$schema":"http://json-schema.org/dra
29964
30671
  /***/ ((module) => {
29965
30672
 
29966
30673
  "use strict";
29967
- module.exports = {"rE":"2.1.4"};
30674
+ module.exports = {"rE":"2.2.0"};
29968
30675
 
29969
30676
  /***/ })
29970
30677
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pathmode/cli",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },