@devrik-tools/claude-gates 0.7.2 → 0.8.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 (78) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +69 -9
  3. package/README.md +68 -7
  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 +11 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +114 -22
  14. package/cli/task.mjs +36 -8
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +1 -1
  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 +98 -212
  29. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  30. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  31. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  32. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  33. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  34. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  35. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  36. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  37. package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
  38. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  39. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  40. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  41. package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
  42. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  43. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  44. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  45. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  46. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  47. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  48. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  49. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  50. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  51. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  52. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  53. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  54. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  55. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  57. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  58. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  59. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  60. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  61. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  62. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  63. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  64. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  65. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  66. package/plugins/gates/hooks/hooks.json +112 -42
  67. package/plugins/gates/hooks/lib/config.mjs +6 -3
  68. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  69. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  70. package/plugins/gates/hooks/lib/git.mjs +262 -0
  71. package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
  72. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  73. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  74. package/plugins/gates/hooks/lib/testing.mjs +137 -0
  75. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  76. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  77. package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
  78. package/registry.json +204 -36
@@ -1,44 +1,20 @@
1
- // no-lint-suppression — denies a write that SILENCES the linter/type-checker instead of
2
- // fixing the code it complains about. Turning a rule off, adding an inline disable, or
3
- // widening an ignore list is the lazy path ("it is easier to disable the linter than to fix
4
- // it"); this gate makes that path a deliberate, escape-hatched choice rather than a default.
5
- //
6
- // justification: no existing gate covers this. lint-commit RUNS the linter and blocks a
7
- // commit when it fails, but a suppression makes the linter pass so lint-commit goes green
8
- // precisely when the code got worse. This gate reads the WRITE, not the lint result, and is
9
- // the only one that catches "made it pass by turning the check off".
10
- //
11
- // The defect is fully visible in the content being written (the disable directive, the rule
12
- // set to "off", the added @ts-ignore), so this is a deterministic deny with an escape hatch —
13
- // not a prose reminder. A legitimate suppression (a documented false positive) is allowed by
14
- // putting the escape hatch on the SAME line as the directive, which also forces a reason to
15
- // live next to it in the diff.
16
- //
17
- // ── What this catches ────────────────────────────────────────────────────────────────
18
- // inline directives in any source file:
19
- // // eslint-disable, /* eslint-disable */, // eslint-disable-next-line,
20
- // // @ts-ignore, // @ts-nocheck, # type: ignore, # noqa, // prettier-ignore,
21
- // // biome-ignore, // stylelint-disable, // NOSONAR
22
- // config edits that weaken the ruleset, in eslint/tsconfig/prettier/biome/stylelint config:
23
- // a rule set to "off" / 0, "@ts-nocheck", disabling strict, or "ignore" additions.
24
- //
25
- // ── What a project can configure (params) ───────────────────────────────────────────
26
- // suppressionPatterns regex sources (case-insensitive) that mark a line as a
27
- // suppression. Replaces the built-in list wholesale.
28
- // escapeHatch substring that, present on the same line as a suppression, allows
29
- // it — so a real false positive is annotated, not smuggled. Default
30
- // 'lint-ok:' (write e.g. `// eslint-disable-next-line ... lint-ok: <reason>`).
31
- // watchedConfigFiles basenames whose edits are also scanned for rule-weakening. Replaces
32
- // the built-in list.
33
- //
34
- // ── Fail-safe shape ──────────────────────────────────────────────────────────────────
35
- // A non-write tool, or a write with no suppression line: allow (silent). A write that adds a
36
- // suppression line WITHOUT the escape hatch on that same line: deny, naming the line.
37
-
38
- import { extname } from 'node:path';
1
+ // no-lint-suppression — denies a write that SILENCES a linter/type-checker instead of fixing
2
+ // the code: a NEW inline disable directive in a source file, or a rule turned off/downgraded
3
+ // in a watched config file. A line already present in the file on disk is not a new
4
+ // suppression (rewriting a file must not re-litigate old ones). A legitimate suppression is
5
+ // allowed by putting the escape hatch on the SAME line, so the reason lives next to it in the
6
+ // diff. Deliberate limits: `@ts-expect-error` WITH a description is the recommended form and
7
+ // passes; in config files only a rule-like key (`a/b`, `no-x`, or one under a rules/overrides
8
+ // block) counts, so `"printWidth": 0` is not a weakening; shell writes are out of scope.
9
+
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { basename, extname, resolve } from 'node:path';
12
+ import { projectRootOf } from '../../lib/config.mjs';
39
13
  import {
40
- runGate,
14
+ compileRegexList,
41
15
  deny,
16
+ escapeRegExp,
17
+ runGate,
42
18
  toolInGroups,
43
19
  writtenContentOf,
44
20
  writtenPathOf,
@@ -47,28 +23,36 @@ import {
47
23
  const GATE_ID = 'no-lint-suppression';
48
24
  const CONFIG_KEY = 'blockLintSuppression';
49
25
 
50
- // Inline suppression directives across the common linters/type-checkers. Sources, compiled
51
- // case-insensitively and matched one line at a time.
52
26
  const DEFAULT_SUPPRESSION_PATTERNS = [
53
- String.raw`eslint-disable`,
54
- String.raw`@ts-ignore`,
55
- String.raw`@ts-nocheck`,
56
- String.raw`@ts-expect-error`,
57
- String.raw`prettier-ignore`,
58
- String.raw`biome-ignore`,
59
- String.raw`stylelint-disable`,
60
- String.raw`nosonar`,
61
- String.raw`#\s*noqa`,
62
- String.raw`#\s*type:\s*ignore`,
63
- String.raw`//\s*@flow-ignore`,
64
- String.raw`istanbul ignore`,
27
+ 'eslint-disable',
28
+ '@ts-ignore',
29
+ '@ts-nocheck',
30
+ '@ts-expect-error\\s*[:-]?\\s*$',
31
+ 'prettier-ignore',
32
+ 'biome-ignore',
33
+ 'stylelint-disable',
34
+ 'nosonar',
35
+ '#\\s*noqa',
36
+ '#\\s*type:\\s*ignore',
37
+ '//\\s*@flow-ignore',
38
+ 'istanbul ignore',
39
+ 'c8 ignore',
40
+ 'pylint:\\s*disable',
41
+ 'pyright:\\s*ignore',
42
+ '//\\s*nolint',
43
+ '#\\[allow\\(',
44
+ '#pragma\\s+warning\\s+disable',
45
+ '@SuppressWarnings',
65
46
  ];
66
47
 
67
- // Config files whose edits are scanned for rule-weakening (a rule set off, strict disabled).
48
+ // Basenames; `*` matches within one name.
68
49
  const DEFAULT_WATCHED_CONFIG_FILES = [
69
50
  'eslint.config.mjs',
70
51
  'eslint.config.js',
71
52
  'eslint.config.cjs',
53
+ 'eslint.config.ts',
54
+ 'eslint.config.mts',
55
+ 'eslint.config.cts',
72
56
  '.eslintrc',
73
57
  '.eslintrc.js',
74
58
  '.eslintrc.cjs',
@@ -76,60 +60,138 @@ const DEFAULT_WATCHED_CONFIG_FILES = [
76
60
  '.eslintrc.yml',
77
61
  '.eslintrc.yaml',
78
62
  'tsconfig.json',
63
+ 'tsconfig.*.json',
64
+ 'jsconfig.json',
79
65
  '.prettierrc',
66
+ '.prettierrc.json',
80
67
  'biome.json',
81
68
  '.stylelintrc',
82
69
  '.stylelintrc.json',
83
- ];
84
-
85
- // Inside a watched config file, these mark a rule being turned off or a check being weakened.
86
- const CONFIG_WEAKENING_PATTERNS = [
87
- // a rule mapped to "off" or 0: "no-unused-vars": "off" 'rule': 0
88
- // (no trailing \b: the value can end in a quote, which is a non-word char, so \b would
89
- // never match after "off" and silently miss every quoted "off")
90
- String.raw`["'][^"']+["']\s*:\s*(?:["']off["']|0)(?:\s|,|}|$)`,
91
- // strict / type-checking disabled in tsconfig
92
- String.raw`"(?:strict|noImplicitAny|strictNullChecks|checkJs)"\s*:\s*false`,
93
- String.raw`@ts-nocheck`,
70
+ 'package.json',
94
71
  ];
95
72
 
96
73
  const DEFAULT_ESCAPE_HATCH = 'lint-ok:';
97
74
 
98
- function compile(sources) {
99
- const compiled = [];
100
- for (const source of sources) {
101
- try {
102
- compiled.push(new RegExp(source, 'i'));
103
- } catch {
104
- // Skip a malformed override pattern rather than crashing; the rest still protect.
105
- }
75
+ const NON_SOURCE_EXTENSIONS = new Set(['.md', '.mdx', '.txt', '.lock', '.log']);
76
+
77
+ // ── Config weakening ────────────────────────────────────────────────────────────────
78
+ const RULES_SECTION_PATTERN = /(?:^|[\s{,])["']?(?:rules|overrides)["']?\s*:/;
79
+ const LINTER_SECTION_PATTERN = /["']linter["']\s*:/;
80
+ const STRICTNESS_OFF_PATTERN =
81
+ /["'](?:strict|noImplicitAny|strictNullChecks|checkJs)["']\s*:\s*false/;
82
+ const LINTER_DISABLED_PATTERN = /["']enabled["']\s*:\s*false/;
83
+ const TS_NOCHECK_PATTERN = /@ts-nocheck/i;
84
+ const DISABLING_TOKEN_PATTERN = /^["']?(?:off|warn|0)["']?(?=\s*(?:[,}\]]|$))/i;
85
+ const RULE_LIKE_KEY = /[/-]/;
86
+ const KEY_CHARACTER = /[\w@./-]/;
87
+
88
+ function hasSectionBefore(lines, index, sectionPattern) {
89
+ for (let cursor = index; cursor >= 0; cursor -= 1)
90
+ if (sectionPattern.test(lines[cursor])) return true;
91
+ return false;
92
+ }
93
+
94
+ function isDisablingValue(rest) {
95
+ const value = rest.trimStart().replace(/^\[\s*/, '');
96
+ return DISABLING_TOKEN_PATTERN.test(value);
97
+ }
98
+
99
+ // Each `key: value` on the line, located by walking back from every colon (a regex over
100
+ // the key run is quadratic on colon-less lines).
101
+ function assignmentsOf(line) {
102
+ const found = [];
103
+ for (
104
+ let colon = line.indexOf(':');
105
+ colon !== -1;
106
+ colon = line.indexOf(':', colon + 1)
107
+ ) {
108
+ let end = colon;
109
+ while (end > 0 && /\s/.test(line[end - 1])) end -= 1;
110
+ if (end > 0 && (line[end - 1] === '"' || line[end - 1] === "'")) end -= 1;
111
+ let start = end;
112
+ while (start > 0 && KEY_CHARACTER.test(line[start - 1])) start -= 1;
113
+ if (start < end)
114
+ found.push({ key: line.slice(start, end), rest: line.slice(colon + 1) });
115
+ }
116
+ return found;
117
+ }
118
+
119
+ function assignsDisablingValue(line, index, lines) {
120
+ for (const { key, rest } of assignmentsOf(line)) {
121
+ if (!isDisablingValue(rest)) continue;
122
+ if (RULE_LIKE_KEY.test(key)) return true;
123
+ if (hasSectionBefore(lines, index, RULES_SECTION_PATTERN)) return true;
106
124
  }
107
- return compiled;
125
+ return false;
126
+ }
127
+
128
+ function weakensConfig(lines, index) {
129
+ const line = lines[index];
130
+ if (STRICTNESS_OFF_PATTERN.test(line) || TS_NOCHECK_PATTERN.test(line))
131
+ return true;
132
+ if (
133
+ LINTER_DISABLED_PATTERN.test(line) &&
134
+ hasSectionBefore(lines, index, LINTER_SECTION_PATTERN)
135
+ )
136
+ return true;
137
+ return assignsDisablingValue(line, index, lines);
108
138
  }
109
139
 
110
- function baseNameOf(filePath) {
111
- const normalized = filePath.replace(/\\/g, '/');
112
- return normalized.slice(normalized.lastIndexOf('/') + 1);
140
+ function watchedFilePatterns(names) {
141
+ return compileRegexList(
142
+ names.map((name) => `^${escapeRegExp(name).replace(/\\\*/g, '[^/]*')}$`),
143
+ ).patterns;
113
144
  }
114
145
 
115
- function isWatchedConfig(filePath, watchedConfigFiles) {
116
- return watchedConfigFiles.includes(baseNameOf(filePath));
146
+ function isWatchedConfig(filePath, content, watchedConfigFiles) {
147
+ const name = basename(filePath.replace(/\\/g, '/'));
148
+ if (name === 'package.json' && !content.includes('eslintConfig'))
149
+ return false;
150
+ return watchedFilePatterns(watchedConfigFiles).some((pattern) =>
151
+ pattern.test(name),
152
+ );
117
153
  }
118
154
 
119
- // A code file's inline directives count everywhere; a config file's edits are read with the
120
- // rule-weakening patterns instead. We never guess a language the directives are recognizable
121
- // on their own.
122
- const NON_SOURCE_EXTENSIONS = new Set(['.md', '.txt', '.lock', '.log']);
155
+ // ── Lines already on disk are not new suppressions ──────────────────────────────────
156
+ // Counted, not just collected: a directive the file already has once does not license a
157
+ // second identical one.
158
+ function existingLineCounts(filePath, root) {
159
+ const counts = new Map();
160
+ const resolved = resolve(root, filePath);
161
+ try {
162
+ if (!existsSync(resolved)) return counts;
163
+ for (const line of readFileSync(resolved, 'utf8').split('\n')) {
164
+ const trimmed = line.trim();
165
+ counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
166
+ }
167
+ } catch {
168
+ counts.clear();
169
+ }
170
+ return counts;
171
+ }
123
172
 
124
- function isPlausibleSource(filePath) {
125
- return !NON_SOURCE_EXTENSIONS.has(extname(filePath).toLowerCase());
173
+ function consumeExisting(existing, trimmed) {
174
+ const remaining = existing.get(trimmed) ?? 0;
175
+ if (remaining === 0) return false;
176
+ existing.set(trimmed, remaining - 1);
177
+ return true;
126
178
  }
127
179
 
128
- /** The first written line matching any pattern and lacking the escape hatch, or null. */
129
- function offendingLine(content, patterns, escapeHatch) {
130
- for (const line of content.split('\n')) {
180
+ function firstOffendingLine({
181
+ content,
182
+ inlinePatterns,
183
+ isConfig,
184
+ escapeHatch,
185
+ existing,
186
+ }) {
187
+ const lines = content.split('\n');
188
+ for (let index = 0; index < lines.length; index += 1) {
189
+ const line = lines[index];
190
+ const trimmed = line.trim();
191
+ if (!trimmed || consumeExisting(existing, trimmed)) continue;
131
192
  if (escapeHatch && line.includes(escapeHatch)) continue;
132
- if (patterns.some((pattern) => pattern.test(line))) return line.trim();
193
+ if (inlinePatterns.some((pattern) => pattern.test(line))) return trimmed;
194
+ if (isConfig && weakensConfig(lines, index)) return trimmed;
133
195
  }
134
196
  return null;
135
197
  }
@@ -145,31 +207,29 @@ runGate(
145
207
  escapeHatch: DEFAULT_ESCAPE_HATCH,
146
208
  },
147
209
  },
148
- ({ toolName, toolInput, parameters }) => {
210
+ ({ toolName, toolInput, parameters, cwd }) => {
149
211
  if (!toolInGroups(toolName, ['write'])) return;
150
212
 
151
213
  const filePath = writtenPathOf(toolInput);
152
- if (!filePath) return;
153
-
154
214
  const content = writtenContentOf(toolInput);
155
- if (!content) return;
156
-
157
- const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
158
-
159
- // Config files: scan for rule-weakening. Any source file: scan for inline suppressions.
160
- const isConfig = isWatchedConfig(filePath, parameters.watchedConfigFiles);
161
- const inlinePatterns = compile(parameters.suppressionPatterns);
162
- const configPatterns = compile(CONFIG_WEAKENING_PATTERNS);
215
+ if (!filePath || !content) return;
163
216
 
164
- // A config file is scanned for both inline directives and rule-weakening; any other
165
- // plausible source file only for inline directives; a non-source file (docs, lockfiles)
166
- // is not scanned at all.
167
- let patterns = [];
168
- if (isConfig) patterns = [...inlinePatterns, ...configPatterns];
169
- else if (isPlausibleSource(filePath)) patterns = inlinePatterns;
170
- if (patterns.length === 0) return;
171
-
172
- const offender = offendingLine(content, patterns, escapeHatch);
217
+ const isConfig = isWatchedConfig(
218
+ filePath,
219
+ content,
220
+ parameters.watchedConfigFiles,
221
+ );
222
+ if (!isConfig && NON_SOURCE_EXTENSIONS.has(extname(filePath).toLowerCase()))
223
+ return;
224
+
225
+ const escapeHatch = String(parameters.escapeHatch ?? '');
226
+ const offender = firstOffendingLine({
227
+ content,
228
+ inlinePatterns: compileRegexList(parameters.suppressionPatterns).patterns,
229
+ isConfig,
230
+ escapeHatch,
231
+ existing: existingLineCounts(filePath, projectRootOf(cwd) ?? cwd),
232
+ });
173
233
  if (!offender) return;
174
234
 
175
235
  deny(
@@ -177,7 +237,7 @@ runGate(
177
237
  `This write silences the linter/type-checker instead of fixing the code: "${offender}". ` +
178
238
  'Fix the underlying issue rather than turning the check off. If this is a genuine, ' +
179
239
  `documented false positive, put "${escapeHatch} <reason>" on the same line so the ` +
180
- 'reason lives next to the suppression, or set blockLintSuppression in .ai/config.json.',
240
+ `reason lives next to the suppression, or set ${CONFIG_KEY} in .ai/config.json.`,
181
241
  );
182
242
  },
183
243
  );
@@ -1,33 +1,29 @@
1
- // no-memory-dependency — denies when a delegation prompt leans on the subagent
2
- // "remembering" something said earlier in conversation instead of carrying the data itself.
3
- // A fresh subagent has no access to the delegator's conversation history: if the data
4
- // matters, it must travel IN the prompt, in a file the subagent reads, or in an
5
- // already-persisted decision. The dependency on unavailable memory is a complete, objective
6
- // fact once stage 2 has ruled out a real persistence instruction — so this blocks.
1
+ // no-memory-dependency — denies a delegation whose prompt refers the subagent to something
2
+ // said earlier in the conversation ("como te dije", "as we discussed") instead of carrying
3
+ // the data itself: a fresh subagent has none of the delegator's history.
7
4
  //
8
- // ── Two stages, same pattern as intent-flow ─────────────────────────────────────────
9
- // Stage 1 (cheap): does any memory-dependency phrase appear anywhere? If not, allow.
10
- // Stage 2 (confirmation, only on candidates): strip quoted text, then discard a phrase
11
- // that has a REAL deterministic persistence INSTRUCTION nearby (not just a noun/verb
12
- // mentioned in passing) "no te olvides de guardar la decision en .ai/decision.md"
13
- // genuinely depends on a file, not on model memory. A persistence noun/verb that merely
14
- // co-occurs in the window without forming an instruction TO persist THIS remembered
15
- // thing (e.g. an unrelated file named elsewhere in the same sentence) does not suppress
16
- // the deny.
17
- //
18
- // ── Escape hatch ─────────────────────────────────────────────────────────────────────
19
- // A memory phrase can be a false positive: "no te olvides de cerrar el server al final"
20
- // directs the SUBAGENT'S own future action, not data it must recall from the delegator. For
21
- // that case the author adds the escapeHatch marker to the prompt to state, explicitly, that
22
- // no cross-conversation memory is actually required.
5
+ // Decisions: the default phrases are references to PRIOR CONVERSATION only — "don't forget
6
+ // to X", "keep in mind that <fact>", "remember to run the tests" carry their own content and
7
+ // are allowed. A reference next to a real persistence instruction ("como quedamos, guardá la
8
+ // decisión en .ai/decision.md") depends on a file, not on memory, and is allowed. The escape
9
+ // hatch marker states that no recalled data is actually needed.
23
10
 
11
+ import {
12
+ allMatches,
13
+ promptExcerpt,
14
+ stripQuoted,
15
+ } from '../../lib/delegation.mjs';
24
16
  import {
25
17
  runGate,
26
18
  deny,
19
+ compileRegexList,
27
20
  toolInGroups,
28
21
  delegationPromptOf,
29
22
  } from '../../lib/hook-io.mjs';
30
- import { PERSISTENCE_VERB } from '../../lib/signals.mjs';
23
+ import {
24
+ PERSISTENCE_VERB,
25
+ withUnicodeWordBoundary,
26
+ } from '../../lib/signals.mjs';
31
27
 
32
28
  const GATE_ID = 'no-memory-dependency';
33
29
  const CONFIG_KEY = 'warnMemoryDependencyInBrief';
@@ -35,64 +31,39 @@ const CONFIG_KEY = 'warnMemoryDependencyInBrief';
35
31
  const DEFAULT_ESCAPE_HATCH = 'memory-not-needed';
36
32
 
37
33
  const DEFAULT_MEMORY_DEPENDENCY_PATTERNS = [
38
- 'acordate de|acu[eé]rdate de',
39
- 'no olvides|no te olvides',
40
- 'record[aá] que|ten[] en cuenta que',
41
- 'como te dije|como ya te dije|ya te dije|te lo dije antes|ya te ped[ií]',
42
- "remember to|don'?t forget|keep in mind",
34
+ 'recuerda que|acu[eé]rdate de (?:lo )?que|acordate de (?:lo )?que|como te dije',
35
+ 'como (vimos|hablamos|dijimos|quedamos)|lo de antes',
36
+ 'lo que te (dije|comenté|pasé)|ya sabes cu[]l',
37
+ 'el (archivo|cambio|tema) de antes',
38
+ 'remember that we|as (i|we) (said|discussed|mentioned|agreed)|as discussed',
39
+ 'as mentioned (earlier|before|above)|the one from (before|earlier)',
40
+ 'what we (discussed|talked about|decided)|you know which',
41
+ 'like (before|last time)|the previous (one|file|change) i mentioned',
43
42
  ];
44
43
 
45
- // A real persistence INSTRUCTION requires an imperative persistence VERB (an actual
46
- // directive to save/persist/write THIS remembered thing), not merely a persistence-
47
- // related NOUN mentioned somewhere nearby (a stray "incident.md" in the same sentence
48
- // names a file without instructing anything be saved to it). Centralized in
49
- // lib/signals.mjs (ES+EN) so this class of signal is not duplicated per gate.
50
- const PERSISTENCE_VERB_PATTERN = PERSISTENCE_VERB;
51
-
52
44
  const PERSISTENCE_WINDOW = 80;
53
45
 
54
- function withUnicodeWordBoundary(alternatives) {
55
- return new RegExp(
56
- `(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
57
- 'iu',
58
- );
59
- }
60
-
61
- function stripQuoted(text) {
62
- return text
63
- .replace(/```[\s\S]*?```/g, ' ')
64
- .replace(/"[^"\n]{0,300}"/g, ' ')
65
- .replace(/'[^'\n]{0,300}'/g, ' ');
66
- }
67
-
68
- function allMatches(pattern, text) {
69
- const flags = pattern.flags.includes('g')
70
- ? pattern.flags
71
- : `${pattern.flags}g`;
72
- return [...text.matchAll(new RegExp(pattern.source, flags))];
73
- }
74
-
75
- /** True only when the window around a memory phrase carries a real persistence
76
- * INSTRUCTION — an imperative persistence verb — not merely a persistence-related noun
77
- * mentioned in passing. A noun alone ("el reporte esta en incident.md") names a file
78
- * without instructing anything be saved to it, so it must NOT suppress the warning. */
79
46
  function hasPersistenceInstructionNearby(text, match) {
80
47
  const from = Math.max(0, match.index - PERSISTENCE_WINDOW);
81
48
  const to = Math.min(
82
49
  text.length,
83
50
  match.index + match[0].length + PERSISTENCE_WINDOW,
84
51
  );
85
- const window = text.slice(from, to);
86
- return PERSISTENCE_VERB_PATTERN.test(window);
52
+ return PERSISTENCE_VERB.test(text.slice(from, to));
87
53
  }
88
54
 
89
- function unresolvedMemoryPhrases(prompt, memoryPattern) {
90
- if (!memoryPattern.test(prompt)) return []; // stage 1: no candidate, allow cheap
91
-
92
- const text = stripQuoted(prompt); // stage 2(a)
93
- const matches = allMatches(memoryPattern, text);
55
+ function memoryPatternFrom(sources) {
56
+ const { patterns } = compileRegexList(sources, 'iu');
57
+ if (patterns.length === 0) return null;
58
+ return withUnicodeWordBoundary(
59
+ patterns.map((pattern) => `(?:${pattern.source})`).join('|'),
60
+ );
61
+ }
94
62
 
95
- return matches
63
+ function unresolvedMemoryPhrases(prompt, memoryPattern) {
64
+ if (!memoryPattern.test(prompt)) return [];
65
+ const text = stripQuoted(prompt);
66
+ return allMatches(memoryPattern, text)
96
67
  .filter((match) => !hasPersistenceInstructionNearby(text, match))
97
68
  .map((match) => match[0].trim());
98
69
  }
@@ -113,15 +84,13 @@ runGate(
113
84
  const prompt = delegationPromptOf(toolInput);
114
85
  if (!prompt.trim()) return;
115
86
 
116
- // Explicit opt-out: the author states no cross-conversation memory is actually required.
117
- const escapeHatch = (
118
- parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH
119
- ).toLowerCase();
87
+ const escapeHatch = String(parameters.escapeHatch ?? '').toLowerCase();
120
88
  if (escapeHatch && prompt.toLowerCase().includes(escapeHatch)) return;
121
89
 
122
- const memoryPattern = withUnicodeWordBoundary(
123
- (parameters.memoryDependencyPatterns ?? []).join('|'),
90
+ const memoryPattern = memoryPatternFrom(
91
+ parameters.memoryDependencyPatterns,
124
92
  );
93
+ if (!memoryPattern) return;
125
94
 
126
95
  const phrases = unresolvedMemoryPhrases(prompt, memoryPattern);
127
96
  if (phrases.length === 0) return;
@@ -129,10 +98,10 @@ runGate(
129
98
  const quotedPhrases = phrases.map((phrase) => `"${phrase}"`).join(', ');
130
99
  deny(
131
100
  CONFIG_KEY,
132
- `This delegation depends on the subagent remembering something (${quotedPhrases}), but a ` +
133
- 'fresh subagent has none of this conversation. Put the data IN the prompt, in a file it ' +
134
- 'reads, a flag, or an already-persisted decision. If the phrase directs the subagent’s ' +
135
- `own future action and needs no recalled data, add the marker "${parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH}" to the prompt.`,
101
+ `This delegation ("${promptExcerpt(prompt)}") depends on the subagent remembering something ` +
102
+ `(${quotedPhrases}), but a fresh subagent has none of this conversation. Put the data IN the ` +
103
+ 'prompt, in a file it reads, a flag, or an already-persisted decision. If the phrase needs no ' +
104
+ `recalled data, add the marker "${escapeHatch || DEFAULT_ESCAPE_HATCH}" to the prompt.`,
136
105
  );
137
106
  },
138
107
  );