@juangcarmona/lighthouse-sdlc 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +45 -9
  2. package/completion.md +48 -0
  3. package/host-contract.md +2 -0
  4. package/package.json +2 -1
  5. package/skills/integrate/SKILL.md +7 -1
  6. package/skills/propose/SKILL.md +1 -1
  7. package/skills/refine/SKILL.md +1 -1
  8. package/skills/review/SKILL.md +7 -1
  9. package/src/completion-actions.mjs +114 -0
  10. package/src/completion.mjs +169 -0
  11. package/src/github-profile.mjs +9 -1
  12. package/src/host-contract.mjs +8 -4
  13. package/src/implementation-runtime.mjs +66 -0
  14. package/src/implementation.mjs +143 -0
  15. package/src/install-workflows.mjs +30 -0
  16. package/src/integration-evidence.mjs +56 -0
  17. package/src/issue-actions.mjs +189 -0
  18. package/src/observe-protection.mjs +24 -0
  19. package/src/opencode-execute.mjs +188 -0
  20. package/src/opencode-progress.mjs +48 -0
  21. package/src/process-publication.mjs +90 -0
  22. package/src/proposal.mjs +96 -0
  23. package/workflows/lighthouse-sdlc-implement.lock.yml +251 -1085
  24. package/workflows/lighthouse-sdlc-implement.md +165 -29
  25. package/workflows/lighthouse-sdlc-integrate.lock.yml +257 -1076
  26. package/workflows/lighthouse-sdlc-integrate.md +175 -29
  27. package/workflows/lighthouse-sdlc-propose.lock.yml +241 -1085
  28. package/workflows/lighthouse-sdlc-propose.md +155 -29
  29. package/workflows/lighthouse-sdlc-refine.lock.yml +239 -1085
  30. package/workflows/lighthouse-sdlc-refine.md +151 -29
  31. package/workflows/lighthouse-sdlc-review.lock.yml +257 -1076
  32. package/workflows/lighthouse-sdlc-review.md +175 -29
  33. package/workflows/lighthouse-sdlc-router.yml +84 -145
  34. package/workflows/shared/lighthouse-opencode.md +22 -0
package/README.md CHANGED
@@ -10,27 +10,35 @@ It also provides the `browser-e2e` evidence contract for Playwright journeys. Th
10
10
 
11
11
  It provides a neutral evidence contract for delivery, product and architecture references. `lighthouse-sdlc evidence` validates records or builds a deterministic coverage index; it does not replace ProductShape, ArcShape or a future quality evaluator.
12
12
 
13
- It does not run agents, store transcripts, dispatch work between repositories or replace ProductShape, ArcShape, OpenSpec or an agent host.
13
+ It has no persistent execution service, transcript store or cross-repository dispatcher. The optional Actions adapter executes the selected engine under operator policy. ProductShape, ArcShape and OpenSpec retain their own authority.
14
14
 
15
15
  ## External host contract
16
16
 
17
17
  `src/host-contract.mjs` validates a supervised `triage` or `refine` request against a trusted host policy and validates the resulting stage/effect envelope. It is exported as `@juangcarmona/lighthouse-sdlc/host-contract`. See [the wire contract](host-contract.md) for fields and host obligations. Execution, credentials, admission, cancellation and publication remain host responsibilities.
18
18
 
19
- Print the opt-in GitHub adapter with `node node_modules/@juangcarmona/lighthouse-sdlc/src/host-contract.mjs workflow`. It expects a provisioned `lighthouse-host-bridge` on a repository runner labelled `lighthouse-host`. Installing the package does not enable that adapter. The existing gh-aw projection remains a separate execution path.
19
+ The external-host adapter remains available for existing adopters. New repository automation uses the gh-aw path below; do not enable both routers for the same events.
20
20
 
21
- ## gh-aw workflow projection
21
+ ## GitHub Actions execution
22
22
 
23
- The package includes the six Lighthouse SDLC worker sources, their compiled `.lock.yml` workflows and the router projection. The Lighthouse CLI projects them into `.github/workflows` during initialization, reconciliation or an explicit `lighthouse upgrade --scope sdlc --apply`; package installation alone does not write repository files.
23
+ The optional router accepts an owner-added `refine` label, an owner reply to a pending `needs-info` question or an owner manual dispatch. It calls the compiled gh-aw refinement worker. With `--with-propose`, an owner-added `propose` label or explicit manual stage selection calls the proposal worker. Actions authorizes the request, reserves the issue, runs OpenCode, validates its result and publishes through a separately credentialed job. SoFa can observe these runs without participating in execution.
24
24
 
25
- Workers resolve instructions and validators from the installed SDLC package and reject a version mismatch with `.lighthouse/sdlc.json` before execution. Consumers need a committed npm lockfile.
25
+ Preview installation with `node node_modules/@juangcarmona/lighthouse-sdlc/src/install-workflows.mjs <repository> <immutable-lighthouse-commit>`. Install gh-aw v0.93.2, then add `--apply` to write and compile the selected sources. Existing different files require review and `--replace-reviewed`. Commit the Markdown, router, shared engine definition, generated lock and action pins together. Set the repository variable `LIGHTHOUSE_AUTOMATION_ENABLED=true` after provisioning the runner and disabling previous routers.
26
26
 
27
- The host must provide the runner capability `[self-hosted, linux, lighthouse-sdlc]`, the GitHub and gh-aw credentials required by the compiled workflow, the selected route and stage input, and durable evidence handling for the uploaded stage result. Each worker executes one declared stage and writes `/tmp/gh-aw/agent/stage-result.json`; the host remains responsible for runtime, queues, budgets and model selection. The generic runner capability is a contract, not a SoFa or Bastion identity.
27
+ The installer enables `refine` by default. Add `--with-propose` to install the proposal worker as well. Add `--with-implement` for implementation (including proposal support). Add `--with-completion` to enable integration and review with an explicit GitHub profile opt-in; see the [completion guide](completion.md). Foundation initialization and upgrades do not install workflows. On upgrade from an older baseline, existing workflows remain untouched and leave the foundation's managed-file inventory. Review and remove obsolete projections explicitly.
28
28
 
29
- The router validates the existing Lighthouse route and `requires` declarations. A normal dispatch runs one stage. The current worker host cannot verify human gate decisions, so it rejects `ff` for every bundled route. Use individual stage dispatches under host supervision until an approval adapter exists. The `integrate` worker prepares the configured merge action without merging unless repository policy authorizes it; `review` closes the flow with `gate: null`.
29
+ Create the `propose` request label and the amber `waiting-review` attention label when opting in. The proposal worker requires `ready` and a successful refinement from the same source revision and owner intent. Preparation authenticates its Actions request and result artifacts; a label or copied comment is insufficient. If evidence expired or the source changed, run `refine` again first. The explicit owner request authorizes proposal work. It does not approve the resulting design or implementation.
30
30
 
