From 8b9b7e1a5a3885f1301fa3c1c187fcadf792ddb4 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Wed, 4 Nov 2026 02:13:48 +0100 Subject: [PATCH] bugfix-850-undersized-sdd-response-loses-its-drafting-stage-context-behind-generic-retryable-workflow-failed: retain safe minimum-response diagnostics for undersized drafts --- build/api/index.js | 83 ++++- .../api/src/data/model/application_error.d.ts | 4 +- .../src/domain/sdd_validation_diagnostic.d.ts | 26 ++ build/cli/index.js | 184 ++++++++--- build/deployment_recovery/index.js | 98 +++++- build/github_action/index.js | 184 ++++++++--- docs/issues/pre-branch-sdds.mdx | 26 ++ .../operations/error-reference.mdx | 10 + specs/CATALOG.md | 8 +- specs/catalog.json | 7 +- specs/pre-branch-sdd-gate.md | 4 + specs/sdd-draft-response-minimum.md | 307 ++++++++++++++++++ .../errors/sdd_draft_preparation_failure.ts | 15 +- ...lication_error_presentation_policy.test.ts | 3 + .../sdd_minimum_presentation.test.ts | 66 ++++ .../application_error_message_catalog.ts | 14 +- .../application_error_presentation_policy.ts | 5 +- .../sdd_preparation_message_catalog.ts | 2 + .../policies/sdd_preparation_status_policy.ts | 32 +- .../sdd/pre_branch_sdd_draft_workflow.test.ts | 41 +-- .../sdd/pre_branch_sdd_draft_workflow.ts | 12 + .../usecases/sdd/sdd_repair_feedback.test.ts | 5 +- .../pre_branch_sdd_gate_use_case.test.ts | 119 +++---- src/data/model/application_error.ts | 9 + .../__tests__/sdd_minimum_diagnostic.test.ts | 47 +++ src/domain/sdd_validation_diagnostic.ts | 27 +- ...dd_minimum_entrypoints.integration.test.ts | 130 ++++++++ .../__tests__/sdd_minimum_failure.test.ts | 110 +++++++ 28 files changed, 1365 insertions(+), 213 deletions(-) create mode 100644 build/api/src/domain/sdd_validation_diagnostic.d.ts create mode 100644 specs/sdd-draft-response-minimum.md create mode 100644 src/application/policies/__tests__/sdd_minimum_presentation.test.ts create mode 100644 src/domain/__tests__/sdd_minimum_diagnostic.test.ts create mode 100644 src/tooling/__tests__/sdd_minimum_entrypoints.integration.test.ts create mode 100644 src/tooling/__tests__/sdd_minimum_failure.test.ts diff --git a/build/api/index.js b/build/api/index.js index f3275a06f..0b1c47c0a 100644 --- a/build/api/index.js +++ b/build/api/index.js @@ -7058,6 +7058,7 @@ const branch_sync_validation_1 = __nccwpck_require__(981); const verification_failure_1 = __nccwpck_require__(177); const branch_sync_catalog_diagnostic_1 = __nccwpck_require__(5677); const agent_source_snapshot_1 = __nccwpck_require__(9205); +const sdd_validation_diagnostic_1 = __nccwpck_require__(4211); exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ ...copilot_command_validation_1.COPILOT_COMMAND_VALIDATION_REASONS, 'pull-request-link-restored', @@ -7075,6 +7076,7 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-workflow-write-required', 'branch-sync-publication-failed', 'sdd-draft-size-exceeded', + 'sdd-draft-response-too-short', 'sdd-catalog-unsupported-profile', 'sdd-catalog-format-invalid', 'sdd-catalog-missing-trusted-source', @@ -7277,9 +7279,11 @@ class ApplicationError extends Error { ? 'Verified assignments and independently completed effects were retained; the failed assignment operation remains unverified.' : options.recovery?.id === 'issue-assignment-count-invalid' ? 'No assignment operation was attempted; independently completed lifecycle effects were retained.' - : options.recovery?.id === 'deployment-publication-context-missing' - ? PRESERVED_STATE - : metadata.retainedState; + : options.recovery?.id === 'sdd-draft-response-too-short' + ? 'Existing answers, selected ownership and published specifications were retained; this attempt published no specification or branch.' + : options.recovery?.id === 'deployment-publication-context-missing' + ? PRESERVED_STATE + : metadata.retainedState; this.correlationId = correlationId; this.recovery = normalizeApplicationErrorRecovery(options.recovery); __classPrivateFieldSet(this, _ApplicationError_cause, options.cause, "f"); @@ -7339,6 +7343,7 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'branch-sync-workflow-write-required': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'branch-sync-publication-failed': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'sdd-draft-size-exceeded': Object.freeze([]), + 'sdd-draft-response-too-short': Object.freeze(['minimum', 'observed', 'attempts']), 'sdd-catalog-unsupported-profile': Object.freeze([]), 'sdd-catalog-format-invalid': Object.freeze([]), 'sdd-catalog-missing-trusted-source': Object.freeze([]), @@ -7378,6 +7383,9 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'sdd-source-context-unavailable' && !(0, agent_source_snapshot_1.isSddSourceContextDiagnostic)(variables)) { throw new TypeError('SDD source context diagnostic is invalid.'); } + if (recovery.id === 'sdd-draft-response-too-short' && !(0, sdd_validation_diagnostic_1.isSddDraftMinimumDiagnostic)(variables)) { + throw new TypeError('SDD minimum response diagnostic is invalid.'); + } if ((0, copilot_command_validation_1.isCopilotCommandValidationReason)(recovery.id) && !(0, copilot_command_validation_1.isValidCopilotCommandValidation)(recovery.id, variables.command)) { throw new TypeError('Comment command validation diagnostic is invalid.'); @@ -9606,6 +9614,75 @@ function isProgressScopeCapacityFacts(value) { } +/***/ }), + +/***/ 4211: +/***/ ((__unused_webpack_module, exports) => { + + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SDD_VALIDATION_DETAILS = exports.SDD_DRAFT_FORMAT_REASONS = void 0; +exports.isSddDraftMinimumDiagnostic = isSddDraftMinimumDiagnostic; +exports.normalizeSddValidationDiagnostic = normalizeSddValidationDiagnostic; +/** Public diagnostics contain only closed checks and bounded ordinal/count facts. */ +exports.SDD_DRAFT_FORMAT_REASONS = Object.freeze([ + 'required-heading', 'test-budget', 'case-identity', 'case-budget', 'requirement-mapping', 'repository-path', +]); +exports.SDD_VALIDATION_DETAILS = Object.freeze([ + 'validation-contract', 'size-limit', 'response-shape', 'minimum-length', 'required-heading', 'numeric-budget', + 'section-count', 'table-count', 'table-separator', 'table-columns', 'table-empty-cell', 'table-empty', + 'derived-slot-count', 'derived-slot-placement', + 'identifier-list', 'requirement-identity', 'case-identity', 'case-reserved', 'case-reference', 'case-shorthand', + 'budget-area', 'budget-integer', 'budget-order', 'budget-total', 'budget-distribution', + 'requirement-reference', 'requirement-coverage', 'traceability-links', 'traceability-membership', 'traceability-requirement', + 'owner-path', 'path-identity', 'create-existing-path', 'create-path-overlap', 'create-path-parent', + 'path-operation', 'path-reference', 'path-link', + 'path-inline-reference', 'path-prose-reference', 'path-link-reference', + 'path-reference-unsafe', 'path-reference-blocked', 'path-reference-missing', 'path-reference-directory', +]); +const REASONS = [...exports.SDD_DRAFT_FORMAT_REASONS, 'size', 'response-shape', 'response-minimum']; +const PHASES = ['response', 'assembly', 'validation']; +/** Counts authorize no publication and never carry the rejected response. */ +function isSddDraftMinimumDiagnostic(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) + || Object.keys(input).sort().join(',') !== 'attempts,minimum,observed') + return false; + const value = input; + return boundedInteger(value.minimum, 1) + && (value.observed === 'unavailable' || (boundedInteger(value.observed, 0) && Number(value.observed) < Number(value.minimum))) + && (value.attempts === 1 || value.attempts === 2); +} +/** Fail closed on extra fields, arbitrary text, unbounded facts or invalid coordinates. */ +function normalizeSddValidationDiagnostic(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) + || Object.keys(input).sort().join(',') !== 'facts,phase,reason' + || !REASONS.includes(input.reason) || !PHASES.includes(input.phase) + || !Array.isArray(input.facts) || input.facts.length < 1 || input.facts.length > 8) { + throw new TypeError('Invalid bounded SDD validation diagnostic.'); + } + const facts = input.facts.map(fact => { + if (!fact || typeof fact !== 'object' || Array.isArray(fact) + || Object.keys(fact).some(key => !['detail', 'section', 'row', 'expected', 'observed'].includes(key)) + || !exports.SDD_VALIDATION_DETAILS.includes(fact.detail) || ![0, 4, 14, 15, 17].includes(fact.section) + || (fact.row !== undefined && !boundedInteger(fact.row, 1)) + || (fact.expected !== undefined && !boundedInteger(fact.expected, 0)) + || (fact.observed !== undefined && !boundedInteger(fact.observed, 0))) { + throw new TypeError('Invalid bounded SDD validation fact.'); + } + return Object.freeze({ ...fact }); + }); + if (input.reason === 'response-minimum' && (input.phase !== 'response' || facts.length !== 1 + || facts[0].detail !== 'minimum-length' || facts[0].section !== 0 || facts[0].row !== undefined + || !isSddDraftMinimumDiagnostic({ minimum: facts[0].expected, observed: facts[0].observed ?? 'unavailable', attempts: 1 }))) { + throw new TypeError('Invalid bounded SDD minimum diagnostic.'); + } + return Object.freeze({ reason: input.reason, phase: input.phase, facts: Object.freeze(facts) }); +} +function boundedInteger(value, minimum) { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= 100000; +} + + /***/ }), /***/ 7122: diff --git a/build/api/src/data/model/application_error.d.ts b/build/api/src/data/model/application_error.d.ts index 93001912d..597d9ba96 100644 --- a/build/api/src/data/model/application_error.d.ts +++ b/build/api/src/data/model/application_error.d.ts @@ -17,9 +17,10 @@ import { type BranchSyncValidationCode } from '../../domain/branch_sync_validati import { type BranchSyncVerificationFailure } from '../../domain/verification_failure'; import { type BranchSyncCatalogDiagnostic } from '../../domain/branch_sync_catalog_diagnostic'; import { type SddSourceContextDiagnostic } from '../../domain/agent_source_snapshot'; +import { type SddDraftMinimumDiagnostic } from '../../domain/sdd_validation_diagnostic'; export type ApplicationErrorKind = 'configuration' | 'authorization' | 'provider' | 'agent' | 'validation' | 'workflow' | 'unknown'; export type ApplicationErrorCode = 'configuration.invalid' | 'configuration.unsupported' | 'authorization.denied' | 'authorization.credential-invalid' | 'provider.not-found' | 'provider.conflict' | 'provider.rate-limited' | 'provider.unavailable' | 'provider.contract-invalid' | 'agent.policy-rejected' | 'agent.failed' | 'locale.output-invalid' | 'locale.translation-failed' | 'validation.invalid-input' | 'validation.evidence-too-large' | 'validation.evidence-unavailable' | 'workflow.invalid-event' | 'workflow.stale' | 'workflow.cancelled' | 'workflow.failed' | 'workflow.presentation-pending' | 'timeout' | 'unexpected'; -export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["comment-command-length", "comment-command-name-missing", "comment-command-name-unknown", "comment-command-argument-count", "comment-command-arguments-unexpected", "comment-command-arguments-missing", "comment-command-finding-quoting", "comment-command-review-format", "comment-command-review-effort", "comment-command-review-boolean", "comment-command-review-option", "comment-command-sync-option", "comment-command-sync-parent", "pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "branch-sync-verification-failed", "branch-sync-catalog-incompatible", "branch-sync-workflow-write-required", "branch-sync-publication-failed", "sdd-draft-size-exceeded", "sdd-catalog-unsupported-profile", "sdd-catalog-format-invalid", "sdd-catalog-missing-trusted-source", "sdd-source-context-unavailable", "sdd-draft-format-invalid", "sdd-draft-case-identity-invalid", "sdd-draft-case-budget-invalid", "sdd-draft-requirement-mapping-invalid", "sdd-draft-repository-path-invalid", "deployment-publication-context-missing", "workspace-changes-rejected", "closed-issue-reconciliation-failed", "approval-evidence-read-failed", "change-size-reconciliation-failed", "issue-assignment-failed", "issue-assignment-count-invalid", "issue-workflow-admission-blocked", "progress-binary-evidence-unavailable", "progress-inventory-incomplete", "bugbot-review-incomplete", "progress-result-contract-invalid", "progress-partition-length-invalid", "progress-assessment-capacity-exceeded", "progress-scope-clause-limit", "progress-completion-recovery-exhausted", "stored-configuration-schema-missing", "stored-configuration-schema-unsupported", "stored-configuration-invalid"]; +export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["comment-command-length", "comment-command-name-missing", "comment-command-name-unknown", "comment-command-argument-count", "comment-command-arguments-unexpected", "comment-command-arguments-missing", "comment-command-finding-quoting", "comment-command-review-format", "comment-command-review-effort", "comment-command-review-boolean", "comment-command-review-option", "comment-command-sync-option", "comment-command-sync-parent", "pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "branch-sync-verification-failed", "branch-sync-catalog-incompatible", "branch-sync-workflow-write-required", "branch-sync-publication-failed", "sdd-draft-size-exceeded", "sdd-draft-response-too-short", "sdd-catalog-unsupported-profile", "sdd-catalog-format-invalid", "sdd-catalog-missing-trusted-source", "sdd-source-context-unavailable", "sdd-draft-format-invalid", "sdd-draft-case-identity-invalid", "sdd-draft-case-budget-invalid", "sdd-draft-requirement-mapping-invalid", "sdd-draft-repository-path-invalid", "deployment-publication-context-missing", "workspace-changes-rejected", "closed-issue-reconciliation-failed", "approval-evidence-read-failed", "change-size-reconciliation-failed", "issue-assignment-failed", "issue-assignment-count-invalid", "issue-workflow-admission-blocked", "progress-binary-evidence-unavailable", "progress-inventory-incomplete", "bugbot-review-incomplete", "progress-result-contract-invalid", "progress-partition-length-invalid", "progress-assessment-capacity-exceeded", "progress-scope-clause-limit", "progress-completion-recovery-exhausted", "stored-configuration-schema-missing", "stored-configuration-schema-unsupported", "stored-configuration-invalid"]; export type ApplicationErrorRecoveryId = typeof APPLICATION_ERROR_RECOVERY_IDS[number]; interface ApplicationErrorRecoveryVariables extends Readonly>; + readonly 'sdd-draft-response-too-short': SddDraftMinimumDiagnostic; readonly 'sdd-catalog-unsupported-profile': Readonly>; readonly 'sdd-catalog-format-invalid': Readonly>; readonly 'sdd-catalog-missing-trusted-source': Readonly>; diff --git a/build/api/src/domain/sdd_validation_diagnostic.d.ts b/build/api/src/domain/sdd_validation_diagnostic.d.ts new file mode 100644 index 000000000..a1a4bd1e8 --- /dev/null +++ b/build/api/src/domain/sdd_validation_diagnostic.d.ts @@ -0,0 +1,26 @@ +/** Public diagnostics contain only closed checks and bounded ordinal/count facts. */ +export declare const SDD_DRAFT_FORMAT_REASONS: readonly ["required-heading", "test-budget", "case-identity", "case-budget", "requirement-mapping", "repository-path"]; +export type SddDraftFormatReason = typeof SDD_DRAFT_FORMAT_REASONS[number]; +export declare const SDD_VALIDATION_DETAILS: readonly ["validation-contract", "size-limit", "response-shape", "minimum-length", "required-heading", "numeric-budget", "section-count", "table-count", "table-separator", "table-columns", "table-empty-cell", "table-empty", "derived-slot-count", "derived-slot-placement", "identifier-list", "requirement-identity", "case-identity", "case-reserved", "case-reference", "case-shorthand", "budget-area", "budget-integer", "budget-order", "budget-total", "budget-distribution", "requirement-reference", "requirement-coverage", "traceability-links", "traceability-membership", "traceability-requirement", "owner-path", "path-identity", "create-existing-path", "create-path-overlap", "create-path-parent", "path-operation", "path-reference", "path-link", "path-inline-reference", "path-prose-reference", "path-link-reference", "path-reference-unsafe", "path-reference-blocked", "path-reference-missing", "path-reference-directory"]; +export type SddValidationDetail = typeof SDD_VALIDATION_DETAILS[number]; +export interface SddValidationFact { + readonly detail: SddValidationDetail; + readonly section: 0 | 4 | 14 | 15 | 17; + readonly row?: number; + readonly expected?: number; + readonly observed?: number; +} +export interface SddValidationDiagnostic { + readonly reason: SddDraftFormatReason | 'size' | 'response-shape' | 'response-minimum'; + readonly phase: 'response' | 'assembly' | 'validation'; + readonly facts: readonly SddValidationFact[]; +} +export interface SddDraftMinimumDiagnostic { + readonly minimum: number; + readonly observed: number | 'unavailable'; + readonly attempts: 1 | 2; +} +/** Counts authorize no publication and never carry the rejected response. */ +export declare function isSddDraftMinimumDiagnostic(input: unknown): input is SddDraftMinimumDiagnostic; +/** Fail closed on extra fields, arbitrary text, unbounded facts or invalid coordinates. */ +export declare function normalizeSddValidationDiagnostic(input: SddValidationDiagnostic): SddValidationDiagnostic; diff --git a/build/cli/index.js b/build/cli/index.js index 3122f04f7..4f7584c05 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -38559,20 +38559,25 @@ const RECOVERY = Object.freeze({ 'required-heading': 'sdd-draft-format-invalid', 'test-budget': 'sdd-draft-format-invalid', 'case-identity': 'sdd-draft-case-identity-invalid', 'case-budget': 'sdd-draft-case-budget-invalid', 'requirement-mapping': 'sdd-draft-requirement-mapping-invalid', 'repository-path': 'sdd-draft-repository-path-invalid', - size: 'sdd-draft-size-exceeded', 'response-shape': undefined, + size: 'sdd-draft-size-exceeded', 'response-shape': undefined, 'response-minimum': undefined, }); /** Carries safe evidence to the feature-owned card without serializing candidate content. */ class SddDraftPreparationFailure extends application_error_1.ApplicationError { constructor(diagnostic, attempts) { const normalized = (0, sdd_validation_diagnostic_1.normalizeSddValidationDiagnostic)(diagnostic); - if ((attempts !== 1 && attempts !== 2) || (attempts === 1 && normalized.reason !== 'response-shape')) { + if ((attempts !== 1 && attempts !== 2) || (attempts === 1 && normalized.reason !== 'response-shape' && normalized.reason !== 'response-minimum')) { throw new TypeError('Invalid bounded SDD attempt count.'); } const id = RECOVERY[normalized.reason]; + const recovery = normalized.reason === 'response-minimum' + ? { id: 'sdd-draft-response-too-short', variables: { minimum: normalized.facts[0].expected, + observed: normalized.facts[0].observed ?? 'unavailable', attempts } } + : id ? { id, variables: {} } : undefined; const message = normalized.reason === 'response-shape' ? 'The drafting agent returned no SDD Markdown.' - : normalized.reason === 'size' ? 'The SDD draft exceeded its size limit after one bounded repair.' - : `The SDD draft still violates its ${normalized.reason} format contract after one bounded repair.`; - super('validation.invalid-input', message, id ? { recovery: { id, variables: {} } } : {}); + : normalized.reason === 'response-minimum' ? 'The SDD drafting response is shorter than the required minimum.' + : normalized.reason === 'size' ? 'The SDD draft exceeded its size limit after one bounded repair.' + : `The SDD draft still violates its ${normalized.reason} format contract after one bounded repair.`; + super('validation.invalid-input', message, recovery ? { recovery } : {}); this.attempts = attempts; this.diagnostic = normalized; } @@ -39685,7 +39690,7 @@ const LABEL_KEYS = Object.freeze([ ...bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, ...branch_sync_publication_messages_1.BRANCH_SYNC_PUBLICATION_LABEL_KEYS, ...issue_assignment_messages_1.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS, - 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', + 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', 'draftLengthUnavailable', ]); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState']); exports.APPLICATION_ERROR_CODES = Object.freeze(Object.keys(application_error_1.APPLICATION_ERROR_METADATA)); @@ -39707,6 +39712,7 @@ const ENGLISH_LABELS = Object.freeze({ yes: 'Yes', no: 'No', reference: 'Reference', + draftLengthUnavailable: 'unavailable', }); const SPANISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.SPANISH_REVIEW_DIAGNOSTIC_LABELS, @@ -39720,6 +39726,7 @@ const SPANISH_LABELS = Object.freeze({ yes: 'Sí', no: 'No', reference: 'Referencia', + draftLengthUnavailable: 'no disponible', }); const ENGLISH_CONTENT = Object.freeze(Object.fromEntries(exports.APPLICATION_ERROR_CODES.map(code => [code, Object.freeze({ impact: application_error_1.APPLICATION_ERROR_METADATA[code].impact, @@ -39983,6 +39990,11 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ action: 'Retry SDD preparation with the canonical specification template and the explicit acceptance requirements.', retainedState: 'Existing answers and published SDDs were preserved; no draft was published.', }), + 'sdd-draft-response-too-short': Object.freeze({ + impact: 'The SDD drafting response was below its {minimum} Unicode code-point minimum (observed: {observed}; draft attempt: {attempts}).', + action: 'Clarify in the issue description that a complete proposed Markdown document is required, preserving resolved requirements and ownership, then retry the same issue workflow. This failure does not start an automatic regeneration.', + retainedState: 'Existing answers, selected ownership and published specifications were retained; this attempt published no specification or branch.', + }), 'sdd-draft-size-exceeded': Object.freeze({ impact: 'SDD preparation stopped because the draft remained too large after one automatic size repair.', action: 'Retry SDD preparation with a proportional appendix or an approved companion specification.', @@ -40183,6 +40195,11 @@ const SPANISH_RECOVERY_CONTENT = Object.freeze({ action: 'Reintenta la preparación del SDD con la plantilla canónica y los criterios de aceptación explícitos.', retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador.', }), + 'sdd-draft-response-too-short': Object.freeze({ + impact: 'La respuesta de redacción del SDD no alcanzó el mínimo de {minimum} puntos de código Unicode (cantidad observada: {observed}; intento de borrador: {attempts}).', + action: 'Aclara en la descripción de esta incidencia que se requiere una propuesta Markdown completa, conservando los requisitos resueltos y su propietario, y repite el mismo workflow. Este fallo no inicia una regeneración automática.', + retainedState: 'Se conservaron las respuestas, la propiedad seleccionada y las especificaciones publicadas; este intento no publicó ninguna especificación ni rama.', + }), 'sdd-draft-size-exceeded': Object.freeze({ impact: 'La preparación del SDD se detuvo porque el borrador seguía siendo demasiado largo tras un intento automático de reducción.', action: 'Reintenta la preparación del SDD con un apéndice proporcional o una especificación complementaria aprobada.', @@ -40320,40 +40337,43 @@ function buildApplicationErrorPresentation(error, message = application_error_me const descriptor = error.recovery ? `error.recovery.${error.recovery.id}` : `error.${error.code}`; - const variables = error.recovery?.id === 'issue-assignment-failed' - ? (0, issue_assignment_diagnostic_presentation_1.assignmentDiagnosticPresentation)(error.recovery.variables, error.code, message) - : error.recovery?.id === 'sdd-source-context-unavailable' - ? { ...(0, agent_source_snapshot_1.sourceContextDiagnostic)(error.recovery.variables) } - : error.recovery?.id === 'progress-partition-length-invalid' - ? (0, progress_partition_length_failure_1.progressPartitionLengthPresentation)(error.recovery.variables) - : error.recovery?.id === 'bugbot-review-incomplete' - ? { ...error.recovery.variables, countKind: error.recovery.variables.countKind === 'at-least' ? '≥' : '=', - resource: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_RESOURCE_LABELS[error.recovery.variables.resource]}`), - unit: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_UNIT_LABELS[error.recovery.variables.unit]}`) } - : error.recovery?.id === 'progress-inventory-incomplete' - ? (0, progress_inventory_diagnostic_1.progressInventoryPresentation)(error.recovery.variables) - : error.recovery?.id === 'progress-binary-evidence-unavailable' - ? (0, progress_binary_evidence_diagnostic_1.progressBinaryEvidencePresentation)(error.recovery.variables) - : error.recovery?.id === 'issue-workflow-admission-blocked' - ? (0, issue_workflow_diagnostic_1.issueWorkflowDiagnosticPresentation)(error.recovery.variables) - : error.recovery && (0, copilot_command_validation_1.isCopilotCommandValidationReason)(error.recovery.id) - ? (0, comment_command_validation_presentation_policy_1.commandValidationPresentationVariables)(error.recovery.id, error.recovery.variables.command) - : error.recovery?.id === 'branch-sync-verification-failed' - ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch' } - : error.recovery?.id === 'branch-sync-workflow-write-required' || error.recovery?.id === 'branch-sync-publication-failed' - ? { - reason: error.recovery.variables.reason, originalSha: error.recovery.variables.originalSha, - candidateSha: error.recovery.variables.candidateSha, observedSha: error.recovery.variables.observedSha, - pushStatus: message(error.recovery.variables.pushAttempted ? 'error.label.syncPushAttempted' : 'error.label.syncPushNotAttempted'), - remoteStatus: message(error.recovery.variables.remoteState === 'unchanged' ? 'error.label.syncRemoteUnchanged' - : error.recovery.variables.remoteState === 'changed' ? 'error.label.syncRemoteChanged' : 'error.label.syncRemoteUnverified'), - publicationGuide: BRANCH_SYNC_PUBLICATION_RECOVERY_URL, syncCommand: '/copilot sync-branch', - } - : error.recovery?.id === 'branch-sync-catalog-incompatible' - ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } - : error.recovery?.id.startsWith('stored-configuration-') - ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } - : error.recovery?.variables; + const variables = error.recovery?.id === 'sdd-draft-response-too-short' + ? { ...error.recovery.variables, observed: error.recovery.variables.observed === 'unavailable' + ? message('error.label.draftLengthUnavailable') : error.recovery.variables.observed } + : error.recovery?.id === 'issue-assignment-failed' + ? (0, issue_assignment_diagnostic_presentation_1.assignmentDiagnosticPresentation)(error.recovery.variables, error.code, message) + : error.recovery?.id === 'sdd-source-context-unavailable' + ? { ...(0, agent_source_snapshot_1.sourceContextDiagnostic)(error.recovery.variables) } + : error.recovery?.id === 'progress-partition-length-invalid' + ? (0, progress_partition_length_failure_1.progressPartitionLengthPresentation)(error.recovery.variables) + : error.recovery?.id === 'bugbot-review-incomplete' + ? { ...error.recovery.variables, countKind: error.recovery.variables.countKind === 'at-least' ? '≥' : '=', + resource: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_RESOURCE_LABELS[error.recovery.variables.resource]}`), + unit: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_UNIT_LABELS[error.recovery.variables.unit]}`) } + : error.recovery?.id === 'progress-inventory-incomplete' + ? (0, progress_inventory_diagnostic_1.progressInventoryPresentation)(error.recovery.variables) + : error.recovery?.id === 'progress-binary-evidence-unavailable' + ? (0, progress_binary_evidence_diagnostic_1.progressBinaryEvidencePresentation)(error.recovery.variables) + : error.recovery?.id === 'issue-workflow-admission-blocked' + ? (0, issue_workflow_diagnostic_1.issueWorkflowDiagnosticPresentation)(error.recovery.variables) + : error.recovery && (0, copilot_command_validation_1.isCopilotCommandValidationReason)(error.recovery.id) + ? (0, comment_command_validation_presentation_policy_1.commandValidationPresentationVariables)(error.recovery.id, error.recovery.variables.command) + : error.recovery?.id === 'branch-sync-verification-failed' + ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch' } + : error.recovery?.id === 'branch-sync-workflow-write-required' || error.recovery?.id === 'branch-sync-publication-failed' + ? { + reason: error.recovery.variables.reason, originalSha: error.recovery.variables.originalSha, + candidateSha: error.recovery.variables.candidateSha, observedSha: error.recovery.variables.observedSha, + pushStatus: message(error.recovery.variables.pushAttempted ? 'error.label.syncPushAttempted' : 'error.label.syncPushNotAttempted'), + remoteStatus: message(error.recovery.variables.remoteState === 'unchanged' ? 'error.label.syncRemoteUnchanged' + : error.recovery.variables.remoteState === 'changed' ? 'error.label.syncRemoteChanged' : 'error.label.syncRemoteUnverified'), + publicationGuide: BRANCH_SYNC_PUBLICATION_RECOVERY_URL, syncCommand: '/copilot sync-branch', + } + : error.recovery?.id === 'branch-sync-catalog-incompatible' + ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } + : error.recovery?.id.startsWith('stored-configuration-') + ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } + : error.recovery?.variables; return { impact: message(`${descriptor}.impact`, variables), code: error.code, @@ -50075,6 +50095,7 @@ const UI = Object.freeze({ referenceOrdinal: ['Visible reference occurrence', 'Aparición de referencia visible'], "expected": ["Expected count", "Cantidad esperada"], "observed": ["Observed count", "Cantidad observada"], + lengthUnavailable: ['unavailable; the rejected response length was not retained', 'no disponible; no se conservó la longitud de la respuesta rechazada'], "recoveredHeading": ["SDD preparation status", "Estado de preparación del SDD"], "published": ["The specification publication was verified on the linked branch. Branch readiness remains subject to its separate checks.", "Se verificó la publicación de la especificación en la rama enlazada. La preparación de la rama sigue sujeta a comprobaciones independientes."], "waiting": ["Waiting for the numbered answers in the specification question card. No new specification was published.", "Se esperan las respuestas numeradas de la tarjeta de preguntas. No se publicó ninguna especificación nueva."], @@ -50086,6 +50107,7 @@ const FACTS = Object.freeze({ "validation-contract": ["A content contract was rejected; this validator supplied no more specific table facts.", "Se rechazó un contrato de contenido; este validador no proporcionó datos más concretos de tablas."], "size-limit": ["The candidate exceeded the complete draft size limit.", "El candidato superó el límite de tamaño del borrador completo."], "response-shape": ["The drafting response did not contain the required Markdown string.", "La respuesta de redacción no contenía la cadena Markdown obligatoria."], + 'minimum-length': ['The drafting Markdown response was below its declared minimum, measured in Unicode code points.', 'La respuesta Markdown de redacción no alcanzó su mínimo declarado, medido en puntos de código Unicode.'], "required-heading": ["A canonical heading is missing; the row identifies its position in the required-heading list.", "Falta una sección canónica; la fila indica su posición en la lista de secciones obligatorias."], "numeric-budget": ["No valid numeric test budget was found.", "No se encontró un presupuesto numérico de pruebas válido."], "section-count": ["The required section is missing or repeated.", "La sección obligatoria falta o está repetida."], @@ -50197,14 +50219,32 @@ function renderSddPreparationFailure(options, catalog = (0, sdd_preparation_mess if (options.catalogReason !== undefined && (!sdd_catalog_compatibility_1.SDD_CATALOG_COMPATIBILITY_REASONS.includes(options.catalogReason) || options.diagnostic)) { throw new TypeError('Invalid exclusive catalog compatibility facts.'); } - const diagnostic = options.diagnostic ? (0, sdd_validation_diagnostic_1.normalizeSddValidationDiagnostic)(options.diagnostic) : undefined; + const recovery = options.error.recovery; + const minimum = recovery?.id === 'sdd-draft-response-too-short' && (0, sdd_validation_diagnostic_1.isSddDraftMinimumDiagnostic)(recovery.variables) ? recovery.variables : undefined; + if (recovery?.id === 'sdd-draft-response-too-short' && !minimum) + throw new TypeError('Invalid SDD minimum recovery facts.'); + let diagnostic = options.diagnostic ? (0, sdd_validation_diagnostic_1.normalizeSddValidationDiagnostic)(options.diagnostic) : undefined; + if (minimum) { + if (options.catalogReason !== undefined || missingCatalogSource !== undefined || options.publicationPending) { + throw new TypeError('SDD minimum response facts cannot describe another failure phase.'); + } + const observed = minimum.observed === 'unavailable' ? undefined : minimum.observed; + if (diagnostic && (diagnostic.reason !== 'response-minimum' || diagnostic.facts[0].expected !== minimum.minimum + || diagnostic.facts[0].observed !== observed)) + throw new TypeError('SDD minimum projections disagree.'); + if (options.attempts !== undefined && options.attempts !== minimum.attempts) + throw new TypeError('SDD minimum attempts disagree.'); + diagnostic = (0, sdd_validation_diagnostic_1.normalizeSddValidationDiagnostic)({ reason: 'response-minimum', phase: 'response', + facts: [{ detail: 'minimum-length', section: 0, expected: minimum.minimum, ...(observed === undefined ? {} : { observed }) }] }); + } + if (diagnostic?.reason === 'response-minimum' && !minimum) + throw new TypeError('SDD minimum response requires its semantic recovery.'); if (options.attempts !== undefined && options.attempts !== 1 && options.attempts !== 2) throw new TypeError('Invalid SDD diagnostic attempt count.'); const message = (key) => catalog.message(key); - const recovery = options.error.recovery; const sourceRecovery = recovery?.id === 'sdd-source-context-unavailable' && (0, agent_source_snapshot_1.isSddSourceContextDiagnostic)(recovery.variables); const source = { code: options.error.code, retryable: options.error.retryable, correlationId: options.error.correlationId, - ...(recovery && (sourceRecovery || (application_error_1.APPLICATION_ERROR_RECOVERY_IDS.includes(recovery.id) + ...(recovery && (sourceRecovery || minimum || (application_error_1.APPLICATION_ERROR_RECOVERY_IDS.includes(recovery.id) && (recovery.id.startsWith('sdd-draft-') || recovery.id.startsWith('sdd-catalog-')) && recovery.variables && Object.keys(recovery.variables).length === 0)) ? { recovery } : {}) }; const view = (0, application_error_presentation_policy_1.buildApplicationErrorPresentation)(source, errorMessage ?? (0, application_error_message_catalog_1.resolveStaticApplicationErrorCatalog)(catalog.locale).message); @@ -50214,8 +50254,8 @@ function renderSddPreparationFailure(options, catalog = (0, sdd_preparation_mess '', options.publicationPending ? message('sddPreparation.publicationRetained') : message('sddPreparation.noPublication'), '', `**${message('sddPreparation.impact')}:** ${view.impact}`, - '', `**${message('sddPreparation.actionLabel')}:** ${diagnostic ? message('sddPreparation.action') + ' ' : ''}${view.action}`, - '', `**${message('sddPreparation.retainedLabel')}:** ${sourceRecovery ? view.retainedState : message('sddPreparation.retained')}`, + '', `**${message('sddPreparation.actionLabel')}:** ${diagnostic && !minimum ? message('sddPreparation.action') + ' ' : ''}${view.action}`, + '', `**${message('sddPreparation.retainedLabel')}:** ${sourceRecovery || minimum ? view.retainedState : message('sddPreparation.retained')}`, '', runUrl ? `[${message('sddPreparation.workflow')}](${runUrl})` : message('sddPreparation.runUnavailable'), '', '
', `${message('sddPreparation.diagnostics')}`, '', `**${message('sddPreparation.code')}:** \`${view.code}\``, @@ -50232,8 +50272,9 @@ function renderSddPreparationFailure(options, catalog = (0, sdd_preparation_mess } else if (diagnostic) { lines.push(`**${message('sddPreparation.reason')}:** \`${diagnostic.reason}\``, `**${message('sddPreparation.phase')}:** \`${diagnostic.phase}\``); - if (options.attempts !== undefined) - lines.push(`**${message('sddPreparation.attempts')}:** ${options.attempts}`); + const attempts = minimum?.attempts ?? options.attempts; + if (attempts !== undefined) + lines.push(`**${message('sddPreparation.attempts')}:** ${attempts}`); for (const fact of diagnostic.facts) { lines.push('', `- ${message(`sddPreparation.fact.${fact.detail}`)}`); for (const key of ['section', 'row', 'expected', 'observed']) { @@ -50243,6 +50284,9 @@ function renderSddPreparationFailure(options, catalog = (0, sdd_preparation_mess lines.push(` - ${message(`sddPreparation.${label}`)}: ${fact[key]}`); } } + if (fact.detail === 'minimum-length' && fact.observed === undefined) { + lines.push(` - ${message('sddPreparation.observed')}: ${message('sddPreparation.lengthUnavailable')}`); + } } } else @@ -65052,6 +65096,7 @@ const sdd_draft_preparation_failure_1 = __nccwpck_require__(12321); const sdd_validation_diagnostic_1 = __nccwpck_require__(34211); const pre_branch_sdd_1 = __nccwpck_require__(34730); const agent_response_length_failure_1 = __nccwpck_require__(28528); +const agent_response_schema_failure_1 = __nccwpck_require__(70809); const sdd_draft_consistency_1 = __nccwpck_require__(27910); /** Size and typed format failures share one regeneration; the writer still validates every candidate. */ async function draftSddMarkdown(agent, configuration, plan, prompt, validate, sourceSnapshot) { @@ -65078,6 +65123,9 @@ async function draftSddMarkdown(agent, configuration, plan, prompt, validate, so }); } catch (error) { + if (error instanceof agent_response_schema_failure_1.AgentResponseSchemaFailure && error.reason === 'string-short' && error.path === '$.markdown') { + throw minimumFailure(attempt); + } // Only the exact local schema's maximum-length rejection is recoverable. // Provider failures and other schema violations retain their original identity. if (!(error instanceof agent_response_length_failure_1.AgentResponseLengthFailure) || error.path !== '$.markdown' || error.maximum !== maximum) @@ -65096,6 +65144,9 @@ async function draftSddMarkdown(agent, configuration, plan, prompt, validate, so diagnostic = { reason: 'size', phase: 'response', facts: [{ detail: 'size-limit', section: 0, expected: maximum }] }; continue; } + const observedLength = [...markdown].length; + if (observedLength < pre_branch_sdd_1.SDD_MIN_DRAFT_CHARACTERS) + throw minimumFailure(attempt, observedLength); let assembledCandidate = false; try { const assembled = (0, sdd_draft_consistency_1.renderSddDerivedTables)(markdown); @@ -65124,6 +65175,11 @@ async function draftSddMarkdown(agent, configuration, plan, prompt, validate, so } throw new sdd_draft_preparation_failure_1.SddDraftPreparationFailure(diagnostic, 2); } +function minimumFailure(attempt, observed) { + return new sdd_draft_preparation_failure_1.SddDraftPreparationFailure({ reason: 'response-minimum', phase: 'response', + facts: [{ detail: 'minimum-length', section: 0, expected: pre_branch_sdd_1.SDD_MIN_DRAFT_CHARACTERS, + ...(observed === undefined ? {} : { observed }) }] }, attempt === 0 ? 1 : 2); +} /***/ }), @@ -83568,6 +83624,7 @@ const branch_sync_validation_1 = __nccwpck_require__(90981); const verification_failure_1 = __nccwpck_require__(60177); const branch_sync_catalog_diagnostic_1 = __nccwpck_require__(15677); const agent_source_snapshot_1 = __nccwpck_require__(59205); +const sdd_validation_diagnostic_1 = __nccwpck_require__(34211); exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ ...copilot_command_validation_1.COPILOT_COMMAND_VALIDATION_REASONS, 'pull-request-link-restored', @@ -83585,6 +83642,7 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-workflow-write-required', 'branch-sync-publication-failed', 'sdd-draft-size-exceeded', + 'sdd-draft-response-too-short', 'sdd-catalog-unsupported-profile', 'sdd-catalog-format-invalid', 'sdd-catalog-missing-trusted-source', @@ -83787,9 +83845,11 @@ class ApplicationError extends Error { ? 'Verified assignments and independently completed effects were retained; the failed assignment operation remains unverified.' : options.recovery?.id === 'issue-assignment-count-invalid' ? 'No assignment operation was attempted; independently completed lifecycle effects were retained.' - : options.recovery?.id === 'deployment-publication-context-missing' - ? PRESERVED_STATE - : metadata.retainedState; + : options.recovery?.id === 'sdd-draft-response-too-short' + ? 'Existing answers, selected ownership and published specifications were retained; this attempt published no specification or branch.' + : options.recovery?.id === 'deployment-publication-context-missing' + ? PRESERVED_STATE + : metadata.retainedState; this.correlationId = correlationId; this.recovery = normalizeApplicationErrorRecovery(options.recovery); __classPrivateFieldSet(this, _ApplicationError_cause, options.cause, "f"); @@ -83849,6 +83909,7 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'branch-sync-workflow-write-required': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'branch-sync-publication-failed': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'sdd-draft-size-exceeded': Object.freeze([]), + 'sdd-draft-response-too-short': Object.freeze(['minimum', 'observed', 'attempts']), 'sdd-catalog-unsupported-profile': Object.freeze([]), 'sdd-catalog-format-invalid': Object.freeze([]), 'sdd-catalog-missing-trusted-source': Object.freeze([]), @@ -83888,6 +83949,9 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'sdd-source-context-unavailable' && !(0, agent_source_snapshot_1.isSddSourceContextDiagnostic)(variables)) { throw new TypeError('SDD source context diagnostic is invalid.'); } + if (recovery.id === 'sdd-draft-response-too-short' && !(0, sdd_validation_diagnostic_1.isSddDraftMinimumDiagnostic)(variables)) { + throw new TypeError('SDD minimum response diagnostic is invalid.'); + } if ((0, copilot_command_validation_1.isCopilotCommandValidationReason)(recovery.id) && !(0, copilot_command_validation_1.isValidCopilotCommandValidation)(recovery.id, variables.command)) { throw new TypeError('Comment command validation diagnostic is invalid.'); @@ -101961,13 +102025,14 @@ exports.SddTrustedCatalogMissingFailure = SddTrustedCatalogMissingFailure; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SDD_VALIDATION_DETAILS = exports.SDD_DRAFT_FORMAT_REASONS = void 0; +exports.isSddDraftMinimumDiagnostic = isSddDraftMinimumDiagnostic; exports.normalizeSddValidationDiagnostic = normalizeSddValidationDiagnostic; /** Public diagnostics contain only closed checks and bounded ordinal/count facts. */ exports.SDD_DRAFT_FORMAT_REASONS = Object.freeze([ 'required-heading', 'test-budget', 'case-identity', 'case-budget', 'requirement-mapping', 'repository-path', ]); exports.SDD_VALIDATION_DETAILS = Object.freeze([ - 'validation-contract', 'size-limit', 'response-shape', 'required-heading', 'numeric-budget', + 'validation-contract', 'size-limit', 'response-shape', 'minimum-length', 'required-heading', 'numeric-budget', 'section-count', 'table-count', 'table-separator', 'table-columns', 'table-empty-cell', 'table-empty', 'derived-slot-count', 'derived-slot-placement', 'identifier-list', 'requirement-identity', 'case-identity', 'case-reserved', 'case-reference', 'case-shorthand', @@ -101978,8 +102043,18 @@ exports.SDD_VALIDATION_DETAILS = Object.freeze([ 'path-inline-reference', 'path-prose-reference', 'path-link-reference', 'path-reference-unsafe', 'path-reference-blocked', 'path-reference-missing', 'path-reference-directory', ]); -const REASONS = [...exports.SDD_DRAFT_FORMAT_REASONS, 'size', 'response-shape']; +const REASONS = [...exports.SDD_DRAFT_FORMAT_REASONS, 'size', 'response-shape', 'response-minimum']; const PHASES = ['response', 'assembly', 'validation']; +/** Counts authorize no publication and never carry the rejected response. */ +function isSddDraftMinimumDiagnostic(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) + || Object.keys(input).sort().join(',') !== 'attempts,minimum,observed') + return false; + const value = input; + return boundedInteger(value.minimum, 1) + && (value.observed === 'unavailable' || (boundedInteger(value.observed, 0) && Number(value.observed) < Number(value.minimum))) + && (value.attempts === 1 || value.attempts === 2); +} /** Fail closed on extra fields, arbitrary text, unbounded facts or invalid coordinates. */ function normalizeSddValidationDiagnostic(input) { if (!input || typeof input !== 'object' || Array.isArray(input) @@ -101999,6 +102074,11 @@ function normalizeSddValidationDiagnostic(input) { } return Object.freeze({ ...fact }); }); + if (input.reason === 'response-minimum' && (input.phase !== 'response' || facts.length !== 1 + || facts[0].detail !== 'minimum-length' || facts[0].section !== 0 || facts[0].row !== undefined + || !isSddDraftMinimumDiagnostic({ minimum: facts[0].expected, observed: facts[0].observed ?? 'unavailable', attempts: 1 }))) { + throw new TypeError('Invalid bounded SDD minimum diagnostic.'); + } return Object.freeze({ reason: input.reason, phase: input.phase, facts: Object.freeze(facts) }); } function boundedInteger(value, minimum) { diff --git a/build/deployment_recovery/index.js b/build/deployment_recovery/index.js index 773a30f0a..0d7f0d157 100644 --- a/build/deployment_recovery/index.js +++ b/build/deployment_recovery/index.js @@ -32372,7 +32372,7 @@ const LABEL_KEYS = Object.freeze([ ...bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, ...branch_sync_publication_messages_1.BRANCH_SYNC_PUBLICATION_LABEL_KEYS, ...issue_assignment_messages_1.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS, - 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', + 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', 'draftLengthUnavailable', ]); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState']); exports.APPLICATION_ERROR_CODES = Object.freeze(Object.keys(application_error_1.APPLICATION_ERROR_METADATA)); @@ -32394,6 +32394,7 @@ const ENGLISH_LABELS = Object.freeze({ yes: 'Yes', no: 'No', reference: 'Reference', + draftLengthUnavailable: 'unavailable', }); const SPANISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.SPANISH_REVIEW_DIAGNOSTIC_LABELS, @@ -32407,6 +32408,7 @@ const SPANISH_LABELS = Object.freeze({ yes: 'Sí', no: 'No', reference: 'Referencia', + draftLengthUnavailable: 'no disponible', }); const ENGLISH_CONTENT = Object.freeze(Object.fromEntries(exports.APPLICATION_ERROR_CODES.map(code => [code, Object.freeze({ impact: application_error_1.APPLICATION_ERROR_METADATA[code].impact, @@ -32670,6 +32672,11 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ action: 'Retry SDD preparation with the canonical specification template and the explicit acceptance requirements.', retainedState: 'Existing answers and published SDDs were preserved; no draft was published.', }), + 'sdd-draft-response-too-short': Object.freeze({ + impact: 'The SDD drafting response was below its {minimum} Unicode code-point minimum (observed: {observed}; draft attempt: {attempts}).', + action: 'Clarify in the issue description that a complete proposed Markdown document is required, preserving resolved requirements and ownership, then retry the same issue workflow. This failure does not start an automatic regeneration.', + retainedState: 'Existing answers, selected ownership and published specifications were retained; this attempt published no specification or branch.', + }), 'sdd-draft-size-exceeded': Object.freeze({ impact: 'SDD preparation stopped because the draft remained too large after one automatic size repair.', action: 'Retry SDD preparation with a proportional appendix or an approved companion specification.', @@ -32870,6 +32877,11 @@ const SPANISH_RECOVERY_CONTENT = Object.freeze({ action: 'Reintenta la preparación del SDD con la plantilla canónica y los criterios de aceptación explícitos.', retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador.', }), + 'sdd-draft-response-too-short': Object.freeze({ + impact: 'La respuesta de redacción del SDD no alcanzó el mínimo de {minimum} puntos de código Unicode (cantidad observada: {observed}; intento de borrador: {attempts}).', + action: 'Aclara en la descripción de esta incidencia que se requiere una propuesta Markdown completa, conservando los requisitos resueltos y su propietario, y repite el mismo workflow. Este fallo no inicia una regeneración automática.', + retainedState: 'Se conservaron las respuestas, la propiedad seleccionada y las especificaciones publicadas; este intento no publicó ninguna especificación ni rama.', + }), 'sdd-draft-size-exceeded': Object.freeze({ impact: 'La preparación del SDD se detuvo porque el borrador seguía siendo demasiado largo tras un intento automático de reducción.', action: 'Reintenta la preparación del SDD con un apéndice proporcional o una especificación complementaria aprobada.', @@ -33939,6 +33951,7 @@ const branch_sync_validation_1 = __nccwpck_require__(981); const verification_failure_1 = __nccwpck_require__(177); const branch_sync_catalog_diagnostic_1 = __nccwpck_require__(5677); const agent_source_snapshot_1 = __nccwpck_require__(9205); +const sdd_validation_diagnostic_1 = __nccwpck_require__(4211); exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ ...copilot_command_validation_1.COPILOT_COMMAND_VALIDATION_REASONS, 'pull-request-link-restored', @@ -33956,6 +33969,7 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-workflow-write-required', 'branch-sync-publication-failed', 'sdd-draft-size-exceeded', + 'sdd-draft-response-too-short', 'sdd-catalog-unsupported-profile', 'sdd-catalog-format-invalid', 'sdd-catalog-missing-trusted-source', @@ -34158,9 +34172,11 @@ class ApplicationError extends Error { ? 'Verified assignments and independently completed effects were retained; the failed assignment operation remains unverified.' : options.recovery?.id === 'issue-assignment-count-invalid' ? 'No assignment operation was attempted; independently completed lifecycle effects were retained.' - : options.recovery?.id === 'deployment-publication-context-missing' - ? PRESERVED_STATE - : metadata.retainedState; + : options.recovery?.id === 'sdd-draft-response-too-short' + ? 'Existing answers, selected ownership and published specifications were retained; this attempt published no specification or branch.' + : options.recovery?.id === 'deployment-publication-context-missing' + ? PRESERVED_STATE + : metadata.retainedState; this.correlationId = correlationId; this.recovery = normalizeApplicationErrorRecovery(options.recovery); __classPrivateFieldSet(this, _ApplicationError_cause, options.cause, "f"); @@ -34220,6 +34236,7 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'branch-sync-workflow-write-required': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'branch-sync-publication-failed': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'sdd-draft-size-exceeded': Object.freeze([]), + 'sdd-draft-response-too-short': Object.freeze(['minimum', 'observed', 'attempts']), 'sdd-catalog-unsupported-profile': Object.freeze([]), 'sdd-catalog-format-invalid': Object.freeze([]), 'sdd-catalog-missing-trusted-source': Object.freeze([]), @@ -34259,6 +34276,9 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'sdd-source-context-unavailable' && !(0, agent_source_snapshot_1.isSddSourceContextDiagnostic)(variables)) { throw new TypeError('SDD source context diagnostic is invalid.'); } + if (recovery.id === 'sdd-draft-response-too-short' && !(0, sdd_validation_diagnostic_1.isSddDraftMinimumDiagnostic)(variables)) { + throw new TypeError('SDD minimum response diagnostic is invalid.'); + } if ((0, copilot_command_validation_1.isCopilotCommandValidationReason)(recovery.id) && !(0, copilot_command_validation_1.isValidCopilotCommandValidation)(recovery.id, variables.command)) { throw new TypeError('Comment command validation diagnostic is invalid.'); @@ -37433,6 +37453,76 @@ function isProgressScopeCapacityFacts(value) { } +/***/ }), + +/***/ 4211: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SDD_VALIDATION_DETAILS = exports.SDD_DRAFT_FORMAT_REASONS = void 0; +exports.isSddDraftMinimumDiagnostic = isSddDraftMinimumDiagnostic; +exports.normalizeSddValidationDiagnostic = normalizeSddValidationDiagnostic; +/** Public diagnostics contain only closed checks and bounded ordinal/count facts. */ +exports.SDD_DRAFT_FORMAT_REASONS = Object.freeze([ + 'required-heading', 'test-budget', 'case-identity', 'case-budget', 'requirement-mapping', 'repository-path', +]); +exports.SDD_VALIDATION_DETAILS = Object.freeze([ + 'validation-contract', 'size-limit', 'response-shape', 'minimum-length', 'required-heading', 'numeric-budget', + 'section-count', 'table-count', 'table-separator', 'table-columns', 'table-empty-cell', 'table-empty', + 'derived-slot-count', 'derived-slot-placement', + 'identifier-list', 'requirement-identity', 'case-identity', 'case-reserved', 'case-reference', 'case-shorthand', + 'budget-area', 'budget-integer', 'budget-order', 'budget-total', 'budget-distribution', + 'requirement-reference', 'requirement-coverage', 'traceability-links', 'traceability-membership', 'traceability-requirement', + 'owner-path', 'path-identity', 'create-existing-path', 'create-path-overlap', 'create-path-parent', + 'path-operation', 'path-reference', 'path-link', + 'path-inline-reference', 'path-prose-reference', 'path-link-reference', + 'path-reference-unsafe', 'path-reference-blocked', 'path-reference-missing', 'path-reference-directory', +]); +const REASONS = [...exports.SDD_DRAFT_FORMAT_REASONS, 'size', 'response-shape', 'response-minimum']; +const PHASES = ['response', 'assembly', 'validation']; +/** Counts authorize no publication and never carry the rejected response. */ +function isSddDraftMinimumDiagnostic(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) + || Object.keys(input).sort().join(',') !== 'attempts,minimum,observed') + return false; + const value = input; + return boundedInteger(value.minimum, 1) + && (value.observed === 'unavailable' || (boundedInteger(value.observed, 0) && Number(value.observed) < Number(value.minimum))) + && (value.attempts === 1 || value.attempts === 2); +} +/** Fail closed on extra fields, arbitrary text, unbounded facts or invalid coordinates. */ +function normalizeSddValidationDiagnostic(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) + || Object.keys(input).sort().join(',') !== 'facts,phase,reason' + || !REASONS.includes(input.reason) || !PHASES.includes(input.phase) + || !Array.isArray(input.facts) || input.facts.length < 1 || input.facts.length > 8) { + throw new TypeError('Invalid bounded SDD validation diagnostic.'); + } + const facts = input.facts.map(fact => { + if (!fact || typeof fact !== 'object' || Array.isArray(fact) + || Object.keys(fact).some(key => !['detail', 'section', 'row', 'expected', 'observed'].includes(key)) + || !exports.SDD_VALIDATION_DETAILS.includes(fact.detail) || ![0, 4, 14, 15, 17].includes(fact.section) + || (fact.row !== undefined && !boundedInteger(fact.row, 1)) + || (fact.expected !== undefined && !boundedInteger(fact.expected, 0)) + || (fact.observed !== undefined && !boundedInteger(fact.observed, 0))) { + throw new TypeError('Invalid bounded SDD validation fact.'); + } + return Object.freeze({ ...fact }); + }); + if (input.reason === 'response-minimum' && (input.phase !== 'response' || facts.length !== 1 + || facts[0].detail !== 'minimum-length' || facts[0].section !== 0 || facts[0].row !== undefined + || !isSddDraftMinimumDiagnostic({ minimum: facts[0].expected, observed: facts[0].observed ?? 'unavailable', attempts: 1 }))) { + throw new TypeError('Invalid bounded SDD minimum diagnostic.'); + } + return Object.freeze({ reason: input.reason, phase: input.phase, facts: Object.freeze(facts) }); +} +function boundedInteger(value, minimum) { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= 100000; +} + + /***/ }), /***/ 7122: diff --git a/build/github_action/index.js b/build/github_action/index.js index cb8e51fa7..901bd3a61 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -39470,20 +39470,25 @@ const RECOVERY = Object.freeze({ 'required-heading': 'sdd-draft-format-invalid', 'test-budget': 'sdd-draft-format-invalid', 'case-identity': 'sdd-draft-case-identity-invalid', 'case-budget': 'sdd-draft-case-budget-invalid', 'requirement-mapping': 'sdd-draft-requirement-mapping-invalid', 'repository-path': 'sdd-draft-repository-path-invalid', - size: 'sdd-draft-size-exceeded', 'response-shape': undefined, + size: 'sdd-draft-size-exceeded', 'response-shape': undefined, 'response-minimum': undefined, }); /** Carries safe evidence to the feature-owned card without serializing candidate content. */ class SddDraftPreparationFailure extends application_error_1.ApplicationError { constructor(diagnostic, attempts) { const normalized = (0, sdd_validation_diagnostic_1.normalizeSddValidationDiagnostic)(diagnostic); - if ((attempts !== 1 && attempts !== 2) || (attempts === 1 && normalized.reason !== 'response-shape')) { + if ((attempts !== 1 && attempts !== 2) || (attempts === 1 && normalized.reason !== 'response-shape' && normalized.reason !== 'response-minimum')) { throw new TypeError('Invalid bounded SDD attempt count.'); } const id = RECOVERY[normalized.reason]; + const recovery = normalized.reason === 'response-minimum' + ? { id: 'sdd-draft-response-too-short', variables: { minimum: normalized.facts[0].expected, + observed: normalized.facts[0].observed ?? 'unavailable', attempts } } + : id ? { id, variables: {} } : undefined; const message = normalized.reason === 'response-shape' ? 'The drafting agent returned no SDD Markdown.' - : normalized.reason === 'size' ? 'The SDD draft exceeded its size limit after one bounded repair.' - : `The SDD draft still violates its ${normalized.reason} format contract after one bounded repair.`; - super('validation.invalid-input', message, id ? { recovery: { id, variables: {} } } : {}); + : normalized.reason === 'response-minimum' ? 'The SDD drafting response is shorter than the required minimum.' + : normalized.reason === 'size' ? 'The SDD draft exceeded its size limit after one bounded repair.' + : `The SDD draft still violates its ${normalized.reason} format contract after one bounded repair.`; + super('validation.invalid-input', message, recovery ? { recovery } : {}); this.attempts = attempts; this.diagnostic = normalized; } @@ -41262,7 +41267,7 @@ const LABEL_KEYS = Object.freeze([ ...bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, ...branch_sync_publication_messages_1.BRANCH_SYNC_PUBLICATION_LABEL_KEYS, ...issue_assignment_messages_1.ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS, - 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', + 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', 'draftLengthUnavailable', ]); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState']); exports.APPLICATION_ERROR_CODES = Object.freeze(Object.keys(application_error_1.APPLICATION_ERROR_METADATA)); @@ -41284,6 +41289,7 @@ const ENGLISH_LABELS = Object.freeze({ yes: 'Yes', no: 'No', reference: 'Reference', + draftLengthUnavailable: 'unavailable', }); const SPANISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.SPANISH_REVIEW_DIAGNOSTIC_LABELS, @@ -41297,6 +41303,7 @@ const SPANISH_LABELS = Object.freeze({ yes: 'Sí', no: 'No', reference: 'Referencia', + draftLengthUnavailable: 'no disponible', }); const ENGLISH_CONTENT = Object.freeze(Object.fromEntries(exports.APPLICATION_ERROR_CODES.map(code => [code, Object.freeze({ impact: application_error_1.APPLICATION_ERROR_METADATA[code].impact, @@ -41560,6 +41567,11 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ action: 'Retry SDD preparation with the canonical specification template and the explicit acceptance requirements.', retainedState: 'Existing answers and published SDDs were preserved; no draft was published.', }), + 'sdd-draft-response-too-short': Object.freeze({ + impact: 'The SDD drafting response was below its {minimum} Unicode code-point minimum (observed: {observed}; draft attempt: {attempts}).', + action: 'Clarify in the issue description that a complete proposed Markdown document is required, preserving resolved requirements and ownership, then retry the same issue workflow. This failure does not start an automatic regeneration.', + retainedState: 'Existing answers, selected ownership and published specifications were retained; this attempt published no specification or branch.', + }), 'sdd-draft-size-exceeded': Object.freeze({ impact: 'SDD preparation stopped because the draft remained too large after one automatic size repair.', action: 'Retry SDD preparation with a proportional appendix or an approved companion specification.', @@ -41760,6 +41772,11 @@ const SPANISH_RECOVERY_CONTENT = Object.freeze({ action: 'Reintenta la preparación del SDD con la plantilla canónica y los criterios de aceptación explícitos.', retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador.', }), + 'sdd-draft-response-too-short': Object.freeze({ + impact: 'La respuesta de redacción del SDD no alcanzó el mínimo de {minimum} puntos de código Unicode (cantidad observada: {observed}; intento de borrador: {attempts}).', + action: 'Aclara en la descripción de esta incidencia que se requiere una propuesta Markdown completa, conservando los requisitos resueltos y su propietario, y repite el mismo workflow. Este fallo no inicia una regeneración automática.', + retainedState: 'Se conservaron las respuestas, la propiedad seleccionada y las especificaciones publicadas; este intento no publicó ninguna especificación ni rama.', + }), 'sdd-draft-size-exceeded': Object.freeze({ impact: 'La preparación del SDD se detuvo porque el borrador seguía siendo demasiado largo tras un intento automático de reducción.', action: 'Reintenta la preparación del SDD con un apéndice proporcional o una especificación complementaria aprobada.', @@ -41897,40 +41914,43 @@ function buildApplicationErrorPresentation(error, message = application_error_me const descriptor = error.recovery ? `error.recovery.${error.recovery.id}` : `error.${error.code}`; - const variables = error.recovery?.id === 'issue-assignment-failed' - ? (0, issue_assignment_diagnostic_presentation_1.assignmentDiagnosticPresentation)(error.recovery.variables, error.code, message) - : error.recovery?.id === 'sdd-source-context-unavailable' - ? { ...(0, agent_source_snapshot_1.sourceContextDiagnostic)(error.recovery.variables) } - : error.recovery?.id === 'progress-partition-length-invalid' - ? (0, progress_partition_length_failure_1.progressPartitionLengthPresentation)(error.recovery.variables) - : error.recovery?.id === 'bugbot-review-incomplete' - ? { ...error.recovery.variables, countKind: error.recovery.variables.countKind === 'at-least' ? '≥' : '=', - resource: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_RESOURCE_LABELS[error.recovery.variables.resource]}`), - unit: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_UNIT_LABELS[error.recovery.variables.unit]}`) } - : error.recovery?.id === 'progress-inventory-incomplete' - ? (0, progress_inventory_diagnostic_1.progressInventoryPresentation)(error.recovery.variables) - : error.recovery?.id === 'progress-binary-evidence-unavailable' - ? (0, progress_binary_evidence_diagnostic_1.progressBinaryEvidencePresentation)(error.recovery.variables) - : error.recovery?.id === 'issue-workflow-admission-blocked' - ? (0, issue_workflow_diagnostic_1.issueWorkflowDiagnosticPresentation)(error.recovery.variables) - : error.recovery && (0, copilot_command_validation_1.isCopilotCommandValidationReason)(error.recovery.id) - ? (0, comment_command_validation_presentation_policy_1.commandValidationPresentationVariables)(error.recovery.id, error.recovery.variables.command) - : error.recovery?.id === 'branch-sync-verification-failed' - ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch' } - : error.recovery?.id === 'branch-sync-workflow-write-required' || error.recovery?.id === 'branch-sync-publication-failed' - ? { - reason: error.recovery.variables.reason, originalSha: error.recovery.variables.originalSha, - candidateSha: error.recovery.variables.candidateSha, observedSha: error.recovery.variables.observedSha, - pushStatus: message(error.recovery.variables.pushAttempted ? 'error.label.syncPushAttempted' : 'error.label.syncPushNotAttempted'), - remoteStatus: message(error.recovery.variables.remoteState === 'unchanged' ? 'error.label.syncRemoteUnchanged' - : error.recovery.variables.remoteState === 'changed' ? 'error.label.syncRemoteChanged' : 'error.label.syncRemoteUnverified'), - publicationGuide: BRANCH_SYNC_PUBLICATION_RECOVERY_URL, syncCommand: '/copilot sync-branch', - } - : error.recovery?.id === 'branch-sync-catalog-incompatible' - ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } - : error.recovery?.id.startsWith('stored-configuration-') - ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } - : error.recovery?.variables; + const variables = error.recovery?.id === 'sdd-draft-response-too-short' + ? { ...error.recovery.variables, observed: error.recovery.variables.observed === 'unavailable' + ? message('error.label.draftLengthUnavailable') : error.recovery.variables.observed } + : error.recovery?.id === 'issue-assignment-failed' + ? (0, issue_assignment_diagnostic_presentation_1.assignmentDiagnosticPresentation)(error.recovery.variables, error.code, message) + : error.recovery?.id === 'sdd-source-context-unavailable' + ? { ...(0, agent_source_snapshot_1.sourceContextDiagnostic)(error.recovery.variables) } + : error.recovery?.id === 'progress-partition-length-invalid' + ? (0, progress_partition_length_failure_1.progressPartitionLengthPresentation)(error.recovery.variables) + : error.recovery?.id === 'bugbot-review-incomplete' + ? { ...error.recovery.variables, countKind: error.recovery.variables.countKind === 'at-least' ? '≥' : '=', + resource: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_RESOURCE_LABELS[error.recovery.variables.resource]}`), + unit: message(`error.label.${bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_UNIT_LABELS[error.recovery.variables.unit]}`) } + : error.recovery?.id === 'progress-inventory-incomplete' + ? (0, progress_inventory_diagnostic_1.progressInventoryPresentation)(error.recovery.variables) + : error.recovery?.id === 'progress-binary-evidence-unavailable' + ? (0, progress_binary_evidence_diagnostic_1.progressBinaryEvidencePresentation)(error.recovery.variables) + : error.recovery?.id === 'issue-workflow-admission-blocked' + ? (0, issue_workflow_diagnostic_1.issueWorkflowDiagnosticPresentation)(error.recovery.variables) + : error.recovery && (0, copilot_command_validation_1.isCopilotCommandValidationReason)(error.recovery.id) + ? (0, comment_command_validation_presentation_policy_1.commandValidationPresentationVariables)(error.recovery.id, error.recovery.variables.command) + : error.recovery?.id === 'branch-sync-verification-failed' + ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch' } + : error.recovery?.id === 'branch-sync-workflow-write-required' || error.recovery?.id === 'branch-sync-publication-failed' + ? { + reason: error.recovery.variables.reason, originalSha: error.recovery.variables.originalSha, + candidateSha: error.recovery.variables.candidateSha, observedSha: error.recovery.variables.observedSha, + pushStatus: message(error.recovery.variables.pushAttempted ? 'error.label.syncPushAttempted' : 'error.label.syncPushNotAttempted'), + remoteStatus: message(error.recovery.variables.remoteState === 'unchanged' ? 'error.label.syncRemoteUnchanged' + : error.recovery.variables.remoteState === 'changed' ? 'error.label.syncRemoteChanged' : 'error.label.syncRemoteUnverified'), + publicationGuide: BRANCH_SYNC_PUBLICATION_RECOVERY_URL, syncCommand: '/copilot sync-branch', + } + : error.recovery?.id === 'branch-sync-catalog-incompatible' + ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } + : error.recovery?.id.startsWith('stored-configuration-') + ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } + : error.recovery?.variables; return { impact: message(`${descriptor}.impact`, variables), code: error.code, @@ -51934,6 +51954,7 @@ const UI = Object.freeze({ referenceOrdinal: ['Visible reference occurrence', 'Aparición de referencia visible'], "expected": ["Expected count", "Cantidad esperada"], "observed": ["Observed count", "Cantidad observada"], + lengthUnavailable: ['unavailable; the rejected response length was not retained', 'no disponible; no se conservó la longitud de la respuesta rechazada'], "recoveredHeading": ["SDD preparation status", "Estado de preparación del SDD"], "published": ["The specification publication was verified on the linked branch. Branch readiness remains subject to its separate checks.", "Se verificó la publicación de la especificación en la rama enlazada. La preparación de la rama sigue sujeta a comprobaciones independientes."], "waiting": ["Waiting for the numbered answers in the specification question card. No new specification was published.", "Se esperan las respuestas numeradas de la tarjeta de preguntas. No se publicó ninguna especificación nueva."], @@ -51945,6 +51966,7 @@ const FACTS = Object.freeze({ "validation-contract": ["A content contract was rejected; this validator supplied no more specific table facts.", "Se rechazó un contrato de contenido; este validador no proporcionó datos más concretos de tablas."], "size-limit": ["The candidate exceeded the complete draft size limit.", "El candidato superó el límite de tamaño del borrador completo."], "response-shape": ["The drafting response did not contain the required Markdown string.", "La respuesta de redacción no contenía la cadena Markdown obligatoria."], + 'minimum-length': ['The drafting Markdown response was below its declared minimum, measured in Unicode code points.', 'La respuesta Markdown de redacción no alcanzó su mínimo declarado, medido en puntos de código Unicode.'], "required-heading": ["A canonical heading is missing; the row identifies its position in the required-heading list.", "Falta una sección canónica; la fila indica su posición en la lista de secciones obligatorias."], "numeric-budget": ["No valid numeric test budget was found.", "No se encontró un presupuesto numérico de pruebas válido."], "section-count": ["The required section is missing or repeated.", "La sección obligatoria falta o está repetida."], @@ -52056,14 +52078,32 @@ function renderSddPreparationFailure(options, catalog = (0, sdd_preparation_mess if (options.catalogReason !== undefined && (!sdd_catalog_compatibility_1.SDD_CATALOG_COMPATIBILITY_REASONS.includes(options.catalogReason) || options.diagnostic)) { throw new TypeError('Invalid exclusive catalog compatibility facts.'); } - const diagnostic = options.diagnostic ? (0, sdd_validation_diagnostic_1.normalizeSddValidationDiagnostic)(options.diagnostic) : undefined; + const recovery = options.error.recovery; + const minimum = recovery?.id === 'sdd-draft-response-too-short' && (0, sdd_validation_diagnostic_1.isSddDraftMinimumDiagnostic)(recovery.variables) ? recovery.variables : undefined; + if (recovery?.id === 'sdd-draft-response-too-short' && !minimum) + throw new TypeError('Invalid SDD minimum recovery facts.'); + let diagnostic = options.diagnostic ? (0, sdd_validation_diagnostic_1.normalizeSddValidationDiagnostic)(options.diagnostic) : undefined; + if (minimum) { + if (options.catalogReason !== undefined || missingCatalogSource !== undefined || options.publicationPending) { + throw new TypeError('SDD minimum response facts cannot describe another failure phase.'); + } + const observed = minimum.observed === 'unavailable' ? undefined : minimum.observed; + if (diagnostic && (diagnostic.reason !== 'response-minimum' || diagnostic.facts[0].expected !== minimum.minimum + || diagnostic.facts[0].observed !== observed)) + throw new TypeError('SDD minimum projections disagree.'); + if (options.attempts !== undefined && options.attempts !== minimum.attempts) + throw new TypeError('SDD minimum attempts disagree.'); + diagnostic = (0, sdd_validation_diagnostic_1.normalizeSddValidationDiagnostic)({ reason: 'response-minimum', phase: 'response', + facts: [{ detail: 'minimum-length', section: 0, expected: minimum.minimum, ...(observed === undefined ? {} : { observed }) }] }); + } + if (diagnostic?.reason === 'response-minimum' && !minimum) + throw new TypeError('SDD minimum response requires its semantic recovery.'); if (options.attempts !== undefined && options.attempts !== 1 && options.attempts !== 2) throw new TypeError('Invalid SDD diagnostic attempt count.'); const message = (key) => catalog.message(key); - const recovery = options.error.recovery; const sourceRecovery = recovery?.id === 'sdd-source-context-unavailable' && (0, agent_source_snapshot_1.isSddSourceContextDiagnostic)(recovery.variables); const source = { code: options.error.code, retryable: options.error.retryable, correlationId: options.error.correlationId, - ...(recovery && (sourceRecovery || (application_error_1.APPLICATION_ERROR_RECOVERY_IDS.includes(recovery.id) + ...(recovery && (sourceRecovery || minimum || (application_error_1.APPLICATION_ERROR_RECOVERY_IDS.includes(recovery.id) && (recovery.id.startsWith('sdd-draft-') || recovery.id.startsWith('sdd-catalog-')) && recovery.variables && Object.keys(recovery.variables).length === 0)) ? { recovery } : {}) }; const view = (0, application_error_presentation_policy_1.buildApplicationErrorPresentation)(source, errorMessage ?? (0, application_error_message_catalog_1.resolveStaticApplicationErrorCatalog)(catalog.locale).message); @@ -52073,8 +52113,8 @@ function renderSddPreparationFailure(options, catalog = (0, sdd_preparation_mess '', options.publicationPending ? message('sddPreparation.publicationRetained') : message('sddPreparation.noPublication'), '', `**${message('sddPreparation.impact')}:** ${view.impact}`, - '', `**${message('sddPreparation.actionLabel')}:** ${diagnostic ? message('sddPreparation.action') + ' ' : ''}${view.action}`, - '', `**${message('sddPreparation.retainedLabel')}:** ${sourceRecovery ? view.retainedState : message('sddPreparation.retained')}`, + '', `**${message('sddPreparation.actionLabel')}:** ${diagnostic && !minimum ? message('sddPreparation.action') + ' ' : ''}${view.action}`, + '', `**${message('sddPreparation.retainedLabel')}:** ${sourceRecovery || minimum ? view.retainedState : message('sddPreparation.retained')}`, '', runUrl ? `[${message('sddPreparation.workflow')}](${runUrl})` : message('sddPreparation.runUnavailable'), '', '
', `${message('sddPreparation.diagnostics')}`, '', `**${message('sddPreparation.code')}:** \`${view.code}\``, @@ -52091,8 +52131,9 @@ function renderSddPreparationFailure(options, catalog = (0, sdd_preparation_mess } else if (diagnostic) { lines.push(`**${message('sddPreparation.reason')}:** \`${diagnostic.reason}\``, `**${message('sddPreparation.phase')}:** \`${diagnostic.phase}\``); - if (options.attempts !== undefined) - lines.push(`**${message('sddPreparation.attempts')}:** ${options.attempts}`); + const attempts = minimum?.attempts ?? options.attempts; + if (attempts !== undefined) + lines.push(`**${message('sddPreparation.attempts')}:** ${attempts}`); for (const fact of diagnostic.facts) { lines.push('', `- ${message(`sddPreparation.fact.${fact.detail}`)}`); for (const key of ['section', 'row', 'expected', 'observed']) { @@ -52102,6 +52143,9 @@ function renderSddPreparationFailure(options, catalog = (0, sdd_preparation_mess lines.push(` - ${message(`sddPreparation.${label}`)}: ${fact[key]}`); } } + if (fact.detail === 'minimum-length' && fact.observed === undefined) { + lines.push(` - ${message('sddPreparation.observed')}: ${message('sddPreparation.lengthUnavailable')}`); + } } } else @@ -63460,6 +63504,7 @@ const sdd_draft_preparation_failure_1 = __nccwpck_require__(12321); const sdd_validation_diagnostic_1 = __nccwpck_require__(34211); const pre_branch_sdd_1 = __nccwpck_require__(34730); const agent_response_length_failure_1 = __nccwpck_require__(28528); +const agent_response_schema_failure_1 = __nccwpck_require__(70809); const sdd_draft_consistency_1 = __nccwpck_require__(27910); /** Size and typed format failures share one regeneration; the writer still validates every candidate. */ async function draftSddMarkdown(agent, configuration, plan, prompt, validate, sourceSnapshot) { @@ -63486,6 +63531,9 @@ async function draftSddMarkdown(agent, configuration, plan, prompt, validate, so }); } catch (error) { + if (error instanceof agent_response_schema_failure_1.AgentResponseSchemaFailure && error.reason === 'string-short' && error.path === '$.markdown') { + throw minimumFailure(attempt); + } // Only the exact local schema's maximum-length rejection is recoverable. // Provider failures and other schema violations retain their original identity. if (!(error instanceof agent_response_length_failure_1.AgentResponseLengthFailure) || error.path !== '$.markdown' || error.maximum !== maximum) @@ -63504,6 +63552,9 @@ async function draftSddMarkdown(agent, configuration, plan, prompt, validate, so diagnostic = { reason: 'size', phase: 'response', facts: [{ detail: 'size-limit', section: 0, expected: maximum }] }; continue; } + const observedLength = [...markdown].length; + if (observedLength < pre_branch_sdd_1.SDD_MIN_DRAFT_CHARACTERS) + throw minimumFailure(attempt, observedLength); let assembledCandidate = false; try { const assembled = (0, sdd_draft_consistency_1.renderSddDerivedTables)(markdown); @@ -63532,6 +63583,11 @@ async function draftSddMarkdown(agent, configuration, plan, prompt, validate, so } throw new sdd_draft_preparation_failure_1.SddDraftPreparationFailure(diagnostic, 2); } +function minimumFailure(attempt, observed) { + return new sdd_draft_preparation_failure_1.SddDraftPreparationFailure({ reason: 'response-minimum', phase: 'response', + facts: [{ detail: 'minimum-length', section: 0, expected: pre_branch_sdd_1.SDD_MIN_DRAFT_CHARACTERS, + ...(observed === undefined ? {} : { observed }) }] }, attempt === 0 ? 1 : 2); +} /***/ }), @@ -74349,6 +74405,7 @@ const branch_sync_validation_1 = __nccwpck_require__(90981); const verification_failure_1 = __nccwpck_require__(60177); const branch_sync_catalog_diagnostic_1 = __nccwpck_require__(15677); const agent_source_snapshot_1 = __nccwpck_require__(59205); +const sdd_validation_diagnostic_1 = __nccwpck_require__(34211); exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ ...copilot_command_validation_1.COPILOT_COMMAND_VALIDATION_REASONS, 'pull-request-link-restored', @@ -74366,6 +74423,7 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-workflow-write-required', 'branch-sync-publication-failed', 'sdd-draft-size-exceeded', + 'sdd-draft-response-too-short', 'sdd-catalog-unsupported-profile', 'sdd-catalog-format-invalid', 'sdd-catalog-missing-trusted-source', @@ -74568,9 +74626,11 @@ class ApplicationError extends Error { ? 'Verified assignments and independently completed effects were retained; the failed assignment operation remains unverified.' : options.recovery?.id === 'issue-assignment-count-invalid' ? 'No assignment operation was attempted; independently completed lifecycle effects were retained.' - : options.recovery?.id === 'deployment-publication-context-missing' - ? PRESERVED_STATE - : metadata.retainedState; + : options.recovery?.id === 'sdd-draft-response-too-short' + ? 'Existing answers, selected ownership and published specifications were retained; this attempt published no specification or branch.' + : options.recovery?.id === 'deployment-publication-context-missing' + ? PRESERVED_STATE + : metadata.retainedState; this.correlationId = correlationId; this.recovery = normalizeApplicationErrorRecovery(options.recovery); __classPrivateFieldSet(this, _ApplicationError_cause, options.cause, "f"); @@ -74630,6 +74690,7 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'branch-sync-workflow-write-required': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'branch-sync-publication-failed': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'sdd-draft-size-exceeded': Object.freeze([]), + 'sdd-draft-response-too-short': Object.freeze(['minimum', 'observed', 'attempts']), 'sdd-catalog-unsupported-profile': Object.freeze([]), 'sdd-catalog-format-invalid': Object.freeze([]), 'sdd-catalog-missing-trusted-source': Object.freeze([]), @@ -74669,6 +74730,9 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'sdd-source-context-unavailable' && !(0, agent_source_snapshot_1.isSddSourceContextDiagnostic)(variables)) { throw new TypeError('SDD source context diagnostic is invalid.'); } + if (recovery.id === 'sdd-draft-response-too-short' && !(0, sdd_validation_diagnostic_1.isSddDraftMinimumDiagnostic)(variables)) { + throw new TypeError('SDD minimum response diagnostic is invalid.'); + } if ((0, copilot_command_validation_1.isCopilotCommandValidationReason)(recovery.id) && !(0, copilot_command_validation_1.isValidCopilotCommandValidation)(recovery.id, variables.command)) { throw new TypeError('Comment command validation diagnostic is invalid.'); @@ -92961,13 +93025,14 @@ exports.SddTrustedCatalogMissingFailure = SddTrustedCatalogMissingFailure; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SDD_VALIDATION_DETAILS = exports.SDD_DRAFT_FORMAT_REASONS = void 0; +exports.isSddDraftMinimumDiagnostic = isSddDraftMinimumDiagnostic; exports.normalizeSddValidationDiagnostic = normalizeSddValidationDiagnostic; /** Public diagnostics contain only closed checks and bounded ordinal/count facts. */ exports.SDD_DRAFT_FORMAT_REASONS = Object.freeze([ 'required-heading', 'test-budget', 'case-identity', 'case-budget', 'requirement-mapping', 'repository-path', ]); exports.SDD_VALIDATION_DETAILS = Object.freeze([ - 'validation-contract', 'size-limit', 'response-shape', 'required-heading', 'numeric-budget', + 'validation-contract', 'size-limit', 'response-shape', 'minimum-length', 'required-heading', 'numeric-budget', 'section-count', 'table-count', 'table-separator', 'table-columns', 'table-empty-cell', 'table-empty', 'derived-slot-count', 'derived-slot-placement', 'identifier-list', 'requirement-identity', 'case-identity', 'case-reserved', 'case-reference', 'case-shorthand', @@ -92978,8 +93043,18 @@ exports.SDD_VALIDATION_DETAILS = Object.freeze([ 'path-inline-reference', 'path-prose-reference', 'path-link-reference', 'path-reference-unsafe', 'path-reference-blocked', 'path-reference-missing', 'path-reference-directory', ]); -const REASONS = [...exports.SDD_DRAFT_FORMAT_REASONS, 'size', 'response-shape']; +const REASONS = [...exports.SDD_DRAFT_FORMAT_REASONS, 'size', 'response-shape', 'response-minimum']; const PHASES = ['response', 'assembly', 'validation']; +/** Counts authorize no publication and never carry the rejected response. */ +function isSddDraftMinimumDiagnostic(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) + || Object.keys(input).sort().join(',') !== 'attempts,minimum,observed') + return false; + const value = input; + return boundedInteger(value.minimum, 1) + && (value.observed === 'unavailable' || (boundedInteger(value.observed, 0) && Number(value.observed) < Number(value.minimum))) + && (value.attempts === 1 || value.attempts === 2); +} /** Fail closed on extra fields, arbitrary text, unbounded facts or invalid coordinates. */ function normalizeSddValidationDiagnostic(input) { if (!input || typeof input !== 'object' || Array.isArray(input) @@ -92999,6 +93074,11 @@ function normalizeSddValidationDiagnostic(input) { } return Object.freeze({ ...fact }); }); + if (input.reason === 'response-minimum' && (input.phase !== 'response' || facts.length !== 1 + || facts[0].detail !== 'minimum-length' || facts[0].section !== 0 || facts[0].row !== undefined + || !isSddDraftMinimumDiagnostic({ minimum: facts[0].expected, observed: facts[0].observed ?? 'unavailable', attempts: 1 }))) { + throw new TypeError('Invalid bounded SDD minimum diagnostic.'); + } return Object.freeze({ reason: input.reason, phase: input.phase, facts: Object.freeze(facts) }); } function boundedInteger(value, minimum) { diff --git a/docs/issues/pre-branch-sdds.mdx b/docs/issues/pre-branch-sdds.mdx index 1ced70e0d..cdcf215c0 100644 --- a/docs/issues/pre-branch-sdds.mdx +++ b/docs/issues/pre-branch-sdds.mdx @@ -465,6 +465,32 @@ requirements or change the acceptance limits below. Codex receives a compatible closed JSON shape; Copilot enforces the complete original string-length schema locally before any document reaches the writer. +#### Undersized draft response + +The Markdown response must contain at least **1,800 Unicode code points**. +`validation.invalid-input` with `sdd-draft-response-too-short` identifies a +drafting response below that minimum. The card and final error show the minimum +and draft attempt, plus a measured count when available. A local adapter +rejection that retained no response length says **unavailable**, rather than +guessing zero or using the length of the proposal you supplied. Unicode code +points count an astral character, such as an emoji, once rather than as two +UTF-16 code units. + +Clarify in the **issue description** that Copilot must return the complete +proposed Markdown document, preserving the resolved requirements and selected +owner, then retry the same issue workflow. Existing answers and published SDDs +remain; publication still requires every validation and freshness check. This +diagnosis does not establish why the model returned a short response or promise +that an unchanged retry will succeed. + +A minimum failure starts no automatic regeneration. It can occur on draft +attempt 1, or on attempt 2 after the existing size/format repair; no third +query is added. Oversized responses keep their existing bounded repair below. +Malformed JSON, missing/nonstring Markdown and actual provider failures retain +their own identities; no rejected candidate is published or downloadable. + +#### Size and format repair + The provider writes the requirements and explicit case inventory once. Copilot calculates the final area counts, total and reciprocal requirement links before validating the document. Every resolved acceptance minimum and scenario must diff --git a/docs/security-operations/operations/error-reference.mdx b/docs/security-operations/operations/error-reference.mdx index fb41346e3..7ed3aa630 100644 --- a/docs/security-operations/operations/error-reference.mdx +++ b/docs/security-operations/operations/error-reference.mdx @@ -78,6 +78,16 @@ is not proof that the candidate or validator is correct. Existing specifications answers remain; see [blocked preparation recovery](/issues/pre-branch-sdds#when-specification-preparation-is-blocked). +Undersized draft responses use nonretryable `validation.invalid-input` with +`sdd-draft-response-too-short`. The validated recovery contains only `minimum`, +`observed` and `attempts`; its phase is draft response. The minimum remains +1,800 Unicode code points. An unretained observed length is `unavailable`, not +zero. Clarify the complete proposed Markdown requirement in the issue description, +preserving resolved requirements and ownership, then retry its normal workflow. +Existing answers, owner and specifications remain. This cause adds no generation +attempt, relaxes no acceptance bound and is separate from provider unavailability. +See [undersized draft recovery](/issues/pre-branch-sdds#undersized-draft-response). + SDD source-view failures use `validation.evidence-unavailable` with `sdd-source-context-unavailable`. The only recovery facts are the immutable `sourceCommit`, `phase` and closed `reason`; unverified identity is `none` / diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 0ee7c328e..2d201ac8b 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -20,7 +20,7 @@ debt or convert unknown historic intent into a design decision. | `local-web-setup-assistant` | Proposed | Offer a packaged, loopback-only Svelte setup interface over the existing engine with four-language guidance, English CLI help, basic/custom first-run paths, editable question and plan review, source-labelled GitHub facts, assisted CI and Project evidence, role-separated PATs, and truthful itemized outcomes; inspect existing configuration and apply independent, scope-aware runtime adjustments with permission, drift and read-back verification | [Local web setup assistant](./local-web-setup-assistant.md) + 2 companion | 248 paths · 2026-10-21 | | `guided-bot-pat-onboarding` | Proposed | Guide creation of the persistent workflow PAT, verify bot identity and grants, recover explicitly from wrong-account input within the session, and install the approved Actions Secret | [Guided bot PAT onboarding](./guided-bot-pat-onboarding.md) | 39 paths · 2026-10-07 | | `temporary-setup-operator-authorization` | Proposed | Guide creation of the one-run operator PAT through GitHub's official form, verify final setup access, and report user-owned deletion accurately | [Assisted setup PAT creation](./temporary-setup-operator-authorization.md) | 36 paths · 2026-10-18 | -| `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons and preserve complete navigable catalog links for accepted repository filenames and safely publish generated catalogs without following destination links; bootstrap truthful proposed SDK capabilities before implementation without changing readiness; reject incomplete specification discovery explicitly without following unregistered symbolic links; report confirmed missing trusted catalogs with immutable source provenance and prerequisite recovery before model or publication; bind pre-branch analysis and drafting to a verified independent read-only view of the selected immutable source without changing the canonical checkout; distinguish visible protocol notation from repository citations, allow only constructive proposed-file directory ancestry and expose bounded private-safe origin diagnostics | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 6 companion | 164 paths · 2026-11-03 | +| `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons and preserve complete navigable catalog links for accepted repository filenames and safely publish generated catalogs without following destination links; bootstrap truthful proposed SDK capabilities before implementation without changing readiness; reject incomplete specification discovery explicitly without following unregistered symbolic links; report confirmed missing trusted catalogs with immutable source provenance and prerequisite recovery before model or publication; bind pre-branch analysis and drafting to a verified independent read-only view of the selected immutable source without changing the canonical checkout; distinguish visible protocol notation from repository citations, allow only constructive proposed-file directory ancestry and expose bounded private-safe origin diagnostics | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 7 companion | 168 paths · 2026-11-04 | | `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) + 1 companion | 101 paths · 2026-11-03 | | `comment-automation` | Implemented | Admit only explicit commands or exact mentions, then route them while protecting repository mutations | [Comment automation and authorization](./comment-automation-and-authorization.md) | 88 paths · 2026-10-26 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, exhaustively analyze bounded diff partitions with version-grounded dependency, policy-to-request transformation and source-bound assertion and generated-source coordinates with independent candidate verification, configuration-selected initial-session credential ownership, delegated test-path and external Action input-contract, host-aware self-repository references and Kotlin/Wasm lexical-binding and renderer-grounded Markdown path evidence, publish stable findings atomically, and apply authorized verified fixes with regression coverage for cwd/pattern-grounded indirect unittest discovery and source-bound module execution evidence, with active-instance Kotlin Json nullable-input policy grounding; verify complete large diffs through a source-bound closed framed profile with immutable local Git proof, live-base freshness and atomic global publication; ground Python exception syntax in declared interpreter versions and PEP 758 while preserving older-target and independent runtime defects; ground filesystem recovery findings in retention/no-overwrite, ownership and publication contracts while preserving required disposable cleanup and data-loss defects; independently adjudicate candidates for supplied Python source, retaining existing bounded source, identity and history controls; trace concrete helper defaults/overrides, database-file parent containment and stored query values before judging assertions, preserving exact fragment coordinates and genuine assertion failures; co-deliver complete long discussion to every source-bound partition within closed native prompt and invocation traffic limits, preserving provenance and truthful provider partial coverage; verify actual resource content, source binding, human authority and intended entry availability before reporting an authentication defect; ground asynchronous findings in complete statements, actual receiver/cycle/caller and reached lifecycle effects, preserving genuine unfinished updates and omitting disproved claims | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 9 companion | 211 paths · 2026-11-03 | @@ -148,12 +148,12 @@ debt or convert unknown historic intent into a design decision. ### `issue-start-and-sdd-readiness` — Uniform issue start and pre-branch SDD readiness - Owner: Copilot maintainers -- Last verified: 2026-11-03 -- Specifications: [`specs/issue-start-and-branch-readiness.md`](./issue-start-and-branch-readiness.md) · [`specs/pre-branch-sdd-gate.md`](./pre-branch-sdd-gate.md) · [`specs/pre-branch-sdd-diff.md`](./pre-branch-sdd-diff.md) · [`specs/pre-branch-sdd-status-readiness.md`](./pre-branch-sdd-status-readiness.md) · [`specs/pre-branch-sdd-source-context.md`](./pre-branch-sdd-source-context.md) · [`specs/sdd-reference-classification.md`](./sdd-reference-classification.md) · [`specs/sdd-case-reference-classification.md`](./sdd-case-reference-classification.md) +- Last verified: 2026-11-04 +- Specifications: [`specs/issue-start-and-branch-readiness.md`](./issue-start-and-branch-readiness.md) · [`specs/pre-branch-sdd-gate.md`](./pre-branch-sdd-gate.md) · [`specs/pre-branch-sdd-diff.md`](./pre-branch-sdd-diff.md) · [`specs/pre-branch-sdd-status-readiness.md`](./pre-branch-sdd-status-readiness.md) · [`specs/pre-branch-sdd-source-context.md`](./pre-branch-sdd-source-context.md) · [`specs/sdd-draft-response-minimum.md`](./sdd-draft-response-minimum.md) · [`specs/sdd-reference-classification.md`](./sdd-reference-classification.md) · [`specs/sdd-case-reference-classification.md`](./sdd-case-reference-classification.md) - Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) - Entrypoints: [`action.yml`](../action.yml) · [`src/actions/common_action.ts`](../src/actions/common_action.ts) · [`src/actions/github_action.ts`](../src/actions/github_action.ts) - Core code: [`src/domain/issue_workflow_runtime_policy.ts`](../src/domain/issue_workflow_runtime_policy.ts) · [`src/domain/copilot_lifecycle.ts`](../src/domain/copilot_lifecycle.ts) · [`src/data/model/execution.ts`](../src/data/model/execution.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.ts) · [`src/application/usecases/steps/issue/prepare_branches_use_case.ts`](../src/application/usecases/steps/issue/prepare_branches_use_case.ts) · [`src/application/policies/setup_issue_workflow_policy.ts`](../src/application/policies/setup_issue_workflow_policy.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/infrastructure/git_commit_adapter.ts`](../src/infrastructure/git_commit_adapter.ts) · [`scripts/validate-specification-catalog.cjs`](../scripts/validate-specification-catalog.cjs) · [`src/domain/issue_start_policy.ts`](../src/domain/issue_start_policy.ts) · [`src/domain/pre_branch_sdd.ts`](../src/domain/pre_branch_sdd.ts) · [`src/application/ports/pre_branch_sdd_ports.ts`](../src/application/ports/pre_branch_sdd_ports.ts) · [`src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts`](../src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts) · [`src/application/usecases/steps/issue/reconcile_branch_readiness_use_case.ts`](../src/application/usecases/steps/issue/reconcile_branch_readiness_use_case.ts) · [`src/data/repository/branch/linked_branch_readiness_repository.ts`](../src/data/repository/branch/linked_branch_readiness_repository.ts) · [`src/infrastructure/pre_branch_sdd_workspace_adapter.ts`](../src/infrastructure/pre_branch_sdd_workspace_adapter.ts) · [`src/infrastructure/specification_catalog_validator.cjs`](../src/infrastructure/specification_catalog_validator.cjs) · [`src/actions/input_boolean_policy.ts`](../src/actions/input_boolean_policy.ts) · [`src/domain/issue_workflow_profile.ts`](../src/domain/issue_workflow_profile.ts) · [`src/application/usecases/read_copilot_status_workflow.ts`](../src/application/usecases/read_copilot_status_workflow.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) · [`src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts`](../src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/domain/agent_response_length_failure.ts`](../src/domain/agent_response_length_failure.ts) · [`src/data/repository/agent_json_schema_validator.ts`](../src/data/repository/agent_json_schema_validator.ts) · [`src/application/ports/sdd_evidence_read_ports.ts`](../src/application/ports/sdd_evidence_read_ports.ts) · [`src/application/policies/sdd_issue_identity_policy.ts`](../src/application/policies/sdd_issue_identity_policy.ts) · [`src/application/policies/sdd_query_card_policy.ts`](../src/application/policies/sdd_query_card_policy.ts) · [`src/application/policies/sdd_status_policy.ts`](../src/application/policies/sdd_status_policy.ts) · [`src/application/usecases/sdd/sdd_evidence_query_use_case.ts`](../src/application/usecases/sdd/sdd_evidence_query_use_case.ts) · [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.ts) · [`src/application/usecases/comment_automation_command_workflow.ts`](../src/application/usecases/comment_automation_command_workflow.ts) · [`src/application/usecases/comment_automation_contracts.ts`](../src/application/usecases/comment_automation_contracts.ts) · [`src/application/usecases/issue_comment_use_case.ts`](../src/application/usecases/issue_comment_use_case.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/infrastructure/composition/sdd_evidence_composition_root.ts`](../src/infrastructure/composition/sdd_evidence_composition_root.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/domain/pre_branch_sdd_diff.ts`](../src/domain/pre_branch_sdd_diff.ts) · [`src/application/policies/sdd_diff_message_catalog.ts`](../src/application/policies/sdd_diff_message_catalog.ts) · [`src/application/policies/sdd_diff_presentation_policy.ts`](../src/application/policies/sdd_diff_presentation_policy.ts) · [`src/application/ports/sdd_diff_read_ports.ts`](../src/application/ports/sdd_diff_read_ports.ts) · [`src/application/usecases/sdd/sdd_diff_query_use_case.ts`](../src/application/usecases/sdd/sdd_diff_query_use_case.ts) · [`src/data/repository/sdd/sdd_diff_link_repository.ts`](../src/data/repository/sdd/sdd_diff_link_repository.ts) · [`src/infrastructure/composition/sdd_diff_composition_root.ts`](../src/infrastructure/composition/sdd_diff_composition_root.ts) · [`src/application/usecases/pull_request_review_comment_use_case.ts`](../src/application/usecases/pull_request_review_comment_use_case.ts) · [`src/application/usecases/steps/common/reply_publication_workflow.ts`](../src/application/usecases/steps/common/reply_publication_workflow.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/architecture/github_publication_mutation_baseline.json`](../src/architecture/github_publication_mutation_baseline.json) · [`src/domain/specification_catalog_path_policy.cjs`](../src/domain/specification_catalog_path_policy.cjs) · [`src/application/policies/github_runtime_doctor_policy.ts`](../src/application/policies/github_runtime_doctor_policy.ts) · [`src/infrastructure/setup_workspace_adapter.ts`](../src/infrastructure/setup_workspace_adapter.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/domain/sdd_prerequisite_policy.cjs`](../src/domain/sdd_prerequisite_policy.cjs) · [`src/application/policies/sdd_analysis_status_policy.ts`](../src/application/policies/sdd_analysis_status_policy.ts) · [`src/domain/sdd_draft_consistency.ts`](../src/domain/sdd_draft_consistency.ts) · [`src/domain/sdd_validation_diagnostic.ts`](../src/domain/sdd_validation_diagnostic.ts) · [`src/application/errors/sdd_draft_preparation_failure.ts`](../src/application/errors/sdd_draft_preparation_failure.ts) · [`src/application/policies/sdd_preparation_message_catalog.ts`](../src/application/policies/sdd_preparation_message_catalog.ts) · [`src/application/policies/sdd_preparation_status_policy.ts`](../src/application/policies/sdd_preparation_status_policy.ts) · [`src/domain/sdd_catalog_compatibility.ts`](../src/domain/sdd_catalog_compatibility.ts) · [`src/infrastructure/specification_catalog_publication.cjs`](../src/infrastructure/specification_catalog_publication.cjs) · [`jest.config.js`](../jest.config.js) · [`src/domain/sdd_trusted_catalog_failure.ts`](../src/domain/sdd_trusted_catalog_failure.ts) · [`src/domain/agent_source_snapshot.ts`](../src/domain/agent_source_snapshot.ts) · [`src/application/policies/agent_source_snapshot_policy.ts`](../src/application/policies/agent_source_snapshot_policy.ts) · [`src/infrastructure/agents/agent_source_snapshot_git.ts`](../src/infrastructure/agents/agent_source_snapshot_git.ts) · [`src/infrastructure/agents/agent_source_snapshot_workspace.ts`](../src/infrastructure/agents/agent_source_snapshot_workspace.ts) · [`src/infrastructure/agents/agent_source_snapshot_integrity.ts`](../src/infrastructure/agents/agent_source_snapshot_integrity.ts) · [`src/domain/sdd_repository_reference.ts`](../src/domain/sdd_repository_reference.ts) · [`src/domain/sdd_visible_references.ts`](../src/domain/sdd_visible_references.ts) · [`src/domain/sdd_case_reference.ts`](../src/domain/sdd_case_reference.ts) -- Tests: [`src/domain/__tests__/issue_workflow_runtime_policy.test.ts`](../src/domain/__tests__/issue_workflow_runtime_policy.test.ts) · [`src/data/model/__tests__/execution.test.ts`](../src/data/model/__tests__/execution.test.ts) · [`src/application/usecases/__tests__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts) · [`src/application/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) · [`src/tooling/__tests__/validate_specification_catalog.test.ts`](../src/tooling/__tests__/validate_specification_catalog.test.ts) · [`src/domain/__tests__/issue_start_policy.test.ts`](../src/domain/__tests__/issue_start_policy.test.ts) · [`src/domain/__tests__/pre_branch_sdd.test.ts`](../src/domain/__tests__/pre_branch_sdd.test.ts) · [`src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/reconcile_branch_readiness_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/reconcile_branch_readiness_use_case.test.ts) · [`src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts`](../src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts) · [`src/architecture/__tests__/github_publication_boundaries.test.ts`](../src/architecture/__tests__/github_publication_boundaries.test.ts) · [`src/actions/__tests__/input_boolean_policy.test.ts`](../src/actions/__tests__/input_boolean_policy.test.ts) · [`src/domain/__tests__/issue_workflow_profile.test.ts`](../src/domain/__tests__/issue_workflow_profile.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts`](../src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/data/repository/__tests__/agent_json_schema_validator.test.ts`](../src/data/repository/__tests__/agent_json_schema_validator.test.ts) · [`src/application/policies/__tests__/sdd_issue_identity_policy.test.ts`](../src/application/policies/__tests__/sdd_issue_identity_policy.test.ts) · [`src/application/policies/__tests__/sdd_query_card_policy.test.ts`](../src/application/policies/__tests__/sdd_query_card_policy.test.ts) · [`src/application/policies/__tests__/sdd_status_policy.test.ts`](../src/application/policies/__tests__/sdd_status_policy.test.ts) · [`src/application/usecases/sdd/__tests__/sdd_evidence_query_use_case.test.ts`](../src/application/usecases/sdd/__tests__/sdd_evidence_query_use_case.test.ts) · [`src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts`](../src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts) · [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts`](../src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts) · [`src/domain/__tests__/pre_branch_sdd_diff.test.ts`](../src/domain/__tests__/pre_branch_sdd_diff.test.ts) · [`src/application/policies/__tests__/sdd_diff_presentation_policy.test.ts`](../src/application/policies/__tests__/sdd_diff_presentation_policy.test.ts) · [`src/application/usecases/sdd/__tests__/sdd_diff_query_use_case.test.ts`](../src/application/usecases/sdd/__tests__/sdd_diff_query_use_case.test.ts) · [`src/data/repository/sdd/__tests__/sdd_diff_link_repository.test.ts`](../src/data/repository/sdd/__tests__/sdd_diff_link_repository.test.ts) · [`src/infrastructure/composition/__tests__/sdd_diff_composition_root.test.ts`](../src/infrastructure/composition/__tests__/sdd_diff_composition_root.test.ts) · [`src/application/usecases/__tests__/sdd_diff_comment_interaction.test.ts`](../src/application/usecases/__tests__/sdd_diff_comment_interaction.test.ts) · [`src/application/usecases/steps/common/__tests__/sdd_diff_reply_publication.test.ts`](../src/application/usecases/steps/common/__tests__/sdd_diff_reply_publication.test.ts) · [`src/application/usecases/__tests__/sdd_status_workflow_integration.test.ts`](../src/application/usecases/__tests__/sdd_status_workflow_integration.test.ts) · [`src/tooling/__tests__/portable_specification_catalog.test.ts`](../src/tooling/__tests__/portable_specification_catalog.test.ts) · [`src/application/policies/__tests__/github_runtime_doctor_policy.test.ts`](../src/application/policies/__tests__/github_runtime_doctor_policy.test.ts) · [`src/infrastructure/__tests__/setup_sdd_prerequisites.test.ts`](../src/infrastructure/__tests__/setup_sdd_prerequisites.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts`](../src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/domain/__tests__/sdd_draft_consistency.test.ts`](../src/domain/__tests__/sdd_draft_consistency.test.ts) · [`src/domain/__tests__/sdd_derived_tables.test.ts`](../src/domain/__tests__/sdd_derived_tables.test.ts) · [`src/domain/__tests__/sdd_validation_diagnostic.test.ts`](../src/domain/__tests__/sdd_validation_diagnostic.test.ts) · [`src/application/policies/__tests__/sdd_preparation_status_policy.test.ts`](../src/application/policies/__tests__/sdd_preparation_status_policy.test.ts) · [`src/domain/__tests__/sdd_catalog_compatibility.test.ts`](../src/domain/__tests__/sdd_catalog_compatibility.test.ts) · [`src/testing/fixtures/sdd-native-catalog/catalog.json`](../src/testing/fixtures/sdd-native-catalog/catalog.json) · [`src/testing/fixtures/sdd-native-catalog/catalog.schema.json`](../src/testing/fixtures/sdd-native-catalog/catalog.schema.json) · [`src/testing/fixtures/sdd-native-catalog/provenance.json`](../src/testing/fixtures/sdd-native-catalog/provenance.json) · [`src/tooling/__tests__/specification_catalog_publication.test.ts`](../src/tooling/__tests__/specification_catalog_publication.test.ts) · [`src/tooling/__tests__/proposed_sdd_catalog.test.ts`](../src/tooling/__tests__/proposed_sdd_catalog.test.ts) · [`src/tooling/__tests__/proposed_sdd_catalog_entrypoint.test.ts`](../src/tooling/__tests__/proposed_sdd_catalog_entrypoint.test.ts) · [`src/tooling/__tests__/specification_catalog_markdown_links.test.ts`](../src/tooling/__tests__/specification_catalog_markdown_links.test.ts) · [`src/tooling/__tests__/specification_catalog_discovery.test.ts`](../src/tooling/__tests__/specification_catalog_discovery.test.ts) · [`src/domain/__tests__/sdd_trusted_catalog_failure.test.ts`](../src/domain/__tests__/sdd_trusted_catalog_failure.test.ts) · [`src/tooling/__tests__/specification_catalog_title_text.test.ts`](../src/tooling/__tests__/specification_catalog_title_text.test.ts) · [`src/domain/__tests__/sdd_repair_coordinates.test.ts`](../src/domain/__tests__/sdd_repair_coordinates.test.ts) · [`src/application/usecases/sdd/sdd_repair_feedback.test.ts`](../src/application/usecases/sdd/sdd_repair_feedback.test.ts) · [`src/application/policies/__tests__/agent_source_snapshot_policy.test.ts`](../src/application/policies/__tests__/agent_source_snapshot_policy.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_inventory.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_inventory.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_workspace.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_workspace.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_regression.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_regression.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_git.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_git.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_integrity.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_integrity.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_gate.integration.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_gate.integration.test.ts) · [`src/application/policies/__tests__/sdd_source_context_presentation.test.ts`](../src/application/policies/__tests__/sdd_source_context_presentation.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_admission.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_admission.test.ts) · [`src/domain/__tests__/sdd_repository_reference.test.ts`](../src/domain/__tests__/sdd_repository_reference.test.ts) · [`src/domain/__tests__/sdd_case_reference.test.ts`](../src/domain/__tests__/sdd_case_reference.test.ts) +- Tests: [`src/domain/__tests__/issue_workflow_runtime_policy.test.ts`](../src/domain/__tests__/issue_workflow_runtime_policy.test.ts) · [`src/data/model/__tests__/execution.test.ts`](../src/data/model/__tests__/execution.test.ts) · [`src/application/usecases/__tests__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts) · [`src/application/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) · [`src/tooling/__tests__/validate_specification_catalog.test.ts`](../src/tooling/__tests__/validate_specification_catalog.test.ts) · [`src/domain/__tests__/issue_start_policy.test.ts`](../src/domain/__tests__/issue_start_policy.test.ts) · [`src/domain/__tests__/pre_branch_sdd.test.ts`](../src/domain/__tests__/pre_branch_sdd.test.ts) · [`src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/reconcile_branch_readiness_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/reconcile_branch_readiness_use_case.test.ts) · [`src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts`](../src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts) · [`src/architecture/__tests__/github_publication_boundaries.test.ts`](../src/architecture/__tests__/github_publication_boundaries.test.ts) · [`src/actions/__tests__/input_boolean_policy.test.ts`](../src/actions/__tests__/input_boolean_policy.test.ts) · [`src/domain/__tests__/issue_workflow_profile.test.ts`](../src/domain/__tests__/issue_workflow_profile.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts`](../src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts) · [`src/tooling/__tests__/sdd_minimum_failure.test.ts`](../src/tooling/__tests__/sdd_minimum_failure.test.ts) · [`src/tooling/__tests__/sdd_minimum_entrypoints.integration.test.ts`](../src/tooling/__tests__/sdd_minimum_entrypoints.integration.test.ts) · [`src/domain/__tests__/sdd_minimum_diagnostic.test.ts`](../src/domain/__tests__/sdd_minimum_diagnostic.test.ts) · [`src/application/policies/__tests__/sdd_minimum_presentation.test.ts`](../src/application/policies/__tests__/sdd_minimum_presentation.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/data/repository/__tests__/agent_json_schema_validator.test.ts`](../src/data/repository/__tests__/agent_json_schema_validator.test.ts) · [`src/application/policies/__tests__/sdd_issue_identity_policy.test.ts`](../src/application/policies/__tests__/sdd_issue_identity_policy.test.ts) · [`src/application/policies/__tests__/sdd_query_card_policy.test.ts`](../src/application/policies/__tests__/sdd_query_card_policy.test.ts) · [`src/application/policies/__tests__/sdd_status_policy.test.ts`](../src/application/policies/__tests__/sdd_status_policy.test.ts) · [`src/application/usecases/sdd/__tests__/sdd_evidence_query_use_case.test.ts`](../src/application/usecases/sdd/__tests__/sdd_evidence_query_use_case.test.ts) · [`src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts`](../src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts) · [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts`](../src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts) · [`src/domain/__tests__/pre_branch_sdd_diff.test.ts`](../src/domain/__tests__/pre_branch_sdd_diff.test.ts) · [`src/application/policies/__tests__/sdd_diff_presentation_policy.test.ts`](../src/application/policies/__tests__/sdd_diff_presentation_policy.test.ts) · [`src/application/usecases/sdd/__tests__/sdd_diff_query_use_case.test.ts`](../src/application/usecases/sdd/__tests__/sdd_diff_query_use_case.test.ts) · [`src/data/repository/sdd/__tests__/sdd_diff_link_repository.test.ts`](../src/data/repository/sdd/__tests__/sdd_diff_link_repository.test.ts) · [`src/infrastructure/composition/__tests__/sdd_diff_composition_root.test.ts`](../src/infrastructure/composition/__tests__/sdd_diff_composition_root.test.ts) · [`src/application/usecases/__tests__/sdd_diff_comment_interaction.test.ts`](../src/application/usecases/__tests__/sdd_diff_comment_interaction.test.ts) · [`src/application/usecases/steps/common/__tests__/sdd_diff_reply_publication.test.ts`](../src/application/usecases/steps/common/__tests__/sdd_diff_reply_publication.test.ts) · [`src/application/usecases/__tests__/sdd_status_workflow_integration.test.ts`](../src/application/usecases/__tests__/sdd_status_workflow_integration.test.ts) · [`src/tooling/__tests__/portable_specification_catalog.test.ts`](../src/tooling/__tests__/portable_specification_catalog.test.ts) · [`src/application/policies/__tests__/github_runtime_doctor_policy.test.ts`](../src/application/policies/__tests__/github_runtime_doctor_policy.test.ts) · [`src/infrastructure/__tests__/setup_sdd_prerequisites.test.ts`](../src/infrastructure/__tests__/setup_sdd_prerequisites.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts`](../src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/domain/__tests__/sdd_draft_consistency.test.ts`](../src/domain/__tests__/sdd_draft_consistency.test.ts) · [`src/domain/__tests__/sdd_derived_tables.test.ts`](../src/domain/__tests__/sdd_derived_tables.test.ts) · [`src/domain/__tests__/sdd_validation_diagnostic.test.ts`](../src/domain/__tests__/sdd_validation_diagnostic.test.ts) · [`src/application/policies/__tests__/sdd_preparation_status_policy.test.ts`](../src/application/policies/__tests__/sdd_preparation_status_policy.test.ts) · [`src/domain/__tests__/sdd_catalog_compatibility.test.ts`](../src/domain/__tests__/sdd_catalog_compatibility.test.ts) · [`src/testing/fixtures/sdd-native-catalog/catalog.json`](../src/testing/fixtures/sdd-native-catalog/catalog.json) · [`src/testing/fixtures/sdd-native-catalog/catalog.schema.json`](../src/testing/fixtures/sdd-native-catalog/catalog.schema.json) · [`src/testing/fixtures/sdd-native-catalog/provenance.json`](../src/testing/fixtures/sdd-native-catalog/provenance.json) · [`src/tooling/__tests__/specification_catalog_publication.test.ts`](../src/tooling/__tests__/specification_catalog_publication.test.ts) · [`src/tooling/__tests__/proposed_sdd_catalog.test.ts`](../src/tooling/__tests__/proposed_sdd_catalog.test.ts) · [`src/tooling/__tests__/proposed_sdd_catalog_entrypoint.test.ts`](../src/tooling/__tests__/proposed_sdd_catalog_entrypoint.test.ts) · [`src/tooling/__tests__/specification_catalog_markdown_links.test.ts`](../src/tooling/__tests__/specification_catalog_markdown_links.test.ts) · [`src/tooling/__tests__/specification_catalog_discovery.test.ts`](../src/tooling/__tests__/specification_catalog_discovery.test.ts) · [`src/domain/__tests__/sdd_trusted_catalog_failure.test.ts`](../src/domain/__tests__/sdd_trusted_catalog_failure.test.ts) · [`src/tooling/__tests__/specification_catalog_title_text.test.ts`](../src/tooling/__tests__/specification_catalog_title_text.test.ts) · [`src/domain/__tests__/sdd_repair_coordinates.test.ts`](../src/domain/__tests__/sdd_repair_coordinates.test.ts) · [`src/application/usecases/sdd/sdd_repair_feedback.test.ts`](../src/application/usecases/sdd/sdd_repair_feedback.test.ts) · [`src/application/policies/__tests__/agent_source_snapshot_policy.test.ts`](../src/application/policies/__tests__/agent_source_snapshot_policy.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_inventory.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_inventory.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_workspace.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_workspace.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_regression.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_regression.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_git.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_git.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_integrity.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_integrity.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_gate.integration.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_gate.integration.test.ts) · [`src/application/policies/__tests__/sdd_source_context_presentation.test.ts`](../src/application/policies/__tests__/sdd_source_context_presentation.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_admission.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_admission.test.ts) · [`src/domain/__tests__/sdd_repository_reference.test.ts`](../src/domain/__tests__/sdd_repository_reference.test.ts) · [`src/domain/__tests__/sdd_case_reference.test.ts`](../src/domain/__tests__/sdd_case_reference.test.ts) - User documentation: [`docs/issues/branch-management.mdx`](../docs/issues/branch-management.mdx) · [`docs/issues/workflow-setup.mdx`](../docs/issues/workflow-setup.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`docs/development/specifications.mdx`](../docs/development/specifications.mdx) · [`docs/issues/pre-branch-sdds.mdx`](../docs/issues/pre-branch-sdds.mdx) · [`docs/issues/index.mdx`](../docs/issues/index.mdx) · [`docs/issues/labels-and-branch-types.mdx`](../docs/issues/labels-and-branch-types.mdx) · [`docs/issues/configuration.mdx`](../docs/issues/configuration.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/single-actions/workflow-and-cli.mdx`](../docs/single-actions/workflow-and-cli.mdx) · [`docs/security-operations/operations/error-reference.mdx`](../docs/security-operations/operations/error-reference.mdx) · [`specs/README.md`](./README.md) ### `managed-issue-lifecycle` — Managed issue and branch lifecycle diff --git a/specs/catalog.json b/specs/catalog.json index a619a8037..5c14d021a 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -1739,13 +1739,14 @@ "status": "implemented", "scope": "Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons and preserve complete navigable catalog links for accepted repository filenames and safely publish generated catalogs without following destination links; bootstrap truthful proposed SDK capabilities before implementation without changing readiness; reject incomplete specification discovery explicitly without following unregistered symbolic links; report confirmed missing trusted catalogs with immutable source provenance and prerequisite recovery before model or publication; bind pre-branch analysis and drafting to a verified independent read-only view of the selected immutable source without changing the canonical checkout; distinguish visible protocol notation from repository citations, allow only constructive proposed-file directory ancestry and expose bounded private-safe origin diagnostics", "owner": "Copilot maintainers", - "lastVerified": "2026-11-03", + "lastVerified": "2026-11-04", "specs": [ "specs/issue-start-and-branch-readiness.md", "specs/pre-branch-sdd-gate.md", "specs/pre-branch-sdd-diff.md", "specs/pre-branch-sdd-status-readiness.md", "specs/pre-branch-sdd-source-context.md", + "specs/sdd-draft-response-minimum.md", "specs/sdd-reference-classification.md", "specs/sdd-case-reference-classification.md" ], @@ -1856,6 +1857,10 @@ "src/domain/__tests__/issue_workflow_profile.test.ts", "src/application/policies/__tests__/status_command_policy.test.ts", "src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts", + "src/tooling/__tests__/sdd_minimum_failure.test.ts", + "src/tooling/__tests__/sdd_minimum_entrypoints.integration.test.ts", + "src/domain/__tests__/sdd_minimum_diagnostic.test.ts", + "src/application/policies/__tests__/sdd_minimum_presentation.test.ts", "src/application/policies/__tests__/application_error_presentation_policy.test.ts", "src/data/repository/__tests__/agent_json_schema_validator.test.ts", "src/application/policies/__tests__/sdd_issue_identity_policy.test.ts", diff --git a/specs/pre-branch-sdd-gate.md b/specs/pre-branch-sdd-gate.md index 9d4871f4c..bab336121 100644 --- a/specs/pre-branch-sdd-gate.md +++ b/specs/pre-branch-sdd-gate.md @@ -19,6 +19,10 @@ analysis, drafting and its existing repair to this gate's loaded snapshot SHA. It preserves the canonical checkout, lifecycle and freshness guards while providing an independently verified SDK-owned read-only source view. +The [draft minimum diagnostic companion](sdd-draft-response-minimum.md) specifies +safe response-stage/minimum/attempt facts for undersized Markdown, preserving +the existing minimum, shared repair ceiling and publication fences. + The [repository-reference companion](sdd-reference-classification.md) distinguishes protocol notation from file citations, defines constructive future directories and adds private-safe reference occurrence diagnostics. Explicit file declarations diff --git a/specs/sdd-draft-response-minimum.md b/specs/sdd-draft-response-minimum.md new file mode 100644 index 000000000..b47087031 --- /dev/null +++ b/specs/sdd-draft-response-minimum.md @@ -0,0 +1,307 @@ +# Safe diagnosis of an undersized SDD draft response + +- Status: Implemented and locally qualified; exact-head delivery gates pending +- Date: 2026-11-04 +- Catalog capability ID: `issue-start-and-sdd-readiness` +- Owners: Copilot maintainers +- Scope: classify a rejected draft below the existing minimum without changing repair or publication authority +- Related issue: [#850](https://github.com/vypdev/copilot/issues/850) +- Required review gates: architecture, security, testing, operator UX and documentation +- Open decisions blocking readiness: none + +## 1. Executive summary + +An undersized generated SDD is a response-contract failure. The existing card, +terminal error and API record MUST identify drafting, the applicable minimum, +known attempt count and a safe observed count or its explicit unavailability. +The author can clarify that a complete proposed Markdown document is required +and retry the same issue. Strict rejection, selected ownership, recorded answers +and existing publication fences remain authoritative. + +```text +Answered plan -> draft response -> minimum satisfied -> existing validation + -> below minimum -> one safe blocked card + -> clarify complete output + -> retry same issue +``` + +Text equivalent: minimum failure terminates this generation attempt and reports +safe facts; it does not regenerate or authorize branch/specification publication. + +## 2. Problem, current behavior and evidence + +The immutable SDK `3e7ceea08e6131360e026937d1c0cc1b120472e0` rejected `$.markdown` +as too short in [reported run 41339905694](https://github.com/vypdev/domus/actions/runs/41339905694). +Its public result exposed generic retryable `workflow.failed`. The original +response content, actual length and reason for that output are unknown. Reported +validation of the author's proposed input does not establish the model's output. +The mutable linked status card subsequently changed; its current content is not +evidence of what the failed attempt published. + +At development base `6bd590dac62bdd075d2894e29aa580791dec81a5`, local schema +validation already throws typed `AgentResponseSchemaFailure('string-short', path)`. +The draft helper recovers only the exact maximum-length error and rethrows the +minimum failure without feature facts. It also accepts a short string from a +query port that bypasses adapter schema validation. Three SDK-owned regressions +fail before correction. They use deterministic transports, not a consumer rerun +or live model, and do not reconstruct the unknown original output. + +## 3. Actors, surfaces and terminology + +The issue author sees the existing preparation card and supplies clarification. +The maintainer inspects its linked run and correlation. CLI/API consumers receive +the same semantic cause. The host owns the response schema, attempt counter, +source snapshot and publication permission; generated content cannot alter them. + +**Minimum** is the declared 1,800 Unicode code points. **Observed** is a count +actually available at this boundary. A typed rejection without retained length +means unavailable, not zero. **Attempt** is the first or second existing draft +query, not an inferred count of provider-internal requests. + +## 4. Requirements and limits + +| ID | Requirement | +|---|---| +| MIN-01 | Strictly reject undersized draft Markdown with response-stage/minimum/attempt facts; preserve unknown observed length as unavailable. | +| MIN-02 | Preserve the shared two-attempt maximum/format ceiling. Minimum failure, unrelated schema errors and provider failures MUST NOT authorize another generation. | +| MIN-03 | Use closed bounded recovery/diagnostic fields; never retain rejected response, source, raw error text or credentials in public failure facts. | +| MIN-04 | Localize the existing card and final error with concrete clarification/retry guidance and retained state; add no notification surface. | +| MIN-05 | Ordinary Issue and Issue Comment flows MUST retain answers/owner and withhold branch/specification publication on failure; preserve freshness, replay and source binding. | +| MIN-06 | Ship meaningful regressions, documentation, catalog and generated package evidence with complete review and all modified lines covered. | + +No minimum reduction, new model prompt repair, unbounded retry, automatic owner +change, consumer mutation, new credential or publication bypass is introduced. + +## 5. Current versus proposed journey + +| Stage | Observed limitation | Required behavior | +|---|---|---| +| Schema rejects short Markdown | Generic workflow failure loses stage | Narrow typed classification at the draft boundary | +| Port returns a short string | Defensive minimum is absent | Reject before writer validation with an actual code-point count | +| Second query is short after size/format repair | Cause loses attempt identity | Report attempt 2 and stop; no third query | +| Operator reads error | Generic retryable operation | Response-contract cause, known minimum and concrete clarification | +| Recovery | Same issue retains existing state | Same ownership, answers, source and ordinary publication fences | + +## 6. State and functional behavior + +Only typed `string-short` at exactly `$.markdown` during the draft query maps to +the undersized-response failure. Do not parse exception messages. Other typed +reasons/paths and forged text retain their original identity and retry policy. + +An adapter rejection supplies the locally declared minimum and actual host +attempt, with observed count unavailable when that error retained no count. +For a returned string, preserve the existing cheap maximum check before counting +its bounded contents with Unicode code-point semantics. Below-minimum results +fail before table assembly, writer validation or publication. Empty strings +report an observed zero; astral characters count once each. + +The new terminal reason is `response-minimum`, phase `response`, with exactly one +`minimum-length` fact in section 0, a positive bounded expected count and optional +observed count strictly below it. No row or unrelated fact can claim this cause. +The application error remains nonretryable `validation.invalid-input`, with +recovery `sdd-draft-response-too-short` and exactly `minimum`, `observed`, +`attempts`. Observed is a bounded nonnegative integer below minimum or the literal +`unavailable`; attempts is 1 or 2. Both projections MUST describe the same facts. + +## 7. Configuration + +There is no new setting, schema migration, command or permission. The existing +minimum remains 1,800 Unicode code points for update, companion and new plans. +The existing update maximum of 18,000 and companion/new maximum of 70,000 remain. +Schema string bounds use code points; downstream maximum checks currently use +JavaScript string length. This correction preserves that existing maximum +behavior and does not claim to harmonize those units. + +Recommended recovery is a concrete complete-output clarification on the same +issue. A genuine oversized draft continues through its existing single repair. +Neither option permits changing the minimum or silently truncating requirements. + +## 8. Clean Architecture + +```mermaid +flowchart LR + A[Findings adapter and schema validator] --> D[Draft application boundary] + P[Pure bounded diagnostics] --> D + D --> E[Semantic preparation failure] + E --> G[Existing SDD gate and freshness fences] + G --> U[Existing localized card] + E --> C[Terminal and API presentation] +``` + +Text equivalent: the adapter enforces its schema, application orchestration owns +stage/attempt and narrow classification, pure domain checks bound facts, and the +existing gate/presentation publish only a safe projection. + +Reuse the existing generic schema-failure type. Do not introduce provider DTOs, +raw response storage or a parallel SDD coordinator. Domain validation imports no +I/O or presentation. The generic application-error boundary validates the exact +new recovery keys. The card admits structured minimum recovery only after its +closed validator passes; its existing zero-parameter SDD recovery guard remains. +Dependency/cycle, publication-call inventory, type and descriptor tests enforce +these boundaries. No new comment-write call site is needed. + +## 9. UI, accessibility and localization + +Reuse the current preparation card and its trusted-bot ownership marker: + +```text +Specification preparation is blocked +No new specification or branch was published by this preparation attempt. +Impact: The drafting response did not meet the Markdown minimum. +Action: Clarify that a complete proposed Markdown document is required, + preserving the resolved requirements and owner; retry this issue workflow. +Retained: Existing answers, selected ownership and published specifications. +Technical details: response-minimum; response; attempt 1; + minimum 1800 Unicode code points; observed unavailable. +``` + +Pending, awaiting-answer, valid, partial-publication and recovered views keep +their existing contracts. A minimum failure cannot claim a partial or verified +publication. Known returned-string counts replace unavailable only when measured. +English and Spanish cover impact/action/retained state, units, labels and unknown +count copy; existing whole-artifact locale fallback remains. Use semantic headings, +plain text and progressive detail on narrow/light/dark surfaces. No browser UI +or new human screen-reader campaign is required for this backend correction. + +## 10. Failure, recovery, replay and cleanup + +| Failure | Generation behavior | Operator action / retained state | +|---|---|---| +| minimum rejection on attempt 1 | terminal after one query | clarify complete output; retain owner/answers | +| minimum rejection on existing repair | terminal after two queries | same; no third query | +| exact maximum or typed format failure | existing shared repair only | unchanged validation and recovery | +| malformed shape / unrelated schema | unchanged terminal identity | no fabricated minimum/provider outage | +| actual unavailable provider | preserve semantic category/correlation | provider recovery; no content-size repair | +| issue/base changes | existing freshness rejection | do not overwrite newer card or publish | + +Retry updates the same owned card through existing replay rules. It creates no +new response journal or rejected-content artifact. Existing private source-view +and runtime cleanup remains responsible for terminal paths; no new workspace, +browser package or background worker is introduced. + +## 11. Security and privacy + +The rejected response may contain secrets or instructions. Never include it, +its fragments, source files, arbitrary exception text or untrusted destinations +in a recovery, card or repair prompt. Facts are copied/frozen and exact-key +validated, with the existing 100,000 numeric ceiling. Missing counts are not +inferred from the author's supplied proposal, JSON byte size or UTF-16 length. +The error's correlation/run coordinates come from trusted existing runtime +ownership, not generated content. Existing visibility and write permissions hold. + +## 12. Observability + +Keep error code, correlation, run link, response phase, closed minimum reason, +applicable bound and actual host attempt. The API/terminal cannot silently lose +those facts while the card shows them. Explicitly label unknown observed count. +Distinguish local fixture, native CI, current-head review and downstream release +evidence. No original provider root cause or repaired consumer run is claimed. + +## 13. Compatibility, rollout and rollback + +This adds a validated recovery variant and bounded diagnostic reason. Existing +source selection, stored answers, owner documents, commands and maximum/format +repair remain. Custom query ports that bypass adapter validation must now honor +the already declared minimum as well. Focused format tests must provide inputs +meeting the minimum so they still exercise their intended writer constraint; +do not weaken those assertions or the acceptance schema. + +Rebuild Action/CLI/API/deployment bundles and declaration closure, verify the +actual package, then follow the managed patch after the bug backlog. Develop +merge alone does not update the released runtime. A reviewed rollback restores +the former diagnostic gap without changing consumer history or remote branches. + +## 14. Numeric test budget + +Minimum **40 new distinct cases**, counted once: 10 pure diagnostic/recovery +boundary/security cases; 14 draft/schema/Unicode/attempt/maximum and identity +controls; 8 localized card/API/terminal privacy cases; and 8 Issue/Issue Comment +lifecycle, freshness, replay and retained-state integration cases. The first +three red regressions belong to the draft group, not an additional budget. + +Test 0/1799/1800 boundaries, astral code points, unavailable counts, first/second +attempts, exact/foreign maximum errors, malformed/missing/nonstring Markdown, +unrelated schema reasons/paths, forged text and real semantic provider failures. +Use the real findings adapter with deterministic CLI transport for schema +classification and real shared routes for Issue/Issue Comment behavior. No live +model, consumer rerun, sleeps or service credentials. Preserve all root/extension +budgets, existing skips and exhaustive pure checks. Require every modified +coverable line hit in local full-suite LCOV and official Codecov against the +live base, with zero misses or partials. + +Local execution on 2026-11-04 passes **83 new distinct cases**: 35 pure +diagnostic cases, 19 draft/schema controls, 20 presentation cases, 8 real-route +integration cases and one exhaustive recovery-inventory case. The original +three regressions failed before the correction and now pass. Existing gate and +repair-feedback controls pass (136 cases); their synthetic generated drafts now +meet the unchanged minimum so they still reach their intended later checks. +The full root run passes 891 suites / 17,429 tests, with 35 preexisting skips; +267 extension tests also pass with 100% statements, branches, functions and +lines. Global coverage budgets pass; the setup ledger retains its 14 explicitly +open human acceptances. Full-suite LCOV hits 52/52 modified coverable lines with no misses or partials +against `6bd590dac62bdd075d2894e29aa580791dec81a5`. Local LCOV is not an official +Codecov result or execution of the original consumer run. Remote gates remain +pending until the exact published head is reviewed and qualified. + +## 15. Documentation + +Update `docs/issues/pre-branch-sdds.mdx` and the application error reference with +the minimum cause, count availability, shared attempt policy and concrete same- +issue recovery. Link this companion from the primary gate and catalog; regenerate +`specs/CATALOG.md`. No setup, permission or migration input is added. Validate +documentation/navigation, specifications/catalog and packaged public declarations. + +## 16. Acceptance + +A short draft never publishes a branch/specification or enters minimum repair. +Both ordinary entrypoints retain recorded answers and owner, show safe stage, +minimum and attempt, and identify a measured count or its absence honestly. +A minimum-valid response still reaches unchanged writer checks. Maximum/format +recovery still has only two total queries, including mixed failures. Provider +errors keep their original semantic identity. Replays/freshness preserve the +current owned card and source. Public serialization and EN/ES output contain +neither rejected content nor unresolved interpolation. + +## 17. Traceability + +| Requirements | Executable evidence | Documentation | +|---|---|---| +| MIN-01–MIN-02 | `src/tooling/__tests__/sdd_minimum_failure.test.ts`; `src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts`; `src/application/usecases/sdd/sdd_repair_feedback.test.ts` | pre-branch SDD guide | +| MIN-03–MIN-04 | `src/domain/__tests__/sdd_minimum_diagnostic.test.ts`; `src/application/policies/__tests__/sdd_minimum_presentation.test.ts`; `src/application/policies/__tests__/application_error_presentation_policy.test.ts` | error reference | +| MIN-05 | `src/tooling/__tests__/sdd_minimum_entrypoints.integration.test.ts`; `src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts` | guide and primary gate | +| MIN-06 | full coverage/budgets, architecture/types/docs/catalog/package, exact-head checks/review/Codecov | this SDD/catalog | + +The counts above refer to executed SDK fixtures. Package, platform, original +consumer and remote review observations retain separate provenance; no +unexecuted budget row is represented as passing evidence. + +## 18. Implementation sequence + +Preserve original immutable failure facts; reproduce SDK regressions; define +bounded minimum facts and recovery; implement narrow typed/fallback rejection; +project the same facts to existing surfaces; add controls and real route tests; +align docs/catalog, update Graphify and generated bundles; validate full local +acceptance/package and normal hooks; obtain complete exact-head remote gates. + +## 19. Definition of Done + +All six requirements and the 40-case floor have actual evidence. The existing +minimum, repair ceiling, source/owner/answer and publication fences remain. +Privacy, API/locale parity, documentation/catalog, architecture and generated +artifacts pass. All applicable checks, complete review with no open findings or +threads and official 100% modified-line coverage with zero partials pass for +the current head/base before an ordinary authorized merge. Verify native issue +closure and retire owned disposable outputs while preserving evidence/rollback. + +## 20. References and decisions + +- [Original issue and reported evidence](https://github.com/vypdev/copilot/issues/850) +- [Primary gate](pre-branch-sdd-gate.md) +- [Immutable source context](pre-branch-sdd-source-context.md) +- [Structural diagnostics and repair](pre-branch-sdd-gate.md#bounded-structural-repair-feedback--833-2026-11-02) + +Prefer narrow feature classification of the existing typed schema failure over +message matching, another generic error class or broader retry. Preserve unknown +original output honestly. A bounded returned string permits a code-point count; +an adapter rejection with no retained count does not. Status and final error +share one validated semantic recovery instead of separate invented diagnoses. diff --git a/src/application/errors/sdd_draft_preparation_failure.ts b/src/application/errors/sdd_draft_preparation_failure.ts index 0b6b355af..5235b4ce7 100644 --- a/src/application/errors/sdd_draft_preparation_failure.ts +++ b/src/application/errors/sdd_draft_preparation_failure.ts @@ -1,12 +1,12 @@ import { normalizeSddValidationDiagnostic, type SddValidationDiagnostic } from '../../domain/sdd_validation_diagnostic'; -import { ApplicationError, type ApplicationErrorRecoveryId } from './application_error'; +import { ApplicationError, type ApplicationErrorRecovery, type ApplicationErrorRecoveryId } from './application_error'; -type SddDraftRecoveryId = Extract; +type SddDraftRecoveryId = Exclude, 'sdd-draft-response-too-short'>; const RECOVERY: Readonly> = Object.freeze({ 'required-heading': 'sdd-draft-format-invalid', 'test-budget': 'sdd-draft-format-invalid', 'case-identity': 'sdd-draft-case-identity-invalid', 'case-budget': 'sdd-draft-case-budget-invalid', 'requirement-mapping': 'sdd-draft-requirement-mapping-invalid', 'repository-path': 'sdd-draft-repository-path-invalid', - size: 'sdd-draft-size-exceeded', 'response-shape': undefined, + size: 'sdd-draft-size-exceeded', 'response-shape': undefined, 'response-minimum': undefined, }); /** Carries safe evidence to the feature-owned card without serializing candidate content. */ @@ -14,14 +14,19 @@ export class SddDraftPreparationFailure extends ApplicationError { readonly diagnostic: SddValidationDiagnostic; constructor(diagnostic: SddValidationDiagnostic, readonly attempts: 1 | 2) { const normalized = normalizeSddValidationDiagnostic(diagnostic); - if ((attempts !== 1 && attempts !== 2) || (attempts === 1 && normalized.reason !== 'response-shape')) { + if ((attempts !== 1 && attempts !== 2) || (attempts === 1 && normalized.reason !== 'response-shape' && normalized.reason !== 'response-minimum')) { throw new TypeError('Invalid bounded SDD attempt count.'); } const id = RECOVERY[normalized.reason]; + const recovery: ApplicationErrorRecovery | undefined = normalized.reason === 'response-minimum' + ? { id: 'sdd-draft-response-too-short', variables: { minimum: normalized.facts[0].expected!, + observed: normalized.facts[0].observed ?? 'unavailable', attempts } } + : id ? { id, variables: {} } : undefined; const message = normalized.reason === 'response-shape' ? 'The drafting agent returned no SDD Markdown.' + : normalized.reason === 'response-minimum' ? 'The SDD drafting response is shorter than the required minimum.' : normalized.reason === 'size' ? 'The SDD draft exceeded its size limit after one bounded repair.' : `The SDD draft still violates its ${normalized.reason} format contract after one bounded repair.`; - super('validation.invalid-input', message, id ? { recovery: { id, variables: {} } } : {}); + super('validation.invalid-input', message, recovery ? { recovery } : {}); this.diagnostic = normalized; } } diff --git a/src/application/policies/__tests__/application_error_presentation_policy.test.ts b/src/application/policies/__tests__/application_error_presentation_policy.test.ts index 275918b87..b3f0c7759 100644 --- a/src/application/policies/__tests__/application_error_presentation_policy.test.ts +++ b/src/application/policies/__tests__/application_error_presentation_policy.test.ts @@ -20,6 +20,9 @@ const RECOVERY_CASES: readonly Readonly<{ english: string; spanish: string; }>[] = [ + { recovery: { id: 'sdd-draft-response-too-short', variables: { minimum: 1800, observed: 'unavailable', attempts: 1 } }, + english: 'Existing answers, selected ownership and published specifications were retained; this attempt published no specification or branch.', + spanish: 'Se conservaron las respuestas, la propiedad seleccionada y las especificaciones publicadas; este intento no publicó ninguna especificación ni rama.' }, { recovery: { id: 'issue-assignment-count-invalid', variables: {} }, english: 'No assignment operation was attempted. Independent completed lifecycle effects are retained in their own results.', spanish: 'No se intentó ninguna operación de asignación. Los efectos independientes completados se conservan en sus propios resultados.' }, diff --git a/src/application/policies/__tests__/sdd_minimum_presentation.test.ts b/src/application/policies/__tests__/sdd_minimum_presentation.test.ts new file mode 100644 index 000000000..db7046045 --- /dev/null +++ b/src/application/policies/__tests__/sdd_minimum_presentation.test.ts @@ -0,0 +1,66 @@ +import { ApplicationError } from '../../errors/application_error'; +import { SddDraftPreparationFailure } from '../../errors/sdd_draft_preparation_failure'; +import { SDD_CATALOG_COMPATIBILITY_REASONS } from '../../../domain/sdd_catalog_compatibility'; +import type { SddValidationDiagnostic } from '../../../domain/sdd_validation_diagnostic'; +import { buildApplicationErrorPresentation, renderApplicationErrorMarkdown, renderApplicationErrorText } from '../application_error_presentation_policy'; +import { resolveStaticApplicationErrorCatalog } from '../application_error_message_catalog'; +import { renderSddPreparationFailure } from '../sdd_preparation_status_policy'; + +const run = { serverOrigin: 'https://github.example.test', owner: 'acme', repository: 'repo', runId: '123' }; +const diagnostic = (observed?: number): SddValidationDiagnostic => ({ reason: 'response-minimum', phase: 'response', + facts: [{ detail: 'minimum-length', section: 0, expected: 1800, ...(observed === undefined ? {} : { observed }) }] }); +const failure = () => new SddDraftPreparationFailure(diagnostic(), 1); + +describe.each(['en-US', 'es-ES'])('minimum response presentation %s', locale => { + it.each([{ observed: undefined, attempts: 1 }, { observed: 0, attempts: 2 }, { observed: 1799, attempts: 1 }] as const)( + 'projects observed $observed and attempt $attempts consistently to card, terminal and API', ({ observed, attempts }) => { + const error = new SddDraftPreparationFailure(diagnostic(observed), attempts); + const message = resolveStaticApplicationErrorCatalog(locale).message; + const view = buildApplicationErrorPresentation(error, message); + const body = renderSddPreparationFailure({ issueNumber: 42, locale, error, run, diagnostic: error.diagnostic, attempts }); + expect(renderSddPreparationFailure({ issueNumber: 42, locale, error, run })).toBe(body); + for (const text of [view.impact, view.action, view.retainedState]) expect(body).toContain(text); + expect(view.action).toContain(locale === 'en-US' ? 'issue description' : 'descripción'); + expect(body).toContain('1800'); expect(body).toContain('response-minimum'); expect(body).toContain('`response`'); + expect(body).toContain('https://github.example.test/acme/repo/actions/runs/123'); + expect(body).toContain('copilot:sdd-analysis-status:v1'); + expect(body).not.toMatch(/PRIVATE|\{\w+\}|Specific table facts|datos concretos de tablas|provider\.unavailable/); + expect(view.retryable).toBe(message('error.label.no')); + if (observed === undefined) expect(body).toContain(locale === 'en-US' ? 'length was not retained' : 'no se conservó la longitud'); + else expect(body).toContain(`${locale === 'en-US' ? 'Observed count' : 'Cantidad observada'}: ${observed}`); + expect(renderApplicationErrorText(error, message)).toContain(view.impact); + expect(renderApplicationErrorMarkdown(error, message)).toContain(view.action); + expect(error.toJSON().recovery).toEqual({ id: 'sdd-draft-response-too-short', variables: { minimum: 1800, observed: observed ?? 'unavailable', attempts } }); + expect(error.toJSON().retainedState).toContain('selected ownership'); + expect(Object.isFrozen(error.recovery)).toBe(true); expect(Object.isFrozen(error.recovery!.variables)).toBe(true); + }, + ); +}); + +describe('minimum response presentation trust boundary', () => { + it.each([ + null, {}, { minimum: 1800, observed: 'unavailable', attempts: 1, response: 'PRIVATE_RESPONSE' }, + { minimum: 0, observed: 'unavailable', attempts: 1 }, { minimum: 1800, observed: 1800, attempts: 1 }, + { minimum: 1800, observed: 'unavailable', attempts: 3 }, + ])('rejects malformed serialized recovery facts before public formatting %j', variables => { + const recovery = { id: 'sdd-draft-response-too-short', variables } as never; + expect(() => new ApplicationError('validation.invalid-input', 'Safe.', { recovery })).toThrow(); + const error = { ...failure().toJSON(), recovery }; + expect(() => renderSddPreparationFailure({ issueNumber: 42, error })).toThrow('minimum recovery facts'); + }); + + it.each([ + { diagnostic: { reason: 'response-shape', phase: 'response', facts: [{ detail: 'response-shape', section: 0 }] } }, + { diagnostic: { ...diagnostic(), facts: [{ ...diagnostic().facts[0], expected: 1801 }] } }, + { diagnostic: diagnostic(1) }, { attempts: 2 }, + { catalogReason: SDD_CATALOG_COMPATIBILITY_REASONS[0] }, + { missingCatalogSource: 'a'.repeat(40) }, { publicationPending: true }, + ])('rejects contradictory phase/count/attempt projections %j', options => { + expect(() => renderSddPreparationFailure({ issueNumber: 42, error: failure(), ...options } as never)).toThrow(); + }); + + it('requires the validated minimum semantic recovery for a minimum diagnostic', () => { + expect(() => renderSddPreparationFailure({ issueNumber: 42, error: new ApplicationError('validation.invalid-input', 'Safe.'), diagnostic: diagnostic() })) + .toThrow('requires its semantic recovery'); + }); +}); diff --git a/src/application/policies/application_error_message_catalog.ts b/src/application/policies/application_error_message_catalog.ts index 66e0caf00..8cfd5fab3 100644 --- a/src/application/policies/application_error_message_catalog.ts +++ b/src/application/policies/application_error_message_catalog.ts @@ -28,7 +28,7 @@ const LABEL_KEYS = Object.freeze([ ...BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, ...BRANCH_SYNC_PUBLICATION_LABEL_KEYS, ...ASSIGNMENT_DIAGNOSTIC_LABEL_KEYS, - 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', + 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', 'draftLengthUnavailable', ] as const); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState'] as const); @@ -68,6 +68,7 @@ const ENGLISH_LABELS: Readonly> = Object.freeze({ yes: 'Yes', no: 'No', reference: 'Reference', + draftLengthUnavailable: 'unavailable', }); const SPANISH_LABELS: Readonly> = Object.freeze({ @@ -82,6 +83,7 @@ const SPANISH_LABELS: Readonly> = Object.freeze({ yes: 'Sí', no: 'No', reference: 'Referencia', + draftLengthUnavailable: 'no disponible', }); type ErrorContent = Readonly>>>; @@ -358,6 +360,11 @@ const ENGLISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ action: 'Retry SDD preparation with the canonical specification template and the explicit acceptance requirements.', retainedState: 'Existing answers and published SDDs were preserved; no draft was published.', }), + 'sdd-draft-response-too-short': Object.freeze({ + impact: 'The SDD drafting response was below its {minimum} Unicode code-point minimum (observed: {observed}; draft attempt: {attempts}).', + action: 'Clarify in the issue description that a complete proposed Markdown document is required, preserving resolved requirements and ownership, then retry the same issue workflow. This failure does not start an automatic regeneration.', + retainedState: 'Existing answers, selected ownership and published specifications were retained; this attempt published no specification or branch.', + }), 'sdd-draft-size-exceeded': Object.freeze({ impact: 'SDD preparation stopped because the draft remained too large after one automatic size repair.', action: 'Retry SDD preparation with a proportional appendix or an approved companion specification.', @@ -560,6 +567,11 @@ const SPANISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ action: 'Reintenta la preparación del SDD con la plantilla canónica y los criterios de aceptación explícitos.', retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador.', }), + 'sdd-draft-response-too-short': Object.freeze({ + impact: 'La respuesta de redacción del SDD no alcanzó el mínimo de {minimum} puntos de código Unicode (cantidad observada: {observed}; intento de borrador: {attempts}).', + action: 'Aclara en la descripción de esta incidencia que se requiere una propuesta Markdown completa, conservando los requisitos resueltos y su propietario, y repite el mismo workflow. Este fallo no inicia una regeneración automática.', + retainedState: 'Se conservaron las respuestas, la propiedad seleccionada y las especificaciones publicadas; este intento no publicó ninguna especificación ni rama.', + }), 'sdd-draft-size-exceeded': Object.freeze({ impact: 'La preparación del SDD se detuvo porque el borrador seguía siendo demasiado largo tras un intento automático de reducción.', action: 'Reintenta la preparación del SDD con un apéndice proporcional o una especificación complementaria aprobada.', diff --git a/src/application/policies/application_error_presentation_policy.ts b/src/application/policies/application_error_presentation_policy.ts index ded492e95..3d3f4f212 100644 --- a/src/application/policies/application_error_presentation_policy.ts +++ b/src/application/policies/application_error_presentation_policy.ts @@ -46,7 +46,10 @@ export function buildApplicationErrorPresentation( const descriptor = error.recovery ? `error.recovery.${error.recovery.id}` as const : `error.${error.code}` as const; - const variables = error.recovery?.id === 'issue-assignment-failed' + const variables = error.recovery?.id === 'sdd-draft-response-too-short' + ? { ...error.recovery.variables, observed: error.recovery.variables.observed === 'unavailable' + ? message('error.label.draftLengthUnavailable') : error.recovery.variables.observed } + : error.recovery?.id === 'issue-assignment-failed' ? assignmentDiagnosticPresentation(error.recovery.variables, error.code, message) : error.recovery?.id === 'sdd-source-context-unavailable' ? { ...sourceContextDiagnostic(error.recovery.variables) } diff --git a/src/application/policies/sdd_preparation_message_catalog.ts b/src/application/policies/sdd_preparation_message_catalog.ts index 84f12e662..36b140c26 100644 --- a/src/application/policies/sdd_preparation_message_catalog.ts +++ b/src/application/policies/sdd_preparation_message_catalog.ts @@ -36,6 +36,7 @@ const UI = Object.freeze({ referenceOrdinal: ['Visible reference occurrence', 'Aparición de referencia visible'] as const, "expected": ["Expected count", "Cantidad esperada"] as const, "observed": ["Observed count", "Cantidad observada"] as const, + lengthUnavailable: ['unavailable; the rejected response length was not retained', 'no disponible; no se conservó la longitud de la respuesta rechazada'] as const, "recoveredHeading": ["SDD preparation status", "Estado de preparación del SDD"] as const, "published": ["The specification publication was verified on the linked branch. Branch readiness remains subject to its separate checks.", "Se verificó la publicación de la especificación en la rama enlazada. La preparación de la rama sigue sujeta a comprobaciones independientes."] as const, "waiting": ["Waiting for the numbered answers in the specification question card. No new specification was published.", "Se esperan las respuestas numeradas de la tarjeta de preguntas. No se publicó ninguna especificación nueva."] as const, @@ -48,6 +49,7 @@ const FACTS = Object.freeze({ "validation-contract": ["A content contract was rejected; this validator supplied no more specific table facts.", "Se rechazó un contrato de contenido; este validador no proporcionó datos más concretos de tablas."] as const, "size-limit": ["The candidate exceeded the complete draft size limit.", "El candidato superó el límite de tamaño del borrador completo."] as const, "response-shape": ["The drafting response did not contain the required Markdown string.", "La respuesta de redacción no contenía la cadena Markdown obligatoria."] as const, + 'minimum-length': ['The drafting Markdown response was below its declared minimum, measured in Unicode code points.', 'La respuesta Markdown de redacción no alcanzó su mínimo declarado, medido en puntos de código Unicode.'] as const, "required-heading": ["A canonical heading is missing; the row identifies its position in the required-heading list.", "Falta una sección canónica; la fila indica su posición en la lista de secciones obligatorias."] as const, "numeric-budget": ["No valid numeric test budget was found.", "No se encontró un presupuesto numérico de pruebas válido."] as const, "section-count": ["The required section is missing or repeated.", "La sección obligatoria falta o está repetida."] as const, diff --git a/src/application/policies/sdd_preparation_status_policy.ts b/src/application/policies/sdd_preparation_status_policy.ts index d1808369b..862437ca6 100644 --- a/src/application/policies/sdd_preparation_status_policy.ts +++ b/src/application/policies/sdd_preparation_status_policy.ts @@ -1,7 +1,7 @@ import { SDD_CATALOG_COMPATIBILITY_REASONS, type SddCatalogCompatibilityReason } from '../../domain/sdd_catalog_compatibility'; import { isSddSourceContextDiagnostic } from '../../domain/agent_source_snapshot'; import { APPLICATION_ERROR_METADATA, APPLICATION_ERROR_RECOVERY_IDS, isApplicationErrorCorrelationId } from '../../data/model/application_error'; -import { normalizeSddValidationDiagnostic, type SddValidationDiagnostic } from '../../domain/sdd_validation_diagnostic'; +import { isSddDraftMinimumDiagnostic, normalizeSddValidationDiagnostic, type SddValidationDiagnostic } from '../../domain/sdd_validation_diagnostic'; import { resolveStaticApplicationErrorCatalog, type ApplicationErrorMessageReader } from './application_error_message_catalog'; import { buildApplicationErrorPresentation, type ApplicationErrorPresentationSource } from './application_error_presentation_policy'; import { sddAnalysisStatusMarker } from './sdd_analysis_status_policy'; @@ -52,13 +52,27 @@ export function renderSddPreparationFailure(options: Readonly<{ if (options.catalogReason !== undefined && (!SDD_CATALOG_COMPATIBILITY_REASONS.includes(options.catalogReason) || options.diagnostic)) { throw new TypeError('Invalid exclusive catalog compatibility facts.'); } - const diagnostic = options.diagnostic ? normalizeSddValidationDiagnostic(options.diagnostic) : undefined; + const recovery = options.error.recovery; + const minimum = recovery?.id === 'sdd-draft-response-too-short' && isSddDraftMinimumDiagnostic(recovery.variables) ? recovery.variables : undefined; + if (recovery?.id === 'sdd-draft-response-too-short' && !minimum) throw new TypeError('Invalid SDD minimum recovery facts.'); + let diagnostic = options.diagnostic ? normalizeSddValidationDiagnostic(options.diagnostic) : undefined; + if (minimum) { + if (options.catalogReason !== undefined || missingCatalogSource !== undefined || options.publicationPending) { + throw new TypeError('SDD minimum response facts cannot describe another failure phase.'); + } + const observed = minimum.observed === 'unavailable' ? undefined : minimum.observed; + if (diagnostic && (diagnostic.reason !== 'response-minimum' || diagnostic.facts[0].expected !== minimum.minimum + || diagnostic.facts[0].observed !== observed)) throw new TypeError('SDD minimum projections disagree.'); + if (options.attempts !== undefined && options.attempts !== minimum.attempts) throw new TypeError('SDD minimum attempts disagree.'); + diagnostic = normalizeSddValidationDiagnostic({ reason: 'response-minimum', phase: 'response', + facts: [{ detail: 'minimum-length', section: 0, expected: minimum.minimum, ...(observed === undefined ? {} : { observed }) }] }); + } + if (diagnostic?.reason === 'response-minimum' && !minimum) throw new TypeError('SDD minimum response requires its semantic recovery.'); if (options.attempts !== undefined && options.attempts !== 1 && options.attempts !== 2) throw new TypeError('Invalid SDD diagnostic attempt count.'); const message = (key: SddPreparationMessageId) => catalog.message(key); - const recovery = options.error.recovery; const sourceRecovery = recovery?.id === 'sdd-source-context-unavailable' && isSddSourceContextDiagnostic(recovery.variables); const source = { code: options.error.code, retryable: options.error.retryable, correlationId: options.error.correlationId, - ...(recovery && (sourceRecovery || (APPLICATION_ERROR_RECOVERY_IDS.includes(recovery.id) + ...(recovery && (sourceRecovery || minimum || (APPLICATION_ERROR_RECOVERY_IDS.includes(recovery.id) && (recovery.id.startsWith('sdd-draft-') || recovery.id.startsWith('sdd-catalog-')) && recovery.variables && Object.keys(recovery.variables).length === 0)) ? { recovery } : {}) }; const view = buildApplicationErrorPresentation(source, errorMessage ?? resolveStaticApplicationErrorCatalog(catalog.locale).message); @@ -68,8 +82,8 @@ export function renderSddPreparationFailure(options: Readonly<{ '', options.publicationPending ? message('sddPreparation.publicationRetained') : message('sddPreparation.noPublication'), '', `**${message('sddPreparation.impact')}:** ${view.impact}`, - '', `**${message('sddPreparation.actionLabel')}:** ${diagnostic ? message('sddPreparation.action') + ' ' : ''}${view.action}`, - '', `**${message('sddPreparation.retainedLabel')}:** ${sourceRecovery ? view.retainedState : message('sddPreparation.retained')}`, + '', `**${message('sddPreparation.actionLabel')}:** ${diagnostic && !minimum ? message('sddPreparation.action') + ' ' : ''}${view.action}`, + '', `**${message('sddPreparation.retainedLabel')}:** ${sourceRecovery || minimum ? view.retainedState : message('sddPreparation.retained')}`, '', runUrl ? `[${message('sddPreparation.workflow')}](${runUrl})` : message('sddPreparation.runUnavailable'), '', '
', `${message('sddPreparation.diagnostics')}`, '', `**${message('sddPreparation.code')}:** \`${view.code}\``, @@ -87,7 +101,8 @@ export function renderSddPreparationFailure(options: Readonly<{ } else if (diagnostic) { lines.push(`**${message('sddPreparation.reason')}:** \`${diagnostic.reason}\``, `**${message('sddPreparation.phase')}:** \`${diagnostic.phase}\``); - if (options.attempts !== undefined) lines.push(`**${message('sddPreparation.attempts')}:** ${options.attempts}`); + const attempts = minimum?.attempts ?? options.attempts; + if (attempts !== undefined) lines.push(`**${message('sddPreparation.attempts')}:** ${attempts}`); for (const fact of diagnostic.facts) { lines.push('', `- ${message(`sddPreparation.fact.${fact.detail}`)}`); for (const key of ['section', 'row', 'expected', 'observed'] as const) { @@ -97,6 +112,9 @@ export function renderSddPreparationFailure(options: Readonly<{ lines.push(` - ${message(`sddPreparation.${label}`)}: ${fact[key]}`); } } + if (fact.detail === 'minimum-length' && fact.observed === undefined) { + lines.push(` - ${message('sddPreparation.observed')}: ${message('sddPreparation.lengthUnavailable')}`); + } } } else lines.push('', message('sddPreparation.noEvidence')); lines.push('', message('sddPreparation.privacy'), '', '
', '', sddAnalysisStatusMarker(options.issueNumber)); diff --git a/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts b/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts index 8c263feb2..f33bae872 100644 --- a/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts +++ b/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts @@ -1,6 +1,6 @@ const sourceSnapshot = { kind: 'pre-branch-sdd', phase: 'draft', baseSha: 'a'.repeat(40) } as const; import { draftSddMarkdown } from './pre_branch_sdd_draft_workflow'; -import { SddDraftFormatFailure, validateSddMarkdown, type SddPlan } from '../../../domain/pre_branch_sdd'; +import { SDD_MIN_DRAFT_CHARACTERS, SddDraftFormatFailure, validateSddMarkdown, type SddPlan } from '../../../domain/pre_branch_sdd'; import { AgentResponseLengthFailure } from '../../../domain/agent_response_length_failure'; import { FindingsAgentAdapter } from '../../../data/repository/ai/findings_agent_adapter'; import { validateSddDraftConsistency } from '../../../domain/sdd_draft_consistency'; @@ -9,6 +9,9 @@ import { sddCaseReferenceFixture, sddConsistencyFixture } from '../../../testing const configuration = { provider: 'codex', model: 'model' } as never; const plan = { action: 'update', path: 'specs/payments.md', capabilityId: 'payments', reason: 'A bounded contract change.', questions: [] } as const; +// Mocked writer constraints receive strings satisfying the independent response minimum. +const writerDraft = (text: string) => text.padEnd(SDD_MIN_DRAFT_CHARACTERS, ' '); + describe('draftSddMarkdown', () => { const assemblyDraft = () => sddConsistencyFixture() .replace(/\| Area \| Minimum distinct cases[\s\S]*?(?=\| Case ID)/u, '{{SDD_CASE_BUDGET}}\n\n') @@ -79,7 +82,7 @@ describe('draftSddMarkdown', () => { it.each(['case-identity', 'case-budget', 'requirement-mapping', 'repository-path'] as const)( 'repairs %s once with closed feedback and returns a specific non-ready recovery on repeated failure', async reason => { - const query = jest.fn().mockResolvedValue({ markdown: 'Untrusted invalid document' }); + const query = jest.fn().mockResolvedValue({ markdown: writerDraft('Untrusted invalid document') }); const validate = jest.fn().mockRejectedValue(new SddDraftFormatFailure(reason, 'untrusted diagnostic @actor /command')); await expect(draftSddMarkdown({ query }, configuration, plan, 'Resolved requirements', validate)).rejects.toMatchObject({ recovery: { id: `sdd-draft-${reason}-invalid`, variables: {} }, @@ -93,10 +96,10 @@ describe('draftSddMarkdown', () => { ); it('revalidates a corrected semantic draft without spending a third generation', async () => { - const query = jest.fn().mockResolvedValueOnce({ markdown: 'Invalid cases' }).mockResolvedValueOnce({ markdown: 'Corrected cases' }); + const query = jest.fn().mockResolvedValueOnce({ markdown: writerDraft('Invalid cases') }).mockResolvedValueOnce({ markdown: writerDraft('Corrected cases') }); const validate = jest.fn().mockRejectedValueOnce(new SddDraftFormatFailure('case-budget', 'Rejected')).mockResolvedValueOnce(undefined); - await expect(draftSddMarkdown({ query }, configuration, plan, 'Resolved requirements', validate)).resolves.toBe('Corrected cases'); - expect(validate.mock.calls.map(call => call[0])).toEqual(['Invalid cases', 'Corrected cases']); + await expect(draftSddMarkdown({ query }, configuration, plan, 'Resolved requirements', validate)).resolves.toBe(writerDraft('Corrected cases')); + expect(validate.mock.calls.map(call => call[0])).toEqual(['Invalid cases', 'Corrected cases'].map(writerDraft)); expect(query).toHaveBeenCalledTimes(2); }); @@ -147,13 +150,13 @@ describe('draftSddMarkdown', () => { const execute = jest.fn().mockResolvedValue(JSON.stringify({ markdown: 'short' })); const adapter = new FindingsAgentAdapter({ cli: { execute } }); await expect(draftSddMarkdown(adapter, { provider: 'codex', modelProvider: 'openai', model: 'gpt-5' }, plan, 'Original', undefined, sourceSnapshot)) - .rejects.toThrow('too short'); + .rejects.toMatchObject({ code: 'validation.invalid-input', attempts: 1, recovery: { id: 'sdd-draft-response-too-short' } }); expect(execute).toHaveBeenCalledTimes(1); }); it('shares the repair ceiling between a schema length rejection and a writer format rejection', async () => { const query = jest.fn().mockRejectedValueOnce(new AgentResponseLengthFailure('$.markdown', 18000)) - .mockResolvedValueOnce({ markdown: 'Invalid format' }); + .mockResolvedValueOnce({ markdown: writerDraft('Invalid format') }); const validate = jest.fn().mockRejectedValue(new SddDraftFormatFailure('test-budget', 'Missing budget')); await expect(draftSddMarkdown({ query }, configuration, plan, 'Original', validate)) .rejects.toMatchObject({ recovery: { id: 'sdd-draft-format-invalid' } }); @@ -162,7 +165,7 @@ describe('draftSddMarkdown', () => { }); it('shares the repair ceiling between a writer format rejection and a schema length rejection', async () => { - const query = jest.fn().mockResolvedValueOnce({ markdown: 'Invalid format' }) + const query = jest.fn().mockResolvedValueOnce({ markdown: writerDraft('Invalid format') }) .mockRejectedValueOnce(new AgentResponseLengthFailure('$.markdown', 18000)); const validate = jest.fn().mockRejectedValue(new SddDraftFormatFailure('required-heading', 'Missing heading')); await expect(draftSddMarkdown({ query }, configuration, plan, 'Original', validate)) @@ -171,18 +174,18 @@ describe('draftSddMarkdown', () => { expect(validate).toHaveBeenCalledTimes(1); }); it.each(['required-heading', 'test-budget'] as const)('repairs typed %s validation once without echoing the rejected draft or diagnostic', async reason => { - const query = jest.fn().mockResolvedValueOnce({ markdown: 'untrusted rejected draft' }).mockResolvedValueOnce({ markdown: 'Accepted draft' }); + const query = jest.fn().mockResolvedValueOnce({ markdown: writerDraft('untrusted rejected draft') }).mockResolvedValueOnce({ markdown: writerDraft('Accepted draft') }); const validate = jest.fn().mockRejectedValueOnce(new SddDraftFormatFailure(reason, 'untrusted provider diagnostic')).mockResolvedValue(undefined); - await expect(draftSddMarkdown({ query }, configuration, plan, 'Original requirements', validate)).resolves.toBe('Accepted draft'); + await expect(draftSddMarkdown({ query }, configuration, plan, 'Original requirements', validate)).resolves.toBe(writerDraft('Accepted draft')); expect(query).toHaveBeenCalledTimes(2); - expect(validate.mock.calls.map(call => call[0])).toEqual(['untrusted rejected draft', 'Accepted draft']); + expect(validate.mock.calls.map(call => call[0])).toEqual(['untrusted rejected draft', 'Accepted draft'].map(writerDraft)); expect(query.mock.calls[1][0].prompt).toContain('exact numbered canonical headings'); expect(query.mock.calls[1][0].prompt).toContain('same owner and plan'); expect(query.mock.calls[1][0].prompt).not.toMatch(/untrusted rejected draft|untrusted provider diagnostic/); }); it('stops after repeated typed format failures with closed recovery and no accepted draft', async () => { - const query = jest.fn().mockResolvedValue({ markdown: 'Invalid draft' }); + const query = jest.fn().mockResolvedValue({ markdown: writerDraft('Invalid draft') }); const validate = jest.fn().mockRejectedValue(new SddDraftFormatFailure('required-heading', 'Missing heading')); await expect(draftSddMarkdown({ query }, configuration, plan, 'Original requirements', validate)).rejects.toMatchObject({ recovery: { id: 'sdd-draft-format-invalid', variables: {} }, @@ -193,14 +196,14 @@ describe('draftSddMarkdown', () => { it('does not retry catalog, authority or workspace failures as format errors', async () => { const error = new Error('Owner changed'); - const query = jest.fn().mockResolvedValue({ markdown: 'Candidate draft' }); + const query = jest.fn().mockResolvedValue({ markdown: writerDraft('Candidate draft') }); const validate = jest.fn().mockRejectedValue(error); await expect(draftSddMarkdown({ query }, configuration, plan, 'Original requirements', validate)).rejects.toBe(error); expect(query).toHaveBeenCalledTimes(1); }); it('shares the one repair between an oversized first draft and a malformed second draft', async () => { - const query = jest.fn().mockResolvedValueOnce({ markdown: 'x'.repeat(18001) }).mockResolvedValueOnce({ markdown: 'Invalid format' }); + const query = jest.fn().mockResolvedValueOnce({ markdown: 'x'.repeat(18001) }).mockResolvedValueOnce({ markdown: writerDraft('Invalid format') }); const validate = jest.fn().mockRejectedValue(new SddDraftFormatFailure('test-budget', 'Missing budget')); await expect(draftSddMarkdown({ query }, configuration, plan, 'Original requirements', validate)).rejects.toMatchObject({ recovery: { id: 'sdd-draft-format-invalid' } }); expect(query).toHaveBeenCalledTimes(2); @@ -208,7 +211,7 @@ describe('draftSddMarkdown', () => { }); it('shares the one repair between a malformed first draft and an oversized second draft', async () => { - const query = jest.fn().mockResolvedValueOnce({ markdown: 'Invalid format' }).mockResolvedValueOnce({ markdown: 'x'.repeat(18001) }); + const query = jest.fn().mockResolvedValueOnce({ markdown: writerDraft('Invalid format') }).mockResolvedValueOnce({ markdown: 'x'.repeat(18001) }); const validate = jest.fn().mockRejectedValue(new SddDraftFormatFailure('required-heading', 'Missing heading')); await expect(draftSddMarkdown({ query }, configuration, plan, 'Original requirements', validate)).rejects.toMatchObject({ recovery: { id: 'sdd-draft-size-exceeded' } }); expect(query).toHaveBeenCalledTimes(2); @@ -227,9 +230,9 @@ describe('draftSddMarkdown', () => { it('regenerates an oversized response once without echoing its untrusted content or changing the owner', async () => { const query = jest.fn().mockResolvedValueOnce({ markdown: 'untrusted previous draft '.repeat(1000) }) - .mockResolvedValueOnce({ markdown: 'Valid regenerated content' }); + .mockResolvedValueOnce({ markdown: writerDraft('Valid regenerated content') }); await expect(draftSddMarkdown({ query }, configuration, plan, 'Original prompt')) - .resolves.toBe('Valid regenerated content'); + .resolves.toBe(writerDraft('Valid regenerated content')); expect(query).toHaveBeenCalledTimes(2); expect(query.mock.calls[1][0].prompt).toContain('same owner and plan'); expect(query.mock.calls[1][0].prompt).toContain('6000 characters'); @@ -262,10 +265,10 @@ describe('draftSddMarkdown', () => { it.each([['update', 18000, 6000], ['companion', 70000, 10000]] as const)( 'uses a compact %s draft target while preserving the hard bound, resolved requirements and repair ceiling', async (action, maximum, target) => { const query = jest.fn().mockResolvedValueOnce({ markdown: 'x'.repeat(maximum + 1) }) - .mockResolvedValueOnce({ markdown: 'Accepted compact complete draft' }); + .mockResolvedValueOnce({ markdown: writerDraft('Accepted compact complete draft') }); const requirements = 'Resolved R1: preserve existing secrets. R2: exact reviewed repositories. R3: numeric tests and recovery.'; await expect(draftSddMarkdown({ query }, configuration, { ...plan, action } as SddPlan, requirements)) - .resolves.toBe('Accepted compact complete draft'); + .resolves.toBe(writerDraft('Accepted compact complete draft')); expect(query).toHaveBeenCalledTimes(2); for (const [request] of query.mock.calls) { expect(request.prompt).toContain(`aim for ${target} characters`); diff --git a/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts b/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts index 660229018..bc916f5ae 100644 --- a/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts +++ b/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts @@ -9,6 +9,7 @@ import { type SddPlan, } from '../../../domain/pre_branch_sdd'; import { AgentResponseLengthFailure } from '../../../domain/agent_response_length_failure'; +import { AgentResponseSchemaFailure } from '../../../domain/agent_response_schema_failure'; import { renderSddDerivedTables, SDD_CONSISTENCY_PROMPT, SDD_DERIVED_TABLES_PROMPT } from '../../../domain/sdd_draft_consistency'; import type { AgentSourceSnapshot } from '../../../domain/agent_source_snapshot'; @@ -43,6 +44,9 @@ export async function draftSddMarkdown( options: { expectJson: true, schemaName: 'pre_branch_sdd_draft', schema }, }); } catch (error) { + if (error instanceof AgentResponseSchemaFailure && error.reason === 'string-short' && error.path === '$.markdown') { + throw minimumFailure(attempt); + } // Only the exact local schema's maximum-length rejection is recoverable. // Provider failures and other schema violations retain their original identity. if (!(error instanceof AgentResponseLengthFailure) || error.path !== '$.markdown' || error.maximum !== maximum) throw error; @@ -60,6 +64,8 @@ export async function draftSddMarkdown( diagnostic = { reason: 'size', phase: 'response', facts: [{ detail: 'size-limit', section: 0, expected: maximum }] }; continue; } + const observedLength = [...markdown].length; + if (observedLength < SDD_MIN_DRAFT_CHARACTERS) throw minimumFailure(attempt, observedLength); let assembledCandidate = false; try { const assembled = renderSddDerivedTables(markdown); @@ -86,3 +92,9 @@ export async function draftSddMarkdown( } throw new SddDraftPreparationFailure(diagnostic, 2); } + +function minimumFailure(attempt: number, observed?: number): SddDraftPreparationFailure { + return new SddDraftPreparationFailure({ reason: 'response-minimum', phase: 'response', + facts: [{ detail: 'minimum-length', section: 0, expected: SDD_MIN_DRAFT_CHARACTERS, + ...(observed === undefined ? {} : { observed }) }] }, attempt === 0 ? 1 : 2); +} diff --git a/src/application/usecases/sdd/sdd_repair_feedback.test.ts b/src/application/usecases/sdd/sdd_repair_feedback.test.ts index 87d3077e8..0c9453e8b 100644 --- a/src/application/usecases/sdd/sdd_repair_feedback.test.ts +++ b/src/application/usecases/sdd/sdd_repair_feedback.test.ts @@ -1,5 +1,5 @@ import { draftSddMarkdown } from './pre_branch_sdd_draft_workflow'; -import { SddDraftFormatFailure } from '../../../domain/pre_branch_sdd'; +import { SddDraftFormatFailure, SDD_MIN_DRAFT_CHARACTERS } from '../../../domain/pre_branch_sdd'; import { AgentResponseLengthFailure } from '../../../domain/agent_response_length_failure'; import { ApplicationError } from '../../errors/application_error'; import type { SddValidationDiagnostic } from '../../../domain/sdd_validation_diagnostic'; @@ -13,7 +13,8 @@ const facts: SddValidationDiagnostic = { reason: 'requirement-mapping', phase: ' { detail: 'requirement-reference', section: 14, row: 2, expected: 0, observed: 1 }, ] }; const failure = () => new SddDraftFormatFailure('requirement-mapping', 'PRIVATE_ERROR @actor /command', facts); -const query = () => jest.fn().mockResolvedValue({ markdown: 'PRIVATE_REJECTED_DRAFT' }); +// Meet the response minimum before exercising writer-validation feedback. +const query = () => jest.fn().mockResolvedValue({ markdown: 'PRIVATE_REJECTED_DRAFT'.padEnd(SDD_MIN_DRAFT_CHARACTERS, ' ') }); const prompt = 'Preserve the complete resolved requirements and existing owner.'; it('sdd-feedback-C09: forwards only closed writer-validation facts on the sole repair with the original request', async () => { diff --git a/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts b/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts index 9a62d7bf4..50e8c5fea 100644 --- a/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts +++ b/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts @@ -1,12 +1,15 @@ import { SddCatalogCompatibilityFailure, SDD_CATALOG_COMPATIBILITY_REASONS } from '../../../../../domain/sdd_catalog_compatibility'; import { SddTrustedCatalogMissingFailure } from '../../../../../domain/sdd_trusted_catalog_failure'; import { SddSourceContextFailure } from '../../../../../domain/agent_source_snapshot'; -import { readSddGateRecord, SddDraftFormatFailure } from '../../../../../domain/pre_branch_sdd'; +import { readSddGateRecord, SddDraftFormatFailure, SDD_MIN_DRAFT_CHARACTERS } from '../../../../../domain/pre_branch_sdd'; import { PreBranchSddGateUseCase, type PreBranchSddContext } from '../../../sdd/pre_branch_sdd_gate_use_case'; import type { SddCatalogSnapshot, SddPreparedDraft } from '../../../../ports/pre_branch_sdd_ports'; import { sddConsistencyFixture } from '../../../../../testing/sdd_consistency_fixture'; import { validateSddDraftConsistency } from '../../../../../domain/sdd_draft_consistency'; +// Synthetic writer fixtures satisfy the response minimum so these cases exercise later gates. +const writerDraft = (markdown: string): string => markdown.padEnd(SDD_MIN_DRAFT_CHARACTERS, ' '); + const baseSha = 'a'.repeat(40); const commitSha = 'b'.repeat(40); const reason = 'The issue changes a product contract that belongs to the payments capability.'; @@ -22,7 +25,7 @@ const snapshot: SddCatalogSnapshot = { specs: ['specs/payments.md'], workflows: [], entrypoints: ['src/index.ts'], code: ['src/index.ts'], tests: ['src/index.test.ts'], documentation: ['docs/payments.mdx'], }], }; -const prepared: SddPreparedDraft = { plan, baseSha, markdown: '# Draft', changedPaths: ['specs/payments.md'] }; +const prepared: SddPreparedDraft = { plan, baseSha, markdown: writerDraft('# Draft'), changedPaths: ['specs/payments.md'] }; function context(): PreBranchSddContext { return { @@ -72,7 +75,7 @@ function harness(presentation?: ConstructorParameters { it('source-context-C39/source-context-C40/source-context-C41: pins analysis, owner evidence and both drafting attempts to one loaded source', async () => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Correctable draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# Correctable draft') }); h.validateDraft.mockRejectedValueOnce(new SddDraftFormatFailure('required-heading', 'Missing required heading')).mockResolvedValueOnce(prepared); expect((await h.useCase.begin(context())).status).toBe('drafted'); expect(h.query.mock.calls.map(([request]) => request.sourceSnapshot)).toEqual([ @@ -157,7 +160,7 @@ describe('PreBranchSddGateUseCase', () => { const h = harness(); h.loadSnapshot.mockResolvedValue({ ...snapshot, capabilities: [{ ...snapshot.capabilities[0], specs: ['specs/payments.md', 'specs/session.md'] }] }); h.readSdd.mockImplementation(async (_sha, path) => path === 'specs/payments.md' ? 'payments-C01 missing bearer' : 'payments-session-C01 valid session'); - h.query.mockResolvedValueOnce({ ...plan, action: 'companion', path: 'specs/change.md', newCapability: null }).mockResolvedValueOnce({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, action: 'companion', path: 'specs/change.md', newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Draft') }); expect((await h.useCase.begin(context())).status).toBe('drafted'); expect(h.readSdd.mock.calls).toEqual([[baseSha, 'specs/payments.md'], [baseSha, 'specs/session.md']]); expect(h.query.mock.calls[1][0].prompt).toContain('["payments-C01","payments-session-C01"]'); @@ -183,7 +186,7 @@ describe('PreBranchSddGateUseCase', () => { it('keeps publication blocked after repeated case inconsistencies', async () => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Contradictory draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# Contradictory draft') }); h.validateDraft.mockRejectedValue(new SddDraftFormatFailure('requirement-mapping', 'Mismatch')); const result = await h.useCase.begin(context()); expect(result.status).toBe('blocked'); @@ -198,7 +201,7 @@ describe('PreBranchSddGateUseCase', () => { code: Array.from({ length: 1500 }, (_, index) => `packages/billing/src/Module${index}.kt`), }] }; h.loadSnapshot.mockResolvedValue(large); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Draft') }); const result = await h.useCase.begin(context()); expect(result.status).toBe('drafted'); const prompt = h.query.mock.calls[1][0].prompt; @@ -211,7 +214,7 @@ describe('PreBranchSddGateUseCase', () => { it('supplies a canonical issue URL and the validated immutable owner evidence when drafting a companion', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, action: 'companion', path: 'specs/payment-status.md', newCapability: null }) - .mockResolvedValueOnce({ markdown: '# Draft companion' }); + .mockResolvedValueOnce({ markdown: writerDraft('# Draft companion') }); await h.useCase.begin({ ...context(), issueUrl: 'https://github.com/vypdev/copilot/issues/42' }); expect(h.query.mock.calls[0][0].prompt).toContain('Canonical issue URL: https://github.com/vypdev/copilot/issues/42'); expect(h.query.mock.calls[0][0].prompt).toContain('consumer repository layout and language conventions'); @@ -225,7 +228,7 @@ describe('PreBranchSddGateUseCase', () => { it.each(['https://github.com/vypdev/copilot/issues/99', 'https://user:password@github.com/vypdev/copilot/issues/42?token=secret'])( 'does not inject an unverified or credential-bearing issue URL: %s', async issueUrl => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Draft') }); await h.useCase.begin({ ...context(), issueUrl }); for (const [request] of h.query.mock.calls) { expect(request.prompt).toContain('Canonical issue URL: (unavailable; do not invent'); @@ -253,7 +256,7 @@ describe('PreBranchSddGateUseCase', () => { it('allows publication preparation after the third question round is fully answered', async () => { const h = await thirdClarificationRound(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: '# Accepted draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Accepted draft') }); const outcome = await h.useCase.begin(context()); expect(outcome.status).toBe('drafted'); if (outcome.status === 'drafted') { @@ -276,18 +279,18 @@ describe('PreBranchSddGateUseCase', () => { it('revalidates a repaired format with the same owner before the final freshness fence', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, newCapability: null }) - .mockResolvedValueOnce({ markdown: '# Invalid draft' }).mockResolvedValueOnce({ markdown: '# Accepted draft' }); + .mockResolvedValueOnce({ markdown: writerDraft('# Invalid draft') }).mockResolvedValueOnce({ markdown: writerDraft('# Accepted draft') }); h.validateDraft.mockRejectedValueOnce(new SddDraftFormatFailure('required-heading', 'Missing heading')); const outcome = await h.useCase.begin(context()); expect(outcome.status).toBe('drafted'); - expect(h.validateDraft.mock.calls.map(call => call.slice(1, 3))).toEqual([[plan, '# Invalid draft'], [plan, '# Accepted draft']]); + expect(h.validateDraft.mock.calls.map(call => call.slice(1, 3))).toEqual([[plan, writerDraft('# Invalid draft')], [plan, writerDraft('# Accepted draft')]]); expect(h.loadSnapshot).toHaveBeenCalledTimes(2); expect(h.publish).not.toHaveBeenCalled(); }); it('refuses a writer that returns no validated draft', async () => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: '# Candidate draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Candidate draft') }); h.validateDraft.mockResolvedValue(undefined); expect((await h.useCase.begin(context())).status).toBe('blocked'); expect(h.publish).not.toHaveBeenCalled(); @@ -296,7 +299,7 @@ describe('PreBranchSddGateUseCase', () => { it('retains earlier decisions with their round and question when Q IDs are reused', async () => { const h = await twoClarificationRounds(); h.comments.push({ id: 102, body: 'SDD Q1: Show newest operations first', user: { login: 'alice' } }); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: '# Complete draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Complete draft') }); const outcome = await h.useCase.begin(context()); expect(outcome.status).toBe('drafted'); if (outcome.status !== 'drafted') throw new Error('Expected draft'); @@ -342,12 +345,12 @@ describe('PreBranchSddGateUseCase', () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, newCapability: null }) .mockResolvedValueOnce({ markdown: 'x'.repeat(18001) }) - .mockResolvedValueOnce({ markdown: '# Regenerated draft' }); + .mockResolvedValueOnce({ markdown: writerDraft('# Regenerated draft') }); const outcome = await h.useCase.begin(context()); expect(outcome.status).toBe('drafted'); expect(h.query).toHaveBeenCalledTimes(3); expect(h.validateDraft).toHaveBeenCalledTimes(1); - expect(h.validateDraft).toHaveBeenCalledWith(snapshot, plan, '# Regenerated draft', undefined); + expect(h.validateDraft).toHaveBeenCalledWith(snapshot, plan, writerDraft('# Regenerated draft'), undefined); expect(h.publish).not.toHaveBeenCalled(); }); @@ -355,7 +358,7 @@ describe('PreBranchSddGateUseCase', () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, newCapability: null }) .mockResolvedValueOnce({ markdown: 'x'.repeat(18001) }) - .mockResolvedValueOnce({ markdown: '# Regenerated draft' }); + .mockResolvedValueOnce({ markdown: writerDraft('# Regenerated draft') }); h.loadSnapshot.mockResolvedValueOnce(snapshot).mockResolvedValueOnce({ ...snapshot, baseSha: 'c'.repeat(40) }); expect((await h.useCase.begin(context())).status).toBe('blocked'); expect(h.validateDraft).toHaveBeenCalledTimes(1); @@ -365,13 +368,13 @@ describe('PreBranchSddGateUseCase', () => { it('retries a pending publication without consuming another clarification round or changing the owner', async () => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Draft') }); const first = await h.useCase.begin(context()); if (first.status !== 'drafted') throw new Error('Expected draft'); h.publish.mockRejectedValueOnce(new Error('Hook failure')); await h.useCase.publish(context(), { ...first, record: { ...first.record, round: 3 } }, 'feature/42-change'); h.query.mockClear(); - h.query.mockResolvedValueOnce({ markdown: '# Retried appendix' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# Retried appendix') }); const retry = await h.useCase.begin(context()); @@ -385,7 +388,7 @@ describe('PreBranchSddGateUseCase', () => { const h = harness(); const parentSha = 'c'.repeat(40); h.loadSnapshot.mockResolvedValueOnce(snapshot).mockResolvedValueOnce({ ...snapshot, baseSha: parentSha }); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Draft') }); const outcome = await h.useCase.begin({ ...context(), workingBranch: 'feature/42-change' }); expect(outcome.status).toBe('drafted'); expect(h.loadSnapshot).toHaveBeenCalledWith('feature/42-change'); @@ -403,13 +406,13 @@ describe('PreBranchSddGateUseCase', () => { it('ignores Action-owned configuration changes when checking issue freshness', async () => { const h = harness(); h.getDescription.mockResolvedValue(context().issueBody + '\n\n'); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Draft') }); expect((await h.useCase.begin(context())).status).toBe('drafted'); }); it('persists a bot-owned pending branch record after a publication failure', async () => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Draft') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('Expected validated draft'); h.publish.mockRejectedValue(new Error('push unavailable')); @@ -420,7 +423,7 @@ describe('PreBranchSddGateUseCase', () => { it('recovers a verified first SDD commit after its card publication was interrupted', async () => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Draft') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('Expected validated draft'); h.publish.mockRejectedValueOnce(new Error('transport failed after push')); @@ -486,7 +489,7 @@ describe('PreBranchSddGateUseCase', () => { expect((await h.useCase.begin(context())).status).toBe('waiting'); h.comments.push({ id: 102, body: 'SDD Q1: Keep old callers working', user: { login: 'alice' } }); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); expect((await h.useCase.begin(context())).status).toBe('drafted'); expect(h.isActorAllowedToModifyFiles).not.toHaveBeenCalled(); }); @@ -497,13 +500,13 @@ describe('PreBranchSddGateUseCase', () => { await h.useCase.begin(context()); h.comments.push({ id: 101, body: 'SDD Q1: Preserve the existing API', user: { login: 'maintainer' } }); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); const outcome = await h.useCase.begin(context()); expect(outcome.status).toBe('drafted'); expect(h.query.mock.calls.map(call => call[0].agentId)).toEqual([ 'pre-branch-sdd-analysis', 'pre-branch-sdd-analysis', 'pre-branch-sdd-draft', ]); - expect(h.validateDraft).toHaveBeenCalledWith(snapshot, expect.objectContaining({ path: plan.path }), '# New SDD content', undefined); + expect(h.validateDraft).toHaveBeenCalledWith(snapshot, expect.objectContaining({ path: plan.path }), writerDraft('# New SDD content'), undefined); expect(h.publish).not.toHaveBeenCalled(); }); @@ -514,18 +517,18 @@ describe('PreBranchSddGateUseCase', () => { scope: 'Invoice behavior', specs: [newPlan.path], }; h.query.mockResolvedValueOnce({ ...newPlan, newCapability }); - h.query.mockResolvedValueOnce({ markdown: '# Invoice contract' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# Invoice contract') }); expect((await h.useCase.begin(context())).status).toBe('drafted'); - expect(h.validateDraft).toHaveBeenCalledWith(snapshot, newPlan, '# Invoice contract', newCapability); + expect(h.validateDraft).toHaveBeenCalledWith(snapshot, newPlan, writerDraft('# Invoice contract'), newCapability); }); it('accepts a new proposed capability with truthful absent implementation arrays', async () => { const h = harness(); const newCapability = { ...snapshot.capabilities[0], id: 'invoicing', title: 'Invoicing', status: 'proposed', specs: [newPlan.path], entrypoints: [], code: [], tests: [] }; - h.query.mockResolvedValueOnce({ ...newPlan, newCapability }).mockResolvedValueOnce({ markdown: '# Prospective invoice contract' }); + h.query.mockResolvedValueOnce({ ...newPlan, newCapability }).mockResolvedValueOnce({ markdown: writerDraft('# Prospective invoice contract') }); expect((await h.useCase.begin(context())).status).toBe('drafted'); - expect(h.validateDraft).toHaveBeenCalledWith(snapshot, newPlan, '# Prospective invoice contract', newCapability); + expect(h.validateDraft).toHaveBeenCalledWith(snapshot, newPlan, writerDraft('# Prospective invoice contract'), newCapability); expect(h.query.mock.calls[0][0].prompt).toContain('Every path field must be an array'); expect(h.query.mock.calls[0][0].prompt).toContain('never fabricate source, wrapper tests or documentation-as-code'); expect(h.publish).not.toHaveBeenCalled(); @@ -545,7 +548,7 @@ describe('PreBranchSddGateUseCase', () => { const prospective = { ...snapshot, capabilities: [{ ...snapshot.capabilities[0], status: 'proposed', entrypoints: [], code: [], tests: [] }] }; h.loadSnapshot.mockResolvedValue(prospective); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: '# Proposed update' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValueOnce({ markdown: writerDraft('# Proposed update') }); expect((await h.useCase.begin(context())).status).toBe('drafted'); expect(h.query.mock.calls[1][0].prompt).toContain('absent implementation evidence'); expect(h.query.mock.calls[1][0].prompt).toContain('preserve Draft/readiness blockers'); @@ -561,7 +564,7 @@ describe('PreBranchSddGateUseCase', () => { ])('blocks a new capability with %s', async (_reason, newCapability) => { const h = harness(); h.query.mockResolvedValueOnce({ ...newPlan, newCapability }); - h.query.mockResolvedValueOnce({ markdown: '# Invoice contract' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# Invoice contract') }); expect((await h.useCase.begin(context())).status).toBe('blocked'); expect(h.validateDraft).not.toHaveBeenCalled(); }); @@ -582,7 +585,7 @@ describe('PreBranchSddGateUseCase', () => { it('publishes on the exact branch, verifies the remote SHA and updates one owned card', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); const draft = await h.useCase.begin(context()); expect(draft.status).toBe('drafted'); if (draft.status !== 'drafted') throw new Error('expected draft'); @@ -598,7 +601,7 @@ describe('PreBranchSddGateUseCase', () => { it('recovers a pushed first commit after a failed status update without another commit', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('expected draft'); h.recoverPublished.mockResolvedValue(commitSha); @@ -610,7 +613,7 @@ describe('PreBranchSddGateUseCase', () => { it('reuses a verified published SDD without another agent call or timeline card', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); const first = await h.useCase.begin(context()); if (first.status !== 'drafted') throw new Error('expected draft'); await h.useCase.publish(context(), first, 'feature/42-change'); @@ -625,7 +628,7 @@ describe('PreBranchSddGateUseCase', () => { it('blocks replay when the published SDD commit is no longer on the linked remote branch', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); const first = await h.useCase.begin(context()); if (first.status !== 'drafted') throw new Error('expected draft'); await h.useCase.publish(context(), first, 'feature/42-change'); @@ -638,7 +641,7 @@ describe('PreBranchSddGateUseCase', () => { it('refuses to publish on a matching remote name that is not linked to this issue', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('expected draft'); h.getLinkedBranch.mockResolvedValue(undefined); @@ -650,7 +653,7 @@ describe('PreBranchSddGateUseCase', () => { it('blocks a changed branch head before publishing and keeps the first commit untouched', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); const first = await h.useCase.begin(context()); if (first.status !== 'drafted') throw new Error('expected draft'); h.getLinkedBranch.mockResolvedValue({ name: 'feature/42-change', headSha: 'd'.repeat(40) }); @@ -662,7 +665,7 @@ describe('PreBranchSddGateUseCase', () => { it('blocks a stale issue body before publication', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); const first = await h.useCase.begin(context()); if (first.status !== 'drafted') throw new Error('expected draft'); h.getDescription.mockResolvedValue('The payment flow changed again.'); @@ -674,7 +677,7 @@ describe('PreBranchSddGateUseCase', () => { it('blocks when remote verification fails after a pushed commit', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); const first = await h.useCase.begin(context()); if (first.status !== 'drafted') throw new Error('expected draft'); h.verifyPublication.mockResolvedValue(false); @@ -686,7 +689,7 @@ describe('PreBranchSddGateUseCase', () => { it('blocks when branch linkage disappears after the SDD commit is pushed', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); const first = await h.useCase.begin(context()); if (first.status !== 'drafted') throw new Error('expected draft'); h.getLinkedBranch.mockResolvedValueOnce({ name: 'feature/42-change', headSha: baseSha }); @@ -699,7 +702,7 @@ describe('PreBranchSddGateUseCase', () => { it('blocks publication after a human title edit but ignores the generated emoji prefix', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# New SDD content' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# New SDD content') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('expected draft'); h.getTitle.mockResolvedValue('🧑‍💻 - Change payments'); @@ -711,7 +714,7 @@ describe('PreBranchSddGateUseCase', () => { it('source-context-C42: revises the owning SDD on the retained branch after a material issue change', async () => { const h = harness(); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# First contract' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# First contract') }); const first = await h.useCase.begin(context()); if (first.status !== 'drafted') throw new Error('expected first draft'); await h.useCase.publish(context(), first, 'feature/42-change'); @@ -722,9 +725,9 @@ describe('PreBranchSddGateUseCase', () => { : snapshot); h.getLinkedBranch.mockResolvedValue({ name: 'feature/42-change', headSha: commitSha }); h.getDescription.mockResolvedValue(revisedContext.issueBody); - h.validateDraft.mockResolvedValue({ ...prepared, baseSha: commitSha, markdown: '# Revised contract' }); + h.validateDraft.mockResolvedValue({ ...prepared, baseSha: commitSha, markdown: writerDraft('# Revised contract') }); h.query.mockResolvedValueOnce({ ...plan, questions: [], newCapability: null }); - h.query.mockResolvedValueOnce({ markdown: '# Revised contract' }); + h.query.mockResolvedValueOnce({ markdown: writerDraft('# Revised contract') }); const revision = await h.useCase.begin(revisedContext); expect(revision.status).toBe('drafted'); expect(h.query.mock.calls.slice(-2).map(([request]) => request.sourceSnapshot)).toEqual([ @@ -805,7 +808,7 @@ describe('safe ownership diagnostics (#636)', () => { await h.useCase.begin(context()); expect(h.addComment).toHaveBeenCalledTimes(1); expect(h.updateComment).toHaveBeenCalledTimes(1); - h.query.mockResolvedValueOnce(plan).mockResolvedValueOnce({ markdown: '# Valid' }); + h.query.mockResolvedValueOnce(plan).mockResolvedValueOnce({ markdown: writerDraft('# Valid') }); expect((await h.useCase.begin(context())).status).toBe('drafted'); expect(h.comments[0].body).toContain('validated ownership'); expect(h.labels()).not.toContain('branched'); @@ -851,7 +854,7 @@ it('reports a later valid ownership analysis in Spanish without a readiness reco const h = harness(); h.query.mockResolvedValueOnce({ ...plan, action: 'companion', capabilityId: 'unknown' }); await h.useCase.begin({ ...context(), issueLocale: 'es-ES' }); - h.query.mockResolvedValueOnce(plan).mockResolvedValueOnce({ markdown: '# Valid' }); + h.query.mockResolvedValueOnce(plan).mockResolvedValueOnce({ markdown: writerDraft('# Valid') }); await h.useCase.begin({ ...context(), issueLocale: 'es-ES' }); expect(h.comments[0].body).toContain('ha validado la propiedad'); expect(readSddGateRecord(h.comments[0].body, 42)).toBeUndefined(); @@ -873,7 +876,7 @@ it('keeps malformed analysis outside ownership recovery and publishes only a saf describe('blocked SDD diagnostics regression (#714)', () => { it('publishes a safe actionable failure card after the single repair is exhausted', async () => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# PRIVATE_CANDIDATE' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# PRIVATE_CANDIDATE') }); h.validateDraft.mockRejectedValue(new SddDraftFormatFailure('requirement-mapping', 'PRIVATE_VALIDATION_TEXT')); const outcome = await h.useCase.begin(context()); expect(outcome.status).toBe('blocked'); @@ -890,7 +893,7 @@ describe('blocked SDD diagnostics regression (#714)', () => { it('updates one owned blocked card on a retry instead of leaving only operator logs', async () => { const h = harness(); h.query.mockImplementation(async request => request.agentId === 'pre-branch-sdd-analysis' - ? { ...plan, newCapability: null } : { markdown: '# PRIVATE_CANDIDATE' }); + ? { ...plan, newCapability: null } : { markdown: writerDraft('# PRIVATE_CANDIDATE') }); h.validateDraft.mockRejectedValue(new SddDraftFormatFailure('case-identity', 'PRIVATE_VALIDATION_TEXT')); await h.useCase.begin(context()); await h.useCase.begin(context()); @@ -905,7 +908,7 @@ describe('blocked SDD diagnostics regression (#714)', () => { describe('SDD diagnostic retention and recovery (#714)', () => { const failDraft = (h: ReturnType) => { h.query.mockImplementation(async request => request.agentId === 'pre-branch-sdd-analysis' - ? { ...plan, newCapability: null } : { markdown: '# PRIVATE_CANDIDATE' }); + ? { ...plan, newCapability: null } : { markdown: writerDraft('# PRIVATE_CANDIDATE') }); h.validateDraft.mockRejectedValue(new SddDraftFormatFailure('case-identity', 'PRIVATE_VALIDATION_TEXT', { reason: 'case-identity', phase: 'validation', facts: [{ detail: 'case-reserved', section: 14, expected: 0, observed: 1 }], })); @@ -947,7 +950,7 @@ describe('SDD diagnostic retention and recovery (#714)', () => { it('reports a partial publication without claiming the linked branch disappeared', async () => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# Draft') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('Expected validated draft'); h.publish.mockRejectedValue(new Error('PRIVATE_PROVIDER_ERROR')); @@ -1009,7 +1012,7 @@ describe('SDD diagnostic retention and recovery (#714)', () => { it.each(['body', 'title', 'base'] as const)('preserves the current cards when publication starts from a stale %s', async changed => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# Draft') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('Expected validated draft'); const current = ' Current run'; @@ -1029,7 +1032,7 @@ describe('SDD diagnostic retention and recovery (#714)', () => { it('rechecks the source before writing either recovery card after a publication failure', async () => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# Draft') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('Expected validated draft'); const current = ' Current run'; @@ -1055,7 +1058,7 @@ describe('SDD diagnostic retention and recovery (#714)', () => { return { requestedLocale: 'fr-FR', resolvedLocale: 'en-US', source: 'fallback', fallbackReason: 'dynamic-response-invalid', messages: request.sourceCatalog.messages }; }); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# Draft') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('Expected validated draft'); const current = ' Current run'; @@ -1071,7 +1074,7 @@ describe('SDD diagnostic retention and recovery (#714)', () => { it.each([false, true])('guards revision recovery against the actual returned commit (foreign advance: %s)', async foreignAdvance => { const h = harness(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# Draft') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('Expected validated draft'); const revision = { ...draft, record: { ...draft.record, branchName: 'feature/42-change', commitSha: baseSha } }; @@ -1235,7 +1238,7 @@ describe('catalog profile preflight and recovery (#716)', () => { const h = harness(undefined, true); h.readSourceRevision.mockResolvedValue(baseSha); h.loadSnapshot.mockRejectedValueOnce(new SddCatalogCompatibilityFailure('unsupported-profile', baseSha)); expect((await h.useCase.begin(context())).status).toBe('blocked'); expect(h.query).not.toHaveBeenCalled(); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# Draft') }); const draft = await h.useCase.begin(context()); expect(draft.status).toBe('drafted'); if (draft.status !== 'drafted') throw new Error('Expected validated SDK draft'); expect((await h.useCase.publish(context(), draft, 'feature/42-change')).status).toBe('published'); @@ -1257,7 +1260,7 @@ describe('catalog profile preflight and recovery (#716)', () => { it('checks the retained linked branch rather than develop when catalog compatibility changes', async () => { const h = harness(undefined, true); h.readSourceRevision.mockResolvedValue(baseSha); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# Draft') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('Expected draft'); await h.useCase.publish(context(), draft, 'feature/42-change'); const record = h.comments.find(comment => readSddGateRecord(comment.body, 42))!.body; @@ -1297,7 +1300,7 @@ describe('confirmed missing trusted catalog recovery (#808)', () => { }); it('checks a retained linked branch and preserves its published record', async () => { const h = harness(undefined, true); h.readSourceRevision.mockResolvedValue(baseSha); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# Draft') }); const draft = await h.useCase.begin(context()); if (draft.status !== 'drafted') throw new Error('Expected draft'); await h.useCase.publish(context(), draft, 'feature/42-change'); const record = h.comments.find(comment => readSddGateRecord(comment.body, 42))!.body; @@ -1332,7 +1335,7 @@ describe('confirmed missing trusted catalog recovery (#808)', () => { expect((await h.useCase.begin(context())).status).toBe('blocked'); expect(h.query).not.toHaveBeenCalled(); h.loadSnapshot.mockResolvedValue({ ...snapshot, baseSha: commitSha }); h.readSourceRevision.mockResolvedValue(commitSha); h.validateDraft.mockResolvedValue({ ...prepared, baseSha: commitSha }); - h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Draft' }); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: writerDraft('# Draft') }); expect((await h.useCase.begin(context())).status).toBe('drafted'); expect(h.validateDraft).toHaveBeenCalledTimes(1); expect(h.comments.filter(comment => comment.body.includes('copilot:sdd-analysis-status:v1'))).toHaveLength(1); }); diff --git a/src/data/model/application_error.ts b/src/data/model/application_error.ts index 10d2b33af..7dd676b68 100644 --- a/src/data/model/application_error.ts +++ b/src/data/model/application_error.ts @@ -18,6 +18,7 @@ import { BRANCH_SYNC_VALIDATION_CODES, type BranchSyncValidationCode } from '../ import { isBranchSyncVerificationFailure, type BranchSyncVerificationFailure } from '../../domain/verification_failure'; import { isBranchSyncCatalogDiagnostic, type BranchSyncCatalogDiagnostic } from '../../domain/branch_sync_catalog_diagnostic'; import { isSddSourceContextDiagnostic, type SddSourceContextDiagnostic } from '../../domain/agent_source_snapshot'; +import { isSddDraftMinimumDiagnostic, type SddDraftMinimumDiagnostic } from '../../domain/sdd_validation_diagnostic'; export type ApplicationErrorKind = | 'configuration' @@ -70,6 +71,7 @@ export const APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-workflow-write-required', 'branch-sync-publication-failed', 'sdd-draft-size-exceeded', + 'sdd-draft-response-too-short', 'sdd-catalog-unsupported-profile', 'sdd-catalog-format-invalid', 'sdd-catalog-missing-trusted-source', @@ -138,6 +140,7 @@ interface ApplicationErrorRecoveryVariables extends Readonly>; + readonly 'sdd-draft-response-too-short': SddDraftMinimumDiagnostic; readonly 'sdd-catalog-unsupported-profile': Readonly>; readonly 'sdd-catalog-format-invalid': Readonly>; readonly 'sdd-catalog-missing-trusted-source': Readonly>; @@ -374,6 +377,8 @@ export class ApplicationError extends Error { ? 'Verified assignments and independently completed effects were retained; the failed assignment operation remains unverified.' : options.recovery?.id === 'issue-assignment-count-invalid' ? 'No assignment operation was attempted; independently completed lifecycle effects were retained.' + : options.recovery?.id === 'sdd-draft-response-too-short' + ? 'Existing answers, selected ownership and published specifications were retained; this attempt published no specification or branch.' : options.recovery?.id === 'deployment-publication-context-missing' ? PRESERVED_STATE : metadata.retainedState; @@ -437,6 +442,7 @@ const RECOVERY_VARIABLE_KEYS: Readonly { + it.each([ + { minimum: 1, observed: 0, attempts: 1 }, + { minimum: 1800, observed: 'unavailable', attempts: 1 }, + { minimum: 1800, observed: 1799, attempts: 2 }, + { minimum: 100000, observed: 99999, attempts: 2 }, + ])('accepts safe minimum/observed/attempt boundaries %j', value => { + expect(isSddDraftMinimumDiagnostic(value)).toBe(true); + }); + + it.each([ + { label: 'null', value: null }, { label: 'array', value: [] }, { label: 'primitive', value: 1 }, + { label: 'extra field', value: { ...recovery, response: 'PRIVATE_RESPONSE' } }, + { label: 'missing observed', value: { minimum: 1800, attempts: 1 } }, + ...[0, 100001, 1.5, Infinity, '1800', undefined].map(minimum => ({ label: `minimum ${String(minimum)}`, value: { ...recovery, minimum } })), + ...[-1, 1800, 1.5, Infinity, '0', 'unknown'].map(observed => ({ label: `observed ${String(observed)}`, value: { ...recovery, observed } })), + ...[0, 3, '1', undefined].map(attempts => ({ label: `attempt ${String(attempts)}`, value: { ...recovery, attempts } })), + ])('rejects $label without accepting arbitrary facts', ({ value }) => { + expect(isSddDraftMinimumDiagnostic(value)).toBe(false); + }); + + it.each([ + { ...diagnostic, phase: 'assembly' }, + { ...diagnostic, facts: [...diagnostic.facts, ...diagnostic.facts] }, + { ...diagnostic, facts: [{ ...diagnostic.facts[0], detail: 'response-shape' }] }, + { ...diagnostic, facts: [{ ...diagnostic.facts[0], section: 4 }] }, + { ...diagnostic, facts: [{ ...diagnostic.facts[0], row: 1 }] }, + { ...diagnostic, facts: [{ ...diagnostic.facts[0], expected: undefined }] }, + { ...diagnostic, facts: [{ ...diagnostic.facts[0], observed: 1800 }] }, + ])('rejects a minimum claim with inconsistent structural facts %j', value => { + expect(() => normalizeSddValidationDiagnostic(value as SddValidationDiagnostic)).toThrow('minimum diagnostic'); + }); + + it.each([undefined, 0, 1799])('copies and freezes a valid response minimum with observed %s', observed => { + const input: SddValidationDiagnostic = { ...diagnostic, + facts: [{ ...diagnostic.facts[0], ...(observed === undefined ? {} : { observed }) }] }; + const value = normalizeSddValidationDiagnostic(input); + expect(value).toEqual(input); expect(value).not.toBe(input); expect(value.facts[0]).not.toBe(input.facts[0]); + expect(Object.isFrozen(value)).toBe(true); expect(Object.isFrozen(value.facts)).toBe(true); expect(Object.isFrozen(value.facts[0])).toBe(true); + }); +}); diff --git a/src/domain/sdd_validation_diagnostic.ts b/src/domain/sdd_validation_diagnostic.ts index 5524972cb..7cc8234ed 100644 --- a/src/domain/sdd_validation_diagnostic.ts +++ b/src/domain/sdd_validation_diagnostic.ts @@ -4,7 +4,7 @@ export const SDD_DRAFT_FORMAT_REASONS = Object.freeze([ ] as const); export type SddDraftFormatReason = typeof SDD_DRAFT_FORMAT_REASONS[number]; export const SDD_VALIDATION_DETAILS = Object.freeze([ - 'validation-contract', 'size-limit', 'response-shape', 'required-heading', 'numeric-budget', + 'validation-contract', 'size-limit', 'response-shape', 'minimum-length', 'required-heading', 'numeric-budget', 'section-count', 'table-count', 'table-separator', 'table-columns', 'table-empty-cell', 'table-empty', 'derived-slot-count', 'derived-slot-placement', 'identifier-list', 'requirement-identity', 'case-identity', 'case-reserved', 'case-reference', 'case-shorthand', @@ -24,13 +24,29 @@ export interface SddValidationFact { readonly observed?: number; } export interface SddValidationDiagnostic { - readonly reason: SddDraftFormatReason | 'size' | 'response-shape'; + readonly reason: SddDraftFormatReason | 'size' | 'response-shape' | 'response-minimum'; readonly phase: 'response' | 'assembly' | 'validation'; readonly facts: readonly SddValidationFact[]; } -const REASONS: readonly string[] = [...SDD_DRAFT_FORMAT_REASONS, 'size', 'response-shape']; +const REASONS: readonly string[] = [...SDD_DRAFT_FORMAT_REASONS, 'size', 'response-shape', 'response-minimum']; const PHASES: readonly string[] = ['response', 'assembly', 'validation']; +export interface SddDraftMinimumDiagnostic { + readonly minimum: number; + readonly observed: number | 'unavailable'; + readonly attempts: 1 | 2; +} + +/** Counts authorize no publication and never carry the rejected response. */ +export function isSddDraftMinimumDiagnostic(input: unknown): input is SddDraftMinimumDiagnostic { + if (!input || typeof input !== 'object' || Array.isArray(input) + || Object.keys(input).sort().join(',') !== 'attempts,minimum,observed') return false; + const value = input as Readonly>; + return boundedInteger(value.minimum, 1) + && (value.observed === 'unavailable' || (boundedInteger(value.observed, 0) && Number(value.observed) < Number(value.minimum))) + && (value.attempts === 1 || value.attempts === 2); +} + /** Fail closed on extra fields, arbitrary text, unbounded facts or invalid coordinates. */ export function normalizeSddValidationDiagnostic(input: SddValidationDiagnostic): SddValidationDiagnostic { if (!input || typeof input !== 'object' || Array.isArray(input) @@ -50,6 +66,11 @@ export function normalizeSddValidationDiagnostic(input: SddValidationDiagnostic) } return Object.freeze({ ...fact }); }); + if (input.reason === 'response-minimum' && (input.phase !== 'response' || facts.length !== 1 + || facts[0].detail !== 'minimum-length' || facts[0].section !== 0 || facts[0].row !== undefined + || !isSddDraftMinimumDiagnostic({ minimum: facts[0].expected, observed: facts[0].observed ?? 'unavailable', attempts: 1 }))) { + throw new TypeError('Invalid bounded SDD minimum diagnostic.'); + } return Object.freeze({ reason: input.reason, phase: input.phase, facts: Object.freeze(facts) }); } diff --git a/src/tooling/__tests__/sdd_minimum_entrypoints.integration.test.ts b/src/tooling/__tests__/sdd_minimum_entrypoints.integration.test.ts new file mode 100644 index 000000000..f40279404 --- /dev/null +++ b/src/tooling/__tests__/sdd_minimum_entrypoints.integration.test.ts @@ -0,0 +1,130 @@ +import { IssueUseCase } from '../../application/usecases/issue_use_case'; +import { IssueCommentUseCase } from '../../application/usecases/issue_comment_use_case'; +import { PreBranchSddGateUseCase } from '../../application/usecases/sdd/pre_branch_sdd_gate_use_case'; +import { ApplicationError } from '../../application/errors/application_error'; +import type { SddCatalogSnapshot } from '../../application/ports/pre_branch_sdd_ports'; +import { FindingsAgentAdapter } from '../../data/repository/ai/findings_agent_adapter'; +import { Ai } from '../../data/model/ai'; +import { Config } from '../../data/model/config'; +import { Result } from '../../data/model/result'; +import type { Execution } from '../../data/model/execution'; +import type { AgentSourceSnapshot } from '../../domain/agent_source_snapshot'; +import { readSddGateRecord } from '../../domain/pre_branch_sdd'; +import { sddConsistencyFixture } from '../../testing/sdd_consistency_fixture'; + +jest.mock('../../utils/logger', () => ({ logInfo: jest.fn(), logDebugInfo: jest.fn(), logError: jest.fn(), logWarn: jest.fn() })); + +const baseSha = '6bd590dac62bdd075d2894e29aa580791dec81a5'; +const title = 'Change payments', body = 'Update payments while preserving the registered owner.'; +const answer = 'SDD Q1: Keep the existing owner and all resolved requirements.'; +type Entry = 'issue' | 'issue-comment'; +type Fault = 'minimum' | 'provider' | 'malformed' | 'stale'; + +async function fixture(fault: Fault) { + let labels = ['feature', 'in-progress'], liveBody = body, awaiting = true; + const comments: { id: number; body: string; user: { login: string } }[] = []; + const owner = sddConsistencyFixture(); + const plan = { action: 'update', path: 'specs/payments.md', capabilityId: 'payments', reason: 'A reviewed change to the registered payments contract.', newCapability: null }; + const execute = jest.fn(async (request: { prompt: string; sourceSnapshot?: AgentSourceSnapshot }) => { + if (request.sourceSnapshot?.phase === 'analysis') return JSON.stringify({ ...plan, + questions: awaiting ? [{ id: 'Q1', text: 'Should the existing owner and requirements remain?', owner: 'issue-author', suggestion: null }] : [] }); + if (fault === 'provider') throw new ApplicationError('provider.unavailable', 'Provider unavailable.', { + correlationId: '6f173f89-96a3-4fc8-b90e-4f8f48e0e319', cause: new Error('PRIVATE_PROVIDER_CAUSE'), + }); + if (fault === 'stale') liveBody = 'A concurrent human edit must be retained.'; + return JSON.stringify({ markdown: fault === 'malformed' ? 42 : 'PRIVATE_REJECTED_RESPONSE' }); + }); + const snapshot: SddCatalogSnapshot = { baseSha, standard: '# Standard', template: '# Template', capabilities: [{ + id: 'payments', title: 'Payments', status: 'implemented', scope: 'Payment workflow', owner: 'Maintainers', lastVerified: '2026-11-04', + specs: ['specs/payments.md'], workflows: [], entrypoints: ['src/main.ts'], code: ['src/payments.ts'], tests: ['test/payments.test.ts'], documentation: ['docs/payments.mdx'], + }] }; + const publish = jest.fn(), validateDraft = jest.fn(), readSdd = jest.fn(async (_sha: string, _path: string) => owner); + const gate = new PreBranchSddGateUseCase(new FindingsAgentAdapter({ cli: { execute } }), { + loadSnapshot: jest.fn(async () => snapshot), readSourceRevision: jest.fn(async () => baseSha), + readSdd, validateDraft, publish, isAncestor: jest.fn(async () => true), + recoverPublished: jest.fn(), verifyPublication: jest.fn(), + }, { + listIssueComments: jest.fn(async () => comments), + addComment: jest.fn(async (_number, text) => { comments.push({ id: Math.max(99, ...comments.map(c => c.id)) + 1, body: text, user: { login: 'bot' } }); }), + updateComment: jest.fn(async (_number, id, text) => { const target = comments.find(c => c.id === id); if (target) target.body = text; }), + }, { getLabels: jest.fn(async () => labels), setLabels: jest.fn(async (_number, next) => { labels = [...next]; }) }, { + isActorAllowedToModifyFiles: jest.fn(async () => true), isActorAllowedToUseMemberOnlyAutomation: jest.fn(async () => true), + }, { getDescription: jest.fn(async () => liveBody) }, { getTitle: jest.fn(async () => title) } as never, + { getLinkedBranch: jest.fn() }, { run: { serverOrigin: 'https://github.example.test', owner: 'acme', repository: 'repo', runId: '123' } }); + const step = (id: string) => ({ taskId: id, invoke: jest.fn(async () => [new Result({ id, success: true, executed: false })]) }); + const prepare = { taskId: 'prepare', invoke: jest.fn(async () => { throw new Error('A failed SDD must never prepare a branch.'); }) }; + const recommend = { taskId: 'recommend', invoke: jest.fn(async () => ({ results: [] })) }; + const deploy = { taskId: 'deploy', invoke: jest.fn(async () => { throw new Error('A failed SDD must never deploy.'); }) }; + const issue = new IssueUseCase(recommend, step('help'), { + checkPermissions: step('permissions'), closeNotAllowedIssue: step('close'), removeIssueBranches: step('remove'), + assignMemberToIssue: step('assign'), updateIssueType: step('type'), linkIssueProject: step('project'), + checkPriorityIssueSize: step('priority'), updateTitle: step('title'), prepareBranches: prepare, + reconcileBranchReadiness: { taskId: 'readiness', invoke: jest.fn(async () => []) }, + removeNotNeededBranches: step('obsolete'), deployAdded: deploy, + }, { listIssueComments: jest.fn(async () => comments) }, undefined, gate); + const unused = { taskId: 'unused', invoke: jest.fn(async () => { throw new Error('SDD answers must use their normal continuation.'); }) }; + const comment = new IssueCommentUseCase(unused, unused, unused, unused, unused, {} as never, {} as never, + undefined, undefined, undefined, undefined, undefined, issue); + const execution = (entry: Entry): Execution => ({ + owner: 'acme', repo: 'repo', actor: 'alice', tokenUser: 'bot', tokens: { token: 'fixture-token' }, + eventName: entry === 'issue' ? 'issues' : 'issue_comment', inputs: { action: entry === 'issue' ? 'labeled' : 'created', issue: { state: 'open' } }, + isIssue: true, isPullRequest: false, issueNumber: 42, preBranchSdd: true, issueWorkflowKind: 'feature', issueWorkflowProfileDigest: 'profile', + issueStartDecision: { started: true, branchRequired: true, sddRequired: true, helpRequired: false }, + cleanIssueBranches: false, managementBranch: 'feature', currentConfiguration: new Config({ branchType: 'feature' }), + issue: { number: 42, title, body, creator: 'alice', url: 'https://github.example.test/acme/repo/issues/42', + opened: false, labeled: entry === 'issue', labelAdded: 'in-progress', desiredAssigneesCount: 0, issueManagedBranches: true, + commentBody: answer, commentAuthor: 'alice', commentId: 101 }, + pullRequest: { number: -1, title: '', opened: false, creator: '', desiredAssigneesCount: 0 }, + labels: { currentIssueLabels: [...labels], isFeature: true, isEnhancement: false, isRelease: false, isHotfix: false, + isQuestion: false, isHelp: false, isMandatoryBranchedLabel: false, isBugfix: false, isBug: false, isDocs: false, + isDocumentation: false, isChore: false, isMaintenance: false, containsBranchedLabel: false, + priorityLabelOnIssueProcessable: false, priorityHigh: 'priority: high', priorityMedium: 'priority: medium', priorityLow: 'priority: low', + feature: 'feature', bugfix: 'bugfix', deploy: 'deploy' }, + emoji: { emojiLabeledTitle: false, branchManagementEmoji: '' }, release: { active: false }, hotfix: { active: false }, + project: { getProjects: () => [], getProjectColumnIssueInProgress: () => 'In Progress', + getProjectColumnIssueCreated: () => 'Todo', getProjectColumnPullRequestCreated: () => 'Review' }, + branches: { main: 'master', defaultBranch: 'master', development: 'develop', featureTree: 'feature', bugfixTree: 'bugfix', docsTree: 'docs', choreTree: 'chore', hotfixTree: 'hotfix' }, + issueTypes: Object.fromEntries(['task', 'bug', 'feature', 'documentation', 'maintenance', 'hotfix', 'release', 'question', 'help'] + .flatMap(name => [[name, name], [`${name}Description`, `${name} description`], [`${name}Color`, 'BLUE']])), + workflows: { release: 'release.yml', hotfix: 'hotfix.yml' }, commitPrefixBuilder: '', + locale: { repository: 'en-US', issue: 'en-US', pullRequest: 'en-US' }, ai: new Ai('', 'model', false, [], false, 'low', 20), + } as unknown as Execution); + await issue.invoke(execution('issue')); + const card = comments.find(c => readSddGateRecord(c.body, 42)); + expect(card).toBeDefined(); expect(execute).toHaveBeenCalledTimes(1); + comments.push({ id: Math.max(...comments.map(c => c.id)) + 1, body: answer, user: { login: 'alice' } }); + awaiting = false; + return { comments, card: card!, execute, publish, validateDraft, readSdd, owner, labels: () => labels, + prepare, recommend, deploy, invoke: (entry: Entry) => entry === 'issue' ? issue.invoke(execution(entry)) : comment.invoke(execution(entry)) }; +} + +describe.each(['issue', 'issue-comment'] as const)('undersized SDD through ordinary %s entrypoint', entry => { + it.each(['minimum', 'provider', 'malformed', 'stale'] as const)('preserves owner/answers and publication fences for %s', async fault => { + const f = await fixture(fault); + const results = await f.invoke(entry); + const errors = results.flatMap(r => [...r.errors]); + expect(errors.some(e => e.code === (fault === 'provider' ? 'provider.unavailable' : fault === 'malformed' ? 'workflow.failed' : 'validation.invalid-input'))).toBe(true); + const draftCalls = () => f.execute.mock.calls.filter(([request]) => request.sourceSnapshot?.phase === 'draft'); + expect(draftCalls()).toHaveLength(1); + expect(f.execute.mock.calls.every(([request]) => request.sourceSnapshot?.baseSha === baseSha)).toBe(true); + expect(f.execute.mock.calls.some(([request]) => request.prompt.includes('Keep the existing owner and all resolved requirements.'))).toBe(true); + expect(readSddGateRecord(f.card.body, 42)).toMatchObject({ plan: { path: 'specs/payments.md', capabilityId: 'payments' }, baseSha }); + expect(f.comments.some(c => c.user.login === 'alice' && c.body === answer)).toBe(true); + expect(f.readSdd.mock.calls.every(call => call.length >= 2 && call[0] === baseSha && call[1] === 'specs/payments.md')).toBe(true); + const status = () => f.comments.filter(c => c.body.includes('copilot:sdd-analysis-status:v1')); + if (fault === 'stale') expect(status()).toHaveLength(0); + else { + expect(status()).toHaveLength(1); + if (fault === 'minimum') { + expect(errors.find(e => e.recovery?.id === 'sdd-draft-response-too-short')?.recovery).toMatchObject({ variables: { minimum: 1800, observed: 'unavailable', attempts: 1 } }); + expect(status()[0].body).toContain('response-minimum'); expect(status()[0].body).toContain('1800'); + const statusId = status()[0].id; + await f.invoke(entry); + expect(status()).toHaveLength(1); expect(status()[0].id).toBe(statusId); expect(draftCalls()).toHaveLength(2); + } else expect(status()[0].body).not.toContain('sdd-draft-response-too-short'); + } + expect(f.prepare.invoke).not.toHaveBeenCalled(); expect(f.publish).not.toHaveBeenCalled(); expect(f.validateDraft).not.toHaveBeenCalled(); + expect(f.recommend.invoke).not.toHaveBeenCalled(); expect(f.deploy.invoke).not.toHaveBeenCalled(); expect(f.labels()).not.toContain('branched'); + expect(JSON.stringify(f.comments) + JSON.stringify(results)).not.toMatch(/PRIVATE_REJECTED_RESPONSE|PRIVATE_PROVIDER_CAUSE|fixture-token/); + }); +}); diff --git a/src/tooling/__tests__/sdd_minimum_failure.test.ts b/src/tooling/__tests__/sdd_minimum_failure.test.ts new file mode 100644 index 000000000..e51213828 --- /dev/null +++ b/src/tooling/__tests__/sdd_minimum_failure.test.ts @@ -0,0 +1,110 @@ +import { draftSddMarkdown } from '../../application/usecases/sdd/pre_branch_sdd_draft_workflow'; +import { FindingsAgentAdapter } from '../../data/repository/ai/findings_agent_adapter'; +import { AgentResponseSchemaFailure } from '../../domain/agent_response_schema_failure'; +import { ApplicationError } from '../../application/errors/application_error'; + +const configuration = { provider: 'codex', modelProvider: 'openai', model: 'model' } as const; +const plan = { action: 'update', path: 'specs/owner.md', capabilityId: 'owner', reason: 'Preserve the reviewed owner.', questions: [] } as const; +const sourceSnapshot = { kind: 'pre-branch-sdd', phase: 'draft', baseSha: '6bd590dac62bdd075d2894e29aa580791dec81a5' } as const; + +describe('undersized SDD response boundary', () => { + it('classifies the real adapter minimum rejection without guessing the discarded response length', async () => { + const execute = jest.fn().mockResolvedValue(JSON.stringify({ markdown: 'PRIVATE_REJECTED_RESPONSE' })); + const adapter = new FindingsAgentAdapter({ cli: { execute } }); + const validate = jest.fn(); + const error = await draftSddMarkdown(adapter, configuration, plan, 'Retain all resolved requirements.', validate, sourceSnapshot).catch(error => error); + expect(error).toMatchObject({ code: 'validation.invalid-input', retryable: false, + recovery: { id: 'sdd-draft-response-too-short', variables: { minimum: 1800, observed: 'unavailable', attempts: 1 } }, + diagnostic: { reason: 'response-minimum', phase: 'response', facts: [{ detail: 'minimum-length', section: 0, expected: 1800 }] }, + }); + expect(execute).toHaveBeenCalledTimes(1); expect(validate).not.toHaveBeenCalled(); + expect(JSON.stringify(error)).not.toContain('PRIVATE_REJECTED_RESPONSE'); + }); + + it('reports the actual second attempt after maximum repair without granting a minimum repair', async () => { + const execute = jest.fn().mockResolvedValueOnce(JSON.stringify({ markdown: 'x'.repeat(18001) })) + .mockResolvedValueOnce(JSON.stringify({ markdown: 'PRIVATE_SECOND_RESPONSE' })); + const adapter = new FindingsAgentAdapter({ cli: { execute } }); + const validate = jest.fn(); + const error = await draftSddMarkdown(adapter, configuration, plan, 'Retain answers and ownership.', validate, sourceSnapshot).catch(error => error); + expect(error).toMatchObject({ code: 'validation.invalid-input', retryable: false, + recovery: { id: 'sdd-draft-response-too-short', variables: { minimum: 1800, observed: 'unavailable', attempts: 2 } }, + attempts: 2, + }); + expect(execute).toHaveBeenCalledTimes(2); expect(validate).not.toHaveBeenCalled(); + expect(JSON.stringify(error) + JSON.stringify(execute.mock.calls)).not.toContain('PRIVATE_SECOND_RESPONSE'); + }); + + it('rejects a returned short string by Unicode code points before writer validation', async () => { + const query = jest.fn().mockResolvedValue({ markdown: '🐈'.repeat(900) }); + const validate = jest.fn(); + const error = await draftSddMarkdown({ query }, configuration, plan, 'Retain existing specifications.', validate).catch(error => error); + expect(error).toMatchObject({ code: 'validation.invalid-input', retryable: false, + recovery: { id: 'sdd-draft-response-too-short', variables: { minimum: 1800, observed: 900, attempts: 1 } }, + diagnostic: { reason: 'response-minimum', phase: 'response', facts: [{ detail: 'minimum-length', section: 0, expected: 1800, observed: 900 }] }, + }); + expect(query).toHaveBeenCalledTimes(1); expect(validate).not.toHaveBeenCalled(); + expect(JSON.stringify(error)).not.toContain('🐈'); + }); +}); + +describe('minimum boundary and unchanged terminal identities', () => { + it.each(['update', 'companion', 'new'] as const)('accepts the exact 1800-code-point schema boundary for %s', async action => { + const markdown = 'x'.repeat(1800), execute = jest.fn().mockResolvedValue(JSON.stringify({ markdown })); + const validate = jest.fn(); + await expect(draftSddMarkdown(new FindingsAgentAdapter({ cli: { execute } }), configuration, { ...plan, action }, 'Complete response.', validate, sourceSnapshot)).resolves.toBe(markdown); + expect(execute).toHaveBeenCalledTimes(1); expect(validate).toHaveBeenCalledWith(markdown); + }); + + it.each([0, 1799])('records an actually returned count of %s without guessing from serialized bytes', async observed => { + const query = jest.fn().mockResolvedValue({ markdown: 'x'.repeat(observed) }), validate = jest.fn(); + await expect(draftSddMarkdown({ query }, configuration, plan, 'Complete response.', validate)).rejects.toMatchObject({ + recovery: { id: 'sdd-draft-response-too-short', variables: { minimum: 1800, observed, attempts: 1 } }, + }); + expect(query).toHaveBeenCalledTimes(1); expect(validate).not.toHaveBeenCalled(); + }); + + it('accepts exactly 1800 astral code points through the real adapter and writer boundary', async () => { + const markdown = '🐈'.repeat(1800), execute = jest.fn().mockResolvedValue(JSON.stringify({ markdown })); + const validate = jest.fn(); + await expect(draftSddMarkdown(new FindingsAgentAdapter({ cli: { execute } }), configuration, plan, 'Complete response.', validate, sourceSnapshot)).resolves.toBe(markdown); + expect(execute).toHaveBeenCalledTimes(1); expect(validate).toHaveBeenCalledWith(markdown); + }); + + it.each([ + { value: null, reason: 'json-object' }, { value: { markdown: null }, reason: 'type' }, + { value: {}, reason: 'required' }, { value: { markdown: 42 }, reason: 'type' }, + ])('preserves the real adapter $reason rejection instead of inventing a minimum failure', async ({ value, reason }) => { + const execute = jest.fn().mockResolvedValue(JSON.stringify(value)), validate = jest.fn(); + const error = await draftSddMarkdown(new FindingsAgentAdapter({ cli: { execute } }), configuration, plan, 'Complete response.', validate, sourceSnapshot).catch(error => error); + if (reason === 'json-object') expect(error.message).toBe('Agent response is not a single valid JSON object.'); + else { expect(error).toBeInstanceOf(AgentResponseSchemaFailure); expect(error.reason).toBe(reason); } + expect(error).not.toHaveProperty('recovery'); expect(execute).toHaveBeenCalledTimes(1); expect(validate).not.toHaveBeenCalled(); + }); + + it.each([ + new AgentResponseSchemaFailure('string-short', '$.other', 'PRIVATE_FOREIGN_ERROR'), + new AgentResponseSchemaFailure('enum', '$.markdown', 'PRIVATE_FOREIGN_ERROR'), + new Error('Agent response schema violation at $.markdown: string is too short.'), + new ApplicationError('provider.unavailable', 'Provider unavailable.', { correlationId: '6f173f89-96a3-4fc8-b90e-4f8f48e0e319' }), + ])('preserves unrelated typed/forged/provider identity without regeneration: %s', async error => { + const query = jest.fn().mockRejectedValue(error), validate = jest.fn(); + await expect(draftSddMarkdown({ query }, configuration, plan, 'Original requirements.', validate)).rejects.toBe(error); + expect(query).toHaveBeenCalledTimes(1); expect(validate).not.toHaveBeenCalled(); + }); + + it('reports a measured short second response after the existing maximum repair and stops', async () => { + const query = jest.fn().mockResolvedValueOnce({ markdown: 'x'.repeat(18001) }).mockResolvedValueOnce({ markdown: 'SECRET' }); + const validate = jest.fn(); + const error = await draftSddMarkdown({ query }, configuration, plan, 'Original requirements.', validate).catch(error => error); + expect(error).toMatchObject({ recovery: { id: 'sdd-draft-response-too-short', variables: { minimum: 1800, observed: 6, attempts: 2 } } }); + expect(JSON.stringify(error)).not.toContain('SECRET'); expect(query).toHaveBeenCalledTimes(2); expect(validate).not.toHaveBeenCalled(); + }); + + it('keeps malformed JSON terminal without a minimum classification or another query', async () => { + const execute = jest.fn().mockResolvedValue('PRIVATE_INVALID_JSON'), validate = jest.fn(); + const error = await draftSddMarkdown(new FindingsAgentAdapter({ cli: { execute } }), configuration, plan, 'Original requirements.', validate, sourceSnapshot).catch(error => error); + expect(error).toBeInstanceOf(Error); expect(error).not.toHaveProperty('recovery.id', 'sdd-draft-response-too-short'); + expect(execute).toHaveBeenCalledTimes(1); expect(validate).not.toHaveBeenCalled(); + }); +});