@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.es.md +129 -11
- package/README.md +120 -10
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +12 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +159 -25
- package/cli/task.mjs +104 -11
- package/cli/toggle.mjs +125 -0
- package/package.json +4 -3
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +152 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +148 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- 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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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(
|
|
52
|
+
normalized = normalized.replace(GH_REPO_OPTION_PATTERN, '$1');
|
|
78
53
|
} while (normalized !== previous);
|
|
79
54
|
return normalized;
|
|
80
55
|
}
|
|
81
56
|
|
|
82
|
-
function
|
|
83
|
-
return text
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
pattern
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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:
|
|
129
|
+
publishRules: DEFAULT_PUBLISH_RULES,
|
|
120
130
|
},
|
|
121
131
|
},
|
|
122
132
|
({ toolName, toolInput, parameters }) => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
79
|
-
return
|
|
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
|
-
|
|
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 =
|
|
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),
|
|
59
|
+
? [join(dirname(catalogPath), FEATURES_DIRECTORY)]
|
|
110
60
|
: [];
|
|
111
|
-
candidates.push(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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(
|
|
76
|
+
content = stripBom(readFileSync(briefPath, 'utf8'));
|
|
138
77
|
} catch {
|
|
139
78
|
return false;
|
|
140
79
|
}
|
|
141
|
-
const frontmatter =
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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;
|
|
117
|
+
if (citedFeatures.length === 0) return;
|
|
190
118
|
|
|
119
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
191
120
|
const catalogLocations =
|
|
192
|
-
parameters.catalogLocations
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
133
|
+
briefPath: briefPathFor(treeRoot, feature),
|
|
201
134
|
}))
|
|
202
|
-
.filter(({
|
|
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,
|
|
139
|
+
.map(({ feature, briefPath }) => `${feature} -> ${briefPath}`)
|
|
209
140
|
.join('\n ');
|
|
210
141
|
deny(
|
|
211
142
|
CONFIG_KEY,
|
|
212
|
-
`This implementation delegation cites
|
|
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 "
|
|
215
|
-
'then add this frontmatter to the TOP of that exact
|
|
216
|
-
'
|
|
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
|
);
|