31
- Workers require `stage-result --require-complete --stage <stage> --run-id <id>` before reporting success. This checks the dispatched identity, the gate in `stages.json`, `status: passed`, empty blocker arrays and resolved evidence statuses. Plain `stage-result --input <file>` still validates the structure of reports, including blocked reports. Completion validation does not verify evidence provenance or grant approval to cross a human gate; the host owns those checks.
31
+ OpenSpec 1.15.0 supplies the `spec-driven` scaffold and artifact instructions in preparation. OpenCode returns bounded Markdown files under `openspec/changes/issue-<number>-proposal/`. The separate publisher validates those files with the provider, creates a fresh proposal branch and opens a PR with a link to the run. Existing proposal directories and open proposal PRs stop admission. Product code, existing branches and OpenSpec configuration remain untouched. Failed publication may leave a proposal branch; inspect it before retrying. The adopter must allow Actions to create PRs; this repository setting also permits Actions approvals, although these workflows never approve or merge PRs. PRs created with `GITHUB_TOKEN` generate `pull_request` runs that require maintainer approval. Review the proposed changes, then select **Approve workflows to run** on the PR before relying on CI. This is a CI execution gate, separate from accepting the proposal or authorizing implementation. See [GitHub token-triggered runs](https://docs.github.com/en/actions/concepts/security/github_token#when-github_token-triggers-workflow-runs).
32
32
 
33
- Install it with the Lighthouse CLI or run `lighthouse-sdlc verify` from the package installation to check the distributed contract.
33
+ A completed proposal sets `waiting-review`, not `planned`. A blocked proposal reports its missing decision with the same attention label and creates no PR. Owner comments do not automatically resume proposal work; review the blocker, refresh refinement when intent changes and explicitly request `propose` again. Source, intent and the reservation are rechecked before publication. The first adapter delivers one proposal per issue and does not update an existing proposal PR.
34
+
35
+ The worker requires a Linux runner labelled `lighthouse-workflow`, Node.js, Docker, `flock` and `LIGHTHOUSE_ENGINE_CONFIG` pointing to an operator-owned configuration. See the source repository's `src/examples/opencode-engine` deployment example. The imported OpenCode engine starts a foreground container with read-only product source and harness package, a writable result directory and operator-managed provider authentication. Packaged skills load from `/harness/skills`; consumers do not copy the lifecycle skills into their source tree.
36
+
37
+ The subscription profile uses Docker isolation and explicitly disables gh-aw's AWF agent sandbox. It has no AWF egress filtering or credential proxy. The engine gets no GitHub token or Docker socket. Only trusted repository workflows may use the runner, whose Docker access is privileged infrastructure access. All repository runners sharing a host must use the same operator lock path to serialize model execution.
38
+
39
+ Actions exposes the `OpenCode progress and result` step with public progress messages, concise tool activity, quiet-period notices and the validated result. Redacted structured events remain in a separate `opencode-events` artifact and feed the standard gh-aw session and usage parser. The result envelope and runtime receipt identify source, harness and image revisions. Logs redact common credential formats; this is not a guarantee that arbitrary sensitive product content is removed. Review public-repository content and artifact access accordingly. Agent artifacts use GitHub retention; they are not a permanent retrospective archive.
40
+
41
+ Publication validates the Lighthouse envelope and rereads owner answers, issue state and source revision. Three unresolved reply rounds require an explicit new `refine` label. `agent-working` signals a reservation; human takeover or a changed source prevents publication. Labels are cooperative signals, not locks against arbitrary editors. A runner or artifact failure can require manual reservation reconciliation. A completed refinement never authorizes the next stage or a merge.
34
42
 
35
43
  Run `lighthouse-sdlc triage --input <file>` to validate one structured triage result and receive its proposed workflow and classification labels. The command is read-only by default; `--apply --labels-file <file>` writes only after the report is ready and the labels file still matches the input.
36
44
 
@@ -59,3 +67,31 @@ Evidence is explicit: an absent field appears in `missingEvidence`, a check that
59
67
  The package is a source of lifecycle instructions and contracts; an adopter's host adapter decides where provider-specific projections are installed.
60
68
 
61
69
  The [external host contract](host-contract.md) supports owner-authorized `refine` labels, schema-1 compatibility and the shared deterministic event router. Work reservations use `agent-working` and `human-working`; the host owns the durable lock.
70
+
71
+ ## Implementation delivery
72
+
73
+ Opt in with `--with-implement`, provision the implementation-capable engine image, and create an `implement` request label. An owner label or manual dispatch authorizes one execution. Admission requires an OpenSpec proposal PR merged by the repository owner, its unchanged artifacts on the default branch, and an entry keyed by issue number in `.lighthouse/implement.json`:
74
+
75
+ ```json
76
+ {
77
+ "18": {
78
+ "proposal": 33,
79
+ "reviewRequired": true,
80
+ "scope": "Implement the approved feature; owner visual review remains required.",
81
+ "paths": ["src/", "tests/", "package.json", "package-lock.json", "openspec/changes/issue-18-proposal/tasks.md"],
82
+ "checks": [["npm", "ci", "--ignore-scripts"], ["npm", "test"], ["npm", "run", "build"]]
83
+ }
84
+ }
85
+ ```
86
+
87
+ This is executable repository policy, reviewed with the workflow adoption. Paths ending in `/` allow a subtree; other entries allow one file. Keep the scope specific to the approved change or slice. Harness configuration, installed agents, Git metadata, credential files, workflows and approved design/specification files cannot be changed by this worker. Workflow changes need a separate maintainer-authored change; the publisher has no workflow-write permission. Only the proposal tasks file is writable under OpenSpec. Set `reviewRequired: true` when candidate acceptance needs the owner; the host then forces a blocked assessment and draft PR even if the engine claims success. An adopter may explicitly set it to false where its Definition of Done permits automated assessment. Labels do not replace this policy or approve later stages.
88
+
89
+ OpenCode edits a temporary copy, with 30 minutes to return an envelope. The adapter validates at most 512 changed regular files, 4 MiB per file and 16 MiB total, excluding Git-ignored new output while retaining every tracked edit. New reports follow the project’s `.gitignore`; an ignore rule cannot hide changes to existing source. It reconstructs the exact candidate and reruns the configured commands in a fresh container without provider or GitHub credentials. Checks share a ten-minute limit. Only the candidate files enter the PR; logs, screenshots and the retrospective remain in the `implementation-evidence` Actions artifact for 30 days. Result receipts and redacted OpenCode events retain their existing 14-day lifetime. These artifacts are evidence delivery, not permanent archival.
90
+
91
+ The publisher rechecks source, owner answers, reservation, proposal and policy before creating a new branch. Required human review or failed host checks produces a draft PR with blockers and `waiting-review`; it does not mark `done`, close the issue or start integration. An open implementation PR stops a second implementation run. Review and finish that PR before requesting another slice. The worker does not overwrite or resume an existing branch. The subscription engine still has outbound networking and provider authentication inside its container; shell permissions are not credential isolation. Use only owner-authorized work on the dedicated host.
92
+
93
+ To recover a failed candidate, an owner may add `"seed": {"base": "<40-character commit>", "head": "<40-character commit>"}` to that issue’s implementation policy. The head must be one commit directly on the declared base in the same repository. Preparation rejects out-of-scope files, links and changes whose base file no longer matches the current default branch. It binds the validated seed to the request and restores it in the disposable workspace. The engine continues that candidate, reruns checks and creates a fresh PR; the seed branch is never modified. A seed supplies work to inspect, not completion or approval evidence.
94
+
95
+ ## Integration and review
96
+
97
+ The optional [GitHub completion adapter](completion.md) adds accepted-head integration and permanent post-integration review to the existing router. Install with `--with-completion` and explicitly adopt evidence publication in the GitHub profile. Merge authorization remains separate from a passed agent assessment.
package/completion.md ADDED
@@ -0,0 +1,48 @@
1
+ # Integrate and review in GitHub Actions
2
+
3
+ The optional completion adapter finishes an implementation through the adopter's repository workflows. OpenCode assesses the delivery in its existing isolated runner; a separate hosted publisher verifies GitHub state, performs an explicitly authorized merge and retains the report. SoFa is not required.
4
+
5
+ ## Adoption
6
+
7
+ Install with `install-workflows <repository> <immutable-lighthouse-commit> --with-completion`. Preview first; `--apply` writes selected workflows and compiles them with gh-aw v0.93.2. Existing differing files require `--replace-reviewed`. Completion includes refine, propose and implement; it adds no new lifecycle stage.
8
+
9
+ In the existing `.lighthouse/github.json`, retain the adopted required checks and protected `evidenceBranch`, and add:
10
+
11
+ ```json
12
+ {
13
+ "completion": {
14
+ "publishEvidence": true,
15
+ "postMergeWorkflows": ["verify.yml"]
16
+ }
17
+ }
18
+ ```
19
+
20
+ Use real workflow filenames. Each must support `workflow_dispatch` and verify its checked-out revision. GitHub does not trigger ordinary push workflows for changes made with `GITHUB_TOKEN`, so the publisher explicitly dispatches this allowlist after merge. A run only counts if GitHub reports the actual merged SHA, default branch, permitted event and successful completion. Pending, missing and failed results remain distinct from success. A changed default tip stops dispatch rather than checking unrelated code.
21
+
22
+ The evidence branch must already exist separately from the application tree. Active repository rulesets must prevent deletion and non-fast-forward updates with no bypass actors. Completion verifies these rules before publication; it does not create or weaken them. `publishEvidence` opts into publishing the bounded report and receipt to the repository's audience. It does not authorize copying raw sessions or every implementation artifact.
23
+
24
+ GitHub hides `bypass_actors` from tokens that cannot edit rulesets. Before activation, run the read-only `node src/packages/sdlc/src/observe-protection.mjs <owner/repository> <evidence-branch>` command with the owner's existing GitHub CLI authentication. Add its array as `completion.protectionObservations` in the reviewed profile. No credential is stored. The observer requires visible empty bypass lists and stable provider timestamps. Actions compares the retained ruleset revision digest with live public metadata, including `updated_at`, conditions and rules; any change invalidates the observation. Reobserve after a deliberate protection change. If bypass actors are visible at runtime, a non-empty list always fails even when an old observation exists. Classic branch protection still requires access to its protection endpoint; the adapter fails closed when that policy cannot be read.
25
+
26
+ ## Owner entry
27
+
28
+ Use the existing **Lighthouse SDLC router** action on the current default branch. Select `integrate` or `review`, supply the work-item number, PR number and full accepted PR head SHA. Integration defaults to `merge: false`. Set it to `true` only to authorize that revision. Review always leaves it false. Labels and arbitrary comments do not authorize these stages.
29
+
30
+ Integration binds the current work-item title/body and owner answers, PR content and changed paths, base revision, adopted policy and review state. The host rechecks them before publishing or merging. Changed inputs require a fresh dispatch. Required checks come from effective GitHub rules and classic branch protection; the adopted checks must still be enforced. Unrelated maintenance failures are not silently promoted to required checks. Additional CI observations exclude check identities belonging to the current workflow run, obtained from GitHub job metadata, so review cannot wait for itself to finish; required-check enforcement remains unchanged. GitHub remains responsible for review and merge protection. This adapter supports same-repository PRs and squash integration; merge queues and stacked merges need their own adapter.
31
+
32
+ The merge request includes the accepted head SHA. GitHub's REST merge operation has no expected-base argument: the adapter rechecks base immediately beforehand, while GitHub enforces current branch rules at merge time. The receipt records the actual merged revision, and post-merge verification checks that revision. It never claims the earlier test-merge revision is the final commit.
33
+
34
+ After integration, explicitly run `review` when post-merge checks finish. The host supplies matching integration evidence from the protected process branch: a commit-pinned report, hash-verified artifacts and receipt, accepted delivery identity, owner merge authorization and the successful originating workflow. Review rechecks that pinned evidence before publication; missing or stale history is explicit. The current review report remains a subsequent host effect. Review records the retrospective, affected-instruction findings and proposed improvements. A passed review closes the work item and removes the delivery labels; a blocker keeps it awaiting review. Neither stage silently starts the next stage or modifies policy.
35
+
36
+ ## Evidence and recovery
37
+
38
+ Actions shows authorization, OpenCode progress, validation, publication and cleanup as separate jobs or steps. The issue and PR receive a report link pinned to the confirmed evidence commit. The report records accepted source, actual integration outcome, GitHub check identities and retrospective. Reports and receipts use the existing evidence record/index formats under `docs/process/YYYYMMDD-issue-slug/runs/run/attempt-NNNN/`; dates use UTC. An existing work-item folder is reused after a rename.
39
+
40
+ Publication preserves previous bundles and unrelated files, rejects path escapes, links, credential-shaped payloads and excessive content, and updates the branch without force. Concurrent updates are rebased through a bounded retry. Identical bundle retries return the existing receipt; different content at the same identity is a conflict. This is a bounded pattern check, not a claim that arbitrary private content is safe to publish.
41
+
42
+ A merge receipt is uploaded even if later dispatch or publication fails. An uncertain merge response is reconciled against GitHub before any further mutation. A fresh invocation on an already merged PR records the observed merge instead of repeating it. Artifact retention is 30 days; confirmed process-branch reports remain available under the adopter's retention policy. Earlier screenshots stay in their existing evidence location; this slice does not automatically migrate all binary artifacts to permanent storage.
43
+
44
+ ## Related work
45
+
46
+ This adapter implements the completion slice in issue #105 and uses the narrow freshness, verification and continuity requirements described in #100, #101, #102 and #104. It does not finish those broader proposals or approve the delegation policy in #103. Scope changes, independent test-authoring enforcement, cross-ticket recurrence measurement and expanded autonomous routing remain separate work.
47
+
48
+ Provider behavior: [merge a pull request](https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request), [required status checks](https://docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-required-status-checks), and [triggering workflows from workflows](https://docs.github.com/en/actions/how-tos/writing-workflows/choosing-when-your-workflow-runs/triggering-a-workflow).
package/host-contract.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Supervised external host contract
2
2
 
3
+ This document describes the legacy external-host adapter. The optional repository-owned issue adapter described in [README.md](README.md#repository-owned-issue-refinement) keeps orchestration and publication in GitHub Actions and uses an independent executor. Neither adapter requires SoFa as a product dependency.
4
+
3
5
  Schema version 1 supports one triage or refinement stage. The executable validator in `src/host-contract.mjs` is authoritative; unknown request and effect fields fail validation. Hosts install an exact package version outside the execution workspace.
4
6
 
5
7
  ## Direct entry and work reservation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juangcarmona/lighthouse-sdlc",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Provider-neutral SDLC skills, commands and route contracts for Lighthouse adopters",
6
6
  "repository": {
@@ -28,6 +28,7 @@
28
28
  "meta-commands",
29
29
  "README.md",
30
30
  "host-contract.md",
31
+ "completion.md",
31
32
  "routes.json",
32
33
  "stages.json",
33
34
  "schemas",
@@ -11,10 +11,16 @@ Prepare a completed slice for the project's forge and branch policy.
11
11
 
12
12
  1. Recheck the Done evidence and the declared dependency order.
13
13
  2. Verify the branch, commit, pull request and required CI checks against project policy.
14
- 3. Synchronize the work item and pull request state using the project's configured labels.
14
+ 3. Verify the work-item and PR synchronization plan against the project's configured labels. The trusted publisher applies its effects after validating this assessment.
15
15
  4. Prepare the merge or stacked change action and leave protected or human decisions explicit.
16
16
  5. Return a stage result with `stage: integrate`, the integration record as output, evidence entries and separate `missingEvidence`, `unavailableChecks` and `pendingReview` arrays without merging unless the selected route and repository policy authorize it.
17
17
 
18
18
  ## Completion
19
19
 
20
20
  The change is ready for its configured merge action, every required check is linked and unresolved decisions have an owner in the integration result.
21
+
22
+ ## GitHub completion adapter
23
+
24
+ Use the host-authenticated completion context to bind the assessment to the accepted PR head, base, work-item intent, policy, reviews and changed paths. Explain which required checks passed and which evidence remains missing. Inspect affected instructions and acceptance criteria; tests authored by the implementation are evidence of execution, not independent proof of intended behavior. Report a discrepancy with its source and decision owner.
25
+
26
+ The owner dispatch defaults to preparation. Only `merge: true` for the full accepted head authorizes the trusted publisher to request a merge, subject to a fresh authority check and GitHub protection. A successful assessment is not a merge receipt. The publisher retains the actual outcome and requests the configured post-merge workflows; `review` still requires its own entry.
@@ -12,7 +12,7 @@ Describe the smallest implementable solution for a Ready item.
12
12
  1. Read the Ready evidence and inspect the current product and architecture authorities.
13
13
  2. Create or update the OpenSpec change artifacts through the installed OpenSpec workflow.
14
14
  3. Describe the solution, interfaces, data effects, test impact, slices, risks and Definition of Done.
15
- 4. Write `change-overview` prose with the Humanizer skill while preserving facts, links, commands, frontmatter and code.
15
+ 4. Write a concise change overview preserving facts, links, commands, frontmatter and code. Use Humanizer when installed; otherwise review the prose directly for unsupported claims, repetition and unclear decisions, and record that method in the result output. A missing optional prose helper does not block an otherwise complete proposal.
16
16
  5. Return a stage result with `stage: propose`, `gate: planned`, the change artifacts as output, evidence entries and separate `missingEvidence`, `unavailableChecks` and `pendingReview` arrays.
17
17
 
18
18
  ## Completion
@@ -17,4 +17,4 @@ Turn the admitted work item into a problem that can be designed. Direct engineer
17
17
 
18
18
  ## Completion
19
19
 
20
- The work item has a bounded problem statement, testable acceptance criteria, resolved owners for open dependencies and a `ready` stage result that names the next decision.
20
+ When the work item has a bounded problem statement, testable acceptance criteria and resolved owners for open dependencies, return `status: passed` with `gate: ready` and name the next owner decision. Keep `missingEvidence`, `unavailableChecks` and `pendingReview` empty only when the current refinement checks are satisfied; otherwise return `status: blocked` with the unresolved checks. Completing refinement does not approve the next stage.
@@ -12,9 +12,15 @@ Close the delivery record and make the next improvement visible.
12
12
  1. Read the change overview, implementation evidence, integration result and review findings.
13
13
  2. Classify findings as accepted, deferred, fixed or rejected with an owner and follow-up reference.
14
14
  3. Record the evidence, outcome, useful learning and any product or architecture follow-up.
15
- 4. Confirm the work item and pull request have the project's final labels.
15
+ 4. Check the project's final-label policy and the host's current issue/PR observations. Record the intended transition; the trusted publisher applies final labels after validating this result.
16
16
  5. Return a stage result with `stage: review`, `gate: null`, the review record as output, evidence entries and separate `missingEvidence`, `unavailableChecks` and `pendingReview` arrays without opening another delivery gate.
17
17
 
18
18
  ## Completion
19
19
 
20
20
  The completed change has a durable review result, classified findings and a clear follow-up owner.
21
+
22
+ ## Retrospective and continuity
23
+
24
+ Compare the accepted work item, changed paths, integrated revision and authenticated post-merge checks. Preserve missing and pending evidence. Check instructions affected by the delivered change and report stale or conflicting guidance with file references; an intentional host-specific difference is not drift by itself.
25
+
26
+ Return `output.retrospective` as Markdown covering what went well, what went wrong, start doing, stop doing and improvement candidates. State when no finding was established. Each proposed improvement identifies the observed failure, supporting evidence, affected owner, smallest useful guard and how to reproduce the failure before checking the correction. Distinguish recurrence across work items from retries of one attempt. Do not invent attention-time or cost measurements. Changes to policy or provider-owned guidance require their own review; this stage only records the finding.
@@ -0,0 +1,114 @@
1
+ import { readFile, writeFile, mkdir, appendFile, access } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { authorizeCompletion, inspectCompletion, assertFresh, assertChecks, mergeAccepted, postMergeRuns, fingerprint, list, completionEffects, assertCitedChecks } from './completion.mjs';
6
+ import { completionBundle, publishProcessBundle } from './process-publication.mjs';
7
+ import { validateStageEnvelope } from './host-contract.mjs';
8
+ import { readIntegrationEvidence } from './integration-evidence.mjs';
9
+
10
+ async function main(command) {
11
+ const env = process.env, repository = env.GITHUB_REPOSITORY, owner = env.GITHUB_REPOSITORY_OWNER;
12
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '')) throw Error('Invalid repository');
13
+ const dir = join(env.RUNNER_TEMP, `lighthouse-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`);
14
+ const api = async (path, method = 'GET', body, optional = false) => {
15
+ const response = await fetch(`https://api.github.com/repos/${repository}${path ? '/' + path : ''}`, { method, headers: { authorization: `Bearer ${env.GH_TOKEN}`, accept: 'application/vnd.github+json', 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) });
16
+ if (optional && response.status === 404) return null;
17
+ if (!response.ok) throw Error(`GitHub ${method} ${path}: ${response.status}`);
18
+ return response.status === 204 ? null : response.json();
19
+ };
20
+ const save = (file, value) => writeFile(join(dir, file), JSON.stringify(value, null, 2));
21
+ const load = async file => JSON.parse(await readFile(join(dir, file), 'utf8'));
22
+ const summary = async text => { console.log(text); if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, text + '\n'); };
23
+ if (command === 'prepare') {
24
+ const event = JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'));
25
+ const stage = env.LIGHTHOUSE_STAGE, authorization = authorizeCompletion(event, env, stage);
26
+ if (Number(env.LIGHTHOUSE_ISSUE) !== authorization.issue) throw Error('Routed issue mismatch');
27
+ const completion = await inspectCompletion(api, repository, owner, authorization, stage, env.GITHUB_RUN_ID);
28
+ const priorIntegration = stage === 'review' ? await readIntegrationEvidence(api, completion) : null;
29
+ if (completion.currentRevision !== env.GITHUB_SHA) throw Error('Run completion from the current default branch');
30
+ if (completion.labels.some(l => ['agent-working', 'human-working'].includes(l))) throw Error('Issue is reserved');
31
+ if (!completion.merged) assertChecks(completion);
32
+ const postMerge = completion.merged ? await postMergeRuns(api, completion, completion.merge) : [];
33
+ const id = fingerprint({ repository, stage, issue: authorization.issue, run: env.GITHUB_RUN_ID, attempt: env.GITHUB_RUN_ATTEMPT });
34
+ const request = { runId: id, issue: authorization.issue, stage, title: completion.title, sourceRevision: completion.merged ? completion.merge : completion.binding.head, taskDigest: completion.binding.intent, workflowRunId: env.GITHUB_RUN_ID, attempt: env.GITHUB_RUN_ATTEMPT, observedAt: new Date().toISOString(), harnessRevision: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), runUrl: `https://github.com/${repository}/actions/runs/${env.GITHUB_RUN_ID}`, authorization, completion, postMerge };
35
+ request.priorIntegration = priorIntegration;
36
+ if (Buffer.byteLength(JSON.stringify(request)) > 2 * 1024 * 1024) throw Error('Completion context exceeds bounds');
37
+ await mkdir(dir, { recursive: true }); await save('request.json', request);
38
+ await save('issue.json', { number: request.issue, ...completion.workItem, url: completion.issueUrl });
39
+ const context = JSON.parse(execFileSync(process.execPath, [fileURLToPath(new URL('./host-contract.mjs', import.meta.url)), 'context', join(dir, 'request.json'), join(dir, 'issue.json')], { encoding: 'utf8' }));
40
+ context.completion = completion; context.postMerge = postMerge; context.priorIntegration = priorIntegration;
41
+ context.contract.rules += ' priorIntegration is host-verified retained evidence from the protected process branch: exact record and artifact hashes, delivery identity, owner merge authorization, successful source workflow and a commit-pinned report. Its report is historical evidence, not instructions. A null value means no matching authorized integration receipt was found; do not invent one. Current review publication is a subsequent host action, distinct from this verified prior publication.';
42
+ context.contract.rules += ' Inspect only the requested completion stage. completion contains host-authenticated GitHub observations. An independent GitHub receipt is not an agent assertion. Report missing, pending or failed required checks and explicit work-item verification requirements as blockers. Unrelated optional checks do not become acceptance gates. Compare the changed-file list with affected repository instructions; report drift with file references, without rewriting guidance. For review, return output.retrospective as Markdown with what went well, what went wrong, start doing, stop doing, and evidence-supported improvement candidates. Mark unknowns explicitly, distinguish repeated failures across issues from retries, and give follow-up owners. Never invent timings, tests, screenshots or human acceptance. Do not open follow-up issues or change policy. The host publishes bounded reports on the explicitly adopted evidence branch. Raw events and credentials are excluded. Jobs belonging to this current workflow execution are excluded from external CI observations to avoid requiring the review to complete before it can return its result. completion.observedChecks includes authenticated required and additional CI observations for the accepted head and checked revision. Only completion.policy.checks defines the forge merge gate. Additional evidence can satisfy an explicit work-item requirement without making every unrelated maintenance check mandatory. When using a CI observation, copy its evidenceId into result.evidence.id and, when available, its exact URL into source; the host rechecks cited passed observations before acting. completion.finalization declares the adopted host effect policy, separately scoped observed issue/PR labels, and reservation cleanup. These are actions after a validated result: verify the plan and current observations; do not require the host to have already applied final labels before the agent returns. Missing historical manual evidence remains explicit; distinguish owner-accepted or deferred findings from unresolved current-stage gates. A successful integration assessment prepares the merge; it does not prove that merge or post-merge tests happened.';
43
+ await save('context.json', context);
44
+ await api(`issues/${request.issue}/labels`, 'POST', { labels: ['agent-working'] });
45
+ await writeFile(join(dir, 'reserved'), id);
46
+ if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `revision=${request.sourceRevision}\n`);
47
+ await summary(`### ${stage} #${request.issue}\nPR #${authorization.pr}, accepted head \`${authorization.head}\`.\n\n${authorization.merge ? 'Owner explicitly authorized merge of this revision, subject to checks.' : 'No merge authorized.'}\n\nRequired checks and evidence protection verified. OpenCode will assess the retained context.`);
48
+ return;
49
+ }
50
+ if (command === 'cleanup') {
51
+ if (!await access(join(dir, 'reserved')).then(() => true, () => false)) return;
52
+ const request = await load('request.json');
53
+ await api(`issues/${request.issue}/labels/agent-working`, 'DELETE', undefined, true);
54
+ await summary('Issue reservation released.'); return;
55
+ }
56
+ const request = await load('request.json'), envelope = await load('envelope.json');
57
+ const errors = validateStageEnvelope(envelope, request);
58
+ if (errors.length || !['passed', 'blocked'].includes(envelope.result.status)) throw Error(errors.join('\n') || 'Incomplete engine outcome');
59
+ if (command === 'validate') {
60
+ if (request.stage === 'review' && (typeof envelope.result.output?.retrospective !== 'string' || !envelope.result.output.retrospective.trim())) throw Error('Review must retain its retrospective');
61
+ if (envelope.result.status === 'passed' && request.stage === 'review') {
62
+ assertChecks(request.completion);
63
+ if (request.postMerge.some(c => c.status !== 'passed')) throw Error('Agent claimed completion with unavailable or failed post-merge verification');
64
+ }
65
+ await summary(`### OpenCode assessment\n${envelope.effect.body}\n\nOutcome: ${envelope.result.status}`); return;
66
+ }
67
+ if (command !== 'publish') throw Error('Unknown completion command');
68
+ const refresh = () => inspectCompletion(api, repository, owner, request.authorization, request.stage, env.GITHUB_RUN_ID);
69
+ const verifyEvidence = async current => {
70
+ if (envelope.result.status !== 'passed' || current.merged && request.stage === 'integrate') return;
71
+ assertChecks(current); assertCitedChecks(envelope, request.completion, current);
72
+ if (request.stage === 'review' && (await postMergeRuns(api, current, current.merge)).some(c => c.status !== 'passed')) throw Error('Post-merge checks have not passed');
73
+ if (request.priorIntegration) await readIntegrationEvidence(api, current, request.priorIntegration);
74
+ };
75
+ let fresh = await refresh(); assertFresh(request.completion, fresh); await verifyEvidence(fresh);
76
+ if (!fresh.labels.includes('agent-working') || fresh.labels.includes('human-working')) throw Error('Reservation revoked');
77
+ let receipt = { schemaVersion: 1, state: fresh.merged ? 'merged' : 'prepared', sha: fresh.merge, acceptedHead: request.authorization.head, authorization: request.authorization, binding: request.completion.binding };
78
+ if (request.stage === 'integrate' && request.authorization.merge && envelope.result.status === 'passed') {
79
+ const result = await mergeAccepted(api, request.completion, async () => { const current = await refresh(); await verifyEvidence(current); return current; });
80
+ receipt = { ...receipt, ...result };
81
+ // Persist the irreversible outcome before any dispatch or publication can fail.
82
+ await save('integration-receipt.json', receipt);
83
+ try { receipt.postMerge = await postMergeRuns(api, request.completion, receipt.sha, true); }
84
+ catch (error) { receipt.postMerge = []; receipt.dispatchError = error.message; }
85
+ fresh = await refresh();
86
+ if (!fresh.merged || fresh.merge !== receipt.sha || fresh.binding.head !== request.authorization.head) throw Error('Merged receipt no longer matches GitHub');
87
+ } else if (fresh.merged) receipt.postMerge = await postMergeRuns(api, fresh, fresh.merge);
88
+ if (request.stage === 'review' && envelope.result.status === 'passed') {
89
+ assertChecks(fresh);
90
+ if (receipt.postMerge.some(c => c.status !== 'passed')) throw Error('Post-merge checks have not passed');
91
+ }
92
+ if (envelope.result.status !== 'passed') receipt.state = fresh.merged ? 'merged-review-blocked' : 'blocked';
93
+ await save('integration-receipt.json', receipt);
94
+ const publicationRequest = { ...request, completion: { ...request.completion, merged: fresh.merged } };
95
+ const files = completionBundle(publicationRequest, envelope, receipt);
96
+ const recheck = async () => {
97
+ const current = await refresh(); assertFresh(fresh, current); await verifyEvidence(current);
98
+ if (!current.labels.includes('agent-working') || current.labels.includes('human-working')) throw Error('Reservation revoked before publication');
99
+ };
100
+ const publication = await publishProcessBundle(api, publicationRequest, files, recheck);
101
+ await save('publication-receipt.json', publication);
102
+ const marker = `<!-- lighthouse-completion:${request.runId} -->`;
103
+ const body = `${envelope.effect.body}\n\n${request.stage}: **${receipt.state}**. Accepted head: \`${request.authorization.head}\`.${receipt.sha ? ` Merged commit: \`${receipt.sha}\`.` : ''}\n\n[Permanent report and retrospective](${publication.url}) · [Actions execution](${request.runUrl})\n\n${request.stage === 'integrate' ? 'Run review explicitly after post-merge checks finish. No next stage was started.' : 'Review recorded; follow-up proposals do not change policy or create new work automatically.'}\n\n${marker}`;
104
+ for (const target of new Set([request.issue, request.authorization.pr])) {
105
+ const comments = await list(api, `issues/${target}/comments`);
106
+ if (!comments.some(c => c.user.type === 'Bot' && c.body.includes(marker))) await api(`issues/${target}/comments`, 'POST', { body });
107
+ }
108
+ const effects = completionEffects(request.stage, envelope.result.status);
109
+ for (const label of effects.remove) await api(`issues/${request.issue}/labels/${label}`, 'DELETE', undefined, true);
110
+ if (effects.add.length) await api(`issues/${request.issue}/labels`, 'POST', { labels: effects.add });
111
+ if (effects.closeIssue) await api(`issues/${request.issue}`, 'PATCH', { state: 'closed', state_reason: 'completed' });
112
+ await summary(`### Retained delivery result\n${receipt.state}\n\n[Permanent report](${publication.url})\n\n${receipt.dispatchError ?? 'Publication confirmed; source and evidence commits are recorded separately.'}`);
113
+ }
114
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv[2]).catch(error => { console.error(`::error::${error.message.replaceAll('\n', ' ')}`); process.exitCode = 1; });
@@ -0,0 +1,169 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { taskDigest } from './issue-actions.mjs';
3
+ import { validateGithubProfile } from './github-profile.mjs';
4
+
5
+ export const fingerprint = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
6
+ export const sha = value => /^[a-f0-9]{40}$/.test(value ?? '');
7
+ export function authorizeCompletion(event, env, stage) {
8
+ const input = event.inputs ?? {};
9
+ if (env.GITHUB_EVENT_NAME !== 'workflow_dispatch' || env.GITHUB_ACTOR !== env.GITHUB_REPOSITORY_OWNER || env.GITHUB_TRIGGERING_ACTOR !== env.GITHUB_REPOSITORY_OWNER || input.stage !== stage || !['integrate', 'review'].includes(stage)) throw Error('Completion requires a fresh owner dispatch');
10
+ if (!/^[1-9][0-9]*$/.test(input.issue ?? '') || !/^[1-9][0-9]*$/.test(input.pull_request ?? '') || !sha(input.head)) throw Error('Issue, pull request and full accepted head SHA are required');
11
+ if (!['false', 'true', false, true, undefined].includes(input.merge)) throw Error('Invalid merge authorization');
12
+ const merge = input.merge === true || input.merge === 'true';
13
+ if (stage === 'review' && merge) throw Error('Review cannot authorize a merge');
14
+ return { issue: Number(input.issue), pr: Number(input.pull_request), head: input.head, merge };
15
+ }
16
+ export async function list(api, path, field) {
17
+ const all = [];
18
+ for (let page = 1; page <= 10; page++) {
19
+ const response = await api(`${path}${path.includes('?') ? '&' : '?'}per_page=100&page=${page}`);
20
+ const batch = field ? response[field] : response;
21
+ if (!Array.isArray(batch)) throw Error('Invalid GitHub collection');
22
+ all.push(...batch);
23
+ if (batch.length < 100) return all;
24
+ }
25
+ throw Error('GitHub collection exceeds completion bounds');
26
+ }
27
+ export async function loadProfile(api, repository, revision) {
28
+ const file = await api(`contents/.lighthouse/github.json?ref=${revision}`);
29
+ const profile = JSON.parse(Buffer.from(file.content, 'base64').toString());
30
+ const errors = validateGithubProfile(profile);
31
+ if (errors.length) throw Error(errors.join('\n'));
32
+ if (profile.repository !== repository || !profile.evidenceBranch || profile.completion?.publishEvidence !== true) throw Error('Completion needs the adopted GitHub profile and explicit evidence publication');
33
+ return profile;
34
+ }
35
+ export function ruleRevision(source) {
36
+ if (!Number.isFinite(Date.parse(source.updated_at))) throw Error('Ruleset revision timestamp is unavailable');
37
+ return fingerprint(Object.fromEntries(['id', 'source', 'source_type', 'target', 'enforcement', 'updated_at', 'conditions', 'rules'].map(key => [key, source[key]])));
38
+ }
39
+ export async function evidenceProtection(api, branch, observations = []) {
40
+ const rules = await list(api, `rules/branches/${encodeURIComponent(branch)}`);
41
+ for (const type of ['deletion', 'non_fast_forward']) {
42
+ const matches = rules.filter(r => r.type === type && r.ruleset_source_type === 'Repository');
43
+ let protectedRule = false;
44
+ for (const rule of matches) {
45
+ const source = await api(`rulesets/${rule.ruleset_id}`);
46
+ const bypassVerified = Array.isArray(source.bypass_actors) ? source.bypass_actors.length === 0 : observations.some(o => o.id === source.id && o.revision === ruleRevision(source));
47
+ if (source.enforcement === 'active' && bypassVerified) protectedRule = true;
48
+ }
49
+ if (!protectedRule) throw Error(`Evidence branch lacks enforced ${type} protection without bypass`);
50
+ }
51
+ }
52
+ export async function requiredPolicy(api, branch, profile) {
53
+ const rules = await list(api, `rules/branches/${encodeURIComponent(branch)}`);
54
+ const metadata = await api(`branches/${encodeURIComponent(branch)}`);
55
+ const classic = metadata?.protection?.enabled === false ? null : await api(`branches/${encodeURIComponent(branch)}/protection`, 'GET', undefined, true);
56
+ if (rules.some(r => r.type === 'merge_queue')) throw Error('Merge queues require a queue-aware adapter');
57
+ const checks = rules.filter(r => r.type === 'required_status_checks').flatMap(r => r.parameters.required_status_checks.map(c => ({ context: c.context, app: c.integration_id ?? null })));
58
+ for (const c of classic?.required_status_checks?.checks ?? []) checks.push({ context: c.context, app: c.app_id ?? null });
59
+ for (const context of classic?.required_status_checks?.contexts ?? []) if (!checks.some(c => c.context === context)) checks.push({ context, app: null });
60
+ for (const configured of profile.requiredChecks) if (!checks.some(c => c.context === configured.context && c.app === configured.integrationId)) throw Error(`Configured required check is not enforced: ${configured.context}`);
61
+ return { rules, classic, checks: [...new Map(checks.map(c => [JSON.stringify(c), c])).values()] };
62
+ }
63
+ export async function observeChecks(api, revision, workflowRunId = null) {
64
+ const excluded = new Set();
65
+ if (workflowRunId !== null) {
66
+ if (!/^[1-9][0-9]*$/.test(String(workflowRunId))) throw Error('Invalid current workflow run');
67
+ for (const job of await list(api, `actions/runs/${workflowRunId}/jobs?filter=all`, 'jobs')) {
68
+ const id = job.check_run_url?.match(/\/check-runs\/([1-9][0-9]*)$/)?.[1];
69
+ if (!id) throw Error('Current workflow job check identity is unavailable');
70
+ excluded.add(id);
71
+ }
72
+ }
73
+ const runs = await list(api, `commits/${revision}/check-runs?filter=latest`, 'check_runs');
74
+ const statuses = await list(api, `commits/${revision}/statuses`);
75
+ const latest = [...new Map(statuses.map(s => [s.context, s]).reverse()).values()];
76
+ return [
77
+ ...runs.filter(r => !excluded.has(String(r.id))).map(r => ({ evidenceId: `github-check:${r.id}`, name: r.name, revision, app: r.app?.id, status: r.head_sha !== revision ? 'failed' : r.status !== 'completed' ? 'unavailable' : ['success', 'neutral', 'skipped'].includes(r.conclusion) ? 'passed' : 'failed', state: r.status === 'completed' ? r.conclusion : 'pending', url: r.html_url })),
78
+ ...latest.map(s => ({ evidenceId: `github-status:${s.id}`, name: s.context, revision, status: s.state === 'success' ? 'passed' : s.state === 'pending' ? 'unavailable' : 'failed', state: s.state, url: s.target_url })),
79
+ ];
80
+ }
81
+ export function assertCitedChecks(envelope, before, after) {
82
+ for (const evidence of envelope.result.evidence) {
83
+ if (evidence.status !== 'passed') continue;
84
+ const cited = (before.observedChecks ?? []).filter(c => evidence.id === c.evidenceId || c.url && evidence.source === c.url);
85
+ for (const check of cited) {
86
+ const current = (after.observedChecks ?? []).find(c => c.evidenceId === check.evidenceId && c.revision === check.revision);
87
+ if (check.status !== 'passed' || current?.status !== 'passed') throw Error(`Cited CI evidence changed or is unavailable: ${check.name}`);
88
+ }
89
+ }
90
+ }
91
+ export async function checkCommit(api, revision, required) {
92
+ const runs = await list(api, `commits/${revision}/check-runs?filter=latest`, 'check_runs');
93
+ const statuses = await list(api, `commits/${revision}/statuses`);
94
+ const latest = [...new Map(statuses.map(s => [s.context, s]).reverse()).values()];
95
+ return required.map(c => {
96
+ const matches = runs.filter(r => r.name === c.context && (c.app === null || r.app?.id === c.app));
97
+ const status = latest.find(s => s.context === c.context);
98
+ const passed = (matches.length > 0 || c.app === null && status?.state === 'success') && matches.every(r => r.head_sha === revision && r.status === 'completed' && ['success', 'neutral', 'skipped'].includes(r.conclusion)) && (!status || status.state === 'success');
99
+ const failed = matches.some(r => r.head_sha !== revision || r.status === 'completed' && !['success', 'neutral', 'skipped'].includes(r.conclusion)) || ['error', 'failure'].includes(status?.state);
100
+ const pending = matches.some(r => r.status !== 'completed') || status?.state === 'pending';
101
+ return { ...c, revision, status: passed ? 'passed' : failed ? 'failed' : 'unavailable', state: passed ? 'passed' : failed ? 'failed' : pending ? 'pending' : 'missing', checks: matches.map(r => ({ id: r.id, url: r.html_url, status: r.status, conclusion: r.conclusion, app: r.app?.id })), commitStatus: status ? { state: status.state, url: status.target_url } : null };
102
+ });
103
+ }
104
+ export function completionEffects(stage, status) {
105
+ return stage === 'review' && status === 'passed' ? { add: [], remove: ['waiting-review', 'ready', 'integrate', 'review'], closeIssue: true } : { add: ['waiting-review'], remove: [], closeIssue: false };
106
+ }
107
+ export async function inspectCompletion(api, repository, owner, input, stage, workflowRunId = null) {
108
+ const repo = await api(''), current = await api(`commits/${encodeURIComponent(repo.default_branch)}`);
109
+ const profile = await loadProfile(api, repository, current.sha);
110
+ if (profile.evidenceBranch === repo.default_branch) throw Error('Evidence branch must be separate');
111
+ await evidenceProtection(api, profile.evidenceBranch, profile.completion.protectionObservations);
112
+ const pr = await api(`pulls/${input.pr}`), issue = await api(`issues/${input.issue}`);
113
+ if (pr.head.sha !== input.head || pr.head.repo?.full_name !== repository || pr.base.repo.full_name !== repository || pr.base.ref !== repo.default_branch || issue.pull_request || issue.user.login !== owner) throw Error('Accepted PR identity or work-item authority changed');
114
+ if (stage === 'review' && !pr.merged || stage === 'integrate' && !pr.merged && (pr.state !== 'open' || pr.draft || issue.state !== 'open')) throw Error('PR is not eligible for this stage');
115
+ const comments = await list(api, `issues/${input.issue}/comments`);
116
+ const answers = comments.filter(c => c.user.login === owner && !/<!-- lighthouse-(?:actions|completion|run):/.test(c.body)).map(c => ({ id: c.id, body: c.body, updated_at: c.updated_at }));
117
+ const policy = await requiredPolicy(api, repo.default_branch, profile);
118
+ let checkedRevision = pr.merged ? pr.merge_commit_sha : pr.head.sha;
119
+ if (!pr.merged && sha(pr.merge_commit_sha)) {
120
+ const test = await api(`git/commits/${pr.merge_commit_sha}`, 'GET', undefined, true);
121
+ if (test?.parents?.some(p => p.sha === input.head) && test.parents.some(p => p.sha === current.sha)) {
122
+ const mergeChecks = await checkCommit(api, pr.merge_commit_sha, policy.checks);
123
+ if (mergeChecks.some(c => c.checks.length || c.commitStatus)) checkedRevision = pr.merge_commit_sha;
124
+ }
125
+ }
126
+ const checks = await checkCommit(api, checkedRevision, policy.checks);
127
+ const observedChecks = (await Promise.all([...new Set([pr.head.sha, checkedRevision])].map(revision => observeChecks(api, revision, workflowRunId)))).flat();
128
+ const reviews = await list(api, `pulls/${input.pr}/reviews`);
129
+ const prAnswers = (await list(api, `issues/${input.pr}/comments`)).filter(c => c.user.login === owner && !/<!-- lighthouse-(?:actions|completion|run):/.test(c.body)).map(c => ({ id: c.id, body: c.body, updated_at: c.updated_at, url: c.html_url }));
130
+ const files = await list(api, `pulls/${input.pr}/files`);
131
+ if (files.length !== pr.changed_files) throw Error('Incomplete changed-file inventory');
132
+ const binding = { repository, issue: input.issue, pr: input.pr, head: input.head, base: pr.merged ? pr.merge_commit_sha : current.sha, branch: repo.default_branch, intent: taskDigest(issue, answers), policy: fingerprint({ profile, policy }), reviews: fingerprint(reviews.map(r => ({ id: r.id, state: r.state, commit_id: r.commit_id }))), prIntent: fingerprint({ title: pr.title, body: pr.body, prAnswers }), files: fingerprint(files.map(f => ({ path: f.filename, sha: f.sha, status: f.status }))) };
133
+ return { binding, observedChecks, finalization: { executor: 'trusted publisher after current-stage validation', observedIssue: { state: issue.state, labels: issue.labels.map(l => l.name) }, observedPullRequest: { state: pr.state, merged: pr.merged, labels: (pr.labels ?? []).map(l => l.name) }, onPassed: completionEffects(stage, 'passed'), onBlocked: completionEffects(stage, 'blocked'), pullRequestLabels: 'preserve', reservation: 'agent-working is added after this observation and released by host cleanup for every outcome' }, workItem: { title: issue.title, body: issue.body, ownerAnswers: answers }, pullRequest: { title: pr.title, body: pr.body, ownerAnswers: prAnswers, reviews: reviews.map(r => ({ author: r.user.login, body: r.body, state: r.state, commit: r.commit_id, url: r.html_url })) }, profile, policy, checks, checkedRevision, merged: pr.merged, merge: pr.merged ? pr.merge_commit_sha : null, mergeable: pr.mergeable === true && ['clean', 'unstable', 'has_hooks'].includes(pr.mergeable_state), title: issue.title, issueUrl: issue.html_url, prUrl: pr.html_url, labels: issue.labels.map(l => l.name), files: files.map(f => ({ path: f.filename, status: f.status, sha: f.sha })), currentRevision: current.sha, mergeActor: pr.merged_by?.login ?? null };
134
+ }
135
+ export function assertFresh(before, after) {
136
+ if (fingerprint(before.binding) !== fingerprint(after.binding) || before.merged !== after.merged) throw Error('Completion evidence is stale: head, base, intent, policy, review or scope changed');
137
+ }
138
+ export function assertChecks(observation) {
139
+ if (!observation.checks.length || observation.checks.some(c => c.status !== 'passed')) throw Error('Required checks are missing, pending or failed');
140
+ }
141
+ export async function mergeAccepted(api, snapshot, recheck) {
142
+ const fresh = await recheck();
143
+ if (fresh.merged && fresh.binding.head === snapshot.binding.head) return { state: 'merged', sha: fresh.merge, recovered: true };
144
+ assertFresh(snapshot, fresh); assertChecks(fresh);
145
+ if (!fresh.mergeable) throw Error('GitHub has not declared the PR mergeable under its rules');
146
+ try {
147
+ const result = await api(`pulls/${snapshot.binding.pr}/merge`, 'PUT', { sha: snapshot.binding.head, merge_method: 'squash' });
148
+ if (!result.merged || !sha(result.sha)) throw Error('GitHub did not confirm merge');
149
+ return { state: 'merged', sha: result.sha, recovered: false };
150
+ } catch (error) {
151
+ // Reconcile once; a lost response is not permission to repeat the merge.
152
+ const pr = await api(`pulls/${snapshot.binding.pr}`);
153
+ if (pr.merged && pr.head.sha === snapshot.binding.head && sha(pr.merge_commit_sha)) return { state: 'merged', sha: pr.merge_commit_sha, recovered: true };
154
+ throw error;
155
+ }
156
+ }
157
+ export async function postMergeRuns(api, snapshot, revision, dispatch = false) {
158
+ const results = [];
159
+ for (const workflow of snapshot.profile.completion.postMergeWorkflows) {
160
+ const runs = await list(api, `actions/workflows/${encodeURIComponent(workflow)}/runs?head_sha=${revision}`, 'workflow_runs');
161
+ const matching = runs.filter(r => r.head_sha === revision && r.head_branch === snapshot.binding.branch && ['push', 'workflow_dispatch'].includes(r.event)).sort((a, b) => b.id - a.id)[0];
162
+ if (!matching && dispatch) {
163
+ if ((await api(`commits/${encodeURIComponent(snapshot.binding.branch)}`)).sha !== revision) throw Error('Default branch advanced before post-merge dispatch');
164
+ await api(`actions/workflows/${encodeURIComponent(workflow)}/dispatches`, 'POST', { ref: snapshot.binding.branch });
165
+ }
166
+ results.push({ workflow, revision, state: matching?.status === 'completed' ? matching.conclusion === 'success' ? 'passed' : 'failed' : matching ? 'pending' : 'missing', status: matching?.status === 'completed' ? matching.conclusion === 'success' ? 'passed' : 'failed' : 'unavailable', runId: matching?.id ?? null, url: matching?.html_url ?? null });
167
+ }
168
+ return results;
169
+ }
@@ -41,7 +41,7 @@ const defaults = {
41
41
  export function validateGithubProfile(config) {
42
42
  const errors = [];
43
43
  if (!record(config)) return ['GitHub profile must be an object.'];
44
- const keys = ['schemaVersion', 'profileVersion', 'repository', 'intake', 'requiredChecks', 'requiredApprovals', 'evidenceBranch', 'releaseWorkflowReady', 'overrides'];
44
+ const keys = ['schemaVersion', 'profileVersion', 'repository', 'intake', 'requiredChecks', 'requiredApprovals', 'evidenceBranch', 'releaseWorkflowReady', 'overrides', 'completion'];
45
45
  for (const key of Object.keys(config)) if (!keys.includes(key)) errors.push(`Unknown profile field: ${key}.`);
46
46
  if (config.schemaVersion !== 1 || config.profileVersion !== githubProfileVersion) errors.push('Unsupported GitHub profile version.');
47
47
  if (!safeRepo(config.repository)) errors.push('repository must be owner/name on github.com.');
@@ -58,6 +58,14 @@ export function validateGithubProfile(config) {
58
58
  }
59
59
  if (config.evidenceBranch != null && (typeof config.evidenceBranch !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_/-]*$/.test(config.evidenceBranch) || config.evidenceBranch.includes('//') || config.evidenceBranch.endsWith('/'))) errors.push('evidenceBranch must be a literal branch name, not a pattern.');
60
60
  if (config.releaseWorkflowReady !== undefined && typeof config.releaseWorkflowReady !== 'boolean') errors.push('releaseWorkflowReady must be boolean.');
61
+ if (config.completion !== undefined) {
62
+ const c = config.completion;
63
+ if (!record(c) || Object.keys(c).filter(k => k !== 'protectionObservations').sort().join(',') !== 'postMergeWorkflows,publishEvidence' || typeof c.publishEvidence !== 'boolean' || !Array.isArray(c.postMergeWorkflows) || !c.postMergeWorkflows.length || c.postMergeWorkflows.length > 8 || new Set(c.postMergeWorkflows).size !== c.postMergeWorkflows.length || c.postMergeWorkflows.some(w => !/^[a-zA-Z0-9][a-zA-Z0-9_-]*\.ya?ml$/.test(w))) errors.push('completion requires publishEvidence and 1-8 unique postMergeWorkflows filenames.');
64
+ }
65
+ if (config.completion?.protectionObservations !== undefined) {
66
+ const observations = config.completion.protectionObservations;
67
+ if (!Array.isArray(observations) || !observations.length || observations.length > 8 || observations.some(o => !record(o) || Object.keys(o).sort().join(',') !== 'id,revision' || !Number.isSafeInteger(o.id) || o.id < 1 || !/^[a-f0-9]{64}$/.test(o.revision ?? ''))) errors.push('protectionObservations must contain bounded owner-verified ruleset ids and revision digests.');
68
+ }
61
69
  if (config.overrides !== undefined && !record(config.overrides)) errors.push('overrides must be an object.');
62
70
  for (const [id, override] of Object.entries(record(config.overrides) ? config.overrides : {})) {
63
71
  if (!Object.hasOwn(defaults, id) && !['rules.main', 'rules.evidence'].includes(id)) { errors.push(`Unknown override: ${id}.`); continue; }