diff --git a/AGENTS.md b/AGENTS.md index 751812d1d..34d364286 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,12 +101,16 @@ Keep it short, stable, and helper-oriented. Deep runbooks belong in checked-in d - `node tools/npm/run-script.mjs handoff:entrypoint:check` refreshes the machine-readable index at `tests/results/_agent/handoff/entrypoint-status.json`. - `node tools/npm/run-script.mjs priority:handoff` prints that machine-readable index and `tests/results/_agent/verification/docker-review-loop-summary.json`. +- `node tools/npm/run-script.mjs priority:continuity` refreshes the continuity receipts at + `tests/results/_agent/runtime/continuity-telemetry.json` and + `tests/results/_agent/handoff/continuity-summary.json`. - `node tools/npm/run-script.mjs priority:pivot:template` evaluates the future-agent-only pivot from queue-empty release-candidate state into `LabviewGitHubCiTemplate`. - Primary live-state artifacts: - `.agent_priority_cache.json` - `tests/results/_agent/issue/router.json` - `tests/results/_agent/issue/no-standing-priority.json` + - `tests/results/_agent/handoff/continuity-summary.json` - `tests/results/_agent/handoff/entrypoint-status.json` - `tests/results/_agent/runtime/` diff --git a/AGENT_HANDOFF.txt b/AGENT_HANDOFF.txt index ec4d77da6..56803babc 100644 --- a/AGENT_HANDOFF.txt +++ b/AGENT_HANDOFF.txt @@ -39,6 +39,7 @@ Live repository state belongs in the machine-generated artifacts under - `tests/results/_agent/issue/router.json` - `tests/results/_agent/issue/no-standing-priority.json` - `tests/results/_agent/verification/docker-review-loop-summary.json` +- `tests/results/_agent/handoff/continuity-summary.json` - `tests/results/_agent/handoff/entrypoint-status.json` - `tests/results/_agent/handoff/human-go-no-go-latest.json` - `tests/results/_agent/handoff/*.json` diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index e1b7f80b0..5b7925840 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -623,6 +623,10 @@ pwsh -File tools/Print-AgentHandoff.ps1 -ApplyToggles -AutoTrim - Refreshes `tests/results/_agent/handoff/entrypoint-status.json` so the standard handoff command also writes the canonical machine-readable entrypoint index for future agents. +- Refreshes `tests/results/_agent/runtime/continuity-telemetry.json` and the + mirrored `tests/results/_agent/handoff/continuity-summary.json` so operator + quiet periods are scored from unattended evidence instead of being treated as + an implicit reset. - Auto-trim policy: if `needsTrim=true`, watcher logs are trimmed to the last ~4000 lines when either `-AutoTrim` is passed or `HANDOFF_AUTOTRIM=1` is set. Dev watcher also trims on start. - Trim thresholds: ~5MB per log file; only oversized logs are trimmed. diff --git a/docs/knowledgebase/Agent-Handoff-Surfaces.md b/docs/knowledgebase/Agent-Handoff-Surfaces.md index 81cc051aa..98f798c72 100644 --- a/docs/knowledgebase/Agent-Handoff-Surfaces.md +++ b/docs/knowledgebase/Agent-Handoff-Surfaces.md @@ -15,6 +15,9 @@ entrypoint and machine-generated live state. output. - It refreshes `tests/results/_agent/handoff/entrypoint-status.json`, which is the canonical machine-readable index for future agents. +- It refreshes `tests/results/_agent/runtime/continuity-telemetry.json` and the + mirrored handoff summary `tests/results/_agent/handoff/continuity-summary.json` + so operator quiet periods can be measured without being mistaken for a reset. - It also refreshes the standing-priority summary, router copy, watcher telemetry, Docker/Desktop verification summary mirror, and session capsule surfaces under `tests/results/_agent/`. @@ -37,6 +40,8 @@ entrypoint and machine-generated live state. - `tests/results/_agent/issue/standing-lane-reconciliation-*.json` - `tests/results/_agent/issue/no-standing-priority.json` - `tests/results/_agent/verification/docker-review-loop-summary.json` +- `tests/results/_agent/runtime/continuity-telemetry.json` +- `tests/results/_agent/handoff/continuity-summary.json` - `tests/results/_agent/handoff/entrypoint-status.json` - `tests/results/_agent/handoff/docker-review-loop-summary.json` - `tests/results/_agent/handoff/*.json` diff --git a/docs/schemas/continuity-telemetry-report-v1.schema.json b/docs/schemas/continuity-telemetry-report-v1.schema.json new file mode 100644 index 000000000..4e8dd11ae --- /dev/null +++ b/docs/schemas/continuity-telemetry-report-v1.schema.json @@ -0,0 +1,321 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Continuity telemetry report", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "generatedAt", + "repoRoot", + "status", + "issueContext", + "continuity", + "sources", + "artifacts" + ], + "properties": { + "schema": { + "const": "priority/continuity-telemetry-report@v1" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "repoRoot": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "maintained", + "at-risk", + "stale" + ] + }, + "issueContext": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "issue", + "present", + "fresh", + "observedAt", + "reason" + ], + "properties": { + "mode": { + "type": "string", + "enum": [ + "issue", + "queue-empty", + "missing" + ] + }, + "issue": { + "type": [ + "integer", + "null" + ] + }, + "present": { + "type": "boolean" + }, + "fresh": { + "type": "boolean" + }, + "observedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "reason": { + "type": [ + "string", + "null" + ] + } + } + }, + "continuity": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "preservedWithoutPrompt", + "promptDependency", + "unattendedSignalCount", + "quietPeriod", + "recommendation" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "maintained", + "at-risk", + "stale" + ] + }, + "preservedWithoutPrompt": { + "type": "boolean" + }, + "promptDependency": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "unattendedSignalCount": { + "type": "integer", + "minimum": 0 + }, + "quietPeriod": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "continuityReferenceAt", + "silenceGapSeconds", + "operatorQuietPeriodTreatedAsPause" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "covered", + "degrading", + "broken" + ] + }, + "continuityReferenceAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "silenceGapSeconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "operatorQuietPeriodTreatedAsPause": { + "type": "boolean" + } + } + }, + "recommendation": { + "type": "string", + "minLength": 1 + } + } + }, + "sources": { + "type": "object", + "additionalProperties": false, + "required": [ + "writerLease", + "router", + "noStanding", + "handoffEntrypoint", + "sessions", + "deliveryState", + "observerHeartbeat" + ], + "properties": { + "writerLease": { + "$ref": "#/$defs/source" + }, + "router": { + "$ref": "#/$defs/source" + }, + "noStanding": { + "$ref": "#/$defs/source" + }, + "handoffEntrypoint": { + "$ref": "#/$defs/source" + }, + "sessions": { + "$ref": "#/$defs/sessionSource" + }, + "deliveryState": { + "$ref": "#/$defs/source" + }, + "observerHeartbeat": { + "$ref": "#/$defs/source" + } + } + }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "required": [ + "runtimePath", + "handoffPath" + ], + "properties": { + "runtimePath": { + "type": "string", + "minLength": 1 + }, + "handoffPath": { + "type": "string", + "minLength": 1 + } + } + } + }, + "$defs": { + "source": { + "type": "object", + "additionalProperties": true, + "required": [ + "path", + "exists", + "observedAt", + "ageSeconds", + "freshnessThresholdSeconds", + "fresh", + "error" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "exists": { + "type": "boolean" + }, + "observedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "ageSeconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "freshnessThresholdSeconds": { + "type": "integer", + "minimum": 1 + }, + "fresh": { + "type": "boolean" + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, + "sessionSource": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "exists", + "observedAt", + "ageSeconds", + "freshnessThresholdSeconds", + "fresh", + "count", + "latestPath" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "exists": { + "type": "boolean" + }, + "observedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "ageSeconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "freshnessThresholdSeconds": { + "type": "integer", + "minimum": 1 + }, + "fresh": { + "type": "boolean" + }, + "count": { + "type": "integer", + "minimum": 0 + }, + "latestPath": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/docs/schemas/handoff-entrypoint-status-v1.schema.json b/docs/schemas/handoff-entrypoint-status-v1.schema.json index 0afa94f0d..ec02d9fc9 100644 --- a/docs/schemas/handoff-entrypoint-status-v1.schema.json +++ b/docs/schemas/handoff-entrypoint-status-v1.schema.json @@ -92,6 +92,7 @@ "router", "noStandingPriority", "dockerReviewLoopSummary", + "continuitySummary", "entrypointStatus", "handoffGlob", "sessionGlob" @@ -101,6 +102,7 @@ "router": { "type": "string", "minLength": 1 }, "noStandingPriority": { "type": "string", "minLength": 1 }, "dockerReviewLoopSummary": { "type": "string", "minLength": 1 }, + "continuitySummary": { "type": "string", "minLength": 1 }, "entrypointStatus": { "type": "string", "minLength": 1 }, "handoffGlob": { "type": "string", "minLength": 1 }, "sessionGlob": { "type": "string", "minLength": 1 } diff --git a/package.json b/package.json index 86368bbaa..90f30115c 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "priority:handoff-tests": "node tools/priority/run-handoff-tests.mjs", "priority:health-snapshot": "pwsh -NoLogo -NoProfile -File tools/priority/Write-HealthSnapshot.ps1", "priority:lease": "node tools/priority/agent-writer-lease.mjs", + "priority:continuity": "node tools/priority/continuity-telemetry.mjs", "priority:lane:marketplace": "node tools/priority/lane-marketplace.mjs", "priority:lane:concurrency:plan": "node tools/priority/concurrent-lane-plan.mjs", "priority:lane:concurrency:apply": "node tools/priority/concurrent-lane-apply.mjs", diff --git a/tools/Print-AgentHandoff.ps1 b/tools/Print-AgentHandoff.ps1 index ac44b1e84..8e90c5819 100644 --- a/tools/Print-AgentHandoff.ps1 +++ b/tools/Print-AgentHandoff.ps1 @@ -1273,6 +1273,21 @@ try { Write-Warning ("Standing priority ensure failed: {0}" -f $_.Exception.Message) } +try { + $continuityScript = Join-Path $repoRoot 'tools' 'priority' 'continuity-telemetry.mjs' + $nodeCmd = Get-Command node -ErrorAction SilentlyContinue + if ($nodeCmd -and (Test-Path -LiteralPath $continuityScript -PathType Leaf)) { + $continuityRuntimePath = Join-Path $ResultsRoot '_agent/runtime/continuity-telemetry.json' + $continuityHandoffPath = Join-Path $ResultsRoot '_agent/handoff/continuity-summary.json' + & $nodeCmd.Source $continuityScript ` + --repo-root $repoRoot ` + --output $continuityRuntimePath ` + --handoff-output $continuityHandoffPath | Out-Host + } +} catch { + Write-Warning ("Failed to refresh continuity telemetry: {0}" -f $_.Exception.Message) +} + try { $priorityContext = Ensure-StandingPriorityContext -RepoRoot (Resolve-Path '.').Path -ResultsRoot $ResultsRoot if ($priorityContext) { @@ -1335,6 +1350,38 @@ try { Write-Warning ("Failed to display standing priority summary: {0}" -f $_.Exception.Message) } +try { + $continuityPath = Join-Path $ResultsRoot '_agent/handoff/continuity-summary.json' + if (Test-Path -LiteralPath $continuityPath -PathType Leaf) { + $continuity = Get-Content -LiteralPath $continuityPath -Raw | ConvertFrom-Json -ErrorAction Stop + Write-Host '' + Write-Host '[Continuity]' -ForegroundColor Cyan + Write-Host (" status : {0}" -f (Format-NullableValue $continuity.status)) + Write-Host (" quiet : {0}" -f (Format-NullableValue $continuity.continuity.quietPeriod.status)) + Write-Host (" gap : {0}s" -f (Format-NullableValue $continuity.continuity.quietPeriod.silenceGapSeconds)) + Write-Host (" pause : {0}" -f (Format-BoolLabel $continuity.continuity.quietPeriod.operatorQuietPeriodTreatedAsPause)) + Write-Host (" context : {0}" -f (Format-NullableValue $continuity.issueContext.mode)) + Write-Host (" signals : {0}" -f (Format-NullableValue $continuity.continuity.unattendedSignalCount)) + Write-Host (" action : {0}" -f (Format-NullableValue $continuity.continuity.recommendation)) + + if ($env:GITHUB_STEP_SUMMARY) { + $continuityLines = @( + '### Continuity', + '', + ('- Status: {0}' -f (Format-NullableValue $continuity.status)), + ('- Quiet period: {0} Gap: {1}s' -f (Format-NullableValue $continuity.continuity.quietPeriod.status), (Format-NullableValue $continuity.continuity.quietPeriod.silenceGapSeconds)), + ('- Operator quiet treated as pause: {0}' -f (Format-BoolLabel $continuity.continuity.quietPeriod.operatorQuietPeriodTreatedAsPause)), + ('- Issue context: {0}' -f (Format-NullableValue $continuity.issueContext.mode)), + ('- Unattended signals: {0}' -f (Format-NullableValue $continuity.continuity.unattendedSignalCount)), + ('- Recommended action: {0}' -f (Format-NullableValue $continuity.continuity.recommendation)) + ) + ($continuityLines -join "`n") | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } + } +} catch { + Write-Warning ("Failed to display continuity summary: {0}" -f $_.Exception.Message) +} + try { $releasePath = Join-Path (Resolve-Path '.').Path 'tests/results/_agent/handoff/release-summary.json' if (Test-Path -LiteralPath $releasePath -PathType Leaf) { diff --git a/tools/Test-AgentHandoffEntryPoint.ps1 b/tools/Test-AgentHandoffEntryPoint.ps1 index 464bb70af..a5c8d11a1 100644 --- a/tools/Test-AgentHandoffEntryPoint.ps1 +++ b/tools/Test-AgentHandoffEntryPoint.ps1 @@ -34,6 +34,7 @@ $requiredArtifacts = @( 'tests/results/_agent/issue/router.json', 'tests/results/_agent/issue/no-standing-priority.json', 'tests/results/_agent/verification/docker-review-loop-summary.json', + 'tests/results/_agent/handoff/continuity-summary.json', 'tests/results/_agent/handoff/entrypoint-status.json', 'tests/results/_agent/handoff/*.json', 'tests/results/_agent/sessions/*.json' @@ -52,6 +53,7 @@ $artifactCatalog = [ordered]@{ router = 'tests/results/_agent/issue/router.json' noStandingPriority = 'tests/results/_agent/issue/no-standing-priority.json' dockerReviewLoopSummary = 'tests/results/_agent/verification/docker-review-loop-summary.json' + continuitySummary = 'tests/results/_agent/handoff/continuity-summary.json' entrypointStatus = 'tests/results/_agent/handoff/entrypoint-status.json' handoffGlob = 'tests/results/_agent/handoff/*.json' sessionGlob = 'tests/results/_agent/sessions/*.json' diff --git a/tools/priority/Import-HandoffState.ps1 b/tools/priority/Import-HandoffState.ps1 index f5a937787..039bc619d 100644 --- a/tools/priority/Import-HandoffState.ps1 +++ b/tools/priority/Import-HandoffState.ps1 @@ -42,6 +42,7 @@ $releaseSummary = Read-HandoffJson -Name 'release-summary.json' $testSummary = Read-HandoffJson -Name 'test-summary.json' $dockerReviewLoopSummary = Read-HandoffJson -Name 'docker-review-loop-summary.json' $entrypointStatus = Read-HandoffJson -Name 'entrypoint-status.json' +$continuitySummary = Read-HandoffJson -Name 'continuity-summary.json' if ($issueSummary) { Write-Host '[handoff] Standing priority snapshot' -ForegroundColor Cyan @@ -215,3 +216,22 @@ if ($entrypointStatus) { } Set-Variable -Name HandoffEntrypointStatus -Scope Global -Value $entrypointStatus -Force } + +if ($continuitySummary) { + Write-Host '[handoff] Continuity summary' -ForegroundColor Cyan + Write-Host (" status : {0}" -f (Format-NullableValue $continuitySummary.status)) + if ($continuitySummary.PSObject.Properties['issueContext'] -and $continuitySummary.issueContext) { + Write-Host (" context : {0}" -f (Format-NullableValue $continuitySummary.issueContext.mode)) + } + if ($continuitySummary.PSObject.Properties['continuity'] -and $continuitySummary.continuity) { + $quiet = $continuitySummary.continuity.quietPeriod + if ($quiet) { + Write-Host (" quiet : {0}" -f (Format-NullableValue $quiet.status)) + Write-Host (" gap : {0}s" -f (Format-NullableValue $quiet.silenceGapSeconds)) + Write-Host (" pause : {0}" -f (Format-BoolLabel $quiet.operatorQuietPeriodTreatedAsPause)) + } + Write-Host (" signals : {0}" -f (Format-NullableValue $continuitySummary.continuity.unattendedSignalCount)) + Write-Host (" action : {0}" -f (Format-NullableValue $continuitySummary.continuity.recommendation)) + } + Set-Variable -Name HandoffContinuitySummary -Scope Global -Value $continuitySummary -Force +} diff --git a/tools/priority/__fixtures__/handoff/entrypoint-status.json b/tools/priority/__fixtures__/handoff/entrypoint-status.json index 0668896a6..db7309964 100644 --- a/tools/priority/__fixtures__/handoff/entrypoint-status.json +++ b/tools/priority/__fixtures__/handoff/entrypoint-status.json @@ -27,6 +27,7 @@ "router": "tests/results/_agent/issue/router.json", "noStandingPriority": "tests/results/_agent/issue/no-standing-priority.json", "dockerReviewLoopSummary": "tests/results/_agent/verification/docker-review-loop-summary.json", + "continuitySummary": "tests/results/_agent/handoff/continuity-summary.json", "entrypointStatus": "tests/results/_agent/handoff/entrypoint-status.json", "handoffGlob": "tests/results/_agent/handoff/*.json", "sessionGlob": "tests/results/_agent/sessions/*.json" diff --git a/tools/priority/__tests__/continuity-telemetry-schema.test.mjs b/tools/priority/__tests__/continuity-telemetry-schema.test.mjs new file mode 100644 index 000000000..170678dee --- /dev/null +++ b/tools/priority/__tests__/continuity-telemetry-schema.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; + +import { buildContinuityTelemetry } from '../continuity-telemetry.mjs'; + +const repoRoot = path.resolve(process.cwd()); + +function writeJson(filePath, payload) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +test('continuity telemetry report matches the checked-in schema', () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'continuity-schema-')); + fs.mkdirSync(path.join(fixtureRoot, '.git', 'agent-writer-leases'), { recursive: true }); + fs.mkdirSync(path.join(fixtureRoot, 'tests', 'results', '_agent', 'issue'), { recursive: true }); + fs.mkdirSync(path.join(fixtureRoot, 'tests', 'results', '_agent', 'handoff'), { recursive: true }); + fs.mkdirSync(path.join(fixtureRoot, 'tests', 'results', '_agent', 'runtime'), { recursive: true }); + fs.mkdirSync(path.join(fixtureRoot, 'tests', 'results', '_agent', 'sessions'), { recursive: true }); + + writeJson(path.join(fixtureRoot, '.git', 'agent-writer-leases', 'workspace.json'), { + schema: 'agent/writer-lease@v1', + scope: 'workspace', + leaseId: 'lease-schema', + owner: 'agent@host:default', + acquiredAt: '2026-03-21T19:40:00.000Z', + heartbeatAt: '2026-03-21T19:58:00.000Z' + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'issue', 'router.json'), { + schema: 'agent/priority-router@v1', + issue: 1663, + updatedAt: '2026-03-21T19:59:00.000Z', + actions: [] + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'handoff', 'entrypoint-status.json'), { + schema: 'agent-handoff/entrypoint-status-v1', + generatedAt: '2026-03-21T19:58:00.000Z', + handoffPath: 'AGENT_HANDOFF.txt', + maxLines: 80, + actualLineCount: 40, + status: 'pass', + checks: { + primaryHeading: true, + lineBudget: true, + requiredHeadings: true, + liveArtifactGuidance: true, + stableEntrypointGuidance: true, + noStatusLogGuidance: true, + machineGeneratedArtifactGuidance: true, + noDatedHistorySections: true + }, + commands: { + bootstrap: 'pwsh -File tools/priority/bootstrap.ps1', + standingPriority: 'pwsh -File tools/Get-StandingPriority.ps1 -Plain', + printHandoff: 'pwsh -File tools/Print-AgentHandoff.ps1', + projectPortfolio: 'node tools/npm/run-script.mjs priority:project:portfolio:check', + developSync: 'node tools/npm/run-script.mjs priority:develop:sync' + }, + artifacts: { + priorityCache: '.agent_priority_cache.json', + router: 'tests/results/_agent/issue/router.json', + noStandingPriority: 'tests/results/_agent/issue/no-standing-priority.json', + dockerReviewLoopSummary: 'tests/results/_agent/verification/docker-review-loop-summary.json', + continuitySummary: 'tests/results/_agent/handoff/continuity-summary.json', + entrypointStatus: 'tests/results/_agent/handoff/entrypoint-status.json', + handoffGlob: 'tests/results/_agent/handoff/*.json', + sessionGlob: 'tests/results/_agent/sessions/*.json' + }, + violations: [] + }); + + const schema = JSON.parse(fs.readFileSync(path.join(repoRoot, 'docs', 'schemas', 'continuity-telemetry-report-v1.schema.json'), 'utf8')); + const ajv = new Ajv2020({ allErrors: true, strict: false }); + addFormats(ajv); + const validate = ajv.compile(schema); + + const { report } = buildContinuityTelemetry({ repoRoot: fixtureRoot }, new Date('2026-03-21T20:00:00.000Z')); + const valid = validate(report); + if (!valid) { + const errors = (validate.errors || []) + .map((entry) => `${entry.instancePath || '(root)'} ${entry.message}`) + .join('\n'); + assert.fail(`Continuity telemetry report failed schema validation:\n${errors}`); + } +}); diff --git a/tools/priority/__tests__/continuity-telemetry.test.mjs b/tools/priority/__tests__/continuity-telemetry.test.mjs new file mode 100644 index 000000000..c68f85e4b --- /dev/null +++ b/tools/priority/__tests__/continuity-telemetry.test.mjs @@ -0,0 +1,303 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { + buildContinuityTelemetry, + parseArgs, + runContinuityTelemetry +} from '../continuity-telemetry.mjs'; + +const repoRoot = path.resolve(process.cwd()); + +function writeJson(filePath, payload) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +function createRepoFixture(name) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); + fs.mkdirSync(path.join(root, '.git', 'agent-writer-leases'), { recursive: true }); + fs.mkdirSync(path.join(root, 'tests', 'results', '_agent', 'issue'), { recursive: true }); + fs.mkdirSync(path.join(root, 'tests', 'results', '_agent', 'handoff'), { recursive: true }); + fs.mkdirSync(path.join(root, 'tests', 'results', '_agent', 'runtime'), { recursive: true }); + fs.mkdirSync(path.join(root, 'tests', 'results', '_agent', 'sessions'), { recursive: true }); + return root; +} + +test('parseArgs captures explicit continuity output paths', () => { + const parsed = parseArgs([ + '--repo-root', + 'C:/repo', + '--output', + 'tests/results/_agent/runtime/custom-continuity.json', + '--handoff-output', + 'tests/results/_agent/handoff/custom-continuity.json', + '--now', + '2026-03-21T20:00:00.000Z' + ]); + + assert.equal(parsed.repoRoot, path.resolve('C:/repo')); + assert.match(parsed.runtimeOutputPath, /custom-continuity\.json$/); + assert.match(parsed.handoffOutputPath, /custom-continuity\.json$/); + assert.equal(parsed.now, '2026-03-21T20:00:00.000Z'); +}); + +test('buildContinuityTelemetry treats a quiet period as covered when unattended signals stay fresh', () => { + const fixtureRoot = createRepoFixture('continuity-maintained'); + const now = new Date('2026-03-21T20:00:00.000Z'); + + writeJson(path.join(fixtureRoot, '.git', 'agent-writer-leases', 'workspace.json'), { + schema: 'agent/writer-lease@v1', + scope: 'workspace', + leaseId: 'lease-1', + owner: 'agent@host:default', + acquiredAt: '2026-03-21T19:40:00.000Z', + heartbeatAt: '2026-03-21T19:55:00.000Z' + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'issue', 'router.json'), { + schema: 'agent/priority-router@v1', + issue: 1663, + updatedAt: '2026-03-21T19:50:00.000Z', + actions: [] + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'handoff', 'entrypoint-status.json'), { + schema: 'agent-handoff/entrypoint-status-v1', + generatedAt: '2026-03-21T19:54:00.000Z', + handoffPath: 'AGENT_HANDOFF.txt', + maxLines: 80, + actualLineCount: 40, + status: 'pass', + checks: { + primaryHeading: true, + lineBudget: true, + requiredHeadings: true, + liveArtifactGuidance: true, + stableEntrypointGuidance: true, + noStatusLogGuidance: true, + machineGeneratedArtifactGuidance: true, + noDatedHistorySections: true + }, + commands: { + bootstrap: 'pwsh -File tools/priority/bootstrap.ps1', + standingPriority: 'pwsh -File tools/Get-StandingPriority.ps1 -Plain', + printHandoff: 'pwsh -File tools/Print-AgentHandoff.ps1', + projectPortfolio: 'node tools/npm/run-script.mjs priority:project:portfolio:check', + developSync: 'node tools/npm/run-script.mjs priority:develop:sync' + }, + artifacts: { + priorityCache: '.agent_priority_cache.json', + router: 'tests/results/_agent/issue/router.json', + noStandingPriority: 'tests/results/_agent/issue/no-standing-priority.json', + dockerReviewLoopSummary: 'tests/results/_agent/verification/docker-review-loop-summary.json', + continuitySummary: 'tests/results/_agent/handoff/continuity-summary.json', + entrypointStatus: 'tests/results/_agent/handoff/entrypoint-status.json', + handoffGlob: 'tests/results/_agent/handoff/*.json', + sessionGlob: 'tests/results/_agent/sessions/*.json' + }, + violations: [] + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'runtime', 'delivery-agent-state.json'), { + schema: 'priority/delivery-agent-runtime-state@v1', + generatedAt: '2026-03-21T19:53:00.000Z', + status: 'running', + activeLane: { issue: 1663 } + }); + + const { report } = buildContinuityTelemetry({ repoRoot: fixtureRoot }, now); + assert.equal(report.status, 'maintained'); + assert.equal(report.issueContext.mode, 'issue'); + assert.equal(report.continuity.preservedWithoutPrompt, true); + assert.equal(report.continuity.quietPeriod.operatorQuietPeriodTreatedAsPause, false); + assert.equal(report.continuity.quietPeriod.status, 'covered'); +}); + +test('buildContinuityTelemetry resolves writer leases from git-common-dir for linked worktrees', () => { + const fixtureRoot = createRepoFixture('continuity-worktree'); + const now = new Date('2026-03-21T20:00:00.000Z'); + const commonGitDir = path.join(fixtureRoot, 'mock-common-dir'); + + fs.rmSync(path.join(fixtureRoot, '.git', 'agent-writer-leases'), { recursive: true, force: true }); + fs.mkdirSync(path.join(commonGitDir, 'agent-writer-leases'), { recursive: true }); + + writeJson(path.join(commonGitDir, 'agent-writer-leases', 'workspace.json'), { + schema: 'agent/writer-lease@v1', + scope: 'workspace', + leaseId: 'lease-worktree', + owner: 'agent@host:default', + acquiredAt: '2026-03-21T19:40:00.000Z', + heartbeatAt: '2026-03-21T19:58:00.000Z' + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'issue', 'router.json'), { + schema: 'agent/priority-router@v1', + issue: 1663, + updatedAt: '2026-03-21T19:59:00.000Z', + actions: [] + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'runtime', 'observer-heartbeat.json'), { + schema: 'priority/runtime-observer-heartbeat@v1', + generatedAt: '2026-03-21T19:57:30.000Z', + outcome: 'running' + }); + + const { report } = buildContinuityTelemetry({ + repoRoot: fixtureRoot, + spawnSyncFn(command, args) { + assert.equal(command, 'git'); + if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') { + return { + status: 0, + stdout: `${fixtureRoot}\n`, + stderr: '' + }; + } + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + return { + status: 0, + stdout: `${path.join(fixtureRoot, '.git')}\n`, + stderr: '' + }; + } + assert.deepEqual(args, ['rev-parse', '--git-common-dir']); + return { + status: 0, + stdout: `${commonGitDir}\n`, + stderr: '' + }; + } + }, now); + + assert.equal(report.sources.writerLease.exists, true); + assert.equal(report.sources.writerLease.path, path.join(commonGitDir, 'agent-writer-leases', 'workspace.json')); + assert.equal(report.status, 'maintained'); +}); + +test('buildContinuityTelemetry preserves queue-empty continuity without inventing an issue', () => { + const fixtureRoot = createRepoFixture('continuity-queue-empty'); + const now = new Date('2026-03-21T20:00:00.000Z'); + + writeJson(path.join(fixtureRoot, '.git', 'agent-writer-leases', 'workspace.json'), { + schema: 'agent/writer-lease@v1', + scope: 'workspace', + leaseId: 'lease-2', + owner: 'agent@host:default', + acquiredAt: '2026-03-21T19:40:00.000Z', + heartbeatAt: '2026-03-21T19:58:00.000Z' + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'issue', 'no-standing-priority.json'), { + schema: 'standing-priority/no-standing@v1', + generatedAt: '2026-03-21T19:58:00.000Z', + reason: 'queue-empty', + openIssueCount: 0, + message: 'Standing-priority queue is empty.' + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'runtime', 'observer-heartbeat.json'), { + schema: 'priority/runtime-observer-heartbeat@v1', + generatedAt: '2026-03-21T19:57:30.000Z', + outcome: 'idle' + }); + + const { report } = buildContinuityTelemetry({ repoRoot: fixtureRoot }, now); + assert.equal(report.status, 'maintained'); + assert.equal(report.issueContext.mode, 'queue-empty'); + assert.equal(report.issueContext.issue, null); + assert.equal(report.continuity.quietPeriod.operatorQuietPeriodTreatedAsPause, false); +}); + +test('buildContinuityTelemetry marks continuity stale when all unattended signals are old or missing', () => { + const fixtureRoot = createRepoFixture('continuity-stale'); + const now = new Date('2026-03-21T20:00:00.000Z'); + + writeJson(path.join(fixtureRoot, '.git', 'agent-writer-leases', 'workspace.json'), { + schema: 'agent/writer-lease@v1', + scope: 'workspace', + leaseId: 'lease-3', + owner: 'agent@host:default', + acquiredAt: '2026-03-20T10:00:00.000Z', + heartbeatAt: '2026-03-20T10:00:00.000Z' + }); + + const { report } = buildContinuityTelemetry({ repoRoot: fixtureRoot }, now); + assert.equal(report.status, 'stale'); + assert.equal(report.issueContext.mode, 'missing'); + assert.equal(report.continuity.quietPeriod.operatorQuietPeriodTreatedAsPause, true); + assert.equal(report.continuity.recommendation, 'run bootstrap and refresh handoff surfaces'); +}); + +test('runContinuityTelemetry writes both runtime and handoff continuity receipts', () => { + const fixtureRoot = createRepoFixture('continuity-write'); + writeJson(path.join(fixtureRoot, '.git', 'agent-writer-leases', 'workspace.json'), { + schema: 'agent/writer-lease@v1', + scope: 'workspace', + leaseId: 'lease-4', + owner: 'agent@host:default', + acquiredAt: '2026-03-21T19:40:00.000Z', + heartbeatAt: '2026-03-21T19:58:00.000Z' + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'issue', 'no-standing-priority.json'), { + schema: 'standing-priority/no-standing@v1', + generatedAt: '2026-03-21T19:58:00.000Z', + reason: 'queue-empty', + openIssueCount: 0, + message: 'Standing-priority queue is empty.' + }); + + const runtimeOutputPath = path.join(fixtureRoot, 'tests', 'results', '_agent', 'runtime', 'continuity-telemetry.json'); + const handoffOutputPath = path.join(fixtureRoot, 'tests', 'results', '_agent', 'handoff', 'continuity-summary.json'); + + runContinuityTelemetry({ + repoRoot: fixtureRoot, + runtimeOutputPath, + handoffOutputPath + }, new Date('2026-03-21T20:00:00.000Z')); + + assert.equal(fs.existsSync(runtimeOutputPath), true); + assert.equal(fs.existsSync(handoffOutputPath), true); +}); + +test('continuity telemetry CLI writes the default runtime and handoff reports', () => { + const fixtureRoot = createRepoFixture('continuity-cli'); + writeJson(path.join(fixtureRoot, '.git', 'agent-writer-leases', 'workspace.json'), { + schema: 'agent/writer-lease@v1', + scope: 'workspace', + leaseId: 'lease-5', + owner: 'agent@host:default', + acquiredAt: '2026-03-21T19:40:00.000Z', + heartbeatAt: '2026-03-21T19:58:00.000Z' + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'issue', 'no-standing-priority.json'), { + schema: 'standing-priority/no-standing@v1', + generatedAt: '2026-03-21T19:58:00.000Z', + reason: 'queue-empty', + openIssueCount: 0, + message: 'Standing-priority queue is empty.' + }); + writeJson(path.join(fixtureRoot, 'tests', 'results', '_agent', 'runtime', 'observer-heartbeat.json'), { + schema: 'priority/runtime-observer-heartbeat@v1', + generatedAt: '2026-03-21T19:57:30.000Z', + outcome: 'idle' + }); + + const result = spawnSync( + process.execPath, + [ + path.join(repoRoot, 'tools', 'priority', 'continuity-telemetry.mjs'), + '--repo-root', + fixtureRoot, + '--now', + '2026-03-21T20:00:00.000Z' + ], + { + cwd: repoRoot, + encoding: 'utf8' + } + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /\[continuity\] status=maintained/); + assert.equal(fs.existsSync(path.join(fixtureRoot, 'tests', 'results', '_agent', 'runtime', 'continuity-telemetry.json')), true); + assert.equal(fs.existsSync(path.join(fixtureRoot, 'tests', 'results', '_agent', 'handoff', 'continuity-summary.json')), true); +}); diff --git a/tools/priority/__tests__/handoff-entrypoint-contract.test.mjs b/tools/priority/__tests__/handoff-entrypoint-contract.test.mjs index 4876a6fe3..cd397f3ac 100644 --- a/tools/priority/__tests__/handoff-entrypoint-contract.test.mjs +++ b/tools/priority/__tests__/handoff-entrypoint-contract.test.mjs @@ -27,6 +27,7 @@ test('AGENT_HANDOFF stays bounded and points agents to live state artifacts', () assert.match(handoff, /tests\/results\/_agent\/issue\/router\.json/); assert.match(handoff, /tests\/results\/_agent\/issue\/no-standing-priority\.json/); assert.match(handoff, /tests\/results\/_agent\/verification\/docker-review-loop-summary\.json/); + assert.match(handoff, /tests\/results\/_agent\/handoff\/continuity-summary\.json/); assert.match(handoff, /tests\/results\/_agent\/handoff\/entrypoint-status\.json/); assert.match(handoff, /tests\/results\/_agent\/handoff\/\*\.json/); assert.match(handoff, /tests\/results\/_agent\/sessions\/\*\.json/); @@ -51,21 +52,28 @@ test('handoff entrypoint contract is wired into automation and operator docs', ( assert.ok(docsEntry); assert.ok(docsEntry.files.includes('docs/knowledgebase/Agent-Handoff-Surfaces.md')); assert.match(runHandoffTests, /handoff:entrypoint:check/); + assert.match(runHandoffTests, /priority:continuity/); assert.match(printHandoff, /Test-AgentHandoffEntryPoint\.ps1/); assert.match(printHandoff, /-ResultsRoot \$ResultsRoot -Quiet/); + assert.match(printHandoff, /continuity-telemetry\.mjs/); + assert.match(printHandoff, /continuity-summary\.json/); assert.match(printHandoff, /docker-review-loop-summary\.json/); assert.match(importHandoff, /entrypoint-status\.json/); + assert.match(importHandoff, /continuity-summary\.json/); + assert.match(importHandoff, /\[handoff\] Continuity summary/); assert.match(importHandoff, /docker-review-loop-summary\.json/); assert.match(importHandoff, /\[handoff\] Entrypoint index/); assert.match(agents, /handoff:entrypoint:check/); assert.match(agents, /priority:handoff/); assert.match(agents, /machine-readable index/i); assert.match(agents, /docker-review-loop-summary\.json/); + assert.match(agents, /continuity-summary\.json/); assert.match(developerGuide, /handoff:entrypoint:check/); assert.match(developerGuide, /priority:handoff/); assert.match(developerGuide, /machine-readable index/i); assert.match(handoffGuide, /AGENT_HANDOFF\.txt/); assert.match(handoffGuide, /entrypoint-status\.json/); + assert.match(handoffGuide, /continuity-summary\.json/); assert.match(handoffGuide, /docker-review-loop-summary\.json/); assert.match(handoffGuide, /priority:handoff/); assert.match(handoffGuide, /queue-empty/); diff --git a/tools/priority/__tests__/reconcile-standing-after-merge.test.mjs b/tools/priority/__tests__/reconcile-standing-after-merge.test.mjs index 8753bb834..b8dfdd5e6 100644 --- a/tools/priority/__tests__/reconcile-standing-after-merge.test.mjs +++ b/tools/priority/__tests__/reconcile-standing-after-merge.test.mjs @@ -2,10 +2,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { parseArgs, runStandingReconciliation } from '../reconcile-standing-after-merge.mjs'; +import { + parseArgs, + resolveStandingReconciliationRepositorySlug, + runStandingReconciliation +} from '../reconcile-standing-after-merge.mjs'; test('parseArgs accepts merge reconciliation controls', () => { const parsed = parseArgs([ @@ -39,6 +43,26 @@ test('parseArgs accepts merge reconciliation controls', () => { }); }); +test('resolveStandingReconciliationRepositorySlug prefers the upstream standing repo for fork worktrees', async (t) => { + const repoRoot = await mkdtemp(path.join(tmpdir(), 'standing-reconcile-root-')); + t.after(() => rm(repoRoot, { recursive: true, force: true })); + + await mkdir(path.join(repoRoot, '.git'), { recursive: true }); + await writeFile( + path.join(repoRoot, '.git', 'config'), + '[remote "origin"]\n url = https://github.com/svelderrainruiz/compare-vi-cli-action.git\n[remote "upstream"]\n url = https://github.com/LabVIEW-Community-CI-CD/compare-vi-cli-action.git\n', + 'utf8' + ); + + const repository = resolveStandingReconciliationRepositorySlug({ + repoRoot, + explicitRepo: 'svelderrainruiz/compare-vi-cli-action', + env: { GITHUB_REPOSITORY: 'svelderrainruiz/compare-vi-cli-action' } + }); + + assert.equal(repository, 'LabVIEW-Community-CI-CD/compare-vi-cli-action'); +}); + test('runStandingReconciliation closes the standing issue and refreshes the router cache after merge completion', async (t) => { const repoRoot = await mkdtemp(path.join(tmpdir(), 'standing-reconcile-')); t.after(() => rm(repoRoot, { recursive: true, force: true })); @@ -111,3 +135,76 @@ test('runStandingReconciliation closes the standing issue and refreshes the rout assert.equal(writes.length, 1); assert.match(String(writes[0].filePath), /standing-lane-reconciliation-1010\.json$/); }); + +test('runStandingReconciliation prefers the upstream standing repo when bootstrap forwards a fork slug', async (t) => { + const repoRoot = await mkdtemp(path.join(tmpdir(), 'standing-reconcile-fork-')); + t.after(() => rm(repoRoot, { recursive: true, force: true })); + + await mkdir(path.join(repoRoot, '.git'), { recursive: true }); + await writeFile( + path.join(repoRoot, '.git', 'config'), + '[remote "origin"]\n url = https://github.com/svelderrainruiz/compare-vi-cli-action.git\n[remote "upstream"]\n url = https://github.com/LabVIEW-Community-CI-CD/compare-vi-cli-action.git\n', + 'utf8' + ); + + const seenRepos = { + issueView: [], + labelRemoval: [], + closeIssue: [], + sync: [] + }; + + const receipt = await runStandingReconciliation({ + repoRoot, + argv: [ + 'node', + 'tools/priority/reconcile-standing-after-merge.mjs', + '--issue', + '1663', + '--repo', + 'svelderrainruiz/compare-vi-cli-action', + '--pr', + '1662', + '--merged' + ], + ensureGhCliFn: () => {}, + readIssueViewFn: async (options) => { + seenRepos.issueView.push(options.repo); + return { + number: 1663, + state: 'OPEN', + title: 'Standing priority issue', + url: 'https://github.com/LabVIEW-Community-CI-CD/compare-vi-cli-action/issues/1663', + labels: [{ name: 'standing-priority' }] + }; + }, + removeStandingLabelsFn: async (options) => { + seenRepos.labelRemoval.push(options.repo); + return { status: 0 }; + }, + closeIssueWithCommentFn: async (options) => { + seenRepos.closeIssue.push(options.repo); + return { status: 0 }; + }, + syncStandingPriorityFn: async (options) => { + seenRepos.sync.push(options.repo); + return { status: 0 }; + }, + readJsonFn: async (filePath) => { + if (String(filePath).endsWith('router.json')) { + return { schema: 'agent/priority-router@v1', issue: 1658, updatedAt: '2026-03-21T00:00:00Z', actions: [] }; + } + if (String(filePath).endsWith('.agent_priority_cache.json')) { + return { number: 1658 }; + } + return null; + }, + writeJsonFn: async (filePath, payload) => filePath && payload + }); + + assert.equal(receipt.repo, 'LabVIEW-Community-CI-CD/compare-vi-cli-action'); + assert.deepEqual(seenRepos.issueView, ['LabVIEW-Community-CI-CD/compare-vi-cli-action']); + assert.deepEqual(seenRepos.labelRemoval, ['LabVIEW-Community-CI-CD/compare-vi-cli-action']); + assert.deepEqual(seenRepos.closeIssue, ['LabVIEW-Community-CI-CD/compare-vi-cli-action']); + assert.deepEqual(seenRepos.sync, ['LabVIEW-Community-CI-CD/compare-vi-cli-action']); +}); diff --git a/tools/priority/bootstrap.ps1 b/tools/priority/bootstrap.ps1 index 0cc2f146d..5b26deb4b 100644 --- a/tools/priority/bootstrap.ps1 +++ b/tools/priority/bootstrap.ps1 @@ -828,6 +828,16 @@ if (-not $PreflightOnly) { Write-Host '[bootstrap] Summarizing safe-git reliability telemetry…' Invoke-SafeGitReliabilitySummary -RepoRoot $priorityHelperRepoRoot -WorkingDirectory $priorityWorkingDirectory + + Write-Host '[bootstrap] Writing continuity telemetry…' + $continuityRuntimePath = Join-Path $priorityWorkingDirectory 'tests/results/_agent/runtime/continuity-telemetry.json' + $continuityHandoffPath = Join-Path $priorityWorkingDirectory 'tests/results/_agent/handoff/continuity-summary.json' + Invoke-NodeScriptFromRepoRoot ` + -RepoRoot $priorityHelperRepoRoot ` + -WorkingDirectory $priorityWorkingDirectory ` + -ScriptRelativePath 'tools/priority/continuity-telemetry.mjs' ` + -Arguments @('--repo-root', $priorityWorkingDirectory, '--output', $continuityRuntimePath, '--handoff-output', $continuityHandoffPath) ` + -AllowFailure:$true } Write-Host '[bootstrap] Bootstrapping complete.' diff --git a/tools/priority/continuity-telemetry.mjs b/tools/priority/continuity-telemetry.mjs new file mode 100644 index 000000000..73dbeedd3 --- /dev/null +++ b/tools/priority/continuity-telemetry.mjs @@ -0,0 +1,540 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { defaultLeaseRoot } from './agent-writer-lease.mjs'; + +const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_REPO_ROOT = path.resolve(MODULE_DIR, '..', '..'); +const DEFAULT_RUNTIME_OUTPUT_PATH = path.join(DEFAULT_REPO_ROOT, 'tests', 'results', '_agent', 'runtime', 'continuity-telemetry.json'); +const DEFAULT_HANDOFF_OUTPUT_PATH = path.join(DEFAULT_REPO_ROOT, 'tests', 'results', '_agent', 'handoff', 'continuity-summary.json'); + +const DEFAULT_THRESHOLDS = Object.freeze({ + writerLeaseFreshSeconds: 30 * 60, + issueContextFreshSeconds: 6 * 60 * 60, + handoffFreshSeconds: 24 * 60 * 60, + sessionFreshSeconds: 7 * 24 * 60 * 60, + deliveryStateFreshSeconds: 6 * 60 * 60, + observerFreshSeconds: 6 * 60 * 60 +}); + +function nowIso(now = new Date()) { + return now.toISOString(); +} + +function safeParseJson(filePath) { + try { + if (!fs.existsSync(filePath)) { + return { exists: false, payload: null, error: null, stat: null }; + } + const raw = fs.readFileSync(filePath, 'utf8'); + const stat = fs.statSync(filePath); + return { + exists: true, + payload: JSON.parse(raw), + error: null, + stat + }; + } catch (error) { + return { + exists: true, + payload: null, + error: error instanceof Error ? error.message : String(error), + stat: null + }; + } +} + +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function writeJson(filePath, payload) { + ensureParentDir(filePath); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); +} + +function parseDate(value) { + if (!value || typeof value !== 'string') { + return null; + } + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) { + return null; + } + return new Date(parsed); +} + +function toIso(value) { + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + return null; + } + return value.toISOString(); +} + +function ageSeconds(referenceDate, now) { + if (!(referenceDate instanceof Date) || Number.isNaN(referenceDate.getTime())) { + return null; + } + return Math.max(0, Math.round((now.getTime() - referenceDate.getTime()) / 1000)); +} + +function newestDate(...values) { + const dates = values + .filter((value) => value instanceof Date && !Number.isNaN(value.getTime())) + .sort((left, right) => right.getTime() - left.getTime()); + return dates[0] || null; +} + +function describeSource({ + path: filePath, + exists, + error, + observedAt, + now, + freshSeconds, + extra = {} +}) { + const age = ageSeconds(observedAt, now); + return { + path: filePath, + exists, + observedAt: toIso(observedAt), + ageSeconds: age, + freshnessThresholdSeconds: freshSeconds, + fresh: age !== null ? age <= freshSeconds : false, + error, + ...extra + }; +} + +function inspectWriterLease(repoRoot, now, thresholds, options = {}) { + const leasePath = path.join(defaultLeaseRoot({ + repoRoot, + env: options.env, + spawnSyncFn: options.spawnSyncFn + }), 'workspace.json'); + const { exists, payload, error, stat } = safeParseJson(leasePath); + const observedAt = newestDate( + parseDate(payload?.heartbeatAt), + parseDate(payload?.acquiredAt), + stat ? new Date(stat.mtimeMs) : null + ); + return describeSource({ + path: leasePath, + exists, + error, + observedAt, + now, + freshSeconds: thresholds.writerLeaseFreshSeconds, + extra: { + schema: payload?.schema || null, + owner: payload?.owner || null, + leaseId: payload?.leaseId || null, + scope: payload?.scope || null + } + }); +} + +function inspectRouter(repoRoot, now, thresholds) { + const routerPath = path.join(repoRoot, 'tests', 'results', '_agent', 'issue', 'router.json'); + const { exists, payload, error, stat } = safeParseJson(routerPath); + const observedAt = stat ? new Date(stat.mtimeMs) : null; + return describeSource({ + path: routerPath, + exists, + error, + observedAt, + now, + freshSeconds: thresholds.issueContextFreshSeconds, + extra: { + schema: payload?.schema || null, + issue: payload?.issue ?? null, + actionCount: Array.isArray(payload?.actions) ? payload.actions.length : 0 + } + }); +} + +function inspectNoStanding(repoRoot, now, thresholds) { + const noStandingPath = path.join(repoRoot, 'tests', 'results', '_agent', 'issue', 'no-standing-priority.json'); + const { exists, payload, error, stat } = safeParseJson(noStandingPath); + const observedAt = stat ? new Date(stat.mtimeMs) : null; + return describeSource({ + path: noStandingPath, + exists, + error, + observedAt, + now, + freshSeconds: thresholds.issueContextFreshSeconds, + extra: { + schema: payload?.schema || null, + reason: payload?.reason || null, + openIssueCount: payload?.openIssueCount ?? null + } + }); +} + +function inspectHandoffEntrypoint(repoRoot, now, thresholds) { + const entrypointPath = path.join(repoRoot, 'tests', 'results', '_agent', 'handoff', 'entrypoint-status.json'); + const { exists, payload, error, stat } = safeParseJson(entrypointPath); + const observedAt = newestDate( + parseDate(payload?.generatedAt), + stat ? new Date(stat.mtimeMs) : null + ); + return describeSource({ + path: entrypointPath, + exists, + error, + observedAt, + now, + freshSeconds: thresholds.handoffFreshSeconds, + extra: { + schema: payload?.schema || null, + status: payload?.status || null + } + }); +} + +function inspectSessions(repoRoot, now, thresholds) { + const sessionsDir = path.join(repoRoot, 'tests', 'results', '_agent', 'sessions'); + let files = []; + try { + if (fs.existsSync(sessionsDir)) { + files = fs.readdirSync(sessionsDir) + .filter((entry) => entry.toLowerCase().endsWith('.json')) + .map((entry) => { + const fullPath = path.join(sessionsDir, entry); + const stat = fs.statSync(fullPath); + return { + path: fullPath, + mtime: new Date(stat.mtimeMs) + }; + }) + .sort((left, right) => right.mtime.getTime() - left.mtime.getTime()); + } + } catch { + files = []; + } + + const latest = files[0] || null; + const age = latest ? ageSeconds(latest.mtime, now) : null; + return { + path: sessionsDir, + exists: fs.existsSync(sessionsDir), + observedAt: latest ? toIso(latest.mtime) : null, + ageSeconds: age, + freshnessThresholdSeconds: thresholds.sessionFreshSeconds, + fresh: age !== null ? age <= thresholds.sessionFreshSeconds : false, + count: files.length, + latestPath: latest ? latest.path : null + }; +} + +function inspectDeliveryState(repoRoot, now, thresholds) { + const deliveryPath = path.join(repoRoot, 'tests', 'results', '_agent', 'runtime', 'delivery-agent-state.json'); + const runtimePath = path.join(repoRoot, 'tests', 'results', '_agent', 'runtime', 'runtime-state.json'); + const preferred = safeParseJson(deliveryPath); + const fallback = preferred.exists ? null : safeParseJson(runtimePath); + const selectedPath = preferred.exists ? deliveryPath : runtimePath; + const selected = preferred.exists ? preferred : (fallback || { exists: false, payload: null, error: null, stat: null }); + const payload = selected.payload; + const observedAt = newestDate( + parseDate(payload?.generatedAt), + parseDate(payload?.lifecycle?.updatedAt), + selected.stat ? new Date(selected.stat.mtimeMs) : null + ); + return describeSource({ + path: selectedPath, + exists: selected.exists, + error: selected.error, + observedAt, + now, + freshSeconds: thresholds.deliveryStateFreshSeconds, + extra: { + schema: payload?.schema || null, + source: preferred.exists ? 'delivery-agent-state' : (selected.exists ? 'runtime-state-compat' : 'missing'), + status: payload?.status || payload?.lifecycle?.status || null, + activeLaneIssue: payload?.activeLane?.issue ?? null + } + }); +} + +function inspectObserverHeartbeat(repoRoot, now, thresholds) { + const observerPath = path.join(repoRoot, 'tests', 'results', '_agent', 'runtime', 'observer-heartbeat.json'); + const { exists, payload, error, stat } = safeParseJson(observerPath); + const observedAt = newestDate( + parseDate(payload?.generatedAt), + stat ? new Date(stat.mtimeMs) : null + ); + return describeSource({ + path: observerPath, + exists, + error, + observedAt, + now, + freshSeconds: thresholds.observerFreshSeconds, + extra: { + schema: payload?.schema || null, + outcome: payload?.outcome || null, + activeLaneIssue: payload?.activeLane?.issue ?? null + } + }); +} + +function resolveIssueContext(router, noStanding) { + if (router.exists && router.issue !== null && router.issue !== undefined) { + return { + mode: 'issue', + issue: router.issue, + present: true, + fresh: router.fresh, + observedAt: router.observedAt, + reason: null + }; + } + + if (noStanding.exists && noStanding.reason === 'queue-empty') { + return { + mode: 'queue-empty', + issue: null, + present: true, + fresh: noStanding.fresh, + observedAt: noStanding.observedAt, + reason: noStanding.reason + }; + } + + return { + mode: 'missing', + issue: null, + present: false, + fresh: false, + observedAt: null, + reason: null + }; +} + +function evaluateContinuity({ + writerLease, + issueContext, + handoffEntrypoint, + sessions, + deliveryState, + observerHeartbeat, + now +}) { + const supplementalFresh = [ + handoffEntrypoint.fresh, + sessions.fresh, + deliveryState.fresh, + observerHeartbeat.fresh + ].filter(Boolean).length; + + const continuityReferenceAt = newestDate( + parseDate(writerLease.observedAt), + parseDate(issueContext.observedAt), + parseDate(handoffEntrypoint.observedAt), + parseDate(sessions.observedAt), + parseDate(deliveryState.observedAt), + parseDate(observerHeartbeat.observedAt) + ); + const silenceGapSeconds = ageSeconds(continuityReferenceAt, now); + + const preservedWithoutPrompt = writerLease.fresh && issueContext.present && supplementalFresh > 0; + const contextPresent = issueContext.present && ( + writerLease.exists || + handoffEntrypoint.exists || + sessions.count > 0 || + deliveryState.exists || + observerHeartbeat.exists + ); + + let status = 'stale'; + let quietPeriodStatus = 'broken'; + let promptDependency = 'high'; + let recommendedAction = 'run bootstrap and refresh handoff surfaces'; + let operatorQuietPeriodTreatedAsPause = true; + + if (preservedWithoutPrompt) { + status = 'maintained'; + quietPeriodStatus = 'covered'; + promptDependency = 'low'; + recommendedAction = 'none'; + operatorQuietPeriodTreatedAsPause = false; + } else if (contextPresent) { + status = 'at-risk'; + quietPeriodStatus = 'degrading'; + promptDependency = 'medium'; + recommendedAction = issueContext.mode === 'queue-empty' + ? 'refresh bootstrap or handoff to keep queue-empty idle state current' + : 'refresh bootstrap or handoff before assuming the standing lane is still current'; + operatorQuietPeriodTreatedAsPause = false; + } + + return { + status, + preservedWithoutPrompt, + promptDependency, + unattendedSignalCount: [ + writerLease.fresh, + issueContext.present, + handoffEntrypoint.fresh, + sessions.fresh, + deliveryState.fresh, + observerHeartbeat.fresh + ].filter(Boolean).length, + quietPeriod: { + status: quietPeriodStatus, + continuityReferenceAt: toIso(continuityReferenceAt), + silenceGapSeconds, + operatorQuietPeriodTreatedAsPause + }, + recommendation: recommendedAction + }; +} + +export function buildContinuityTelemetry({ + repoRoot = DEFAULT_REPO_ROOT, + thresholds = DEFAULT_THRESHOLDS, + runtimeOutputPath = DEFAULT_RUNTIME_OUTPUT_PATH, + handoffOutputPath = DEFAULT_HANDOFF_OUTPUT_PATH, + env = process.env, + spawnSyncFn +} = {}, now = new Date()) { + const writerLease = inspectWriterLease(repoRoot, now, thresholds, { env, spawnSyncFn }); + const router = inspectRouter(repoRoot, now, thresholds); + const noStanding = inspectNoStanding(repoRoot, now, thresholds); + const handoffEntrypoint = inspectHandoffEntrypoint(repoRoot, now, thresholds); + const sessions = inspectSessions(repoRoot, now, thresholds); + const deliveryState = inspectDeliveryState(repoRoot, now, thresholds); + const observerHeartbeat = inspectObserverHeartbeat(repoRoot, now, thresholds); + const issueContext = resolveIssueContext(router, noStanding); + const continuity = evaluateContinuity({ + writerLease, + issueContext, + handoffEntrypoint, + sessions, + deliveryState, + observerHeartbeat, + now + }); + + const report = { + schema: 'priority/continuity-telemetry-report@v1', + generatedAt: nowIso(now), + repoRoot, + status: continuity.status, + issueContext, + continuity, + sources: { + writerLease, + router, + noStanding, + handoffEntrypoint, + sessions, + deliveryState, + observerHeartbeat + }, + artifacts: { + runtimePath: runtimeOutputPath, + handoffPath: handoffOutputPath + } + }; + + return { + report, + runtimeOutputPath, + handoffOutputPath + }; +} + +export function parseArgs(argv = process.argv.slice(2)) { + const parsed = { + repoRoot: DEFAULT_REPO_ROOT, + runtimeOutputPath: DEFAULT_RUNTIME_OUTPUT_PATH, + handoffOutputPath: DEFAULT_HANDOFF_OUTPUT_PATH, + now: null + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + switch (token) { + case '--repo-root': + parsed.repoRoot = path.resolve(argv[++index] || DEFAULT_REPO_ROOT); + break; + case '--output': + parsed.runtimeOutputPath = path.resolve(parsed.repoRoot, argv[++index] || DEFAULT_RUNTIME_OUTPUT_PATH); + break; + case '--handoff-output': + parsed.handoffOutputPath = path.resolve(parsed.repoRoot, argv[++index] || DEFAULT_HANDOFF_OUTPUT_PATH); + break; + case '--now': + parsed.now = argv[++index] || null; + break; + case '--help': + case '-h': + parsed.help = true; + break; + default: + throw new Error(`Unknown argument: ${token}`); + } + } + + if (parsed.runtimeOutputPath === DEFAULT_RUNTIME_OUTPUT_PATH) { + parsed.runtimeOutputPath = path.join(parsed.repoRoot, 'tests', 'results', '_agent', 'runtime', 'continuity-telemetry.json'); + } + if (parsed.handoffOutputPath === DEFAULT_HANDOFF_OUTPUT_PATH) { + parsed.handoffOutputPath = path.join(parsed.repoRoot, 'tests', 'results', '_agent', 'handoff', 'continuity-summary.json'); + } + + return parsed; +} + +function printUsage() { + console.log(`Usage: + node tools/priority/continuity-telemetry.mjs [options] + +Options: + --repo-root Repository root to inspect + --output Runtime continuity report path + --handoff-output Handoff continuity summary path + --now Override current time (tests) +`); +} + +export function runContinuityTelemetry(options = {}, now = null) { + const effectiveNow = now || (options.now ? new Date(options.now) : new Date()); + const result = buildContinuityTelemetry(options, effectiveNow); + writeJson(result.runtimeOutputPath, result.report); + writeJson(result.handoffOutputPath, result.report); + return result; +} + +async function main() { + const options = parseArgs(); + if (options.help) { + printUsage(); + return 0; + } + + const result = runContinuityTelemetry(options); + process.stdout.write( + `[continuity] status=${result.report.status} quiet=${result.report.continuity.quietPeriod.status} pause=${result.report.continuity.quietPeriod.operatorQuietPeriodTreatedAsPause} -> ${result.runtimeOutputPath}\n` + ); + return 0; +} + +const modulePath = path.resolve(fileURLToPath(import.meta.url)); +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null; + +if (invokedPath && invokedPath === modulePath) { + try { + const code = await main(); + process.exit(code); + } catch (error) { + process.stderr.write(`[continuity] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } +} diff --git a/tools/priority/reconcile-standing-after-merge.mjs b/tools/priority/reconcile-standing-after-merge.mjs index f4397bf0e..1aa99f164 100644 --- a/tools/priority/reconcile-standing-after-merge.mjs +++ b/tools/priority/reconcile-standing-after-merge.mjs @@ -7,7 +7,7 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { ensureGhCli } from './lib/remote-utils.mjs'; import { getRepoRoot } from './lib/branch-utils.mjs'; -import { resolveRepositorySlug } from './sync-standing-priority.mjs'; +import { resolveRepositorySlug, resolveUpstreamRepositorySlug } from './sync-standing-priority.mjs'; const RECONCILIATION_SCHEMA = 'priority/standing-lane-reconciliation@v1'; const DEFAULT_RECEIPT_DIR = path.join('tests', 'results', '_agent', 'issue'); @@ -83,6 +83,22 @@ function buildCachePath(repoRoot, explicitPath = null) { return path.join(repoRoot, DEFAULT_CACHE_RELATIVE_PATH); } +export function resolveStandingReconciliationRepositorySlug({ + repoRoot, + explicitRepo = null, + env = process.env +} = {}) { + const resolvedRepo = normalizeText(explicitRepo) || resolveRepositorySlug(repoRoot, env); + const upstreamRepo = resolveUpstreamRepositorySlug(repoRoot, resolvedRepo, env); + if ( + upstreamRepo && + normalizeText(upstreamRepo).toLowerCase() !== normalizeText(resolvedRepo).toLowerCase() + ) { + return upstreamRepo; + } + return resolvedRepo; +} + export function parseArgs(argv = process.argv) { const args = argv.slice(2); const options = { @@ -373,7 +389,11 @@ export async function runStandingReconciliation({ ensureGhCliFn?.(); const resolvedRepoRoot = repoRoot || getRepoRoot(); - const repository = options.repo || resolveRepositorySlug(resolvedRepoRoot, process.env); + const repository = resolveStandingReconciliationRepositorySlug({ + repoRoot: resolvedRepoRoot, + explicitRepo: options.repo, + env: process.env + }); const issuePath = buildSummaryPath(resolvedRepoRoot, options.issue, options.summaryPath); const routerPath = buildRouterPath(resolvedRepoRoot, options.routerPath); const cachePath = buildCachePath(resolvedRepoRoot, options.cachePath); diff --git a/tools/priority/run-handoff-tests.mjs b/tools/priority/run-handoff-tests.mjs index 926316a4c..f7ee2ea2f 100644 --- a/tools/priority/run-handoff-tests.mjs +++ b/tools/priority/run-handoff-tests.mjs @@ -183,7 +183,7 @@ async function run() { if (!available) { notes.push(availabilityMessage || 'npm wrapper check failed'); } else { - const scripts = ['priority:test', 'hooks:test', 'handoff:entrypoint:check', 'semver:check', 'priority:policy']; + const scripts = ['priority:test', 'hooks:test', 'priority:continuity', 'handoff:entrypoint:check', 'semver:check', 'priority:policy']; const rawSkipPolicyEnv = process.env.PRIORITY_HANDOFF_SKIP_POLICY; const skipPolicyEnv = rawSkipPolicyEnv || ''; const normalizedSkipPolicy = skipPolicyEnv.trim().toLowerCase();