@orchestrator-claude/definitions 3.37.0 → 3.37.2

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 (73) hide show
  1. package/agents/adversarial-reviewer.md +6 -1
  2. package/agents/api-extractor.md +6 -0
  3. package/agents/azure-devops-engineer.md +1 -2
  4. package/agents/business-rule-miner.md +6 -0
  5. package/agents/cicd-platform-engineer.md +1 -2
  6. package/agents/ciclo-engineer.md +50 -21
  7. package/agents/code-archaeologist.md +6 -0
  8. package/agents/debug-sidecar.md +6 -0
  9. package/agents/doc-sidecar.md +6 -0
  10. package/agents/docs-guardian.md +9 -3
  11. package/agents/frontend-engineer.md +1 -2
  12. package/agents/{ai-architect.md → harness-architect.md} +8 -3
  13. package/agents/implementer.md +6 -1
  14. package/agents/legacy-discoverer.md +6 -0
  15. package/agents/legacy-synthesizer.md +6 -0
  16. package/agents/methodology-researcher.md +6 -1
  17. package/agents/operational-artifact-builder.md +6 -1
  18. package/agents/orchestrator.md +27 -87
  19. package/agents/planner.md +6 -1
  20. package/agents/presentation-engineer.md +29 -15
  21. package/agents/python-data-engineer.md +0 -1
  22. package/agents/researcher.md +6 -0
  23. package/agents/reviewer.md +6 -1
  24. package/agents/schema-extractor.md +6 -0
  25. package/agents/software-architect.md +10 -5
  26. package/agents/specifier.md +6 -1
  27. package/agents/task-generator.md +6 -1
  28. package/agents/test-sidecar.md +6 -0
  29. package/dist/scripts/generate-hooks-matcher.d.ts +106 -0
  30. package/dist/scripts/generate-hooks-matcher.d.ts.map +1 -0
  31. package/dist/scripts/generate-hooks-matcher.js +153 -0
  32. package/dist/scripts/generate-hooks-matcher.js.map +1 -0
  33. package/dist/scripts/generate-manifest.d.ts +22 -0
  34. package/dist/scripts/generate-manifest.d.ts.map +1 -1
  35. package/dist/scripts/generate-manifest.js +67 -2
  36. package/dist/scripts/generate-manifest.js.map +1 -1
  37. package/hooks/agent-git-guard.mjs +63 -11
  38. package/hooks/dangling-guard.mjs +42 -12
  39. package/hooks/gate-guardian.mjs +38 -8
  40. package/hooks/hooks.json +3 -9
  41. package/hooks/lib/api-client.ts +128 -9
  42. package/hooks/lib/capability-scanner.ts +462 -55
  43. package/hooks/lib/dispatch-marker.ts +214 -0
  44. package/hooks/lib/git-checkpoint.ts +34 -37
  45. package/hooks/lib/observations-helper.ts +14 -1
  46. package/hooks/lib/security-gate-core.ts +271 -0
  47. package/hooks/mailbox-listener.mjs +69 -17
  48. package/hooks/playwright-output-guard.mjs +40 -10
  49. package/hooks/post-implement.mjs +38 -8
  50. package/hooks/pre-agent-dispatch.mjs +137 -16
  51. package/hooks/pre-agent-dispatch.ts +82 -15
  52. package/hooks/security-gate-honest.mjs +302 -0
  53. package/hooks/security-gate-honest.ts +148 -0
  54. package/hooks/session-start.mjs +304 -106
  55. package/hooks/session-start.ts +317 -71
  56. package/hooks/subagent-start.mjs +7673 -22
  57. package/hooks/subagent-start.ts +367 -16
  58. package/hooks/subagent-stop.mjs +102 -62
  59. package/hooks/subagent-stop.ts +124 -28
  60. package/hooks/user-prompt.mjs +43 -13
  61. package/hooks/verification-before-completion.mjs +63 -11
  62. package/hooks/workflow-guard.mjs +45 -11
  63. package/hooks-settings-template.json +1 -7
  64. package/manifest.json +77 -61
  65. package/package.json +4 -2
  66. package/skills/checkpoint-protocol/SKILL.md +19 -4
  67. package/skills/ciclo-lane-dispatch/SKILL.md +117 -8
  68. package/skills/fase-1b-projecao/SKILL.md +150 -0
  69. package/skills/lane-1-entrevista/SKILL.md +173 -0
  70. package/skills/orchestrator/SKILL.md +11 -3
  71. package/skills/workflow-coordination/SKILL.md +3 -3
  72. package/hooks/post-compact.mjs +0 -7931
  73. package/hooks/post-compact.ts +0 -170
