gstack/scripts/resolvers/lens-layer.ts

461 lines
19 KiB
TypeScript

/**
* Stakeholder lens resolver for /review.
*
* The resolver emits orchestration instructions. Registry validation, routing,
* bundle bounds, finding validation, reconciliation, stable IDs, synthesis
* validation, and event persistence are implemented by scripts/lenses and the
* bin/gstack-lens-* helpers.
*/
import * as path from 'path';
import type { TemplateContext } from './types';
import { loadLensRegistry, readyLenses } from '../lenses/registry';
import type { LensSpec } from '../lenses/types';
function repoRoot(ctx: TemplateContext): string {
return path.resolve(path.dirname(ctx.tmplPath), '..');
}
function specsFor(ctx: TemplateContext): LensSpec[] {
return loadLensRegistry(repoRoot(ctx));
}
function lensTable(specs: LensSpec[]): string {
return [...specs]
.sort((a, b) => a.lens.localeCompare(b.lens))
.map((spec) => `- \`${spec.lens}\`${spec.cli_aliases.length ? ` (aliases: ${spec.cli_aliases.map((alias) => `\`${alias}\``).join(', ')})` : ''} [${spec.status}]: ${spec.summary}`)
.join('\n');
}
function descriptionBlocks(specs: LensSpec[]): string {
return [...specs].sort((a, b) => a.lens.localeCompare(b.lens)).map((spec) => `### ${spec.lens} [${spec.status}]
${spec.summary}
- Primary skill: ${spec.primary_skill.join(', ')}
- Supported skills: ${spec.supported_skills.join(', ') || 'none'}
- Required artifacts: ${spec.required_artifacts.join(', ') || 'none'}
- Required context: ${spec.required_context.join(', ') || 'none'}
- Scope: ${spec.scope_disclaimer}
- Ranking: ${spec.ranking}`).join('\n\n');
}
export function generateLensEarlyCommands(ctx: TemplateContext): string {
const specs = specsFor(ctx);
return `## Stakeholder lens documentation commands: check before the preamble
Inspect the user's exact invocation before running bash or any other workflow step.
If the invocation contains \`--lens list\`:
1. Print the registry below.
2. Explain that READY lenses run normally, DRAFT lenses require explicit naming plus \`--lens-draft\`, and DEFERRED lenses are specifications only.
3. Stop. Do not run the preamble or review workflow.
${lensTable(specs)}
If the invocation contains \`--lens describe <name>\`:
1. Resolve canonical names and aliases from the registry above.
2. Print the matching block below.
3. Read \`${ctx.paths.skillRoot}/review/lenses/<canonical-name>.md\` and include its "When I use this lens" and "Objective" sections.
4. Stop. Do not run the preamble or review workflow.
${descriptionBlocks(specs)}
These are documentation commands only. Otherwise continue normally.`;
}
export function generateLensPrepare(ctx: TemplateContext): string {
const ready = readyLenses(specsFor(ctx)).map((spec) => spec.lens).join(', ');
if (ctx.host !== 'claude') {
return `## Step 4.4: Stakeholder lens invocation and policy check
Run \`${ctx.paths.binDir}/gstack-lens-route --mode mandatory --base <base>\` to detect project-mandated lenses even when no lens flag was supplied.
If no lens was requested and no mandatory lens matched, continue with the existing review unchanged.
If a lens was requested or a mandatory lens matched, report that stakeholder lens execution is supported on the Claude host only in V0.5. If a mandatory lens matched, stop and mark the review blocked because the configured policy could not be satisfied. READY lenses: ${ready}.`;
}
return `## Step 4.4: Stakeholder lens invocation and policy check
Stakeholder lenses are opt-in unless a checked-in project policy makes a READY lens mandatory for the changed surface.
Recognized invocation forms:
- \`--lens <name-or-comma-list>\`
- \`--lenses recommended\`
- \`--lenses all\`
- \`--lens-only\`
- \`--lens-draft\`
- \`--surface <name-or-comma-list>\`
- \`--no-mandatory-lenses "<reason>"\`
- \`--allow-degraded-lens-isolation\`
Determine the routing mode:
- Explicit \`--lens\`: \`explicit\`
- \`--lenses recommended\`: \`recommended\`
- \`--lenses all\`: \`all\`
- No lens flag: \`mandatory\`
Always run the routing helper before Review Army so a plain \`/review\` honors project policy:
\`\`\`bash
${ctx.paths.binDir}/gstack-lens-route --mode <mode> --base <base> [--requested "<names>"] [--allow-draft] [--surface "<surfaces>"] [--no-mandatory-lenses "<reason>"]
\`\`\`
Translate invocation flags for the helper:
- Pass the value of \`--lens\` through \`--requested\`.
- If the invocation contains \`--lens-draft\`, pass \`--allow-draft\`.
- Pass \`--surface\` values unchanged.
- Pass \`--no-mandatory-lenses\` and its rationale unchanged.
Store the returned JSON as \`LENS_ROUTE\`.
- If \`unmatched_requested\` is non-empty, report the unknown names and stop the lens layer.
- If \`selected\` is empty, set \`LENS_MODE=off\` and continue. The lens layer must not change technical findings, Fix-First classification, or PR Quality Score.
- If \`selected\` is non-empty, set \`LENS_MODE=on\`.
- If \`mandatory_bypassed=true\`, include the recorded rationale in the final lens run header and event log.
- If \`--lens-only\` is present, set \`LENS_ONLY=true\`; otherwise set it false.
When \`LENS_ONLY=true\`, skip the complete Review Army section that immediately follows, including specialist selection, dispatch, merge, red-team dispatch, and quality-score calculation. Resume at Step 4.7. Core Step 4 still runs.
READY lenses in V0.5: ${ready}.
DRAFT lenses require explicit naming plus \`--lens-draft\`. DEFERRED lenses do not execute in V0.5.`;
}
function lensRuntimeForClaude(ctx: TemplateContext): string {
return `## Step 4.7: Stakeholder Lens Layer
**Activation:** Run this section only when \`LENS_MODE=on\`. Otherwise continue to Step 5.
The lens layer is additive decision support. It does not alter the technical PR Quality Score. Every V0.5 lens finding defaults to \`INVESTIGATE\`.
### Step 4.7.0: Confirm only recommended additions
Reuse \`LENS_ROUTE\` from Step 4.4. Do not rerun routing unless the user edits the selected set.
- Explicit lenses do not require confirmation.
- \`--lenses all\` does not require confirmation.
- Mandatory lenses do not require confirmation.
- For \`--lenses recommended\`, ask one confirmation question only for non-mandatory recommendations. The user may edit or skip those additions, but mandatory lenses remain selected unless the invocation supplied \`--no-mandatory-lenses "<reason>"\`.
The routing confirmation does not count against the three-question context budget.
Read \`${ctx.paths.skillRoot}/review/lenses/shared-behavior.md\` and each selected lens file.
### Step 4.7.1: Shared context preflight, maximum three questions total
Build baseline context from:
- The full diff already collected in Step 3
- Relevant surrounding code already read during Step 4
- PR body and labels when available
- \`.gstack/product-context.yaml\` when present
- \`.gstack/lens-policy.yaml\` when present
- Existing project learnings
For every selected lens, read its required artifacts, optional artifacts, required context, and optional context from frontmatter.
Artifact rules:
- \`diff_or_plan\` is satisfied by the current diff.
- A required artifact may be satisfied by an explicit context field, a checked-in artifact whose contents were verified, or repository evidence that clearly represents the artifact.
- Record provenance as \`explicit_context\`, \`checked_in_artifact\`, or \`code_inferred\`.
- Do not infer a required artifact from a filename alone.
- Missing required evidence never becomes an assumption.
Compute the union of missing required fields. Ask no more than three questions total, deduplicated across lenses. Prioritize questions that prevent the largest number of selected lenses from returning \`INSUFFICIENT_EVIDENCE\`.
Each question must state what is missing, which lenses require it, why it changes the review, and the recommended way to provide it.
After three questions, missing required fields remain missing. Missing optional context may become an explicit assumption. Construct a minimal, lens-specific context package. Do not send the complete product-context object to every lens.
### Step 4.7.2: Materialize bounded evidence bundles
The main orchestrator gathers evidence. Lens subagents do not browse the repository.
For each evidence-complete lens, create a JSON object with:
- \`manifest\`: every supplied artifact and its provenance
- \`context\`: only fields declared by that lens
- \`required_missing\`: an empty array
- \`evidence\`: relevant diff hunks, directly relevant surrounding code, and verified artifacts
- \`omissions\`: content omitted for size or relevance, with reason
If required evidence remains missing after preflight, the orchestrator creates the structured \`INSUFFICIENT_EVIDENCE\` result and does not dispatch that lens. Missing foundations are not a model task.
Exclude technical findings, red-team findings, generic adversarial findings, and other lens outputs. Wrap repository-derived text in \`<untrusted_evidence>...</untrusted_evidence>\` inside each evidence entry.
Create a run ID and write each bundle:
\`\`\`bash
printf '%s' '<bundle-json>' | ${ctx.paths.binDir}/gstack-lens-bundle write --run-id <run-id> --lens <lens>
\`\`\`
The helper enforces a 200 KiB limit, directory mode 0700, and file mode 0600. If a bundle exceeds the limit, reduce it by relevance. Do not silently truncate evidence.
### Step 4.7.3: Stage A independent lens dispatch
Use the custom \`gstack-lens-reviewer\` subagent. It has an empty tool allowlist. Custom agents may still receive ambient CLAUDE.md, project memory, and git status from Claude Code, so the task prompt must state that those are out of scope and cannot be used as evidence.
For each evidence-complete lens, read its materialized bundle through the main thread:
\`\`\`bash
${ctx.paths.binDir}/gstack-lens-bundle read --run-id <run-id> --lens <lens>
\`\`\`
Launch all evidence-complete lenses in one message with one foreground Agent call per lens. Use \`subagent_type: "gstack-lens-reviewer"\`.
Each task message contains only:
\`\`\`text
<lens_contract>
<selected lens frontmatter and prompt>
</lens_contract>
<shared_behavior>
<shared behavior text>
</shared_behavior>
<evidence_bundle>
<bundle JSON>
</evidence_bundle>
\`\`\`
Do not pass technical findings, red-team findings, generic adversarial findings, or other lens outputs.
If the custom subagent is unavailable:
- If any selected lens is mandatory, fail closed. Mark the review blocked because policy-required review could not run.
- If no selected lens is mandatory and \`--allow-degraded-lens-isolation\` is absent, report \`LENS_AGENT_UNAVAILABLE\` and continue the technical review without fabricating findings.
- If \`--allow-degraded-lens-isolation\` is present, use a foreground general-purpose subagent with the same bounded task prompt, instruct it not to use tools, and record \`isolation_mode: "behavioral"\`. This is not enforced isolation.
With the custom subagent, record \`isolation_mode: "empty_tool_allowlist"\` and \`ambient_context_inherited: true\`.
#### Safety-output validation
For a lens with a non-null safety directive, invoke the foreground \`gstack-lens-output-validator\` custom subagent over that lens output only. The validator receives no repository evidence. It returns exactly \`SAFE\` or \`UNSAFE: <reason>\`.
If unsafe, discard the output and retry the lens once with the safety directive repeated. If the retry is unsafe, surface \`SAFETY_VALIDATION_FAILED\`, persist a malformed-output event, and exclude the output from disposition.
### Step 4.7.4: Parse Stage A outputs
For each lens, write the raw output to a temporary file and run:
\`\`\`bash
${ctx.paths.binDir}/gstack-lens-parse --lens <lens> --file <raw-output-file> --repo-root "$(pwd)"
\`\`\`
Normalize into one of:
- \`FINDINGS\`
- \`NO_MATERIAL_FINDINGS\`
- \`INSUFFICIENT_EVIDENCE\`
Preserve malformed lines separately. Do not convert prose into findings.
### Step 4.7.5: Stage B structured reconciliation
Create a temporary JSON file:
\`\`\`json
{
"novelty_mode": "production",
"lens_results": [],
"tech_findings": []
}
\`\`\`
- \`lens_results\`: parsed Stage A results
- \`tech_findings\`: core and Review Army findings. Include \`claim_key\`, \`control_or_asset\`, and structured evidence when those fields exist. Under \`--lens-only\`, use an empty array.
- \`generic_adversarial_findings\`: include only when an evaluation harness ran a generic baseline before Stage A. Omit in ordinary production review because the generic adversarial step runs later.
- Set \`novelty_mode: "evaluation"\` only for a labeled evaluation fixture with a complete baseline.
Run:
\`\`\`bash
${ctx.paths.binDir}/gstack-lens-reconcile --from-file <reconcile-input.json> --repo-root "$(pwd)"
\`\`\`
The helper:
- Validates findings against lens frontmatter
- Enforces evidence and materiality thresholds
- Requires \`claim_key\`, \`control_or_asset\`, \`remediation_key\`, and \`remediation_effect\`
- Assigns stable IDs
- Uses exact structured matches for evidence clustering and contradictions
- Returns novelty as \`OVERLAPS_BASELINE\` on an exact structured claim match
- Returns \`AMBIGUOUS\` when the baseline cites the same evidence but lacks comparable structured claim fields
- Returns \`NOT_MEASURED\` for an exact-match miss in production
- Returns \`NOVEL\` only in evaluation mode with a labeled baseline
- Never labels shared evidence as confirmation
- Produces a structured CTO synthesis input only when material or blocking findings span at least two independent lenses
### Step 4.7.6: Stage C CTO synthesis
A CTO is not another lens. The synthesis stage identifies connective tissue across independent stakeholder perspectives.
If reconciliation returns \`synthesis.required=false\`, skip this stage.
If \`synthesis.required=true\`:
1. Write \`synthesis.input\` to a temporary JSON file.
2. Invoke a foreground \`gstack-cto-synthesizer\` subagent with only that structured JSON.
3. The synthesizer cannot read repository content, create new findings, change severity, or silently arbitrate contradictions.
4. Save the raw synthesis output and validate it:
\`\`\`bash
${ctx.paths.binDir}/gstack-lens-synthesis-validate --input <synthesis-input.json> --output <synthesis-output.json>
\`\`\`
If validation fails, report \`SYNTHESIS_INVALID\` and continue with the reconciled findings. Do not discard valid lens findings.
Render validated synthesis under:
- Shared technical primitives
- Reinforcing constraints
- Tensions
- Sequencing
- Decisions required
Every synthesis item must cite supplied finding IDs.
### Step 4.7.7: Render the lens review
Render:
\`\`\`text
=== Stakeholder Lens Review ===
Lenses run: ...
Routing: ...
Mandatory policy: satisfied | bypassed with rationale | not applicable
Context: N questions, sources ...
Isolation: empty-tool allowlist | behavioral degradation
Ambient context inherited: true
Insufficient evidence: ...
Malformed or rejected outputs: ...
Scope disclaimers:
<lens>: <scope>
Top findings:
1. [decision impact, evidence strength, inference status, urgency]
[lens or evidence cluster]
evidence: material claim
frame(s)
recommended action
CTO synthesis:
shared primitives ...
tensions ...
sequencing ...
decisions required ...
\`\`\`
Each lens contributes at most its top finding. A multi-lens evidence cluster is one top-level entry with multiple frames.
Do not include lens findings in the technical PR Quality Score.
### Step 4.7.8: Persist append-only local evaluation events
Persist one \`lens_run\` event, then events for findings, insufficient evidence, malformed output, and validated synthesis:
\`\`\`bash
printf '%s' '<event-json>' | ${ctx.paths.binDir}/gstack-lens-event --repo-root "$(pwd)"
\`\`\`
The event helper writes \`~/.gstack/projects/<slug>/lens-events.jsonl\` with mode 0600, applies retention, and redacts secret-shaped content while preserving instruction-like evidence as local untrusted data. Lens evidence and findings are not sent to standard gstack telemetry or GBrain by this workflow.
Persist cost only when the host reports it or a versioned pricing calculation is available. Otherwise use \`cost_source: "unavailable"\` and \`cost_estimate_usd: null\`.
After Stage A and any synthesis validation, purge the evidence bundles:
\`\`\`bash
${ctx.paths.binDir}/gstack-lens-bundle purge --run-id <run-id>
\`\`\`
Keep reconciled findings, clusters, and synthesis in working context for Step 5e.`;
}
export function generateLensLayer(ctx: TemplateContext): string {
specsFor(ctx);
if (ctx.host !== 'claude') {
return `## Step 4.7: Stakeholder Lens Layer
If \`LENS_MODE=off\`, skip. If \`LENS_MODE=on\`, the current host does not expose the bounded custom-subagent workflow required by V0.5. Report the limitation. If a mandatory lens matched, block review completion; otherwise continue without fabricating lens findings.`;
}
return lensRuntimeForClaude(ctx);
}
export function generateLensDisposition(ctx: TemplateContext): string {
specsFor(ctx);
return `## Step 5e: Stakeholder lens disposition
Run this block after technical Fix-First handling. Skip it when \`LENS_MODE=off\`, no valid lens findings remain, or every lens returned \`NO_MATERIAL_FINDINGS\` or \`INSUFFICIENT_EVIDENCE\`.
Lens findings are decision support and default to \`INVESTIGATE\`. Do not auto-edit code, policy, permissions, disclosures, pricing, retention, approval boundaries, or product scope from a lens finding.
### Group by evidence cluster
- Review findings with an \`evidence_cluster_id\` once per cluster.
- Preserve every lens frame.
- If structured remediation keys converge, offer one action with one reason per lens.
- If remediations differ, present the alternatives separately.
- If \`CONTRADICTION\` is present, state the conflict and the decision required. Do not arbitrate it.
### Blocking findings
Ask about each BLOCKING finding or cluster individually:
- A) Fix now
- B) Track, with rationale
- C) Defer, with rationale
- D) Accept risk, with rationale
- E) Dismiss, with rationale
### Material findings
Ask once per MATERIAL cluster:
- A) Fix now
- B) Track
- C) Defer
- D) Accept risk
- E) Dismiss
### Advisory findings
Advisory findings may be batched:
- A) Track all as TODOs
- B) Review individually
- C) Dismiss all
Global accept-all or dismiss-all is prohibited for BLOCKING and MATERIAL findings.
### Create a real action artifact
When the decision is \`fix_now\` or \`track\`:
1. Read \`.gstack/lens-policy.yaml\` for \`todo_target\`.
2. Supported targets: \`plan_file\`, \`todos_md\`, \`pr_checklist\`, \`issue\`.
3. Default: append to \`TODOS.md\` if it exists. Otherwise print a copy-ready TODO and state that no durable task target is configured.
4. For a convergent evidence cluster, create one TODO with one reason per lens.
5. Do not create a GitHub issue unless \`todo_target: issue\` is explicitly configured and the user approved the disposition.
### Persist dispositions
For each finding in the cluster, append a separate \`disposition\` event with the same decision and TODO reference:
\`\`\`bash
printf '%s' '<disposition-json>' | ${ctx.paths.binDir}/gstack-lens-event --repo-root "$(pwd)"
\`\`\`
Keep \`validity\`, \`relevance\`, \`decision\`, and \`routing_feedback\` separate. Tracking a finding is not proof that it is valid.
After dispositions, print a compact summary of fixed, tracked, deferred, accepted-risk, dismissed, insufficient-evidence, and no-material-findings outcomes.
Lens review completion does not change the technical Eng Review status persisted in Step 5.8. If a mandatory lens failed to run, returned invalid output, or was not dispositioned, the review is not cleared under project lens policy.`;
}