@devrik-tools/claude-gates 0.3.1 → 0.6.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 (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +25 -4
  3. package/README.md +75 -43
  4. package/cli/config.mjs +126 -124
  5. package/cli/index.mjs +154 -104
  6. package/cli/init.mjs +303 -276
  7. package/cli/install.mjs +281 -175
  8. package/cli/materialize.mjs +103 -102
  9. package/cli/registry.mjs +139 -136
  10. package/cli/smoke-fixtures.json +441 -0
  11. package/cli/smoke.mjs +129 -0
  12. package/cli/task.mjs +140 -140
  13. package/package.json +1 -1
  14. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  15. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  16. package/plugins/gates/hooks/doctor.mjs +207 -207
  17. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  18. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  19. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  20. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  21. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  22. package/plugins/gates/hooks/gates/capability-map/index.mjs +377 -0
  23. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  24. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  25. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  26. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  27. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  28. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  29. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  30. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  31. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  32. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  33. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  34. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  35. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  36. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  37. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  38. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  39. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  40. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  41. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  42. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  43. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  44. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  45. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  46. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  47. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  48. package/plugins/gates/hooks/hooks.json +51 -0
  49. package/plugins/gates/hooks/lib/config.mjs +179 -172
  50. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  51. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  52. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  53. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  54. package/plugins/tasks/hooks/hooks.json +26 -26
  55. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  56. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  57. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  58. package/registry.json +171 -1
@@ -1,144 +1,149 @@
1
- // protected-paths — denies writes and mutating shell commands that target a path
2
- // containing one of the project's protected fragments (.env, lockfiles, the harness
3
- // itself). Migrated from ~/.claude/hooks/guard-protected-paths.mjs: that guard protected
4
- // hardcoded "code directories" (src, tests) to force delegation through a Leader role.
5
- // This gate keeps only the path-protection mechanism — sensitive files no write should
6
- // ever touch — and drops the Leader-role/delegation policy, which is a different concern.
7
- //
8
- // ── What a project can configure (params) ───────────────────────────────────────────
9
- // protectedPaths path fragments (case-insensitive) that no write or mutating
10
- // command may target. Replaces the built-in list wholesale.
11
- // mutatingCommands shell command names treated as mutating, so a merely-reading
12
- // command that mentions a protected path (echo, cat, grep) is
13
- // never denied. Replaces the built-in list wholesale.
14
- // The defaults live here, in the source, so a project reads them and knows exactly what
15
- // its override replaces.
16
-
17
- import { runGate, deny, toolInGroups, writtenPathOf } from '../../lib/hook-io.mjs';
18
-
19
- const GATE_ID = 'protected-paths';
20
- const CONFIG_KEY = 'blockWritesToProtectedPaths';
21
-
22
- const DEFAULT_PROTECTED_PATHS = [
23
- '.env',
24
- 'package-lock.json',
25
- 'yarn.lock',
26
- 'pnpm-lock.yaml',
27
- 'hooks/',
28
- ];
29
-
30
- const DEFAULT_MUTATING_COMMANDS = [
31
- 'touch',
32
- 'rm',
33
- 'cp',
34
- 'mv',
35
- 'sed\\s+-i',
36
- 'tee',
37
- 'install',
38
- 'chmod',
39
- 'chown',
40
- 'truncate',
41
- ];
42
-
43
- function escapeRegExp(fragment) {
44
- return fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
45
- }
46
-
47
- // Windows delivers absolute paths with backslashes ('C:\repo\hooks\gates\x.mjs'), but a
48
- // protected fragment like 'hooks/' is written with a forward slash. Normalizing the
49
- // separator before comparing means the fragment matches regardless of which OS produced
50
- // the path, without weakening the existing case-insensitive comparison.
51
- function toForwardSlashes(path) {
52
- return path.replace(/\\/g, '/');
53
- }
54
-
55
- function isProtectedPath(path, protectedPaths) {
56
- const normalized = toForwardSlashes(path).toLowerCase();
57
- return protectedPaths.some((fragment) =>
58
- normalized.includes(fragment.toLowerCase()),
59
- );
60
- }
61
-
62
- function buildCommandPatterns(protectedPaths, mutatingCommands) {
63
- const pathAlternation = protectedPaths.map(escapeRegExp).join('|');
64
- const mutatingAlternation = mutatingCommands.join('|');
65
- const wordBoundary = String.raw`\b`;
66
- const redirectPrefix = String.raw`>>?\s*\S*`;
67
- return {
68
- mutatingCommand: new RegExp(
69
- `${wordBoundary}(${mutatingAlternation})${wordBoundary}`,
70
- 'i',
71
- ),
72
- protectedTarget: new RegExp(`(${pathAlternation})`, 'i'),
73
- redirectToProtected: new RegExp(
74
- `${redirectPrefix}(${pathAlternation})`,
75
- 'i',
76
- ),
77
- };
78
- }
79
-
80
- /** The write-tool branch: deny when the target path matches a protected fragment. */
81
- function checkWrite(toolInput, protectedPaths) {
82
- const target = writtenPathOf(toolInput);
83
- if (target && isProtectedPath(target, protectedPaths)) {
84
- deny(
85
- GATE_ID,
86
- `Writing to '${target}' is not allowed: it matches a protected path (${protectedPaths.join(', ')}).`,
87
- );
88
- }
89
- }
90
-
91
- /** The shell-tool branch: deny a mutating command whose target matches a protected path. */
92
- function checkShellCommand(toolInput, protectedPaths, mutatingCommands) {
93
- const rawCommand = String(toolInput.CommandLine ?? toolInput.command ?? '');
94
- if (!rawCommand.trim()) return;
95
- // Same separator normalization as the write branch: a command embedding a Windows
96
- // path ('rm C:\repo\hooks\gates\evil.mjs') must still match a 'hooks/' fragment.
97
- const command = toForwardSlashes(rawCommand);
98
-
99
- const { mutatingCommand, protectedTarget, redirectToProtected } =
100
- buildCommandPatterns(protectedPaths, mutatingCommands);
101
-
102
- const targetsProtected =
103
- redirectToProtected.test(command) ||
104
- (mutatingCommand.test(command) && protectedTarget.test(command));
105
-
106
- if (targetsProtected) {
107
- deny(
108
- GATE_ID,
109
- `This command targets a protected path (${protectedPaths.join(', ')}) with a mutating operation. ` +
110
- 'Reading (echo/cat/grep) is fine; modifying it is not.',
111
- );
112
- }
113
- }
114
-
115
- runGate(
116
- {
117
- id: GATE_ID,
118
- configKey: CONFIG_KEY,
119
- enabledByDefault: true,
120
- defaultParams: {
121
- protectedPaths: DEFAULT_PROTECTED_PATHS,
122
- mutatingCommands: DEFAULT_MUTATING_COMMANDS,
123
- },
124
- },
125
- ({ toolName, toolInput, parameters }) => {
126
- const isWrite = toolInGroups(toolName, ['write']);
127
- const isShell = toolInGroups(toolName, ['shell']);
128
- if (!isWrite && !isShell) return;
129
-
130
- const protectedPaths = parameters.protectedPaths ?? [];
131
- if (protectedPaths.length === 0) return;
132
-
133
- if (isWrite) {
134
- checkWrite(toolInput, protectedPaths);
135
- return;
136
- }
137
-
138
- checkShellCommand(
139
- toolInput,
140
- protectedPaths,
141
- parameters.mutatingCommands ?? [],
142
- );
143
- },
144
- );
1
+ // protected-paths — denies writes and mutating shell commands that target a path
2
+ // containing one of the project's protected fragments (.env, lockfiles, the harness
3
+ // itself). Migrated from ~/.claude/hooks/guard-protected-paths.mjs: that guard protected
4
+ // hardcoded "code directories" (src, tests) to force delegation through a Leader role.
5
+ // This gate keeps only the path-protection mechanism — sensitive files no write should
6
+ // ever touch — and drops the Leader-role/delegation policy, which is a different concern.
7
+ //
8
+ // ── What a project can configure (params) ───────────────────────────────────────────
9
+ // protectedPaths path fragments (case-insensitive) that no write or mutating
10
+ // command may target. Replaces the built-in list wholesale.
11
+ // mutatingCommands shell command names treated as mutating, so a merely-reading
12
+ // command that mentions a protected path (echo, cat, grep) is
13
+ // never denied. Replaces the built-in list wholesale.
14
+ // The defaults live here, in the source, so a project reads them and knows exactly what
15
+ // its override replaces.
16
+
17
+ import {
18
+ runGate,
19
+ deny,
20
+ toolInGroups,
21
+ writtenPathOf,
22
+ } from '../../lib/hook-io.mjs';
23
+
24
+ const GATE_ID = 'protected-paths';
25
+ const CONFIG_KEY = 'blockWritesToProtectedPaths';
26
+
27
+ const DEFAULT_PROTECTED_PATHS = [
28
+ '.env',
29
+ 'package-lock.json',
30
+ 'yarn.lock',
31
+ 'pnpm-lock.yaml',
32
+ 'hooks/',
33
+ ];
34
+
35
+ const DEFAULT_MUTATING_COMMANDS = [
36
+ 'touch',
37
+ 'rm',
38
+ 'cp',
39
+ 'mv',
40
+ 'sed\\s+-i',
41
+ 'tee',
42
+ 'install',
43
+ 'chmod',
44
+ 'chown',
45
+ 'truncate',
46
+ ];
47
+
48
+ function escapeRegExp(fragment) {
49
+ return fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50
+ }
51
+
52
+ // Windows delivers absolute paths with backslashes ('C:\repo\hooks\gates\x.mjs'), but a
53
+ // protected fragment like 'hooks/' is written with a forward slash. Normalizing the
54
+ // separator before comparing means the fragment matches regardless of which OS produced
55
+ // the path, without weakening the existing case-insensitive comparison.
56
+ function toForwardSlashes(path) {
57
+ return path.replace(/\\/g, '/');
58
+ }
59
+
60
+ function isProtectedPath(path, protectedPaths) {
61
+ const normalized = toForwardSlashes(path).toLowerCase();
62
+ return protectedPaths.some((fragment) =>
63
+ normalized.includes(fragment.toLowerCase()),
64
+ );
65
+ }
66
+
67
+ function buildCommandPatterns(protectedPaths, mutatingCommands) {
68
+ const pathAlternation = protectedPaths.map(escapeRegExp).join('|');
69
+ const mutatingAlternation = mutatingCommands.join('|');
70
+ const wordBoundary = String.raw`\b`;
71
+ const redirectPrefix = String.raw`>>?\s*\S*`;
72
+ return {
73
+ mutatingCommand: new RegExp(
74
+ `${wordBoundary}(${mutatingAlternation})${wordBoundary}`,
75
+ 'i',
76
+ ),
77
+ protectedTarget: new RegExp(`(${pathAlternation})`, 'i'),
78
+ redirectToProtected: new RegExp(
79
+ `${redirectPrefix}(${pathAlternation})`,
80
+ 'i',
81
+ ),
82
+ };
83
+ }
84
+
85
+ /** The write-tool branch: deny when the target path matches a protected fragment. */
86
+ function checkWrite(toolInput, protectedPaths) {
87
+ const target = writtenPathOf(toolInput);
88
+ if (target && isProtectedPath(target, protectedPaths)) {
89
+ deny(
90
+ GATE_ID,
91
+ `Writing to '${target}' is not allowed: it matches a protected path (${protectedPaths.join(', ')}).`,
92
+ );
93
+ }
94
+ }
95
+
96
+ /** The shell-tool branch: deny a mutating command whose target matches a protected path. */
97
+ function checkShellCommand(toolInput, protectedPaths, mutatingCommands) {
98
+ const rawCommand = String(toolInput.CommandLine ?? toolInput.command ?? '');
99
+ if (!rawCommand.trim()) return;
100
+ // Same separator normalization as the write branch: a command embedding a Windows
101
+ // path ('rm C:\repo\hooks\gates\evil.mjs') must still match a 'hooks/' fragment.
102
+ const command = toForwardSlashes(rawCommand);
103
+
104
+ const { mutatingCommand, protectedTarget, redirectToProtected } =
105
+ buildCommandPatterns(protectedPaths, mutatingCommands);
106
+
107
+ const targetsProtected =
108
+ redirectToProtected.test(command) ||
109
+ (mutatingCommand.test(command) && protectedTarget.test(command));
110
+
111
+ if (targetsProtected) {
112
+ deny(
113
+ GATE_ID,
114
+ `This command targets a protected path (${protectedPaths.join(', ')}) with a mutating operation. ` +
115
+ 'Reading (echo/cat/grep) is fine; modifying it is not.',
116
+ );
117
+ }
118
+ }
119
+
120
+ runGate(
121
+ {
122
+ id: GATE_ID,
123
+ configKey: CONFIG_KEY,
124
+ enabledByDefault: true,
125
+ defaultParams: {
126
+ protectedPaths: DEFAULT_PROTECTED_PATHS,
127
+ mutatingCommands: DEFAULT_MUTATING_COMMANDS,
128
+ },
129
+ },
130
+ ({ toolName, toolInput, parameters }) => {
131
+ const isWrite = toolInGroups(toolName, ['write']);
132
+ const isShell = toolInGroups(toolName, ['shell']);
133
+ if (!isWrite && !isShell) return;
134
+
135
+ const protectedPaths = parameters.protectedPaths ?? [];
136
+ if (protectedPaths.length === 0) return;
137
+
138
+ if (isWrite) {
139
+ checkWrite(toolInput, protectedPaths);
140
+ return;
141
+ }
142
+
143
+ checkShellCommand(
144
+ toolInput,
145
+ protectedPaths,
146
+ parameters.mutatingCommands ?? [],
147
+ );
148
+ },
149
+ );
@@ -1,89 +1,91 @@
1
- import { readFileSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
4
-
5
- const GATE_ID = 'recurrence-lock';
6
- const CONFIG_KEY = 'blockRegisteredRecurrences';
7
-
8
- /**
9
- * De-duplicates occurrences before counting them against the threshold. An occurrence is
10
- * identified by its `id`/`hash` field when present (the intended identity for a logged
11
- * occurrence); a bare-scalar entry (number/string, as in the plain fixture shape used by
12
- * tests) is deduplicated by its own value instead, since it carries no other identity.
13
- * Without this, the same occurrence logged twice (a race or a bug in the writer) inflates
14
- * the count and trips the lock as if two distinct occurrences had happened.
15
- */
16
- function dedupOccurrences(occurrences) {
17
- const seen = new Set();
18
- const deduped = [];
19
- for (const occurrence of occurrences) {
20
- const identity =
21
- occurrence && typeof occurrence === 'object'
22
- ? String(occurrence.id ?? occurrence.hash ?? JSON.stringify(occurrence))
23
- : String(occurrence);
24
- if (seen.has(identity)) continue;
25
- seen.add(identity);
26
- deduped.push(occurrence);
27
- }
28
- return deduped;
29
- }
30
-
31
- // The source guard (guard-reincidence-lock.mjs) reads pending recurrences
32
- // from a full tracking module (../scripts/memory/recurrences.mjs) with
33
- // close/decide/pending subcommands. That infrastructure belongs to another
34
- // project and does not exist here. This gate only reads a plain state file
35
- // at .ai/reincidencias.json if present, with the shape:
36
- // { classes: [{ class, occurrences: [...], status }] }
37
- // If neither the module nor the file exists, it allows silently — there is
38
- // nothing to enforce without a recurrence record.
39
- function loadOpenRecurrences(projectRoot, thresholdAppearances) {
40
- const filePath = join(projectRoot, '.ai', 'reincidencias.json');
41
- if (!existsSync(filePath)) return [];
42
-
43
- let parsed;
44
- try {
45
- parsed = JSON.parse(readFileSync(filePath, 'utf8'));
46
- } catch {
47
- return [];
48
- }
49
-
50
- const classes = Array.isArray(parsed?.classes) ? parsed.classes : [];
51
- return classes.filter((entry) => {
52
- const occurrences = Array.isArray(entry?.occurrences)
53
- ? dedupOccurrences(entry.occurrences)
54
- : [];
55
- const occurrenceCount = occurrences.length;
56
- // Trim before lowercasing so a padded status ("Closed ", "cerrada\n") is recognized —
57
- // a status compared without trimming left a validly-closed (but padded) recurrence
58
- // stuck as still-open, blocking unrelated work indefinitely.
59
- const status = String(entry?.status ?? '').trim().toLowerCase();
60
- const isClosed = status === 'closed' || status === 'cerrada';
61
- return occurrenceCount >= thresholdAppearances && !isClosed;
62
- });
63
- }
64
-
65
- runGate(
66
- {
67
- id: GATE_ID,
68
- configKey: CONFIG_KEY,
69
- enabledByDefault: true,
70
- defaultParams: {
71
- thresholdAppearances: 2,
72
- },
73
- },
74
- ({ toolName, parameters }) => {
75
- if (!toolInGroups(toolName, ['execution', 'delegation'])) return;
76
-
77
- const openRecurrences = loadOpenRecurrences(
78
- process.cwd(),
79
- parameters.thresholdAppearances,
80
- );
81
- if (openRecurrences.length === 0) return;
82
-
83
- const names = openRecurrences.map((entry) => entry.class).join(', ');
84
- deny(
85
- GATE_ID,
86
- `Registered recurring issue classes are still open and at/above threshold: ${names}. Resolve or close them before proceeding.`,
87
- );
88
- },
89
- );
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
4
+
5
+ const GATE_ID = 'recurrence-lock';
6
+ const CONFIG_KEY = 'blockRegisteredRecurrences';
7
+
8
+ /**
9
+ * De-duplicates occurrences before counting them against the threshold. An occurrence is
10
+ * identified by its `id`/`hash` field when present (the intended identity for a logged
11
+ * occurrence); a bare-scalar entry (number/string, as in the plain fixture shape used by
12
+ * tests) is deduplicated by its own value instead, since it carries no other identity.
13
+ * Without this, the same occurrence logged twice (a race or a bug in the writer) inflates
14
+ * the count and trips the lock as if two distinct occurrences had happened.
15
+ */
16
+ function dedupOccurrences(occurrences) {
17
+ const seen = new Set();
18
+ const deduped = [];
19
+ for (const occurrence of occurrences) {
20
+ const identity =
21
+ occurrence && typeof occurrence === 'object'
22
+ ? String(occurrence.id ?? occurrence.hash ?? JSON.stringify(occurrence))
23
+ : String(occurrence);
24
+ if (seen.has(identity)) continue;
25
+ seen.add(identity);
26
+ deduped.push(occurrence);
27
+ }
28
+ return deduped;
29
+ }
30
+
31
+ // The source guard (guard-reincidence-lock.mjs) reads pending recurrences
32
+ // from a full tracking module (../scripts/memory/recurrences.mjs) with
33
+ // close/decide/pending subcommands. That infrastructure belongs to another
34
+ // project and does not exist here. This gate only reads a plain state file
35
+ // at .ai/reincidencias.json if present, with the shape:
36
+ // { classes: [{ class, occurrences: [...], status }] }
37
+ // If neither the module nor the file exists, it allows silently — there is
38
+ // nothing to enforce without a recurrence record.
39
+ function loadOpenRecurrences(projectRoot, thresholdAppearances) {
40
+ const filePath = join(projectRoot, '.ai', 'reincidencias.json');
41
+ if (!existsSync(filePath)) return [];
42
+
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(readFileSync(filePath, 'utf8'));
46
+ } catch {
47
+ return [];
48
+ }
49
+
50
+ const classes = Array.isArray(parsed?.classes) ? parsed.classes : [];
51
+ return classes.filter((entry) => {
52
+ const occurrences = Array.isArray(entry?.occurrences)
53
+ ? dedupOccurrences(entry.occurrences)
54
+ : [];
55
+ const occurrenceCount = occurrences.length;
56
+ // Trim before lowercasing so a padded status ("Closed ", "cerrada\n") is recognized —
57
+ // a status compared without trimming left a validly-closed (but padded) recurrence
58
+ // stuck as still-open, blocking unrelated work indefinitely.
59
+ const status = String(entry?.status ?? '')
60
+ .trim()
61
+ .toLowerCase();
62
+ const isClosed = status === 'closed' || status === 'cerrada';
63
+ return occurrenceCount >= thresholdAppearances && !isClosed;
64
+ });
65
+ }
66
+
67
+ runGate(
68
+ {
69
+ id: GATE_ID,
70
+ configKey: CONFIG_KEY,
71
+ enabledByDefault: true,
72
+ defaultParams: {
73
+ thresholdAppearances: 2,
74
+ },
75
+ },
76
+ ({ toolName, parameters }) => {
77
+ if (!toolInGroups(toolName, ['execution', 'delegation'])) return;
78
+
79
+ const openRecurrences = loadOpenRecurrences(
80
+ process.cwd(),
81
+ parameters.thresholdAppearances,
82
+ );
83
+ if (openRecurrences.length === 0) return;
84
+
85
+ const names = openRecurrences.map((entry) => entry.class).join(', ');
86
+ deny(
87
+ GATE_ID,
88
+ `Registered recurring issue classes are still open and at/above threshold: ${names}. Resolve or close them before proceeding.`,
89
+ );
90
+ },
91
+ );