@@ -0,0 +1,214 @@
1
+ /**
2
+ * dispatch-marker.ts — boundary marker per git ref (S445, RFC-042).
3
+ *
4
+ * WHAT THIS IS. At the instant a committing agent is dispatched, record WHERE
5
+ * HEAD WAS, as a git ref pointing at the full 40-hex sha. That is the whole
6
+ * feature: a COORDINATE for "the state before this agent started".
7
+ *
8
+ * WHAT THIS IS NOT, deliberately. It does not restore anything. RFC-042 settled
9
+ * that a checkpoint is an AUDIT MARK (D1) and is EXPLICIT (D2); asked whether a
10
+ * restore path was wanted, the answer was that rollback has been needed only a
11
+ * few times and plain git handled it. So rollback stays plain git:
12
+ *
13
+ * git reset --hard <ref>
14
+ *
15
+ * No restore code lives here. An unused restore path is precisely how the
16
+ * existing checkpoint subsystem accumulated four independent breaks (see the
17
+ * checkpoint-protocol skill) — a coordinate that git can consume cannot rot the
18
+ * same way, because git is the thing that consumes it.
19
+ *
20
+ * IT DOES NOT CREATE A COMMIT AND DOES NOT TOUCH THE WORKING TREE. A ref is git
21
+ * metadata: writing one leaves `git status` byte-identical. That is what lets
22
+ * this coexist with the S445 rule that ordinary machine operation must never
23
+ * write a git-tracked file.
24
+ *
25
+ * THE COLON TRAP (the reason sanitization is a requirement, not a nicety).
26
+ * Under plugin scope agents are named `orchestrator:implementer`. A git ref name
27
+ * may not contain a colon (nor space, tilde, caret, question mark, asterisk,
28
+ * open bracket, backslash, or a double dot). Passing a raw agent name through
29
+ * would make `update-ref` fail — and since the calling hook is fail-open, it
30
+ * would fail SILENTLY, leaving the whole feature a green no-op for exactly the
31
+ * agents that can commit. Hence sanitizeRefComponent, and hence a test that
32
+ * validates the output with git's own `check-ref-format`.
33
+ *
34
+ * RETENTION. Refs are GC roots, so markers would accumulate forever and we would
35
+ * have traded collectable orphan objects for uncollectable refs. Hence a cap.
36
+ */
37
+
38
+ import { execFileSync } from 'node:child_process';
39
+
40
+ /**
41
+ * Namespace for dispatch boundary markers. Verified free before adoption:
42
+ * `git for-each-ref refs/orchestrator/` returned 0 refs. The 6 pre-existing
43
+ * `orchestrator/*` TAGS live under `refs/tags/orchestrator/`, which is a
44
+ * DIFFERENT namespace — these markers do not collide with them, and are not
45
+ * tags (a tag is a published, human-meaningful mark; this is machine bookkeeping
46
+ * with a retention cap).
47
+ */
48
+ export const DISPATCH_MARKER_REF_PREFIX = 'refs/orchestrator/dispatch';
49
+
50
+ /**
51
+ * Maximum markers retained PER KEY (per branch — see writeDispatchBoundaryMarker
52
+ * for why branch and not workflow). Oldest are pruned once the cap is exceeded.
53
+ *
54
+ * WHY A CAP EXISTS AT ALL: a git ref is a GC ROOT. Every marker pins the commit
55
+ * it points at, so without pruning these would accumulate without bound and
56
+ * would keep otherwise-unreachable objects alive forever — trading collectable
57
+ * orphans for uncollectable refs, which is a worse deal than the problem being
58
+ * solved. 20 is deliberately small: markers are a rollback coordinate for RECENT
59
+ * work, and a coordinate 20 dispatches old has been superseded by ordinary
60
+ * commits and branches. Raise it only against a measured need.
61
+ */
62
+ export const MAX_DISPATCH_MARKERS_PER_KEY = 20;
63
+
64
+ /** Fallback when an input sanitizes down to nothing. */
65
+ const EMPTY_COMPONENT_PLACEHOLDER = 'unknown';
66
+
67
+ /**
68
+ * Reduces arbitrary text to a single ref-name component that git accepts.
69
+ *
70
+ * The allowlist is deliberately narrower than git's own rules rather than a
71
+ * blocklist of forbidden characters. Permitting only [A-Za-z0-9_-] makes whole
72
+ * FAMILIES of git's restrictions unreachable instead of individually handled:
73
+ * dropping '.' rules out a leading dot, a trailing dot, a '..' sequence and a
74
+ * '.lock' suffix at once; dropping '@' and '{' rules out '@{' and the lone '@'.
75
+ * A blocklist would have to enumerate each rule correctly and stay correct as
76
+ * git evolves; this cannot fail open by omission.
77
+ */
78
+ export function sanitizeRefComponent(raw: string): string {
79
+ const cleaned = (raw || '')
80
+ .replace(/[^A-Za-z0-9_-]/g, '-')
81
+ .replace(/-+/g, '-')
82
+ .replace(/^-+|-+$/g, '');
83
+
84
+ return cleaned || EMPTY_COMPONENT_PLACEHOLDER;
85
+ }
86
+
87
+ /**
88
+ * Compact UTC stamp, e.g. `20260804T142439123Z`. Colons are stripped because a
89
+ * plain ISO-8601 string (`2026-08-04T14:24:39.123Z`) is an INVALID ref name for
90
+ * the very same reason the agent name is. Fixed width and zero-padded, so
91
+ * lexicographic ref order equals chronological order — which is what makes
92
+ * "prune the oldest" a plain sort.
93
+ */
94
+ export function formatMarkerStamp(d: Date): string {
95
+ return d.toISOString().replace(/[-:.]/g, '');
96
+ }
97
+
98
+ /** `refs/orchestrator/dispatch/<branch>/<stamp>-<agent>`. */
99
+ export function buildMarkerRefName(branch: string, agent: string, stamp: string): string {
100
+ const branchPart = sanitizeRefComponent(branch);
101
+ const agentPart = sanitizeRefComponent(agent);
102
+ const stampPart = sanitizeRefComponent(stamp);
103
+
104
+ return `${DISPATCH_MARKER_REF_PREFIX}/${branchPart}/${stampPart}-${agentPart}`;
105
+ }
106
+
107
+ export type DispatchMarkerOutcome =
108
+ | { status: 'written'; ref: string; sha: string; pruned: string[] }
109
+ | { status: 'failed'; reason: string };
110
+
111
+ export interface DispatchMarkerDeps {
112
+ /** Runs git and returns stdout. MUST throw on non-zero exit. */
113
+ runGit: (args: string[]) => string;
114
+ /** Injectable clock (tests need deterministic, ordered stamps). */
115
+ now?: () => Date;
116
+ }
117
+
118
+ /** Production git runner: fixed argv, no shell, so no interpolation surface. */
119
+ export function runGitDefault(args: string[]): string {
120
+ return execFileSync('git', args, {
121
+ encoding: 'utf-8',
122
+ stdio: ['ignore', 'pipe', 'ignore'],
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Removes the oldest markers under `prefix` until at most `cap` remain.
128
+ * Lexicographic ref order is chronological (see formatMarkerStamp).
129
+ */
130
+ function pruneOldestMarkers(prefix: string, cap: number, deps: DispatchMarkerDeps): string[] {
131
+ const listed = deps
132
+ .runGit(['for-each-ref', '--format=%(refname)', '--sort=refname', `${prefix}/`])
133
+ .split('\n')
134
+ .map((l) => l.trim())
135
+ .filter(Boolean);
136
+
137
+ const excess = listed.length - cap;
138
+ if (excess <= 0) return [];
139
+
140
+ const doomed = listed.slice(0, excess);
141
+ for (const ref of doomed) {
142
+ deps.runGit(['update-ref', '-d', ref]);
143
+ }
144
+ return doomed;
145
+ }
146
+
147
+ /**
148
+ * Writes the boundary marker for `agentName` on `branch`.
149
+ *
150
+ * RETENTION KEY IS THE BRANCH, NOT THE WORKFLOW — a deliberate substitution,
151
+ * reported as such. The workflow id is only obtainable here through an async API
152
+ * call, which would make the marker ABSENT precisely when the system is degraded
153
+ * (API down) — the opposite of what a rollback coordinate is for. The branch is
154
+ * already in hand locally and needs no network. It is also the better key on the
155
+ * merits: `git reset --hard` is a branch-scoped operation, so grouping markers by
156
+ * branch matches the unit they would be consumed at.
157
+ *
158
+ * FAIL-OPEN IN EVERY DIMENSION: git missing, no commits yet, a ref-name that
159
+ * somehow still fails validation, a broken prune — all return a `failed` outcome.
160
+ * This function NEVER throws, so it can never block or delay a dispatch. It is
161
+ * fail-open but NOT silent: it returns the reason, and the caller surfaces it.
162
+ */
163
+ export function writeDispatchBoundaryMarker(
164
+ agentName: string,
165
+ branch: string,
166
+ deps: DispatchMarkerDeps
167
+ ): DispatchMarkerOutcome {
168
+ let ref = '';
169
+ try {
170
+ // An agent name that sanitizes to nothing yields a marker nobody can
171
+ // attribute — litter that consumes the retention cap rather than a usable
172
+ // coordinate. NOT a hypothetical guard: a `...-unknown` ref was written into
173
+ // the real repository during S445's negative-control run, because this hook's
174
+ // entry module executes on import (top-level `main().catch()`) and therefore
175
+ // ran with production deps and an empty stdin. The caller gates on
176
+ // isCommittingAgent, so this never fires in production — it is the backstop
177
+ // for the paths that bypass that gate.
178
+ if (sanitizeRefComponent(agentName) === EMPTY_COMPONENT_PLACEHOLDER) {
179
+ return {
180
+ status: 'failed',
181
+ reason: `refusing to write an unattributable marker (agent name "${agentName}")`,
182
+ };
183
+ }
184
+
185
+ const sha = deps.runGit(['rev-parse', 'HEAD']).trim();
186
+ if (!/^[0-9a-f]{40}$/.test(sha)) {
187
+ return { status: 'failed', reason: `HEAD did not resolve to a 40-hex sha (got "${sha}")` };
188
+ }
189
+
190
+ const stamp = formatMarkerStamp((deps.now ?? (() => new Date()))());
191
+ ref = buildMarkerRefName(branch, agentName, stamp);
192
+
193
+ deps.runGit(['update-ref', ref, sha]);
194
+
195
+ // The marker now exists. Pruning is best-effort bookkeeping ON TOP of a
196
+ // successful write — a prune failure must never downgrade the outcome, or a
197
+ // full namespace would start reporting failure for markers that were in fact
198
+ // written.
199
+ let pruned: string[] = [];
200
+ try {
201
+ pruned = pruneOldestMarkers(
202
+ `${DISPATCH_MARKER_REF_PREFIX}/${sanitizeRefComponent(branch)}`,
203
+ MAX_DISPATCH_MARKERS_PER_KEY,
204
+ deps
205
+ );
206
+ } catch {
207
+ pruned = [];
208
+ }
209
+
210
+ return { status: 'written', ref, sha, pruned };
211
+ } catch (err) {
212
+ return { status: 'failed', reason: `${ref ? `ref="${ref}" ` : ''}${String(err)}` };
213
+ }
214
+ }
@@ -3,7 +3,6 @@
3
3
  * Replaces post-phase-checkpoint.sh git logic.
4
4
  */
5
5
 
6
- import { execSync } from 'node:child_process';
7
6
  import { readFileSync, writeFileSync } from 'node:fs';
8
7
  import { join, dirname } from 'node:path';
9
8
  import { fileURLToPath } from 'node:url';
@@ -24,22 +23,33 @@ function getCompletedPhase(currentPhase: string): string | null {
24
23
  return idx > 0 ? (PHASE_ORDER[idx - 1] ?? null) : null;
25
24
  }
26
25
 
27
- function exec(cmd: string): string {
28
- return execSync(cmd, { cwd: PROJECT_ROOT, encoding: 'utf-8' }).trim();
29
- }
30
-
31
26
  /**
32
- * Create a git checkpoint if the phase changed and there are staged changes.
33
- * Returns the commit hash or null.
27
+ * Register a checkpoint (PostgreSQL side only) if the phase changed since the
28
+ * last checkpointed phase.
29
+ *
30
+ * TD-257 (S444): this function used to also stage and commit the working
31
+ * tree (`git add -A` + `git commit --no-verify`, unattended, on every phase
32
+ * transition). That was DISARMED after measurement — the decision was made
33
+ * by the user from data, not opinion: 212 such commits by subject over 7
34
+ * months, 145/212 (68%) pure machine-noise, 42/212 (20%) MIXED (real work
35
+ * buried alongside noise), one of which (`01c28adf`) deleted 1627 lines
36
+ * including `EvaluatePhaseGateUseCase.ts` and 3 suites under the message
37
+ * "Auto-checkpoint after tasks phase" — with zero restores in that window,
38
+ * and no production caller for `CheckpointManager.restore()` at all. The
39
+ * separate, deliberate `createCheckpoint` MCP tool (`CheckpointManager.ts`,
40
+ * `GitOperations.ts`) is UNAFFECTED by this change — it is named, tagged,
41
+ * and stays. Returns `true` when a checkpoint was registered for a newly
42
+ * completed phase, `false` otherwise (no completed phase, already
43
+ * checkpointed, or the registration call failed).
34
44
  */
35
45
  export async function createCheckpointIfNeeded(
36
46
  workflowId: string,
37
47
  currentPhase: string
38
- ): Promise<string | null> {
48
+ ): Promise<boolean> {
39
49
  const completedPhase = getCompletedPhase(currentPhase);
40
50
  if (!completedPhase) {
41
51
  log('CHECKPOINT', `No completed phase from: ${currentPhase}`);
42
- return null;
52
+ return false;
43
53
  }
44
54
 
45
55
  // Check if already checkpointed
@@ -51,41 +61,28 @@ export async function createCheckpointIfNeeded(
51
61
  }
52
62
  if (lastPhase === completedPhase) {
53
63
  log('CHECKPOINT', `Already checkpointed: ${completedPhase}`);
54
- return null;
64
+ return false;
55
65
  }
56
66
 
57
67
  try {
58
- // Stage all changes
59
- exec('git add -A');
60
-
61
- // Check if there are staged changes
62
- try {
63
- exec('git diff --cached --quiet');
64
- // No changes
65
- log('CHECKPOINT', `No changes for phase: ${completedPhase}`);
66
- writeFileSync(LAST_PHASE_FILE, completedPhase);
67
- return null;
68
- } catch {
69
- // Has changes — good, proceed to commit
70
- }
71
-
72
- const commitMsg = `[orchestrator] ${completedPhase}: Auto-checkpoint after ${completedPhase} phase`;
73
- exec(`git commit -m "${commitMsg}" --no-verify`);
74
- const commitHash = exec('git rev-parse HEAD');
75
-
76
- log('CHECKPOINT', `Created: phase=${completedPhase} commit=${commitHash}`);
68
+ log('CHECKPOINT', `Registering checkpoint for phase: ${completedPhase}`);
77
69
 
78
- // Register via API
79
- await registerCheckpoint(
80
- workflowId,
81
- `Auto-checkpoint after ${completedPhase} phase`,
82
- commitHash
83
- );
70
+ // TD-257: no commit is made anymore, so there is no real hash to report.
71
+ // registerCheckpoint's `commitHash` parameter is still required by its
72
+ // signature (out of this task's scope to change — see implementation
73
+ // report) — '' is the honest value ("no commit exists"), not an invented
74
+ // placeholder: it mirrors how the separate createCheckpoint MCP tool path
75
+ // already reports "no commit" as `gitCommit: undefined`
76
+ // (CreateCheckpointHandler.ts) when there is nothing to point to, and the
77
+ // field is unused server-side regardless — CreateCheckpointBodySchema
78
+ // (checkpoints.schemas.ts) has no commitHash field at all, and the route
79
+ // handler (checkpoints.routes.ts) never reads it off the request body.
80
+ await registerCheckpoint(workflowId, `Auto-checkpoint after ${completedPhase} phase`, '');
84
81
 
85
82
  writeFileSync(LAST_PHASE_FILE, completedPhase);
86
- return commitHash;
83
+ return true;
87
84
  } catch (err) {
88
85
  log('CHECKPOINT', `Error: ${err}`);
89
- return null;
86
+ return false;
90
87
  }
91
88
  }
@@ -4,11 +4,22 @@
4
4
  * Provides types and helper to write observations.yaml files.
5
5
  * Used by subagent-stop.ts to record what each sub-agent observed.
6
6
  * All functions are fail-open: errors logged to stderr, never thrown.
7
+ *
8
+ * TD-214/TD-239 TASK-003 (RF-004): `agent` is normalized via
9
+ * `normalizeAgentName` BEFORE it is used to build `agentDir`. Under plugin
10
+ * scope the harness namespaces dispatch names (`orchestrator:reviewer`); in
11
+ * project scope the same agent arrives bare (`reviewer`). Without this,
12
+ * the two scopes silently fork into two different observations.yaml
13
+ * directories for the SAME agent. `data.agent` (the field written INSIDE the
14
+ * YAML) intentionally keeps the raw, un-normalized input — only the
15
+ * directory-path use is normalized — so the file still records exactly what
16
+ * the harness sent, for audit purposes.
7
17
  */
8
18
 
9
19
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
10
20
  import { join } from 'node:path';
11
21
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
22
+ import { normalizeAgentName } from './api-client.js';
12
23
 
13
24
  // ─── Types ───────────────────────────────────────────────
14
25
 
@@ -47,7 +58,9 @@ export function appendObservation(
47
58
  entry: Omit<ObservationEntry, 'promoted'>
48
59
  ): void {
49
60
  try {
50
- const agentDir = join(memoryDir, agent);
61
+ // TD-214/TD-239 TASK-003: normalize BEFORE building the directory path —
62
+ // see module doc for why (plugin-scope vs project-scope namespacing).
63
+ const agentDir = join(memoryDir, normalizeAgentName(agent));
51
64
  const filePath = join(agentDir, 'observations.yaml');
52
65
 
53
66
  // Ensure directory exists
@@ -0,0 +1,271 @@
1
+ /**
2
+ * security-gate-core.ts — lógica PURA do gate honesto de commit (S505, ficha 3).
3
+ *
4
+ * Separado de `security-gate-honest.ts` de propósito: aquele arquivo tem um
5
+ * `main()` de topo que aguarda stdin, e importá-lo num teste trava o runner.
6
+ * A convenção anterior desta casa contornava isso RE-IMPLEMENTANDO a lógica
7
+ * dentro do teste (ver `tests/unit/hooks/post-implement.test.ts`), o que
8
+ * duplica a regra e deixa as duas cópias divergirem em silêncio — a família
9
+ * TD-232 que este repo persegue. Extrair o núcleo resolve os dois problemas:
10
+ * o teste importa o que RODA, e não uma cópia dele.
11
+ *
12
+ * Nada aqui faz IO. O `git show` e a saída do hook ficam no arquivo do hook.
13
+ */
14
+
15
+ /** Abaixo disto, um veredito de L2 é fallback disfarçado, não análise. */
16
+ export const L2_LATENCY_FLOOR_MS = 400;
17
+
18
+ /** O que a L1 cobre — declarado, porque cobertura não-dita vira cobertura presumida. */
19
+ export const L1_COVERAGE =
20
+ 'literais de credencial no diff (chaves de provedor, tokens, blocos PEM, URLs com senha)';
21
+
22
+ export type Verdict =
23
+ | { state: 'clean'; layer: string; detail: string }
24
+ | { state: 'findings'; layer: string; detail: string; hits: Finding[] }
25
+ | { state: 'not-analyzed'; layer: string; reason: string };
26
+
27
+ export interface Finding {
28
+ rule: string;
29
+ file: string;
30
+ line: number;
31
+ evidence: string;
32
+ }
33
+
34
+ // ─────────────────────────────────────────────────────────────
35
+ // L1 — padrões. Estreitos de propósito: cada regra que dispara
36
+ // falso positivo em massa treina quem lê a ignorar, e um gate
37
+ // ignorado é um gate desligado com aparência de ligado.
38
+ // ─────────────────────────────────────────────────────────────
39
+ export const SECRET_RULES: { rule: string; re: RegExp }[] = [
40
+ { rule: 'aws-access-key-id', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
41
+ { rule: 'anthropic-api-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
42
+ { rule: 'openai-api-key', re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/ },
43
+ { rule: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/ },
44
+ { rule: 'slack-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/ },
45
+ { rule: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
46
+ { rule: 'npm-token', re: /\bnpm_[A-Za-z0-9]{36}\b/ },
47
+ { rule: 'private-key-block', re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
48
+ { rule: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
49
+ { rule: 'url-with-password', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s:@/]{6,}@/i },
50
+ ];
51
+
52
+ /**
53
+ * Marcas que indicam placeholder e não segredo real. Sem isto o gate grita em
54
+ * cima do `.env.example` e ensina o leitor a ignorá-lo.
55
+ */
56
+ const PLACEHOLDER =
57
+ /(?:x{6,}|\.{3,}|<[^>]{2,}>|\$\{|EXAMPLE|PLACEHOLDER|YOUR_|CHANGEME|REDACTED|FAKE|DUMMY|\bTODO\b)/i;
58
+
59
+ /** Mascara a evidência: o gate não pode ser o veículo que vaza o segredo que achou. */
60
+ export function maskEvidence(s: string): string {
61
+ const t = s.trim().slice(0, 80);
62
+ return t.length <= 12 ? '***' : `${t.slice(0, 6)}…${t.slice(-4)} (${String(t.length)} ch)`;
63
+ }
64
+
65
+ /**
66
+ * Varre um diff unificado. Só linhas ADICIONADAS (`+`) — uma linha removida
67
+ * já está no histórico e culpar o commit que a REMOVE inverte o sinal.
68
+ */
69
+ export function scanDiff(diff: string): Finding[] {
70
+ const out: Finding[] = [];
71
+ let file = '(desconhecido)';
72
+ let lineNo = 0;
73
+
74
+ for (const raw of diff.split('\n')) {
75
+ if (raw.startsWith('+++ b/')) {
76
+ file = raw.slice(6);
77
+ lineNo = 0;
78
+ continue;
79
+ }
80
+ if (raw.startsWith('@@')) {
81
+ const m = /\+(\d+)/.exec(raw);
82
+ lineNo = m ? parseInt(m[1], 10) - 1 : 0;
83
+ continue;
84
+ }
85
+ if (!raw.startsWith('+') || raw.startsWith('+++')) continue;
86
+ lineNo += 1;
87
+
88
+ const content = raw.slice(1);
89
+ if (PLACEHOLDER.test(content)) continue;
90
+
91
+ for (const { rule, re } of SECRET_RULES) {
92
+ const hit = re.exec(content);
93
+ if (hit) out.push({ rule, file, line: lineNo, evidence: maskEvidence(hit[0]) });
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * Detecta `git commit` — e NÃO `git commit --dry-run`, que não cria commit.
101
+ *
102
+ * Aceita opções globais com valor separado (`git -c user.name=x commit`), que a
103
+ * primeira versão desta função deixava passar: o `-c` consome o traço, mas o
104
+ * `user.name=x` seguinte não, e o `commit` deixava de casar. Pego por teste.
105
+ *
106
+ * LIMITAÇÃO CONHECIDA, declarada em vez de escondida: isto é casamento de
107
+ * texto, não parsing de shell. Um comando que apenas CONTENHA a frase
108
+ * (`ls "git commit"`) dispara o gate, que então descreve o HEAD corrente — um
109
+ * commit que não é daquele comando. É ruído, não risco: toda saída nomeia o que
110
+ * analisou, então o leitor vê sobre o que o veredito fala. Parsear shell de
111
+ * verdade custa mais do que o defeito que evita.
112
+ */
113
+ export function isCommitCommand(command: string): boolean {
114
+ return /git\s+(?:-\S+(?:\s+\S+)?\s+)*commit\b/.test(command) && !/--dry-run\b/.test(command);
115
+ }
116
+
117
+ // ─────────────────────────────────────────────────────────────
118
+ // Núcleo
119
+ // ─────────────────────────────────────────────────────────────
120
+
121
+ /** O diff mais a declaração de COMO foi obtido — cobertura não-dita vira cobertura presumida. */
122
+ export interface DiffSource {
123
+ text: string;
124
+ /** Ex.: "git show HEAD" · "git show -m --first-parent HEAD (merge de 2 pais)". */
125
+ how: string;
126
+ }
127
+
128
+ export interface GateDeps {
129
+ getDiff: () => DiffSource;
130
+ /** L2 devolve null enquanto não houver credencial decidida (ficha 3, item d). */
131
+ runSemantic: (diff: string) => { hits: Finding[]; elapsedMs: number } | null;
132
+ l2UnavailableReason: () => string;
133
+ }
134
+
135
+ export function evaluate(deps: GateDeps): Verdict[] {
136
+ const verdicts: Verdict[] = [];
137
+
138
+ let src: DiffSource;
139
+ try {
140
+ src = deps.getDiff();
141
+ } catch (e) {
142
+ // A falha de leitura do diff é NÃO ANALISEI para TODAS as camadas.
143
+ // Este é o ponto exato em que o plugin medido diria "limpo".
144
+ const reason = `não consegui ler o diff do commit (${(e as Error).message.slice(0, 80)})`;
145
+ return [
146
+ { state: 'not-analyzed', layer: 'L1', reason },
147
+ { state: 'not-analyzed', layer: 'L2', reason },
148
+ ];
149
+ }
150
+
151
+ const diff = src.text;
152
+ if (!diff.trim()) {
153
+ // Distinguir POR QUE veio vazio: um merge sem conflito e um `git show` que
154
+ // falhou pedem leituras diferentes, e medida que não separa estados que
155
+ // pedem ações diferentes é medida errada (regra de desenho da S480).
156
+ const reason =
157
+ `o diff veio vazio via ${src.how} — nada para analisar. ` + 'Isto NÃO é o mesmo que limpo';
158
+ return [
159
+ { state: 'not-analyzed', layer: 'L1', reason },
160
+ { state: 'not-analyzed', layer: 'L2', reason },
161
+ ];
162
+ }
163
+
164
+ // ── L1 ──
165
+ const hits = scanDiff(diff);
166
+ const cover = `${L1_COVERAGE} · via ${src.how}`;
167
+ verdicts.push(
168
+ hits.length
169
+ ? { state: 'findings', layer: 'L1', detail: cover, hits }
170
+ : { state: 'clean', layer: 'L1', detail: cover }
171
+ );
172
+
173
+ // ── L2 ──
174
+ const sem = deps.runSemantic(diff);
175
+ if (sem === null) {
176
+ verdicts.push({ state: 'not-analyzed', layer: 'L2', reason: deps.l2UnavailableReason() });
177
+ } else if (sem.elapsedMs < L2_LATENCY_FLOOR_MS) {
178
+ // A guarda de latência. Não confia no veredito: análise semântica de um
179
+ // diff não fecha nesse tempo, então isto é fallback, não resultado.
180
+ verdicts.push({
181
+ state: 'not-analyzed',
182
+ layer: 'L2',
183
+ reason:
184
+ `devolveu veredito em ${String(sem.elapsedMs)} ms, abaixo do piso de ${String(L2_LATENCY_FLOOR_MS)} ms — ` +
185
+ 'tratado como fallback, não como análise',
186
+ });
187
+ } else {
188
+ verdicts.push(
189
+ sem.hits.length
190
+ ? { state: 'findings', layer: 'L2', detail: 'análise semântica', hits: sem.hits }
191
+ : { state: 'clean', layer: 'L2', detail: 'análise semântica' }
192
+ );
193
+ }
194
+
195
+ return verdicts;
196
+ }
197
+
198
+ /**
199
+ * A linha ÚNICA que o humano vê (`systemMessage`), contra o texto por camada que
200
+ * só chega ao modelo (`additionalContext`).
201
+ *
202
+ * Achado do Manager na S505, e é o coração da ficha: **falhar alto significa a
203
+ * PESSOA ver.** Um gate cujo aviso só alcança o modelo tem o mesmo efeito
204
+ * prático do plugin que ele substitui — ninguém é avisado. Por isso a saída sai
205
+ * pelos dois canais.
206
+ *
207
+ * Reporta o PIOR estado, porque é o único que muda o que a pessoa faz agora:
208
+ * achados > nada analisado > cobertura parcial > tudo analisado e limpo.
209
+ */
210
+ export function renderShort(verdicts: Verdict[]): string {
211
+ const findings = verdicts.filter((v) => v.state === 'findings');
212
+ if (findings.length) {
213
+ const n = findings.reduce((a, v) => a + (v.state === 'findings' ? v.hits.length : 0), 0);
214
+ const layers = findings.map((v) => v.layer).join('+');
215
+ return `SECURITY-GATE: ${String(n)} achado(s) em ${layers} — confira antes de publicar`;
216
+ }
217
+
218
+ const analyzed = verdicts.filter((v) => v.state !== 'not-analyzed').map((v) => v.layer);
219
+ const skipped = verdicts.filter((v) => v.state === 'not-analyzed').map((v) => v.layer);
220
+
221
+ if (!analyzed.length) {
222
+ return 'SECURITY-GATE: NENHUMA camada analisou o commit — isto NÃO é "limpo", é ausência de análise';
223
+ }
224
+ if (skipped.length) {
225
+ return `SECURITY-GATE: cobertura PARCIAL — ${analyzed.join('+')} analisou, ${skipped.join('+')} não`;
226
+ }
227
+ return `SECURITY-GATE: ${analyzed.join('+')} analisou, sem achados`;
228
+ }
229
+
230
+ /**
231
+ * Renderiza. Nunca produz a frase "no security issues found" nem equivalente
232
+ * global: o veredito é sempre por camada, sempre com a cobertura ao lado.
233
+ */
234
+ export function render(verdicts: Verdict[]): string {
235
+ const lines = ['[SECURITY-GATE] Revisão do commit — veredito por camada:'];
236
+
237
+ for (const v of verdicts) {
238
+ if (v.state === 'clean') {
239
+ lines.push(` ${v.layer} ANALISADO — LIMPO · cobertura: ${v.detail}`);
240
+ } else if (v.state === 'findings') {
241
+ lines.push(
242
+ ` ${v.layer} ANALISADO — ${String(v.hits.length)} ACHADO(S) · cobertura: ${v.detail}`
243
+ );
244
+ for (const h of v.hits.slice(0, 10)) {
245
+ lines.push(` ${h.rule} · ${h.file}:${String(h.line)} · ${h.evidence}`);
246
+ }
247
+ if (v.hits.length > 10) lines.push(` … e mais ${String(v.hits.length - 10)}`);
248
+ } else {
249
+ lines.push(` ${v.layer} NÃO ANALISEI — ${v.reason}`);
250
+ }
251
+ }
252
+
253
+ const analyzed = verdicts.filter((v) => v.state !== 'not-analyzed').map((v) => v.layer);
254
+ const skipped = verdicts.filter((v) => v.state === 'not-analyzed').map((v) => v.layer);
255
+
256
+ if (!analyzed.length) {
257
+ lines.push(
258
+ ' ⚠ NENHUMA camada analisou este commit. Isto NÃO é "limpo" — é ausência de informação.'
259
+ );
260
+ } else if (skipped.length) {
261
+ lines.push(
262
+ ` ⚠ Cobertura PARCIAL: ${analyzed.join('+')} rodou, ${skipped.join('+')} não. ` +
263
+ 'O que não foi analisado não está limpo — está por analisar.'
264
+ );
265
+ }
266
+
267
+ if (verdicts.some((v) => v.state === 'findings')) {
268
+ lines.push(' → Confira antes de publicar. O commit local já existe; nada foi bloqueado.');
269
+ }
270
+ return lines.join('\n');
271
+ }