From e8fecebae83e98e5e5706437f8c386c27acdb04d Mon Sep 17 00:00:00 2001 From: sunlei Date: Sun, 14 Jun 2026 21:04:58 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=BC=96=E5=86=99API=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...4-api-deployment-observation-foundation.md | 1522 +++++++++++++++++ 1 file changed, 1522 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-api-deployment-observation-foundation.md diff --git a/docs/superpowers/plans/2026-06-14-api-deployment-observation-foundation.md b/docs/superpowers/plans/2026-06-14-api-deployment-observation-foundation.md new file mode 100644 index 0000000..7be8e9a --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-api-deployment-observation-foundation.md @@ -0,0 +1,1522 @@ +# API Deployment Observation Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build Phase 2 of the approved API Runtime Foundation: a ktWorkflow deployment observation adapter that gathers Jenkins, K8s, Pod, `/health/runtime`, and task smoke evidence before any deployed feature is called complete. + +**Architecture:** Keep API runtime health as the source contract and implement the observation workflow in `mcp/ktWorkflow`. The new tool generates a bounded, read-only NAS script by default, can execute it only when `execute=true`, normalizes command output into an API-compatible runtime evidence summary, and writes evidence under `.kt-workspace/test-artifacts/deploy-observation`. Deployment rollout evidence remains separate from functional smoke evidence. + +**Tech Stack:** TypeScript ESM, `@modelcontextprotocol/sdk`, Zod, Node `child_process`, PowerShell, `ssh nas`, Kubernetes `kubectl`, Jenkins home/log read-only inspection, `curl`, pnpm. + +--- + +## Current Context + +- API repo: `D:\MyFiles\KT\Node\kt-template-online-api`, branch `dev`, currently clean at the time this plan was written. +- ktWorkflow repo: `D:\MyFiles\KT\mcp\ktWorkflow`, branch `main`, currently has pre-existing changes in `README.md`, `src/selfTest.ts`, and `src/tools/review.ts`. +- Do not create a `.worktree`. The user explicitly prefers a normal development branch. +- Do not overwrite the dirty ktWorkflow files. The execution phase must classify or preserve those changes before editing files that overlap this plan. +- API public health contract is already implemented as plain JSON: + +```ts +export type RuntimeHealthStatus = 'live' | 'ready' | 'degraded' | 'blocked'; + +export interface RuntimeHealthCheck { + name: string; + status: RuntimeHealthStatus; + critical: boolean; + message: string; + detail?: Record; +} + +export interface RuntimeHealthReport { + service: 'kt-template-online-api'; + checkedAt: string; + status: RuntimeHealthStatus; + checks: RuntimeHealthCheck[]; +} +``` + +## File Structure + +- Create `D:\MyFiles\KT\mcp\ktWorkflow\src\tools\deployObservation.ts` + - Builds the read-only NAS observation command. + - Parses marked command sections from stdout. + - Normalizes Jenkins, Deployment, Pod, runtime health, and smoke data into evidence. + - Writes JSON evidence under `.kt-workspace/test-artifacts/deploy-observation`. + +- Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\types.ts` + - Add `DeployObservationInput`, evidence DTOs, and assertion types. + +- Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\core\cli.ts` + - Add `parseDeployObservationCliArgs(argv)`. + +- Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\registerTools.ts` + - Register MCP tool `kt_deploy_observation`. + +- Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\server.ts` + - Add `--deploy-observation` CLI branch. + +- Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\core\constants.ts` + - Add `kt_deploy_observation` to `registeredToolNames`. + +- Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\tools\closeout.ts` + - Point `deploy-observation` upgrade target to the new tool file. + +- Modify `D:\MyFiles\KT\mcp\ktWorkflow\src\selfTest.ts` + - Add self-tests for CLI parsing, stdout section parsing, evidence normalization, and dry-run command generation. + +- Modify `D:\MyFiles\KT\mcp\ktWorkflow\package.json` + - Add `deploy-observation` script. + +- Modify `D:\MyFiles\KT\mcp\ktWorkflow\README.md` + - Document the new MCP tool and CLI. + +- Modify `D:\MyFiles\KT\TASKS.md` + - Record Phase 2 implementation and verification evidence after code is complete. + +## Task 0: Protect Branch And Existing Changes + +**Files:** +- Read: `D:\MyFiles\KT\mcp\ktWorkflow\README.md` +- Read: `D:\MyFiles\KT\mcp\ktWorkflow\src\selfTest.ts` +- Read: `D:\MyFiles\KT\mcp\ktWorkflow\src\tools\review.ts` + +- [ ] **Step 1: Confirm repo states** + +Run: + +```powershell +git -C D:\MyFiles\KT\Node\kt-template-online-api status --short --branch +git -C D:\MyFiles\KT\mcp\ktWorkflow status --short --branch +git -C D:\MyFiles\KT status --short --branch +``` + +Expected: + +```text +API repo is on dev and clean. +ktWorkflow repo shows existing modified README.md, src/selfTest.ts, src/tools/review.ts. +Root repo may be dirty from unrelated workspace records. +``` + +- [ ] **Step 2: Inspect overlapping ktWorkflow changes** + +Run: + +```powershell +git -C D:\MyFiles\KT\mcp\ktWorkflow diff -- README.md src/selfTest.ts src/tools/review.ts +``` + +Expected: + +```text +The diff is read before edits. Existing hunks are preserved or deliberately carried into the development branch. +``` + +- [ ] **Step 3: Use a normal development branch** + +Run: + +```powershell +git -C D:\MyFiles\KT\mcp\ktWorkflow branch --show-current +git -C D:\MyFiles\KT\mcp\ktWorkflow switch -c dev +``` + +Expected if `dev` does not exist: + +```text +Switched to a new branch 'dev' +``` + +Expected if `dev` already exists: + +```powershell +git -C D:\MyFiles\KT\mcp\ktWorkflow switch dev +``` + +```text +Switched to branch 'dev' +``` + +- [ ] **Step 4: Stop if switching would overwrite files** + +If Git refuses the branch switch because local changes would be overwritten, stop execution and report the exact file list. Do not run `git reset`, `git checkout --`, or any destructive cleanup without explicit user approval. + +## Task 1: Add Failing Self-Tests For Deploy Observation + +**Files:** +- Modify: `D:\MyFiles\KT\mcp\ktWorkflow\src\selfTest.ts` + +- [ ] **Step 1: Add imports that should fail before implementation** + +Modify the import area in `src/selfTest.ts`: + +```ts +import { + parseDeployObservationCliArgs, + parseGlobalReviewCliArgs, + parseWorkstreamCloseoutCliArgs, +} from "./core/cli.js"; +import { + buildDeployObservation, + buildDeployObservationSectionMap, + normalizeDeployObservationEvidence, +} from "./tools/deployObservation.js"; +``` + +- [ ] **Step 2: Add the deploy observation self-test block** + +Add this block after the existing workstream closeout self-tests and before the final test artifact write: + +```ts + const deployObservationInput = parseDeployObservationCliArgs([ + "node", + "server", + "--deploy-observation", + "--project", + "api", + "--job", + "kt-template-online-api", + "--build", + "132", + "--commit", + "abc1234", + "--image-tag", + "abc1234", + "--namespace", + "kt-prod", + "--deployment", + "kt-template-online-api", + "--container", + "api", + "--health-url", + "http://127.0.0.1:30085/health/runtime", + "--smoke", + "curl -fsS --max-time 8 http://127.0.0.1:30085/health/runtime", + ]); + if ( + deployObservationInput.project !== "api" || + deployObservationInput.jobName !== "kt-template-online-api" || + deployObservationInput.buildNumber !== "132" || + deployObservationInput.expectedCommit !== "abc1234" || + deployObservationInput.imageTag !== "abc1234" || + deployObservationInput.namespace !== "kt-prod" || + deployObservationInput.deployment !== "kt-template-online-api" || + deployObservationInput.container !== "api" || + deployObservationInput.healthUrl !== + "http://127.0.0.1:30085/health/runtime" || + deployObservationInput.smoke !== + "curl -fsS --max-time 8 http://127.0.0.1:30085/health/runtime" || + deployObservationInput.execute !== false + ) { + throw new Error("deploy observation CLI parser self-check failed"); + } + + const deployObservationSections = buildDeployObservationSectionMap( + [ + "__KT_SECTION:jenkins_meta__", + "buildNumber=132", + "result=SUCCESS", + "__KT_SECTION:jenkins_log_tail__", + "Checking out Revision abc1234", + "Finished: SUCCESS", + "__KT_SECTION:deployment_json__", + JSON.stringify({ + metadata: { generation: 12 }, + spec: { + template: { + spec: { + containers: [ + { + image: + "k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234", + name: "api", + }, + ], + }, + }, + }, + status: { + observedGeneration: 12, + readyReplicas: 1, + updatedReplicas: 1, + }, + }), + "__KT_SECTION:pods_json__", + JSON.stringify({ + items: [ + { + metadata: { + name: "kt-template-online-api-abc", + }, + spec: { + containers: [ + { + image: + "k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234", + name: "api", + }, + ], + }, + status: { + containerStatuses: [ + { + image: + "k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234", + name: "api", + ready: true, + restartCount: 0, + }, + ], + phase: "Running", + startTime: "2026-06-14T00:00:00Z", + }, + }, + ], + }), + "__KT_SECTION:health_json__", + JSON.stringify({ + checkedAt: "2026-06-14T00:00:01.000Z", + checks: [ + { + critical: true, + message: "NestJS process answered runtime health request", + name: "process", + status: "live", + }, + ], + service: "kt-template-online-api", + status: "ready", + }), + "__KT_SECTION:smoke_text__", + "{\"service\":\"kt-template-online-api\",\"status\":\"ready\"}", + ].join("\n"), + ); + const deployObservationEvidence = normalizeDeployObservationEvidence({ + checkedAt: new Date("2026-06-14T00:00:02.000Z"), + input: deployObservationInput, + sectionMap: deployObservationSections, + }); + if ( + deployObservationEvidence.status !== "passed" || + deployObservationEvidence.details.deployment.image !== + "k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234" || + deployObservationEvidence.details.deployment.observedGeneration !== 12 || + deployObservationEvidence.details.pod.name !== + "kt-template-online-api-abc" || + deployObservationEvidence.details.pod.restartCount !== 0 || + deployObservationEvidence.details.runtimeHealth.status !== "ready" || + !deployObservationEvidence.assertions.every((item) => item.passed) + ) { + throw new Error("deploy observation evidence normalization self-check failed"); + } + + const deployObservationDryRun = await buildDeployObservation({ + deployment: "kt-template-online-api", + execute: false, + healthUrl: "http://127.0.0.1:30085/health/runtime", + imageTag: "abc1234", + jobName: "kt-template-online-api", + namespace: "kt-prod", + project: "api", + smoke: "curl -fsS --max-time 8 http://127.0.0.1:30085/health/runtime", + }); + const deployObservationCommandText = JSON.stringify( + deployObservationDryRun.commands, + ); + if ( + deployObservationDryRun.execute !== false || + !deployObservationCommandText.includes("tr -d '\\015' | bash -s") || + !deployObservationCommandText.includes("kubectl") || + !deployObservationCommandText.includes("/health/runtime") || + deployObservationDryRun.evidence + ) { + throw new Error("deploy observation dry-run self-check failed"); + } +``` + +- [ ] **Step 3: Run self-test and confirm the expected failure** + +Run: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test +``` + +Expected: + +```text +FAIL: Cannot find module './tools/deployObservation.js' or exported member parseDeployObservationCliArgs. +``` + +- [ ] **Step 4: Commit the red test only if the team wants TDD commits** + +Run only when working branch policy allows red commits: + +```powershell +git -C D:\MyFiles\KT\mcp\ktWorkflow add src/selfTest.ts +git -C D:\MyFiles\KT\mcp\ktWorkflow commit -m "test: 覆盖部署观测工作流" +``` + +Expected: + +```text +Commit is created on dev, or the red test remains staged for the green implementation commit. +``` + +## Task 2: Add Deploy Observation Types + +**Files:** +- Modify: `D:\MyFiles\KT\mcp\ktWorkflow\src\types.ts` + +- [ ] **Step 1: Add deploy observation status and input types** + +Insert after `RemoteHealthCheckInput`: + +```ts +export type DeployObservationStatus = + | 'passed' + | 'failed' + | 'blocked' + | 'skipped'; + +export interface DeployObservationInput { + artifactRoot?: string; + buildNumber?: string; + container?: string; + deployment?: string; + execute?: boolean; + expectedCommit?: string; + healthUrl?: string; + imageTag?: string; + jobName?: string; + jenkinsHome?: string; + kubeconfigPath?: string; + namespace?: string; + project?: string; + selector?: string; + smoke?: string; + sshPort?: number; + sshTarget?: string; +} +``` + +- [ ] **Step 2: Add evidence DTOs** + +Insert after `DeployObservationInput`: + +```ts +export interface DeployObservationAssertion { + critical: boolean; + message: string; + name: string; + passed: boolean; +} + +export interface DeployObservationCommand { + command: string; + name: string; +} + +export interface DeployObservationJenkinsEvidence { + buildNumber: string | null; + commitMatched: boolean | null; + expectedCommit: string | null; + finishedStatus: string | null; + jobName: string; +} + +export interface DeployObservationDeploymentEvidence { + container: string; + generation: number | null; + image: string | null; + namespace: string; + observedGeneration: number | null; + readyReplicas: number | null; + updatedReplicas: number | null; +} + +export interface DeployObservationPodEvidence { + image: string | null; + name: string | null; + phase: string | null; + ready: boolean | null; + restartCount: number | null; +} + +export interface DeployObservationRuntimeHealthEvidence { + checkCount: number | null; + service: string | null; + status: string | null; +} + +export interface DeployObservationEvidence { + assertions: DeployObservationAssertion[]; + details: { + deployment: DeployObservationDeploymentEvidence; + eventsTail: string; + jenkins: DeployObservationJenkinsEvidence; + pod: DeployObservationPodEvidence; + runtimeHealth: DeployObservationRuntimeHealthEvidence; + smoke: { + command: string | null; + output: string; + }; + }; + endedAt: string; + environment: 'production'; + operation: 'kt_deploy_observation'; + project: string; + schemaVersion: 1; + startedAt: string; + status: DeployObservationStatus; + target: string; + taskType: 'deploy'; + title: string; +} + +export interface DeployObservationResult { + artifactPath?: string; + commands: DeployObservationCommand[]; + evidence?: DeployObservationEvidence; + execute: boolean; + notes: string[]; + requiredEvidence: string[]; + result?: ExecResult; +} +``` + +- [ ] **Step 3: Run typecheck and confirm the expected failure** + +Run: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck +``` + +Expected: + +```text +FAIL because deployObservation.ts and parseDeployObservationCliArgs are not implemented yet. +``` + +## Task 3: Implement The Deploy Observation Tool + +**Files:** +- Create: `D:\MyFiles\KT\mcp\ktWorkflow\src\tools\deployObservation.ts` + +- [ ] **Step 1: Create the tool file** + +Create `src/tools/deployObservation.ts` with this implementation: + +```ts +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { + DeployObservationAssertion, + DeployObservationCommand, + DeployObservationDeploymentEvidence, + DeployObservationEvidence, + DeployObservationInput, + DeployObservationJenkinsEvidence, + DeployObservationPodEvidence, + DeployObservationResult, + DeployObservationRuntimeHealthEvidence, + DeployObservationStatus, +} from '../types.js'; +import { tryPowerShell } from '../core/exec.js'; +import { + formatDateInShanghai, + resolveInsideRoot, + workspaceRoot, +} from '../core/workspace.js'; + +interface NormalizedInput extends Required< + Pick< + DeployObservationInput, + | 'artifactRoot' + | 'container' + | 'deployment' + | 'healthUrl' + | 'jenkinsHome' + | 'jobName' + | 'kubeconfigPath' + | 'namespace' + | 'project' + | 'selector' + | 'sshPort' + | 'sshTarget' + > +> { + buildNumber?: string; + execute: boolean; + expectedCommit?: string; + imageTag?: string; + smoke?: string; +} + +interface NormalizeEvidenceInput { + checkedAt?: Date; + input: DeployObservationInput; + sectionMap: Map; +} + +function normalizeInput(input: DeployObservationInput = {}): NormalizedInput { + return { + artifactRoot: + input.artifactRoot || '.kt-workspace/test-artifacts/deploy-observation', + buildNumber: input.buildNumber?.trim() || undefined, + container: input.container?.trim() || 'api', + deployment: input.deployment?.trim() || 'kt-template-online-api', + execute: input.execute === true, + expectedCommit: input.expectedCommit?.trim() || undefined, + healthUrl: + input.healthUrl?.trim() || + 'http://127.0.0.1:30085/health/runtime', + imageTag: input.imageTag?.trim() || undefined, + jenkinsHome: input.jenkinsHome?.trim() || '/vol1/docker/jenkins_home', + jobName: input.jobName?.trim() || 'kt-template-online-api', + kubeconfigPath: + input.kubeconfigPath?.trim() || + '/vol1/docker/kt-k8s/kubeconfig/kt-nas.jenkins.yaml', + namespace: input.namespace?.trim() || 'kt-prod', + project: input.project?.trim() || 'api', + selector: + input.selector?.trim() || 'app=kt-template-online-api', + smoke: input.smoke?.trim() || undefined, + sshPort: input.sshPort || 2202, + sshTarget: input.sshTarget?.trim() || 'nas', + }; +} + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function powerShellSingleQuote(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function numberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function parseJsonObject(value: string): Record | null { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function getPath(source: unknown, keys: string[]): unknown { + return keys.reduce((current, key) => { + if (!current || typeof current !== 'object') return undefined; + return (current as Record)[key]; + }, source); +} + +function getArray(source: unknown, keys: string[]): unknown[] { + const value = getPath(source, keys); + return Array.isArray(value) ? value : []; +} + +function buildRemoteScript(input: NormalizedInput): string { + const smokeBlock = input.smoke + ? [ + "section smoke_text", + `${input.smoke} 2>&1 || true`, + ].join('\n') + : [ + "section smoke_text", + "echo 'no task smoke command provided'", + ].join('\n'); + + return [ + 'set -u', + 'section() { printf "\\n__KT_SECTION:%s__\\n" "$1"; }', + `JOB_NAME=${shellSingleQuote(input.jobName)}`, + `BUILD_NUMBER=${shellSingleQuote(input.buildNumber || '')}`, + `JENKINS_HOME=${shellSingleQuote(input.jenkinsHome)}`, + `KUBECONFIG_PATH=${shellSingleQuote(input.kubeconfigPath)}`, + `NAMESPACE=${shellSingleQuote(input.namespace)}`, + `DEPLOYMENT=${shellSingleQuote(input.deployment)}`, + `SELECTOR=${shellSingleQuote(input.selector)}`, + `HEALTH_URL=${shellSingleQuote(input.healthUrl)}`, + 'JOB_DIR="$JENKINS_HOME/jobs/$JOB_NAME/builds"', + 'if [ -z "$BUILD_NUMBER" ] && [ -d "$JOB_DIR" ]; then BUILD_NUMBER="$(find "$JOB_DIR" -maxdepth 1 -type d -printf "%f\\n" 2>/dev/null | grep -E "^[0-9]+$" | sort -n | tail -1 || true)"; fi', + 'BUILD_DIR="$JOB_DIR/$BUILD_NUMBER"', + 'section jenkins_meta', + 'printf "jobName=%s\\n" "$JOB_NAME"', + 'printf "buildNumber=%s\\n" "$BUILD_NUMBER"', + 'if [ -f "$BUILD_DIR/build.xml" ]; then grep -E "||" "$BUILD_DIR/build.xml" | tail -20 || true; fi', + 'section jenkins_log_tail', + 'if [ -f "$BUILD_DIR/log" ]; then tail -160 "$BUILD_DIR/log" || true; else echo "jenkins log not found"; fi', + 'section deployment_json', + 'kubectl --kubeconfig "$KUBECONFIG_PATH" -n "$NAMESPACE" get deployment "$DEPLOYMENT" -o json 2>/dev/null || true', + 'section pods_json', + 'kubectl --kubeconfig "$KUBECONFIG_PATH" -n "$NAMESPACE" get pods -l "$SELECTOR" -o json 2>/dev/null || true', + 'section events_tail', + 'kubectl --kubeconfig "$KUBECONFIG_PATH" -n "$NAMESPACE" get events --sort-by=.lastTimestamp 2>/dev/null | tail -80 || true', + 'section health_json', + 'curl -fsS --max-time 8 "$HEALTH_URL" 2>/dev/null || true', + smokeBlock, + ].join('\n'); +} + +function buildPowerShellCommand(input: NormalizedInput): string { + const remoteScript = buildRemoteScript(input); + return [ + `$remoteScript = @'`, + remoteScript, + `'@`, + `$remoteScript | ssh -p ${input.sshPort} ${input.sshTarget} "tr -d '\\015' | bash -s"`, + ].join('\n'); +} + +export function buildDeployObservationCommands( + input: DeployObservationInput = {}, +): DeployObservationCommand[] { + const normalized = normalizeInput(input); + return [ + { + command: buildPowerShellCommand(normalized), + name: 'nas-deploy-observation', + }, + ]; +} + +export function buildDeployObservationSectionMap(stdout: string): Map { + const sections = new Map(); + let currentName: string | null = null; + let currentLines: string[] = []; + + for (const line of stdout.split(/\r?\n/)) { + const marker = line.match(/^__KT_SECTION:([a-z0-9_-]+)__$/i); + if (marker) { + if (currentName) sections.set(currentName, currentLines.join('\n').trim()); + currentName = marker[1]; + currentLines = []; + } else if (currentName) { + currentLines.push(line); + } + } + + if (currentName) sections.set(currentName, currentLines.join('\n').trim()); + return sections; +} + +function normalizeJenkinsEvidence( + input: NormalizedInput, + sectionMap: Map, +): DeployObservationJenkinsEvidence { + const meta = sectionMap.get('jenkins_meta') || ''; + const log = sectionMap.get('jenkins_log_tail') || ''; + const buildNumber = + input.buildNumber || + meta.match(/^buildNumber=(.+)$/m)?.[1]?.trim() || + null; + const finishedStatus = + log.match(/Finished:\s*([A-Z]+)/)?.[1] || + meta.match(/([^<]+)<\/result>/)?.[1] || + null; + const expectedCommit = input.expectedCommit || null; + + return { + buildNumber, + commitMatched: expectedCommit + ? `${meta}\n${log}`.includes(expectedCommit) + : null, + expectedCommit, + finishedStatus, + jobName: input.jobName, + }; +} + +function normalizeDeploymentEvidence( + input: NormalizedInput, + sectionMap: Map, +): DeployObservationDeploymentEvidence { + const deployment = parseJsonObject(sectionMap.get('deployment_json') || ''); + const containers = getArray(deployment, [ + 'spec', + 'template', + 'spec', + 'containers', + ]); + const container = containers.find( + (item) => + item && + typeof item === 'object' && + (item as Record).name === input.container, + ) as Record | undefined; + + return { + container: input.container, + generation: numberOrNull(getPath(deployment, ['metadata', 'generation'])), + image: stringOrNull(container?.image), + namespace: input.namespace, + observedGeneration: numberOrNull( + getPath(deployment, ['status', 'observedGeneration']), + ), + readyReplicas: numberOrNull(getPath(deployment, ['status', 'readyReplicas'])), + updatedReplicas: numberOrNull( + getPath(deployment, ['status', 'updatedReplicas']), + ), + }; +} + +function normalizePodEvidence( + input: NormalizedInput, + sectionMap: Map, +): DeployObservationPodEvidence { + const pods = parseJsonObject(sectionMap.get('pods_json') || ''); + const items = getArray(pods, ['items']); + const normalizedPods = items.map((item) => { + const pod = item as Record; + const statuses = getArray(pod, ['status', 'containerStatuses']); + const status = statuses.find( + (candidate) => + candidate && + typeof candidate === 'object' && + (candidate as Record).name === input.container, + ) as Record | undefined; + return { + image: stringOrNull(status?.image), + name: stringOrNull(getPath(pod, ['metadata', 'name'])), + phase: stringOrNull(getPath(pod, ['status', 'phase'])), + ready: typeof status?.ready === 'boolean' ? status.ready : null, + restartCount: numberOrNull(status?.restartCount), + }; + }); + + return ( + normalizedPods.find( + (pod) => + pod.phase === 'Running' && + (!input.imageTag || pod.image?.includes(input.imageTag)), + ) || + normalizedPods.find((pod) => pod.phase === 'Running') || + normalizedPods[0] || { + image: null, + name: null, + phase: null, + ready: null, + restartCount: null, + } + ); +} + +function normalizeRuntimeHealthEvidence( + sectionMap: Map, +): DeployObservationRuntimeHealthEvidence { + const health = parseJsonObject(sectionMap.get('health_json') || ''); + const checks = Array.isArray(health?.checks) ? health.checks : null; + + return { + checkCount: checks ? checks.length : null, + service: stringOrNull(health?.service), + status: stringOrNull(health?.status), + }; +} + +function buildAssertion( + name: string, + passed: boolean, + message: string, + critical = true, +): DeployObservationAssertion { + return { + critical, + message, + name, + passed, + }; +} + +function aggregateStatus(assertions: DeployObservationAssertion[]): DeployObservationStatus { + if (assertions.some((item) => item.critical && !item.passed)) return 'failed'; + if (assertions.some((item) => !item.critical && !item.passed)) return 'blocked'; + return 'passed'; +} + +export function normalizeDeployObservationEvidence({ + checkedAt = new Date(), + input, + sectionMap, +}: NormalizeEvidenceInput): DeployObservationEvidence { + const normalized = normalizeInput(input); + const jenkins = normalizeJenkinsEvidence(normalized, sectionMap); + const deployment = normalizeDeploymentEvidence(normalized, sectionMap); + const pod = normalizePodEvidence(normalized, sectionMap); + const runtimeHealth = normalizeRuntimeHealthEvidence(sectionMap); + const smokeOutput = sectionMap.get('smoke_text') || ''; + const assertions = [ + buildAssertion( + 'jenkins-finished', + jenkins.finishedStatus === 'SUCCESS', + `Jenkins result is ${jenkins.finishedStatus || 'missing'}`, + ), + buildAssertion( + 'jenkins-commit', + jenkins.commitMatched !== false, + jenkins.expectedCommit + ? `Expected commit ${jenkins.expectedCommit} was found in Jenkins evidence` + : 'No expected commit supplied; commit match skipped', + Boolean(jenkins.expectedCommit), + ), + buildAssertion( + 'deployment-observed-generation', + deployment.generation !== null && + deployment.observedGeneration !== null && + deployment.observedGeneration >= deployment.generation, + `Deployment observedGeneration=${deployment.observedGeneration} generation=${deployment.generation}`, + ), + buildAssertion( + 'deployment-ready-replicas', + deployment.updatedReplicas !== null && + deployment.readyReplicas !== null && + deployment.updatedReplicas > 0 && + deployment.readyReplicas >= deployment.updatedReplicas, + `Deployment updated=${deployment.updatedReplicas} ready=${deployment.readyReplicas}`, + ), + buildAssertion( + 'pod-running-for-image', + pod.phase === 'Running' && + pod.ready === true && + (!normalized.imageTag || Boolean(pod.image?.includes(normalized.imageTag))), + `Pod ${pod.name || 'missing'} phase=${pod.phase || 'missing'} image=${pod.image || 'missing'}`, + ), + buildAssertion( + 'pod-restart-count-zero', + pod.restartCount === 0, + `Pod restartCount=${pod.restartCount}`, + ), + buildAssertion( + 'runtime-health-not-blocked', + runtimeHealth.service === 'kt-template-online-api' && + runtimeHealth.status !== null && + runtimeHealth.status !== 'blocked', + `Runtime health status=${runtimeHealth.status || 'missing'}`, + ), + buildAssertion( + 'task-smoke-present', + Boolean(normalized.smoke && smokeOutput.trim().length > 0), + normalized.smoke + ? 'Task smoke command produced output' + : 'No task smoke command supplied; rollout evidence is not functional evidence', + Boolean(normalized.smoke), + ), + ]; + + return { + assertions, + details: { + deployment, + eventsTail: sectionMap.get('events_tail') || '', + jenkins, + pod, + runtimeHealth, + smoke: { + command: normalized.smoke || null, + output: smokeOutput, + }, + }, + endedAt: checkedAt.toISOString(), + environment: 'production', + operation: 'kt_deploy_observation', + project: normalized.project, + schemaVersion: 1, + startedAt: checkedAt.toISOString(), + status: aggregateStatus(assertions), + target: `${normalized.namespace}/${normalized.deployment}`, + taskType: 'deploy', + title: 'KT API deployment observation', + }; +} + +function writeEvidenceArtifact( + artifactRoot: string, + evidence: DeployObservationEvidence, +): string { + const root = resolveInsideRoot(artifactRoot); + const directory = path.join(root, formatDateInShanghai()); + mkdirSync(directory, { recursive: true }); + const filePath = path.join( + directory, + `${evidence.project}-${Date.now()}-deploy-observation.json`, + ); + writeFileSync(filePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8'); + return filePath; +} + +export async function buildDeployObservation( + input: DeployObservationInput = {}, +): Promise { + const normalized = normalizeInput(input); + const commands = buildDeployObservationCommands(normalized); + const requiredEvidence = [ + 'Jenkins job/build number and final Finished status', + 'Expected commit hash match when a commit is supplied', + 'K8s Deployment generation, observedGeneration, updatedReplicas, readyReplicas', + 'Running Pod selected by current image tag with restartCount', + 'GET /health/runtime response with service/status/check count', + 'Task-specific smoke command output', + ]; + const notes = [ + 'Default execute=false only returns the bounded read-only command.', + 'execute=true runs through ssh nas with CRLF-stripped here-string and writes local JSON evidence.', + 'Jenkins/K8s rollout evidence is deployment evidence; task smoke is still required for functional completion.', + 'The command reads Jenkins build files, kubectl status, events, and HTTP health only; it does not read Secrets or mutate remote state.', + ]; + + if (!normalized.execute) { + return { + commands, + execute: false, + notes, + requiredEvidence, + }; + } + + const command = commands[0]?.command || ''; + const result = await tryPowerShell(command, workspaceRoot, 180_000); + const sectionMap = buildDeployObservationSectionMap(result.stdout); + const evidence = normalizeDeployObservationEvidence({ + input: normalized, + sectionMap, + }); + const artifactPath = writeEvidenceArtifact(normalized.artifactRoot, evidence); + + return { + artifactPath, + commands, + evidence, + execute: true, + notes, + requiredEvidence, + result, + }; +} +``` + +- [ ] **Step 2: Run typecheck and confirm parser/register failures remain** + +Run: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck +``` + +Expected: + +```text +FAIL only because parseDeployObservationCliArgs and tool registration are not wired yet. +``` + +## Task 4: Wire CLI And MCP Registration + +**Files:** +- Modify: `D:\MyFiles\KT\mcp\ktWorkflow\src\core\cli.ts` +- Modify: `D:\MyFiles\KT\mcp\ktWorkflow\src\registerTools.ts` +- Modify: `D:\MyFiles\KT\mcp\ktWorkflow\src\server.ts` +- Modify: `D:\MyFiles\KT\mcp\ktWorkflow\src\core\constants.ts` +- Modify: `D:\MyFiles\KT\mcp\ktWorkflow\package.json` + +- [ ] **Step 1: Add CLI parser** + +Modify the type imports in `src/core/cli.ts`: + +```ts +import type { + DeployObservationInput, + GlobalCodeReviewInput, + ObsidianContextInput, + ObsidianSyncInput, + ObsidianValidateInput, + WorkstreamCloseoutInput, + WorkstreamReusablePattern, +} from '../types.js'; +``` + +Append this parser to `src/core/cli.ts`: + +```ts +export function parseDeployObservationCliArgs(argv: string[]): DeployObservationInput { + const sshPort = Number(readOption(argv, ['--ssh-port', '--sshPort'])); + + return { + artifactRoot: readOption(argv, ['--artifact-root', '--artifactRoot']), + buildNumber: readOption(argv, ['--build', '--build-number', '--buildNumber']), + container: readOption(argv, ['--container']), + deployment: readOption(argv, ['--deployment']), + execute: argv.includes('--execute'), + expectedCommit: readOption(argv, ['--commit', '--expected-commit', '--expectedCommit']), + healthUrl: readOption(argv, ['--health-url', '--healthUrl']), + imageTag: readOption(argv, ['--image-tag', '--imageTag']), + jenkinsHome: readOption(argv, ['--jenkins-home', '--jenkinsHome']), + jobName: readOption(argv, ['--job', '--job-name', '--jobName']), + kubeconfigPath: readOption(argv, ['--kubeconfig', '--kubeconfig-path', '--kubeconfigPath']), + namespace: readOption(argv, ['--namespace']), + project: readOption(argv, ['--project']), + selector: readOption(argv, ['--selector']), + smoke: readOption(argv, ['--smoke']), + sshPort: Number.isFinite(sshPort) ? sshPort : undefined, + sshTarget: readOption(argv, ['--ssh', '--ssh-target', '--sshTarget']), + }; +} +``` + +- [ ] **Step 2: Register the MCP tool** + +Modify imports in `src/registerTools.ts`: + +```ts +import { buildDeployObservation } from './tools/deployObservation.js'; +``` + +Add this registration after `kt_remote_health_check`: + +```ts + server.registerTool( + 'kt_deploy_observation', + { + description: + '生成或执行 API 发布后的只读部署观测:Jenkins、K8s Deployment、Pod、/health/runtime 和任务 smoke,并输出运行态证据。', + inputSchema: { + artifactRoot: z.string().optional(), + buildNumber: z.string().optional(), + container: z.string().default('api'), + deployment: z.string().default('kt-template-online-api'), + execute: z.boolean().default(false), + expectedCommit: z.string().optional(), + healthUrl: z.string().default('http://127.0.0.1:30085/health/runtime'), + imageTag: z.string().optional(), + jenkinsHome: z.string().default('/vol1/docker/jenkins_home'), + jobName: z.string().default('kt-template-online-api'), + kubeconfigPath: z + .string() + .default('/vol1/docker/kt-k8s/kubeconfig/kt-nas.jenkins.yaml'), + namespace: z.string().default('kt-prod'), + project: z.string().default('api'), + selector: z.string().default('app=kt-template-online-api'), + smoke: z.string().optional(), + sshPort: z.number().int().min(1).max(65_535).default(2202), + sshTarget: z.string().default('nas'), + }, + title: 'KT Deploy Observation', + }, + async (input) => response(await buildDeployObservation(input)), + ); +``` + +- [ ] **Step 3: Add CLI branch** + +Modify imports in `src/server.ts`: + +```ts +import { + parseDeployObservationCliArgs, + parseGlobalReviewCliArgs, + parseObsidianContextCliArgs, + parseObsidianCliArgs, + parseObsidianSyncCliArgs, + parseWorkstreamCloseoutCliArgs, +} from './core/cli.js'; +import { buildDeployObservation } from './tools/deployObservation.js'; +``` + +Add this branch before `--self-test`: + +```ts +} else if (process.argv.includes('--deploy-observation')) { + console.log( + JSON.stringify( + await buildDeployObservation(parseDeployObservationCliArgs(process.argv)), + null, + 2, + ), + ); +``` + +- [ ] **Step 4: Add registered tool name** + +Add to `registeredToolNames` in `src/core/constants.ts` immediately after `kt_remote_health_check`: + +```ts + 'kt_deploy_observation', +``` + +- [ ] **Step 5: Add package script** + +Add to `scripts` in `package.json`: + +```json +"deploy-observation": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --deploy-observation" +``` + +- [ ] **Step 6: Run typecheck and self-test** + +Run: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test +``` + +Expected: + +```text +typecheck passes. +self-test writes .kt-workspace/test-artifacts/ktWorkflow-self-test.json and exits successfully. +``` + +- [ ] **Step 7: Commit the tool wiring** + +Run: + +```powershell +git -C D:\MyFiles\KT\mcp\ktWorkflow add src/tools/deployObservation.ts src/types.ts src/core/cli.ts src/registerTools.ts src/server.ts src/core/constants.ts src/selfTest.ts package.json +git -C D:\MyFiles\KT\mcp\ktWorkflow commit -m "feat: 添加部署观测工作流" +``` + +Expected: + +```text +Commit succeeds on dev and contains only ktWorkflow deploy observation code and tests. +``` + +## Task 5: Update ktWorkflow Documentation And Closeout Guidance + +**Files:** +- Modify: `D:\MyFiles\KT\mcp\ktWorkflow\README.md` +- Modify: `D:\MyFiles\KT\mcp\ktWorkflow\src\tools\closeout.ts` + +- [ ] **Step 1: Update closeout deploy-observation target** + +In `src/tools/closeout.ts`, change the `deploy-observation` target to: + +```ts + 'deploy-observation': { + files: ['src/tools/deployObservation.ts', 'src/tools/workflow.ts', 'src/tools/testing.ts', 'README.md'], + label: '部署观测', + prompt: '把 Jenkins/K8s build、commit、镜像、Deployment、Pod、/health/runtime、日志和 smoke 观测步骤写成固定计划。', + }, +``` + +- [ ] **Step 2: Update README capability list** + +Add this bullet to the capability list in `README.md`: + +```markdown +- 生成或执行部署观测:把 Jenkins build、commit、K8s Deployment、Pod、`/health/runtime` 和任务 smoke 汇总成 `.kt-workspace/test-artifacts/deploy-observation` 下的运行态证据。 +``` + +- [ ] **Step 3: Update README tool table** + +Add this row after `kt_remote_health_check`: + +```markdown +| `kt_deploy_observation` | 生成或执行 API 发布后的部署观测,汇总 Jenkins、K8s、Pod、`/health/runtime` 和任务 smoke 证据 | +``` + +- [ ] **Step 4: Update README scripts table** + +Add this row in the script table: + +```markdown +| `pnpm run deploy-observation` | 默认 dry-run 输出只读 NAS 观测命令;传 `--execute` 时执行 Jenkins/K8s/health/smoke 观测并写入 `.kt-workspace/test-artifacts/deploy-observation`。 | +``` + +- [ ] **Step 5: Add README usage guidance** + +Add this guidance near the remote health check note: + +```markdown +- API 推送触发 Jenkins/K8s 后先运行 `pnpm run deploy-observation -- --project api --job kt-template-online-api --namespace kt-prod --deployment kt-template-online-api --container api --health-url http://127.0.0.1:30085/health/runtime --smoke "curl -fsS --max-time 8 http://127.0.0.1:30085/health/runtime"` 查看 dry-run 命令;确认需要线上只读观测时再加 `--execute`。Deployment/Pod 成功只算发布证据,功能完成还必须看 smoke 输出。 +``` + +- [ ] **Step 6: Run self-test** + +Run: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test +``` + +Expected: + +```text +self-test passes after closeout guidance change. +``` + +- [ ] **Step 7: Commit docs and closeout guidance** + +Run: + +```powershell +git -C D:\MyFiles\KT\mcp\ktWorkflow add README.md src/tools/closeout.ts +git -C D:\MyFiles\KT\mcp\ktWorkflow commit -m "docs: 补充部署观测工作流说明" +``` + +Expected: + +```text +Commit succeeds on dev with README and closeout guidance only. +``` + +## Task 6: Validate Dry-Run And Read-Only Observation + +**Files:** +- Evidence output: `D:\MyFiles\KT\.kt-workspace\test-artifacts\deploy-observation` + +- [ ] **Step 1: Confirm package manager and Node compatibility** + +Run: + +```powershell +node -v +pnpm -v +Get-Content D:\MyFiles\KT\mcp\ktWorkflow\package.json | Select-String -Pattern '"engines"|"packageManager"' +``` + +Expected: + +```text +Node satisfies >=20.19.0 and pnpm matches or is compatible with pnpm@10.28.2. +``` + +If Node does not satisfy `engines`, run: + +```powershell +nvm ls +nvm use 20.19.0 +``` + +- [ ] **Step 2: Run static validation** + +Run: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run typecheck +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run self-test +``` + +Expected: + +```text +Both commands pass. +``` + +- [ ] **Step 3: Run deploy observation dry-run** + +Run: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run deploy-observation -- --project api --job kt-template-online-api --namespace kt-prod --deployment kt-template-online-api --container api --health-url http://127.0.0.1:30085/health/runtime --smoke "curl -fsS --max-time 8 http://127.0.0.1:30085/health/runtime" +``` + +Expected: + +```text +JSON output has execute=false, a nas-deploy-observation command, requiredEvidence, and notes. +No SSH command is executed. +No artifact is written. +``` + +- [ ] **Step 4: Run read-only online observation** + +Run only after confirming NAS SSH is expected to be reachable: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run deploy-observation -- --execute --project api --job kt-template-online-api --namespace kt-prod --deployment kt-template-online-api --container api --health-url http://127.0.0.1:30085/health/runtime --smoke "curl -fsS --max-time 8 http://127.0.0.1:30085/health/runtime" +``` + +Expected: + +```text +JSON output includes execute=true, result.ok, evidence, and artifactPath. +artifactPath is under D:\MyFiles\KT\.kt-workspace\test-artifacts\deploy-observation. +Evidence contains Jenkins, Deployment, Pod, runtimeHealth, smoke, and assertions. +If Jenkins/K8s/health is unavailable, the evidence status is failed with concrete failing assertions. +``` + +- [ ] **Step 5: Inspect artifact without leaking noisy payloads** + +Run: + +```powershell +Get-ChildItem D:\MyFiles\KT\.kt-workspace\test-artifacts\deploy-observation -Recurse -Filter *deploy-observation.json | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object { Get-Content $_.FullName | ConvertFrom-Json | Select-Object title,status,target,operation,project } +``` + +Expected: + +```text +The latest artifact summary shows operation=kt_deploy_observation and a concrete status. +``` + +## Task 7: Documentation Sync, Cleanup, Review, And Commits + +**Files:** +- Modify: `D:\MyFiles\KT\TASKS.md` + +- [ ] **Step 1: Run doc sync plan** + +MCP call: + +```json +{ + "changedFiles": [ + "mcp/ktWorkflow/src/tools/deployObservation.ts", + "mcp/ktWorkflow/src/types.ts", + "mcp/ktWorkflow/src/core/cli.ts", + "mcp/ktWorkflow/src/registerTools.ts", + "mcp/ktWorkflow/src/server.ts", + "mcp/ktWorkflow/src/core/constants.ts", + "mcp/ktWorkflow/src/selfTest.ts", + "mcp/ktWorkflow/src/tools/closeout.ts", + "mcp/ktWorkflow/README.md", + "mcp/ktWorkflow/package.json" + ], + "project": "mcp", + "taskType": "mcp" +} +``` + +Expected: + +```text +Doc sync identifies README and KT workflow documentation as handled; no API contract doc change is required because /health/runtime contract did not change. +``` + +If the MCP tool is unavailable in the execution environment, inspect `mcp/ktWorkflow/README.md`, `Node/kt-template-online-api/README.md`, `Node/kt-template-online-api/API.md`, and `docs/obsidian/modules/KT 模块 - API Backend.md` manually, then record the manual doc sync result in closeout evidence. + +- [ ] **Step 2: Update TASKS.md** + +Add a recent record with this content shape: + +```markdown +### 2026-06-14:API Runtime Foundation Phase 2 部署观测 + +- 范围:mcp/ktWorkflow、Node/kt-template-online-api docs +- 关键词:API Runtime Foundation、DeployObservationAdapter、Jenkins、K8s、/health/runtime、ktWorkflow +- 验证:pnpm --dir mcp/ktWorkflow run typecheck;pnpm --dir mcp/ktWorkflow run self-test;deploy-observation dry-run;deploy-observation --execute 只读观测或明确记录 NAS 不可达阻塞。 +``` + +- [ ] **Step 3: Run cleanup preview** + +Run: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --dry-run +``` + +Expected: + +```text +Output lists deleted=[] or a finite set of stale .kt-workspace artifacts. +``` + +If stale artifacts are listed, run: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --execute +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run cleanup-history -- --dry-run +``` + +Expected: + +```text +Final dry-run shows deleted=[]. +``` + +- [ ] **Step 4: Run KT global review** + +Run: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review +``` + +Expected: + +```text +No Critical or Important findings caused by this change. +Known pre-existing dirty files are classified separately. +``` + +- [ ] **Step 5: Request Superpowers code review** + +Use `superpowers:requesting-code-review` after implementation and before declaring the work complete. Review must cover: + +```text +mcp/ktWorkflow deploy observation command safety +evidence normalization correctness +Jenkins/K8s success not being treated as functional smoke success +dirty ktWorkflow files preserved rather than overwritten +``` + +Expected: + +```text +All Critical and Important findings are fixed or explicitly rejected with evidence before finishing. +``` + +- [ ] **Step 6: Run closeout audit** + +Run: + +```powershell +pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run workstream-closeout -- --title "API Runtime Foundation Phase 2 部署观测" --project mcp --project api --verification "typecheck/self-test/deploy-observation dry-run/read-only observation evidence" --doc-sync "kt_change_doc_sync README handled; API health contract unchanged" --cleanup "cleanup-history final dry-run deleted=[]" --cleanup-final-deleted 0 --review "global-review no Critical/Important findings caused by this change" --superpowers-review "Superpowers reviewer completed; no Critical/Important findings remain" --problem "无新卡点或已记录 NAS/Jenkins/K8s 只读观测阻塞" --solution "kt_deploy_observation 固化 Jenkins/K8s/health/smoke 观测" --pattern deploy-observation --kt-workflow-updated --upgrade-notes "新增 kt_deploy_observation 工具和 CLI" +``` + +Expected: + +```text +canReportComplete=true. +``` + +- [ ] **Step 7: Commit root TASKS update if changed** + +Run: + +```powershell +git -C D:\MyFiles\KT status --short -- TASKS.md +git -C D:\MyFiles\KT add TASKS.md +git -C D:\MyFiles\KT commit -m "docs: 记录API部署观测重构进度" +``` + +Expected: + +```text +Root commit contains only the task record update made for this phase. +``` + +## Self-Review + +- Spec coverage: + - Jenkins job/build number is covered by `jenkins_meta` and `jenkins_log_tail`. + - Commit hash is covered by `expectedCommit` and `jenkins-commit` assertion. + - Image tag is covered by Deployment container image and Pod image match. + - Deployment `generation`, `observedGeneration`, `updatedReplicas`, and `readyReplicas` are parsed from `deployment_json`. + - Running Pod and restart count are parsed from `pods_json`. + - Failing logs/events are included through `jenkins_log_tail` and `events_tail`. + - `/health/runtime` is fetched as `health_json` and normalized without exposing config topology. + - Task-specific smoke is separate from rollout evidence through `task-smoke-present`. + +- Placeholder scan: + - This plan contains concrete file paths, code snippets, commands, expected outputs, and commit messages. + - No step asks the implementer to invent a missing function name or validation command. + +- Type consistency: + - `parseDeployObservationCliArgs`, `buildDeployObservation`, `buildDeployObservationSectionMap`, and `normalizeDeployObservationEvidence` are introduced in early tasks and referenced consistently in later tasks. + - `DeployObservationInput`, `DeployObservationEvidence`, and `DeployObservationResult` live in `src/types.ts` and are consumed by the new tool and registration.