@devrik-tools/claude-gates 0.7.2 → 0.9.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 (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. package/registry.json +249 -38
@@ -1,113 +1,123 @@
1
1
  // block-remote-publish — denies publishing to a remote (`git push`, `gh pr merge`,
2
- // `gh release create`) without fresh human authorization. Split out of bash-commands so it
3
- // carries its OWN enabled flag: a project can turn remote-publish blocking off in config
4
- // (blockRemotePublish: false) WITHOUT losing the destructive-command protections (rm -rf,
5
- // git reset --hard) that live in bash-commands. Nothing is hardcoded any more this gate
6
- // obeys its flag like every other, and appears in the CLI's gate selector on its own.
7
- //
8
- // Default: enabled. The safe posture is that the human states the authorization in chat,
9
- // naming commits, branch, remote and target — a project that wants the agent to push on its
10
- // own opts in explicitly by disabling this gate.
11
- //
12
- // ── Two-stage intent check, only for delegation prompts ─────────────────────────────
13
- // A real command's `command` field IS what the shell runs — a single-stage regex is right.
14
- // A delegation prompt is natural language that may DESCRIBE a command ("I extended the guard
15
- // to deny git push") without asking anyone to run it. There, publish rules run a second
16
- // stage: strip quoted/example text, then require that at least one surviving mention is not
17
- // governed by a reporting verb before denying.
2
+ // `gh release create`) without fresh human authorization. Split out of bash-commands so a
3
+ // project can let the agent push (blockRemotePublish: false) without losing the destructive
4
+ // command protections. Deliberate limits: only a command segment that RUNS git/gh is judged
5
+ // (a mention inside grep, echo or a --grep value is not a publish), a `--dry-run` push writes
6
+ // nothing, and a delegation prompt is denied only when it orders the publish rather than
7
+ // describing or forbidding it.
18
8
 
9
+ import { hasRealCommandIntent } from '../../lib/delegation.mjs';
10
+ import { normalizeGitCommand } from '../../lib/git.mjs';
19
11
  import {
20
- runGate,
12
+ compileRegex,
13
+ delegationPromptOf,
21
14
  deny,
15
+ normalizeRulePairs,
16
+ runGate,
17
+ shellCommandOf,
22
18
  toolInGroups,
23
- delegationPromptOf,
24
19
  } from '../../lib/hook-io.mjs';
25
20
 
26
21
  const GATE_ID = 'block-remote-publish';
27
22
  const CONFIG_KEY = 'blockRemotePublish';
28
23
 
29
- // Remote-publish rules as `[regexSource, reason]`. Sources (not RegExp) so a project could
30
- // override them through config if it ever needed to.
31
- function defaultPublishRules() {
32
- return [
33
- [
34
- String.raw`\bgit\s+(?:-C\s+\S+\s+)?push\b`,
35
- "Publishing to a remote ('git push') needs fresh authorization from the user naming " +
36
- 'commits, local branch, remote and target. This gate cannot verify that from the ' +
37
- 'command itself — ask the user and let them run it. To let the agent push on its ' +
38
- 'own, set "blockRemotePublish": false in .ai/config.json.',
39
- ],
40
- [
41
- String.raw`\bgh\s+(?:pr\s+merge|release\s+create)\b`,
42
- 'Publishing via GitHub CLI (merging a PR or creating a release) needs fresh ' +
43
- 'authorization from the user naming commits, local branch, remote and target. ' +
44
- 'Ask the user and let them run it. To let the agent publish on its own, set ' +
45
- '"blockRemotePublish": false in .ai/config.json.',
46
- ],
47
- ];
48
- }
24
+ const AUTHORIZATION_REMEDY =
25
+ 'needs fresh authorization from the user naming commits, local branch, remote and ' +
26
+ 'target. This gate cannot verify that from the command itself — ask the user and let ' +
27
+ 'them run it. To let the agent publish on its own, set "blockRemotePublish": false in ' +
28
+ '.ai/config.json.';
49
29
 
50
- // How far back from a mention to look for a governing verb, and the reporting-verb lexicon
51
- // that marks a mention as description rather than an order. Only the text before a mention
52
- // is inspected: what governs it is what precedes it (appending words after a real command
53
- // would otherwise be a trivial bypass).
54
- const DESCRIPTION_LOOK_BACK = 80;
55
- const REPORTING_VERB_PATTERN =
56
- /(describe|explain|summar|mention|added|built|extended|denies?|deny|prohibit|protection|report|documentation|changelog)/i;
30
+ const DEFAULT_PUBLISH_RULES = [
31
+ [
32
+ String.raw`\bgit\s+push\b`,
33
+ `Publishing to a remote ('git push') ${AUTHORIZATION_REMEDY}`,
34
+ ],
35
+ [
36
+ String.raw`\bgh\s+(?:pr\s+merge|release\s+create)\b`,
37
+ `Publishing via GitHub CLI (merging a PR or creating a release) ${AUTHORIZATION_REMEDY}`,
38
+ ],
39
+ ];
40
+ const DEFAULT_REASON = `Publishing to a remote ${AUTHORIZATION_REMEDY}`;
57
41
 
58
- function compile(source) {
59
- return new RegExp(source, 'i');
60
- }
42
+ // `gh -R owner/repo pr merge` and `gh pr --repo x merge` name the repo between the words a
43
+ // rule looks for; stripping the option first keeps the rules readable.
44
+ const GH_REPO_OPTION_PATTERN =
45
+ /(\bgh\s+(?:pr\s+|release\s+)?)(?:-R|--repo)(?:=|\s+)\S+\s+/i;
61
46
 
62
- // git's GLOBAL options sit between `git` and the subcommand: `git -C <path> push`, etc. A
63
- // pattern that matches `git push` contiguously is evaded by any of them. Stripping these
64
- // first — turning `git -C /repo push` back into `git push` — closes that bypass at once.
65
- const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
66
- const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
67
- const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
68
- String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
69
- 'i',
70
- );
71
-
72
- function normalizeGitOptions(command) {
47
+ function normalizeGhCommand(text) {
48
+ let normalized = text;
73
49
  let previous;
74
- let normalized = command;
75
50
  do {
76
51
  previous = normalized;
77
- normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
52
+ normalized = normalized.replace(GH_REPO_OPTION_PATTERN, '$1');
78
53
  } while (normalized !== previous);
79
54
  return normalized;
80
55
  }
81
56
 
82
- function stripQuoted(text) {
83
- return text
84
- .replace(/```[\s\S]*?```/g, ' ')
85
- .replace(/"[^"\n]{0,300}"/g, ' ')
86
- .replace(/'[^'\n]{0,300}'/g, ' ');
57
+ function normalizeCommand(text) {
58
+ return normalizeGhCommand(normalizeGitCommand(text));
59
+ }
60
+
61
+ // ── Command segments that run git/gh ────────────────────────────────────────────────
62
+ const SEGMENT_SEPARATOR = /;|&&|\|\||\||\n|\$\(|`/;
63
+ const WRAPPER_WORD_PATTERN =
64
+ /^(?:\w+=\S*|command|sudo|exec|env|time|nohup|builtin)$/i;
65
+
66
+ function stripLeadingWrappers(segment) {
67
+ const tokens = segment.replace(/^[\s(]+/, '').split(/\s+/);
68
+ while (tokens.length > 0 && WRAPPER_WORD_PATTERN.test(tokens[0]))
69
+ tokens.shift();
70
+ return tokens.join(' ');
71
+ }
72
+
73
+ const SHELL_WRAPPER_PATTERN =
74
+ /^(?:sh|bash|zsh|dash|ksh)\s+(-\w+)\s+(?:"([^"]*)"|'([^']*)')/i;
75
+ const QUOTED_PATTERN = /"[^"]*"|'[^']*'/g;
76
+ const PUBLISHING_BINARY_PATTERN = /^(?:git|gh)(?=\s|$)/i;
77
+
78
+ function segmentsRunningGit(command) {
79
+ const segments = [];
80
+ for (const rawSegment of String(command).split(SEGMENT_SEPARATOR)) {
81
+ const segment = stripLeadingWrappers(rawSegment);
82
+ const wrapped = SHELL_WRAPPER_PATTERN.exec(segment);
83
+ if (wrapped && wrapped[1].includes('c')) {
84
+ segments.push(...segmentsRunningGit(wrapped[2] ?? wrapped[3]));
85
+ } else if (PUBLISHING_BINARY_PATTERN.test(segment)) {
86
+ segments.push(segment.replace(QUOTED_PATTERN, ' '));
87
+ }
88
+ }
89
+ return segments;
87
90
  }
88
91
 
89
- /** True when a surviving, non-quoted mention is not governed by a reporting verb. */
90
- function hasRealPublishIntent(text, pattern) {
91
- if (!pattern.test(text)) return false;
92
- const cleaned = stripQuoted(text);
93
- const global = new RegExp(
94
- pattern.source,
95
- `${pattern.flags.replace('g', '')}g`,
96
- );
97
- const matches = [...cleaned.matchAll(global)];
98
- if (matches.length === 0) return false;
99
- return matches.some((match) => {
100
- const from = Math.max(0, match.index - DESCRIPTION_LOOK_BACK);
101
- return !REPORTING_VERB_PATTERN.test(cleaned.slice(from, match.index));
102
- });
92
+ const DRY_RUN_PUSH_PATTERN = /\bgit\s+push\b.*(?:\s--dry-run\b|\s-n\b)/i;
93
+
94
+ function publishRules(parameters) {
95
+ return normalizeRulePairs(parameters.publishRules, DEFAULT_REASON)
96
+ .map(({ source, reason }) => ({ pattern: compileRegex(source), reason }))
97
+ .filter((rule) => rule.pattern !== null);
103
98
  }
104
99
 
105
- /** The text to inspect: a real command's command line, or the delegation prompt. */
106
- function commandTextFrom(toolName, toolInput) {
107
- if (toolInGroups(toolName, ['shell'])) {
108
- return String(toolInput.CommandLine ?? toolInput.command ?? '');
100
+ function checkShellCommand(command, parameters) {
101
+ const rules = publishRules(parameters);
102
+ for (const segment of segmentsRunningGit(normalizeCommand(command))) {
103
+ if (DRY_RUN_PUSH_PATTERN.test(segment)) continue;
104
+ for (const { pattern, reason } of rules) {
105
+ if (pattern.test(segment))
106
+ deny(CONFIG_KEY, `${reason} (command: "${segment.trim()}")`);
107
+ }
108
+ }
109
+ }
110
+
111
+ function checkDelegationPrompt(prompt, parameters) {
112
+ const normalized = normalizeCommand(prompt);
113
+ for (const { pattern, reason } of publishRules(parameters)) {
114
+ if (hasRealCommandIntent(normalized, pattern)) {
115
+ deny(
116
+ CONFIG_KEY,
117
+ `${reason} The delegation prompt orders the publish; a subagent must not run it either.`,
118
+ );
119
+ }
109
120
  }
110
- return delegationPromptOf(toolInput);
111
121
  }
112
122
 
113
123
  runGate(
@@ -116,25 +126,14 @@ runGate(
116
126
  configKey: CONFIG_KEY,
117
127
  enabledByDefault: true,
118
128
  defaultParams: {
119
- publishRules: defaultPublishRules(),
129
+ publishRules: DEFAULT_PUBLISH_RULES,
120
130
  },
121
131
  },
122
132
  ({ toolName, toolInput, parameters }) => {
123
- const isShell = toolInGroups(toolName, ['shell']);
124
- const isDelegation = toolInGroups(toolName, ['delegation']);
125
- if (!isShell && !isDelegation) return;
126
-
127
- const command = commandTextFrom(toolName, toolInput);
128
- // For a real shell command, normalize git's global options first. For a delegation
129
- // prompt (free text), the intent check runs on the raw text.
130
- const shellCommand = normalizeGitOptions(command);
131
- for (const [source, reason] of parameters.publishRules) {
132
- const pattern = compile(source);
133
- if (isShell) {
134
- if (pattern.test(shellCommand)) deny(CONFIG_KEY, reason);
135
- } else if (hasRealPublishIntent(command, pattern)) {
136
- deny(CONFIG_KEY, reason);
137
- }
133
+ if (toolInGroups(toolName, ['shell'])) {
134
+ checkShellCommand(shellCommandOf(toolInput), parameters);
135
+ } else if (toolInGroups(toolName, ['delegation'])) {
136
+ checkDelegationPrompt(delegationPromptOf(toolInput), parameters);
138
137
  }
139
138
  },
140
139
  );
@@ -1,38 +1,25 @@
1
- // brief-approved — denies an implementation delegation (STANDARD/HIGH-RISK, an
2
- // implementation verb) that targets a feature whose brief/spec has no recorded human
3
- // approval. Migrated from a real incident: the assistant took an ambiguous user reply
4
- // ("hagamos el brief") as approval of a spec it had already written, delegated
5
- // implementation straight away, and never pasted the full brief into the chat for the
6
- // user to confirm line by line. `sdd-specs` already blocks implementing a feature with
7
- // NO contract at all; this gate blocks implementing a feature whose contract exists but
8
- // was never actually approved by the user — a distinct failure this incident exposed.
1
+ // brief-approved — denies a STANDARD/HIGH-RISK implementation delegation that cites a
2
+ // feature whose brief.md carries no recorded human approval. Born from a real incident: an
3
+ // ambiguous reply ("hagamos el brief") was taken as approval of a spec the assistant had
4
+ // written itself, and implementation was delegated without the user ever reading it.
9
5
  //
10
- // ── What "approved" means here (deliberately narrow) ────────────────────────────────
11
- // A hook cannot read the chat, so it cannot verify the assistant actually pasted the
12
- // full brief and that the user actually read it. What IS checkable: the brief file
13
- // itself carries a frontmatter block written ONLY once that confirmation happened
14
- // ---
15
- // status: approved
16
- // approved_at: <ISO timestamp>
17
- // approval_quote: "<user's own words>"
18
- // ---
19
- // The gate does not (cannot) verify the quote is genuine; it verifies the field exists
20
- // and is non-empty, which at minimum forces the assistant to stop and produce a
21
- // specific artifact instead of silently inferring consent from a vague reply.
22
- //
23
- // ── Auto-off when there is no brief to approve ──────────────────────────────────────
24
- // Same discovery as sdd-specs: only fires once a feature contract tree exists under
25
- // .ai/features/<name>/ (or the configured catalog's sibling `features` dir) AND that
26
- // feature has a brief.md/asserts.md file. A project with no such file for the cited
27
- // feature is out of this gate's scope (sdd-specs already denies that case).
28
- //
29
- // ── What is NOT configurable (base, non-negotiable) ─────────────────────────────────
30
- // The frontmatter field names (status/approved_at/approval_quote) are fixed, not a
31
- // project param — a configurable field name would let a delegation "approve" itself by
32
- // pointing the gate at whatever key it just wrote.
6
+ // Decisions: a hook cannot read the chat, so "approved" means the brief's frontmatter
7
+ // carries `status: approved` AND a non-empty `approval_quote` an artifact the assistant
8
+ // must stop and produce. The field names are fixed on purpose: a configurable key would let
9
+ // a delegation approve itself. The gate enforces ONLY when the feature directory contains
10
+ // brief.md; a feature with other contract files or none is sdd-specs' business.
33
11
 
34
12
  import { existsSync, readFileSync } from 'node:fs';
35
- import { dirname, join } from 'node:path';
13
+ import { dirname, isAbsolute, join } from 'node:path';
14
+ import { projectRootOf, stripBom } from '../../lib/config.mjs';
15
+ import {
16
+ DEFAULT_EXEMPT_SUBAGENTS,
17
+ DEMANDING_LEVELS,
18
+ featureNamesCitedIn,
19
+ isImplementationRequest,
20
+ isSubagentNamedIn,
21
+ operativeLevelOf,
22
+ } from '../../lib/delegation.mjs';
36
23
  import {
37
24
  runGate,
38
25
  deny,
@@ -44,125 +31,68 @@ const GATE_ID = 'brief-approved';
44
31
  const CONFIG_KEY = 'requireApprovedBriefBeforeImplementing';
45
32
 
46
33
  const CATALOG_FILE_NAME = 'feature_list.json';
34
+ const FEATURES_DIRECTORY = 'features';
35
+ const BRIEF_FILE = 'brief.md';
47
36
  const DEFAULT_CATALOG_LOCATIONS = [
48
37
  join('.ai', CATALOG_FILE_NAME),
49
38
  CATALOG_FILE_NAME,
50
39
  ];
51
40
 
52
- const DEFAULT_EXEMPT_SUBAGENTS = [
53
- 'explore',
54
- 'plan',
55
- 'scout',
56
- 'revision',
57
- 'contraste',
58
- 'test-planner',
59
- 'qa',
60
- 'ui',
61
- 'ux',
62
- ];
63
-
64
- /** Files that count as a feature's contract; the first one found on disk is the one
65
- * checked for approval (mirrors sdd-specs' CONTRACT_FILES precedence). */
66
- const CONTRACT_FILES = [
67
- 'brief.md',
68
- 'requirements.md',
69
- 'design.md',
70
- 'tasks.md',
71
- 'asserts.md',
72
- ];
73
-
74
- const APPROVED_FRONTMATTER_PATTERN = /^---\r?\n(?<body>[\s\S]*?)\r?\n---/;
75
- const APPROVED_STATUS_PATTERN = /^status:[ \t]*approved[ \t]*$/im;
76
- const APPROVAL_QUOTE_PATTERN = /^approval_quote:[ \t]*(\S.*)$/im;
41
+ const FRONTMATTER_PATTERN = /^---\r?\n(?<body>[\s\S]*?)\r?\n---/;
42
+ const APPROVED_STATUS_PATTERN = /^status:[ \t]*["']?approved["']?[ \t]*$/im;
43
+ const APPROVAL_QUOTE_PATTERN = /^approval_quote:(.*)$/im;
77
44
 
78
- function withWordBoundary(alternation) {
79
- return new RegExp(
80
- `(?<![\\p{L}\\p{N}_])(${alternation})(?![\\p{L}\\p{N}_])`,
81
- 'iu',
82
- );
45
+ function resolveFrom(root, path) {
46
+ return isAbsolute(path) ? path : join(root, path);
83
47
  }
84
48
 
85
- const IMPLEMENTATION_VERBS = withWordBoundary(
86
- 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
87
- 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
88
- 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
89
- 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
90
- 'escrib(í|e)|escribir|implement\\w*|writ(e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
91
- 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
92
- );
93
-
94
- const DEMANDING_LEVEL_PATTERN =
95
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
96
- const EXEMPT_LEVEL_PATTERN =
97
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
98
-
99
- function findCatalog(catalogLocations) {
49
+ function findCatalog(root, catalogLocations) {
100
50
  for (const relative of catalogLocations) {
101
- const path = join(process.cwd(), relative);
51
+ const path = resolveFrom(root, relative);
102
52
  if (existsSync(path)) return path;
103
53
  }
104
54
  return null;
105
55
  }
106
56
 
107
- function contractTreeRootFor(catalogPath) {
57
+ function contractTreeRootFor(root, catalogPath) {
108
58
  const candidates = catalogPath
109
- ? [join(dirname(catalogPath), 'features')]
59
+ ? [join(dirname(catalogPath), FEATURES_DIRECTORY)]
110
60
  : [];
111
- candidates.push(join(process.cwd(), '.ai', 'features'));
112
- candidates.push(join(process.cwd(), 'features'));
113
- for (const candidate of candidates) {
114
- if (existsSync(candidate)) return candidate;
115
- }
116
- return null;
61
+ candidates.push(
62
+ join(root, '.ai', FEATURES_DIRECTORY),
63
+ join(root, FEATURES_DIRECTORY),
64
+ );
65
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
117
66
  }
118
67
 
119
- /** The first existing contract file's path for a feature, or null if none exists. */
120
- function contractFileFor(treeRoot, featureName) {
121
- if (!treeRoot) return null;
122
- const featureDirectory = join(treeRoot, featureName);
123
- if (!existsSync(featureDirectory)) return null;
124
- for (const file of CONTRACT_FILES) {
125
- const path = join(featureDirectory, file);
126
- if (existsSync(path)) return path;
127
- }
128
- return null;
68
+ function briefPathFor(treeRoot, featureName) {
69
+ const path = join(treeRoot, featureName, BRIEF_FILE);
70
+ return existsSync(path) ? path : null;
129
71
  }
130
72
 
131
- /** Whether a contract file's frontmatter records a real approval: `status: approved`
132
- * plus a non-empty `approval_quote`. Both must be present — status alone is a label
133
- * the assistant could set on itself with no evidence a quote was ever collected. */
134
- function isApproved(contractPath) {
73
+ function isApproved(briefPath) {
135
74
  let content;
136
75
  try {
137
- content = readFileSync(contractPath, 'utf8');
76
+ content = stripBom(readFileSync(briefPath, 'utf8'));
138
77
  } catch {
139
78
  return false;
140
79
  }
141
- const frontmatter = APPROVED_FRONTMATTER_PATTERN.exec(content)?.groups?.body;
80
+ const frontmatter = FRONTMATTER_PATTERN.exec(content)?.groups?.body;
142
81
  if (!frontmatter) return false;
143
82
  if (!APPROVED_STATUS_PATTERN.test(frontmatter)) return false;
144
- const quoteMatch = APPROVAL_QUOTE_PATTERN.exec(frontmatter);
145
- const quote = quoteMatch?.[1]?.replace(/^["']|["']$/g, '').trim();
146
- return Boolean(quote);
83
+ const quote = APPROVAL_QUOTE_PATTERN.exec(frontmatter)?.[1] ?? '';
84
+ return (
85
+ quote
86
+ .trim()
87
+ .replace(/^["']|["']$/g, '')
88
+ .trim().length > 0
89
+ );
147
90
  }
148
91
 
149
92
  function isExemptDelegation(toolInput, prompt, exemptSubagents) {
150
- const subagentType = String(
151
- toolInput.subagent_type ?? toolInput.subagentType ?? '',
152
- ).toLowerCase();
153
- if (exemptSubagents.includes(subagentType)) return true;
154
- if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
155
- if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
156
- if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
157
- return false;
158
- }
159
-
160
- function featureNamesCitedIn(prompt) {
161
- const pattern = /\.(?:ai)[\\/]features[\\/]([\w.@-]+)/gi;
162
- const names = [];
163
- let match;
164
- while ((match = pattern.exec(prompt)) !== null) names.push(match[1]);
165
- return names;
93
+ if (isSubagentNamedIn(toolInput, exemptSubagents)) return true;
94
+ if (!DEMANDING_LEVELS.has(operativeLevelOf(prompt))) return true;
95
+ return !isImplementationRequest(prompt);
166
96
  }
167
97
 
168
98
  runGate(
@@ -175,45 +105,46 @@ runGate(
175
105
  exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
176
106
  },
177
107
  },
178
- ({ toolName, toolInput, parameters }) => {
108
+ ({ toolName, toolInput, parameters, cwd }) => {
179
109
  if (!toolInGroups(toolName, ['delegation'])) return;
180
110
 
181
111
  const prompt = delegationPromptOf(toolInput);
182
112
  if (!prompt.trim()) return;
183
-
184
- const exemptSubagents =
185
- parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
186
- if (isExemptDelegation(toolInput, prompt, exemptSubagents)) return;
113
+ if (isExemptDelegation(toolInput, prompt, parameters.exemptSubagents))
114
+ return;
187
115
 
188
116
  const citedFeatures = featureNamesCitedIn(prompt);
189
- if (citedFeatures.length === 0) return; // no citation: nothing this gate can check
117
+ if (citedFeatures.length === 0) return;
190
118
 
119
+ const root = projectRootOf(cwd) ?? cwd;
191
120
  const catalogLocations =
192
- parameters.catalogLocations ?? DEFAULT_CATALOG_LOCATIONS;
193
- const catalogPath = findCatalog(catalogLocations);
194
- const treeRoot = contractTreeRootFor(catalogPath);
195
- if (!treeRoot) return; // no SDD harness adopted: stay silent
121
+ parameters.catalogLocations.length > 0
122
+ ? parameters.catalogLocations
123
+ : DEFAULT_CATALOG_LOCATIONS;
124
+ const treeRoot = contractTreeRootFor(
125
+ root,
126
+ findCatalog(root, catalogLocations),
127
+ );
128
+ if (!treeRoot) return;
196
129
 
197
130
  const unapproved = citedFeatures
198
131
  .map((feature) => ({
199
132
  feature,
200
- contractPath: contractFileFor(treeRoot, feature),
133
+ briefPath: briefPathFor(treeRoot, feature),
201
134
  }))
202
- .filter(({ contractPath }) => contractPath) // no contractPath: sdd-specs already denies that case
203
- .filter(({ contractPath }) => !isApproved(contractPath));
204
-
135
+ .filter(({ briefPath }) => briefPath && !isApproved(briefPath));
205
136
  if (unapproved.length === 0) return;
206
137
 
207
138
  const fileList = unapproved
208
- .map(({ feature, contractPath }) => `${feature} -> ${contractPath}`)
139
+ .map(({ feature, briefPath }) => `${feature} -> ${briefPath}`)
209
140
  .join('\n ');
210
141
  deny(
211
142
  CONFIG_KEY,
212
- `This implementation delegation cites unapproved feature(s):\n ${fileList}\n` +
143
+ `This implementation delegation cites feature(s) whose brief has no recorded approval:\n ${fileList}\n` +
213
144
  'Paste the FULL brief into the chat, get an explicit confirmation from the user ' +
214
- '(not a vague "dale"/"sigamos" — an actual sentence confirming they read it), ' +
215
- 'then add this frontmatter to the TOP of that exact contract file before ' +
216
- 'relaunching (no filesystem exploration needed — the path above is the file to edit):\n' +
145
+ '(not a vague "ok"/"sigamos" — an actual sentence confirming they read it), ' +
146
+ 'then add this frontmatter to the TOP of that exact brief.md before relaunching ' +
147
+ '(no filesystem exploration needed — the path above is the file to edit):\n' +
217
148
  '---\nstatus: approved\napproved_at: <ISO timestamp>\n' +
218
149
  'approval_quote: "<the user\'s own confirming words>"\n---',
219
150
  );