@friedbotstudio/create-baseline 0.2.1 → 0.4.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/README.md +17 -7
- package/bin/cli.js +197 -119
- package/obj/template/.claude/commands/grant-push.md +19 -0
- package/obj/template/.claude/commands/init-project.md +26 -4
- package/obj/template/.claude/hooks/consent_gate_grant.mjs +107 -0
- package/obj/template/.claude/hooks/git_commit_guard.mjs +224 -0
- package/obj/template/.claude/hooks/harness_continuation.sh +101 -34
- package/obj/template/.claude/hooks/lib/common.mjs +283 -0
- package/obj/template/.claude/hooks/lib/common.sh +1 -1
- package/obj/template/.claude/hooks/memory_session_start.sh +20 -6
- package/obj/template/.claude/hooks/memory_stop.sh +161 -2
- package/obj/template/.claude/hooks/spec_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/swarm_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +7 -7
- package/obj/template/.claude/hooks/tests/fixtures/memory_stop_landmark_baseline.txt +21 -0
- package/obj/template/.claude/hooks/tests/fixtures/regenerate-ac008.sh +47 -0
- package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +7 -3
- package/obj/template/.claude/hooks/tests/memory_stop_intent_test.sh +329 -0
- package/obj/template/.claude/hooks/tests/regenerate_ac008_test.sh +99 -0
- package/obj/template/.claude/memory/README.md +8 -3
- package/obj/template/.claude/memory/backlog.md +12 -0
- package/obj/template/.claude/project.json +6 -1
- package/obj/template/.claude/settings.json +3 -4
- package/obj/template/.claude/skills/audit-baseline/audit.sh +28 -16
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md +4 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md +9 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_no_opener.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/preamble_check_test.sh +147 -0
- package/obj/template/.claude/skills/changelog/SKILL.md +69 -0
- package/obj/template/.claude/skills/changelog/changelog.mjs +163 -0
- package/obj/template/.claude/skills/changelog/classifier.mjs +49 -0
- package/obj/template/.claude/skills/changelog/state-writer.mjs +19 -0
- package/obj/template/.claude/skills/changelog/tests/consent-expired_test.sh +126 -0
- package/obj/template/.claude/skills/changelog/tests/golden-path_test.sh +191 -0
- package/obj/template/.claude/skills/changelog/tests/idempotent-reentry_test.sh +121 -0
- package/obj/template/.claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs +149 -0
- package/obj/template/.claude/skills/changelog/tests/non-git-shortcircuit_test.sh +98 -0
- package/obj/template/.claude/skills/changelog/tests/preview-only_test.sh +96 -0
- package/obj/template/.claude/skills/changelog/tests/run.sh +28 -0
- package/obj/template/.claude/skills/changelog/unreleased-writer.mjs +155 -0
- package/obj/template/.claude/skills/changelog/version-preview.mjs +124 -0
- package/obj/template/.claude/skills/chore/SKILL.md +5 -3
- package/obj/template/.claude/skills/commit/SKILL.md +5 -4
- package/obj/template/.claude/skills/copywriting/LICENSE +21 -0
- package/obj/template/.claude/skills/copywriting/NOTICE +23 -0
- package/obj/template/.claude/skills/copywriting/SKILL.md +1 -1
- package/obj/template/.claude/skills/design-ui/SKILL.md +23 -5
- package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +26 -5
- package/obj/template/.claude/skills/design-ui/references/orchestration.md +1 -0
- package/obj/template/.claude/skills/design-ui/references/state-machine.md +5 -3
- package/obj/template/.claude/skills/documentation/LICENSE +202 -0
- package/obj/template/.claude/skills/documentation/NOTICE +22 -0
- package/obj/template/.claude/skills/harness/SKILL.md +5 -1
- package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
- package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
- package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
- package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
- package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
- package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
- package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
- package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
- package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
- package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
- package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
- package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
- package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
- package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
- package/obj/template/.claude/skills/triage/SKILL.md +11 -5
- package/obj/template/CLAUDE.md +36 -25
- package/obj/template/docs/init/seed.md +39 -24
- package/obj/template/manifest.json +73 -33
- package/package.json +5 -2
- package/src/CLAUDE.template.md +36 -25
- package/src/cli/merge.js +15 -10
- package/src/cli/tui/doctor.js +56 -0
- package/src/cli/tui/install.js +79 -0
- package/src/cli/tui/meta.js +30 -0
- package/src/cli/tui/tokens.js +38 -0
- package/src/cli/tui/upgrade.js +100 -0
- package/src/memory/backlog.template.md +12 -0
- package/src/project.template.json +6 -1
- package/src/seed.template.md +39 -24
- package/src/settings.template.json +3 -4
- package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
- package/obj/template/.claude/hooks/git_commit_guard.sh +0 -93
package/README.md
CHANGED
|
@@ -37,11 +37,11 @@ A discipline layer for Claude Code. Hooks at every tool boundary, a workflow tha
|
|
|
37
37
|
> [!IMPORTANT]
|
|
38
38
|
> **Install in one line:** `npx @friedbotstudio/create-baseline ./your-project`
|
|
39
39
|
>
|
|
40
|
-
> The CLI fetches the published package, runs the install, and leaves your project with `.claude/`, `CLAUDE.md`, `docs/init/seed.md`, and `.mcp.json`. Re-run with
|
|
40
|
+
> The CLI fetches the published package, runs the install, and leaves your project with `.claude/`, `CLAUDE.md`, `docs/init/seed.md`, and `.mcp.json`. Re-run with the `upgrade` subcommand to bring an existing install forward (interactive in a TTY, batch-mode in CI). Add `--dry-run` to preview, and run `doctor` to report drift (pass `--json` for machine output).
|
|
41
41
|
|
|
42
42
|
## What this is
|
|
43
43
|
|
|
44
|
-
The Claude Code Baseline is a repository overlay shipped via `npx @friedbotstudio/create-baseline ./target`. It installs **22 hooks** at Claude's tool boundaries, **
|
|
44
|
+
The Claude Code Baseline is a repository overlay shipped via `npx @friedbotstudio/create-baseline ./target`. It installs **22 hooks** at Claude's tool boundaries, **37 skills** organised into nine categories, **1 subagent** for parallel work in isolated worktrees, an **11-phase workflow** from intake to commit, and **3 user-typed consent gates** that Claude cannot forge.
|
|
45
45
|
|
|
46
46
|
Every soft engineering rule a team usually repeats every session — *don't push, don't `--amend`, don't self-approve specs, don't skip phases, don't mock internal modules* — becomes a structural guarantee because the hooks run **outside Claude's tool boundary**. Claude cannot disable a hook with a flag, cannot write a consent marker, cannot reorder the phases without an explicit exception that triage records on disk.
|
|
47
47
|
|
|
@@ -64,7 +64,7 @@ A team that installs the baseline stops typing *"don't push, don't `--amend`, do
|
|
|
64
64
|
| **Hooks** at PreToolUse / PostToolUse / SessionStart / Stop / PreCompact / UserPromptSubmit | 22 | `.claude/hooks/` |
|
|
65
65
|
| **Skills** across artifact drafting, workflow phases, phase workers, spec helpers, orchestration, memory, audit, alternate tracks, and shared globals | 36 | `.claude/skills/` |
|
|
66
66
|
| **Subagent** — `swarm-worker`, executes pre-decided recipes inside isolated git worktrees | 1 | `.claude/agents/` |
|
|
67
|
-
| **Workflow phases** — intake → scout → research → spec → tdd → simplify → security → integrate → document → archive → commit | 11 | enforced by `track_guard` |
|
|
67
|
+
| **Workflow phases** — intake → scout → research → spec → tdd → simplify → security → integrate → document → archive → memory-flush → commit | 11 | enforced by `track_guard` |
|
|
68
68
|
| **Consent gates** — `/approve-spec`, `/approve-swarm`, `/grant-commit`. User-typed; structurally un-invokable by Claude | 3 | `consent_gate_grant` UserPromptSubmit hook |
|
|
69
69
|
| **MCP servers** declared in `.mcp.json` — `context7` (third-party API docs), `plantuml` (diagram render), `playwright` (cross-engine smoke) | 3 | `.mcp.json` |
|
|
70
70
|
|
|
@@ -94,12 +94,14 @@ npx @friedbotstudio/create-baseline ./your-project
|
|
|
94
94
|
# Force-overwrite an existing install (interactive — type 'overwrite')
|
|
95
95
|
npx @friedbotstudio/create-baseline ./your-project --overwrite
|
|
96
96
|
|
|
97
|
-
#
|
|
97
|
+
# Upgrade an existing install against a newer baseline version.
|
|
98
|
+
# In a TTY, each customised file becomes a keep-mine / take-theirs / abort
|
|
99
|
+
# prompt. In CI / piped stdout, reproduces the prior --merge behaviour:
|
|
98
100
|
# - adds new baseline files
|
|
99
101
|
# - refreshes baseline files the user has not touched
|
|
100
102
|
# - preserves user-customised files (exit 3 if any)
|
|
101
103
|
# - removes baseline files the upstream removed (only if untouched locally)
|
|
102
|
-
npx @friedbotstudio/create-baseline ./your-project
|
|
104
|
+
npx @friedbotstudio/create-baseline upgrade ./your-project
|
|
103
105
|
|
|
104
106
|
# Preview without writing anything
|
|
105
107
|
npx @friedbotstudio/create-baseline ./your-project --dry-run
|
|
@@ -124,6 +126,10 @@ npx @friedbotstudio/create-baseline doctor ./your-project
|
|
|
124
126
|
# Strict mode — print TAMPERED: shipped vs observed sha256 for every
|
|
125
127
|
# customised file and exit 1 on any drift.
|
|
126
128
|
npx @friedbotstudio/create-baseline doctor ./your-project --strict
|
|
129
|
+
|
|
130
|
+
# JSON mode — emit the structured report on stdout for CI parsers.
|
|
131
|
+
# Same exit codes; honours --strict.
|
|
132
|
+
npx @friedbotstudio/create-baseline doctor ./your-project --json
|
|
127
133
|
```
|
|
128
134
|
|
|
129
135
|
## Quickstart
|
|
@@ -148,13 +154,17 @@ cd ./your-project
|
|
|
148
154
|
/harness
|
|
149
155
|
```
|
|
150
156
|
|
|
151
|
-
The three consent gates pause the workflow until you type the corresponding command:
|
|
157
|
+
The three workflow-phase consent gates pause the workflow until you type the corresponding command:
|
|
152
158
|
|
|
153
159
|
- **`/approve-spec <slug>`** — after the spec phase, before any code is written
|
|
154
160
|
- **`/approve-swarm <slug>`** — after `/swarm-plan`, before parallel dispatch
|
|
155
161
|
- **`/grant-commit`** — after `/archive`, before the commit lands
|
|
156
162
|
|
|
157
|
-
|
|
163
|
+
A fourth consent gate sits outside the phase pipeline:
|
|
164
|
+
|
|
165
|
+
- **`/grant-push`** — opens a 5-minute window for `git push` on a protected branch (per `project.json → git.protected_branches`). Pushes on non-protected branches need no consent.
|
|
166
|
+
|
|
167
|
+
Each gate writes a short-lived consent marker via a UserPromptSubmit hook that runs *before* Claude is invoked on the body. Claude cannot forge the marker; the write-boundary guard validates it on disk before allowing the approval token through.
|
|
158
168
|
|
|
159
169
|
## How the enforcement works
|
|
160
170
|
|
package/bin/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
|
-
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
|
|
8
8
|
import * as io from '../src/cli/io.js';
|
|
@@ -17,18 +17,24 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
17
17
|
const PKG_ROOT = resolve(__dirname, '..');
|
|
18
18
|
|
|
19
19
|
const HELP_TEXT = `Usage:
|
|
20
|
-
create-baseline <target> [options] install
|
|
20
|
+
create-baseline <target> [options] install the baseline
|
|
21
|
+
create-baseline upgrade [target] three-way merge against an installed baseline
|
|
21
22
|
create-baseline doctor [target] report drift in an installed target
|
|
22
23
|
|
|
23
24
|
Materializes the Claude Code baseline (.claude/, CLAUDE.md, .mcp.json,
|
|
24
25
|
docs/init/seed.md, plus vendored LICENSE/NOTICE) into <target>.
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
Install modes:
|
|
27
28
|
(default) Fresh install. Refuses if any sentinel path already present.
|
|
28
29
|
--force Overwrite unconditionally (requires typing 'overwrite' in TTY).
|
|
29
|
-
--
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
--dry-run Print intended actions without writing.
|
|
31
|
+
|
|
32
|
+
Upgrade:
|
|
33
|
+
Replaces the prior --merge flag. Reads <target>/.claude/.baseline-manifest.json
|
|
34
|
+
and runs a three-way merge against the shipped template. Prunes baseline files
|
|
35
|
+
removed upstream that the user hadn't touched; customized-stale files are
|
|
36
|
+
preserved (exit 3) — or interactively resolved when stdout is a TTY (keep
|
|
37
|
+
mine / take theirs / abort).
|
|
32
38
|
--dry-run Print intended actions without writing.
|
|
33
39
|
|
|
34
40
|
Doctor:
|
|
@@ -38,6 +44,8 @@ Doctor:
|
|
|
38
44
|
--strict Promote customized to exit 1 and prefix tampered paths
|
|
39
45
|
with "TAMPERED:" with shipped vs observed sha256. Detects
|
|
40
46
|
post-install supply-chain tampering of the baseline tree.
|
|
47
|
+
--json Emit the structured report as JSON to stdout instead of
|
|
48
|
+
the text renderer. Honours --strict; same exit codes.
|
|
41
49
|
|
|
42
50
|
PlantUML jar (~19 MB, fetched at install time from upstream):
|
|
43
51
|
--no-plantuml Skip the jar download entirely.
|
|
@@ -54,12 +62,24 @@ Misc:
|
|
|
54
62
|
|
|
55
63
|
Exit codes:
|
|
56
64
|
0 success / clean doctor
|
|
57
|
-
1 user abort, conflict-without-force
|
|
58
|
-
2 argv error, non-TTY where TTY required,
|
|
59
|
-
3
|
|
65
|
+
1 user abort, conflict-without-force, doctor reports missing files, or upgrade aborted
|
|
66
|
+
2 argv error, non-TTY where TTY required, doctor finds no manifest, or --merge passed
|
|
67
|
+
3 upgrade had skipped customizations (or stale-customized prunes)
|
|
60
68
|
4 --require-plantuml fetch failure
|
|
61
69
|
`;
|
|
62
70
|
|
|
71
|
+
const OPTIONS = {
|
|
72
|
+
help: { type: 'boolean', short: 'h' },
|
|
73
|
+
version: { type: 'boolean' },
|
|
74
|
+
force: { type: 'boolean' },
|
|
75
|
+
'dry-run': { type: 'boolean' },
|
|
76
|
+
'no-plantuml': { type: 'boolean' },
|
|
77
|
+
'require-plantuml': { type: 'boolean' },
|
|
78
|
+
'with-npmrc': { type: 'boolean' },
|
|
79
|
+
strict: { type: 'boolean' },
|
|
80
|
+
json: { type: 'boolean' },
|
|
81
|
+
};
|
|
82
|
+
|
|
63
83
|
async function readPackageVersion() {
|
|
64
84
|
try {
|
|
65
85
|
const pkg = JSON.parse(await readFile(join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
@@ -77,16 +97,141 @@ function getTemplateDir() {
|
|
|
77
97
|
throw new Error(`Template directory not found at ${candidate}. Run \`npm run build\` (or rely on prepack).`);
|
|
78
98
|
}
|
|
79
99
|
|
|
80
|
-
function listShippedFiles(templateDir) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
100
|
+
async function listShippedFiles(templateDir) {
|
|
101
|
+
const out = [];
|
|
102
|
+
await walkFiles(templateDir, templateDir, out);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function walkFiles(dir, base, acc) {
|
|
107
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
108
|
+
const full = join(dir, entry.name);
|
|
109
|
+
if (entry.isDirectory()) await walkFiles(full, base, acc);
|
|
110
|
+
else if (entry.isFile()) acc.push(full.slice(base.length + 1).split('/').join('/'));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function dispatchInstall(target, values, templateDir) {
|
|
115
|
+
const dryRun = !!values['dry-run'];
|
|
116
|
+
if (process.stdout.isTTY && !values.force && !dryRun) {
|
|
117
|
+
return await runBrandedInstall(target, values, templateDir);
|
|
118
|
+
}
|
|
119
|
+
return await runPlainInstall(target, values, templateDir);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function runBrandedInstall(target, values, templateDir) {
|
|
123
|
+
const tui = await import('../src/cli/tui/install.js');
|
|
124
|
+
return tui.run({
|
|
125
|
+
target,
|
|
126
|
+
opts: {
|
|
127
|
+
templateDir,
|
|
128
|
+
noPlantuml: !!values['no-plantuml'],
|
|
129
|
+
requirePlantuml: !!values['require-plantuml'],
|
|
130
|
+
withNpmrc: !!values['with-npmrc'],
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function runPlainInstall(target, values, templateDir) {
|
|
136
|
+
const dryRun = !!values['dry-run'];
|
|
137
|
+
if (values.force) {
|
|
138
|
+
if (!process.stdin.isTTY) {
|
|
139
|
+
io.error('--force requires an interactive TTY for the confirmation prompt');
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
if (!dryRun) {
|
|
143
|
+
const answer = await io.ask("type 'overwrite' to proceed: ");
|
|
144
|
+
if (answer.toLowerCase() !== 'overwrite') {
|
|
145
|
+
io.error('confirmation declined');
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
87
148
|
}
|
|
88
|
-
}
|
|
89
|
-
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
if (values.force) {
|
|
153
|
+
if (dryRun) io.log(`Would force-install into ${target}`);
|
|
154
|
+
else await forceInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
|
|
155
|
+
} else {
|
|
156
|
+
if (dryRun) io.log(`Would fresh-install into ${target}`);
|
|
157
|
+
else await freshInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
io.error(`install failed: ${err.message}`);
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!dryRun) {
|
|
165
|
+
const plantumlExit = await fetchPlantumlPlain(target, values);
|
|
166
|
+
if (plantumlExit !== 0) return plantumlExit;
|
|
167
|
+
io.log(`Installed manifest version 1 to ${target}.`);
|
|
168
|
+
io.log(`Pin via "@friedbotstudio/create-baseline@<exact-version>" in your bootstrap docs.`);
|
|
169
|
+
}
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function fetchPlantumlPlain(target, values) {
|
|
174
|
+
const result = await fetchPlantumlIfMissing(target, {
|
|
175
|
+
noPlantuml: values['no-plantuml'],
|
|
176
|
+
requirePlantuml: values['require-plantuml'],
|
|
177
|
+
});
|
|
178
|
+
if (result.outcome === FETCH_OUTCOMES.WARNED_NETWORK_FAILURE
|
|
179
|
+
|| result.outcome === FETCH_OUTCOMES.WARNED_HASH_MISMATCH) {
|
|
180
|
+
io.warn(`PlantUML jar fetch failed (${result.reason}); install continued. Retry with --require-plantuml or set system plantuml on PATH.`);
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
if (result.outcome === FETCH_OUTCOMES.ERRORED_REQUIRE_PLANTUML) {
|
|
184
|
+
io.error(`--require-plantuml: ${result.reason}`);
|
|
185
|
+
return 4;
|
|
186
|
+
}
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function dispatchUpgrade(target, values, templateDir) {
|
|
191
|
+
const manifestPath = join(target, '.claude/.baseline-manifest.json');
|
|
192
|
+
if (!existsSync(manifestPath)) {
|
|
193
|
+
io.error(`No baseline manifest at ${manifestPath}. Run a fresh install first.`);
|
|
194
|
+
return 2;
|
|
195
|
+
}
|
|
196
|
+
if (process.stdout.isTTY) {
|
|
197
|
+
const tui = await import('../src/cli/tui/upgrade.js');
|
|
198
|
+
return tui.run({
|
|
199
|
+
target,
|
|
200
|
+
opts: { templateDir, dryRun: !!values['dry-run'] },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return await runPlainUpgrade(target, values, templateDir, manifestPath);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function runPlainUpgrade(target, values, templateDir, manifestPath) {
|
|
207
|
+
const oldManifest = await loadManifest(manifestPath);
|
|
208
|
+
const tplFiles = await listShippedFiles(templateDir);
|
|
209
|
+
const newManifest = await buildManifestFromDir(templateDir, tplFiles);
|
|
210
|
+
if (values['dry-run']) {
|
|
211
|
+
io.log(`Would upgrade ${tplFiles.length} files into ${target}`);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
const report = await threeWayMerge(templateDir, target, oldManifest, newManifest);
|
|
215
|
+
for (const action of report.actions) {
|
|
216
|
+
io.log(`${action.kind.padEnd(24)} ${action.path}`);
|
|
217
|
+
}
|
|
218
|
+
return report.exitCode;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function dispatchDoctor(positionals, values) {
|
|
222
|
+
const target = resolve(positionals[1] ?? '.');
|
|
223
|
+
const report = await runDoctor(target, { strict: !!values.strict });
|
|
224
|
+
if (values.json) {
|
|
225
|
+
io.log(JSON.stringify(report));
|
|
226
|
+
return report.exitCode;
|
|
227
|
+
}
|
|
228
|
+
if (process.stdout.isTTY) {
|
|
229
|
+
const tui = await import('../src/cli/tui/doctor.js');
|
|
230
|
+
tui.render(report);
|
|
231
|
+
} else {
|
|
232
|
+
process.stdout.write(formatReport(report));
|
|
233
|
+
}
|
|
234
|
+
return report.exitCode;
|
|
90
235
|
}
|
|
91
236
|
|
|
92
237
|
async function main(argv) {
|
|
@@ -94,21 +239,15 @@ async function main(argv) {
|
|
|
94
239
|
try {
|
|
95
240
|
parsed = parseArgs({
|
|
96
241
|
args: argv.slice(2),
|
|
97
|
-
options:
|
|
98
|
-
help: { type: 'boolean', short: 'h' },
|
|
99
|
-
version: { type: 'boolean' },
|
|
100
|
-
force: { type: 'boolean' },
|
|
101
|
-
merge: { type: 'boolean' },
|
|
102
|
-
'dry-run': { type: 'boolean' },
|
|
103
|
-
'no-plantuml': { type: 'boolean' },
|
|
104
|
-
'require-plantuml': { type: 'boolean' },
|
|
105
|
-
'with-npmrc': { type: 'boolean' },
|
|
106
|
-
strict: { type: 'boolean' },
|
|
107
|
-
},
|
|
242
|
+
options: OPTIONS,
|
|
108
243
|
strict: true,
|
|
109
244
|
allowPositionals: true,
|
|
110
245
|
});
|
|
111
246
|
} catch (err) {
|
|
247
|
+
if (/--merge/.test(err.message)) {
|
|
248
|
+
io.error('--merge has been removed; use `create-baseline upgrade <target>` instead.');
|
|
249
|
+
return 2;
|
|
250
|
+
}
|
|
112
251
|
io.error(err.message);
|
|
113
252
|
return 2;
|
|
114
253
|
}
|
|
@@ -116,26 +255,42 @@ async function main(argv) {
|
|
|
116
255
|
const { values, positionals } = parsed;
|
|
117
256
|
|
|
118
257
|
if (values.help) {
|
|
119
|
-
|
|
258
|
+
const version = await readPackageVersion();
|
|
259
|
+
if (process.stdout.isTTY) {
|
|
260
|
+
const meta = await import('../src/cli/tui/meta.js');
|
|
261
|
+
meta.renderHelp(HELP_TEXT, version);
|
|
262
|
+
} else {
|
|
263
|
+
io.log(HELP_TEXT);
|
|
264
|
+
}
|
|
120
265
|
return 0;
|
|
121
266
|
}
|
|
122
267
|
if (values.version) {
|
|
123
|
-
|
|
268
|
+
const version = await readPackageVersion();
|
|
269
|
+
if (process.stdout.isTTY) {
|
|
270
|
+
const meta = await import('../src/cli/tui/meta.js');
|
|
271
|
+
meta.renderVersion(version);
|
|
272
|
+
} else {
|
|
273
|
+
io.log(version);
|
|
274
|
+
}
|
|
124
275
|
return 0;
|
|
125
276
|
}
|
|
126
277
|
|
|
127
|
-
// `doctor` subcommand: read-only drift check against an installed target's manifest.
|
|
128
278
|
if (positionals[0] === 'doctor') {
|
|
129
|
-
|
|
130
|
-
const report = await runDoctor(target, { strict: !!values.strict });
|
|
131
|
-
io.log(formatReport(report));
|
|
132
|
-
return report.exitCode;
|
|
279
|
+
return await dispatchDoctor(positionals, values);
|
|
133
280
|
}
|
|
134
281
|
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
282
|
+
if (positionals[0] === 'upgrade') {
|
|
283
|
+
const target = resolve(positionals[1] ?? '.');
|
|
284
|
+
let templateDir;
|
|
285
|
+
try {
|
|
286
|
+
templateDir = getTemplateDir();
|
|
287
|
+
} catch (err) {
|
|
288
|
+
io.error(err.message);
|
|
289
|
+
return 2;
|
|
290
|
+
}
|
|
291
|
+
return await dispatchUpgrade(target, values, templateDir);
|
|
138
292
|
}
|
|
293
|
+
|
|
139
294
|
if (values['no-plantuml'] && values['require-plantuml']) {
|
|
140
295
|
io.error('--no-plantuml and --require-plantuml are mutually exclusive');
|
|
141
296
|
return 2;
|
|
@@ -151,8 +306,6 @@ async function main(argv) {
|
|
|
151
306
|
}
|
|
152
307
|
|
|
153
308
|
const target = resolve(positionals[0]);
|
|
154
|
-
const dryRun = !!values['dry-run'];
|
|
155
|
-
|
|
156
309
|
let templateDir;
|
|
157
310
|
try {
|
|
158
311
|
templateDir = getTemplateDir();
|
|
@@ -163,88 +316,13 @@ async function main(argv) {
|
|
|
163
316
|
|
|
164
317
|
const sentinels = await scanSentinels(target);
|
|
165
318
|
const hasConflict = sentinels.length > 0;
|
|
166
|
-
|
|
167
|
-
if (hasConflict && !values.force && !values.merge) {
|
|
319
|
+
if (hasConflict && !values.force) {
|
|
168
320
|
io.error(`existing baseline detected at ${target}: ${sentinels.join(', ')}`);
|
|
169
|
-
io.error('pass --force to overwrite or
|
|
321
|
+
io.error('pass --force to overwrite or use `create-baseline upgrade <target>` to three-way merge');
|
|
170
322
|
return 1;
|
|
171
323
|
}
|
|
172
324
|
|
|
173
|
-
|
|
174
|
-
if (!process.stdin.isTTY) {
|
|
175
|
-
io.error('--force requires an interactive TTY for the confirmation prompt');
|
|
176
|
-
return 2;
|
|
177
|
-
}
|
|
178
|
-
if (!dryRun) {
|
|
179
|
-
const answer = await io.ask("type 'overwrite' to proceed: ");
|
|
180
|
-
if (answer.toLowerCase() !== 'overwrite') {
|
|
181
|
-
io.error('confirmation declined');
|
|
182
|
-
return 1;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (values.merge) {
|
|
188
|
-
if (!process.stdin.isTTY && !dryRun) {
|
|
189
|
-
io.error('--merge requires an interactive TTY for the confirmation prompt');
|
|
190
|
-
return 2;
|
|
191
|
-
}
|
|
192
|
-
if (!dryRun) {
|
|
193
|
-
const answer = await io.ask("type 'merge' to proceed: ");
|
|
194
|
-
if (answer.toLowerCase() !== 'merge') {
|
|
195
|
-
io.error('confirmation declined');
|
|
196
|
-
return 1;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
let exitCode = 0;
|
|
202
|
-
try {
|
|
203
|
-
if (values.merge) {
|
|
204
|
-
const oldManifest = await loadManifest(join(target, '.claude/.baseline-manifest.json'));
|
|
205
|
-
const tplFiles = listShippedFiles(templateDir);
|
|
206
|
-
const newManifest = await buildManifestFromDir(templateDir, tplFiles);
|
|
207
|
-
if (dryRun) {
|
|
208
|
-
io.log(`Would merge ${tplFiles.length} files into ${target}`);
|
|
209
|
-
} else {
|
|
210
|
-
const report = await threeWayMerge(templateDir, target, oldManifest, newManifest);
|
|
211
|
-
for (const a of report.actions) {
|
|
212
|
-
io.log(`${a.kind.padEnd(24)} ${a.path}`);
|
|
213
|
-
}
|
|
214
|
-
exitCode = report.exitCode;
|
|
215
|
-
}
|
|
216
|
-
} else if (values.force) {
|
|
217
|
-
if (dryRun) io.log(`Would force-install into ${target}`);
|
|
218
|
-
else await forceInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
|
|
219
|
-
} else {
|
|
220
|
-
if (dryRun) io.log(`Would fresh-install into ${target}`);
|
|
221
|
-
else await freshInstall(templateDir, target, { withNpmrc: !!values['with-npmrc'] });
|
|
222
|
-
}
|
|
223
|
-
} catch (err) {
|
|
224
|
-
io.error(`install failed: ${err.message}`);
|
|
225
|
-
return 1;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (!dryRun) {
|
|
229
|
-
const fetchResult = await fetchPlantumlIfMissing(target, {
|
|
230
|
-
noPlantuml: values['no-plantuml'],
|
|
231
|
-
requirePlantuml: values['require-plantuml'],
|
|
232
|
-
});
|
|
233
|
-
if (fetchResult.outcome === FETCH_OUTCOMES.WARNED_NETWORK_FAILURE
|
|
234
|
-
|| fetchResult.outcome === FETCH_OUTCOMES.WARNED_HASH_MISMATCH) {
|
|
235
|
-
io.warn(`PlantUML jar fetch failed (${fetchResult.reason}); install continued. Retry with --require-plantuml or set system plantuml on PATH.`);
|
|
236
|
-
} else if (fetchResult.outcome === FETCH_OUTCOMES.ERRORED_REQUIRE_PLANTUML) {
|
|
237
|
-
io.error(`--require-plantuml: ${fetchResult.reason}`);
|
|
238
|
-
return 4;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (!dryRun && exitCode === 0) {
|
|
243
|
-
io.log(`Installed manifest version 1 to ${target}.`);
|
|
244
|
-
io.log(`Pin via "@friedbotstudio/create-baseline@<exact-version>" in your bootstrap docs.`);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return exitCode;
|
|
325
|
+
return await dispatchInstall(target, values, templateDir);
|
|
248
326
|
}
|
|
249
327
|
|
|
250
328
|
main(process.argv).then((code) => { process.exit(code); }).catch((err) => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Grant consent for Claude to run `git push`. Valid for 5 minutes. Required by the Git Commit Guard hook on protected branches.
|
|
3
|
+
argument-hint: "[optional note]"
|
|
4
|
+
allowed-tools: Bash(mkdir:*), Bash(date:*), Bash(tee:*), Bash(git:*), Write
|
|
5
|
+
disable-model-invocation: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Write a consent token to `.claude/state/push_consent` so the Git Commit Guard hook allows the next `git push` on a protected branch. The token is the current UNIX epoch timestamp on line 1; any optional note goes on line 2.
|
|
9
|
+
|
|
10
|
+
How this works structurally: when the user typed `/grant-push`, the `consent_gate_grant` UserPromptSubmit hook ran *before* this body was passed to Claude and wrote a short-lived consent marker at `.claude/state/.push_consent_grant`. The `git_commit_guard` PreToolUse hook (Write matcher) reads that marker and allows Claude to write the consent file because the marker is fresh. Claude cannot forge the marker — that's what makes the gate structural. The Bash-matcher leg of the same guard then enforces the consent token on the actual `git push` invocation, but only when the current branch matches `project.json → git.protected_branches`.
|
|
11
|
+
|
|
12
|
+
Steps:
|
|
13
|
+
|
|
14
|
+
1. **Git-repo precheck.** Run `git rev-parse --is-inside-work-tree 2>/dev/null`. If the exit status is non-zero, this project is not a git repository: refuse to write the consent token and tell the user "Not a git repository — `/grant-push` is inapplicable. Push has no meaning outside a git repo." Stop here.
|
|
15
|
+
2. Run `date +%s` to get the current epoch.
|
|
16
|
+
3. Write the epoch (and the optional note `$ARGUMENTS` on line 2 if non-empty) to `.claude/state/push_consent`, overwriting any prior token.
|
|
17
|
+
4. Confirm to the user: "Push consent granted at <epoch>, valid for 300s (until <HH:MM:SS local>). The next `git push` on a protected branch will be allowed. Pushes on branches NOT in `project.json → git.protected_branches` do not require this consent."
|
|
18
|
+
|
|
19
|
+
Do not run `git push` yourself in this command. The user asks explicitly when they want a push; this command only opens the window.
|
|
@@ -63,7 +63,9 @@ The recommender's SKILL.md instructs it to:
|
|
|
63
63
|
|
|
64
64
|
Capture both the narrative and the JSON. Save the JSON to `.claude/state/init/<timestamp>.recommender.json`.
|
|
65
65
|
|
|
66
|
-
## Step 5 — Aggregate + present
|
|
66
|
+
## Step 5 — Aggregate + present (REVIEW ONLY — NOTHING WRITTEN YET)
|
|
67
|
+
|
|
68
|
+
**This step is a proposal, not configuration.** Nothing has been written to disk yet: `.claude/project.json` still reads `configured: false`, no new skills/hooks/MCPs are wired, and the `swarm-worker` agent file has not been re-rendered. The user is reading a *proposal* that takes effect only when they explicitly approve in this step.
|
|
67
69
|
|
|
68
70
|
Show the user one review surface before writing anything:
|
|
69
71
|
|
|
@@ -77,13 +79,32 @@ Show the user one review surface before writing anything:
|
|
|
77
79
|
3. **Recommender additions** (from JSON `additions`): MCP servers, skills, hooks, and any `swarm_worker_skills` to preload — name + reason for each.
|
|
78
80
|
4. **Gaps flagged** (from JSON `gaps`): things the baseline doesn't cover but might warrant a future spec.
|
|
79
81
|
|
|
80
|
-
|
|
82
|
+
After presenting the four blocks, **explicitly tell the user the project is NOT yet configured**. Print this exact block above the confirmation prompt:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
⚠ The baseline is still in PROJECT-AGNOSTIC MODE.
|
|
81
86
|
|
|
82
|
-
|
|
87
|
+
None of the proposal above has been applied. `project.json → configured`
|
|
88
|
+
is still `false`. test_runner / lint_runner are still in guide mode.
|
|
89
|
+
Closing this session now leaves the project unconfigured.
|
|
90
|
+
|
|
91
|
+
The next prompt is an action gate. You must explicitly approve to proceed
|
|
92
|
+
to Step 6 (apply) — otherwise nothing changes.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Use `AskUserQuestion` to confirm. The question SHALL be a full sentence that names the un-configured state explicitly — not "Apply these changes?" alone, but: **"The project is NOT yet configured. Proceed to apply this proposal and finish setup?"** Options:
|
|
96
|
+
|
|
97
|
+
- `Proceed and apply` — advances to Step 6.
|
|
98
|
+
- `Edit before applying` — take the user's adjustments inline, re-show the surface, ask again.
|
|
99
|
+
- `Cancel — leave project unconfigured` — exit without writing; `configured` stays `false`; surface that the project remains in project-agnostic mode and `/init-project` can be re-run later.
|
|
100
|
+
|
|
101
|
+
If `Edit before applying`: take the user's adjustments inline, re-show the surface, **ask the same gate again**. Do not silently apply — the gate fires after every edit cycle until the user picks `Proceed and apply` or `Cancel`.
|
|
83
102
|
|
|
84
103
|
## Step 6 — Apply
|
|
85
104
|
|
|
86
|
-
Write to disk now.
|
|
105
|
+
Write to disk now. **This is the first step in the protocol that mutates files in the user's project** — until this step runs, `.claude/project.json` still reads `configured: false` and the baseline stays in project-agnostic mode. Reaching this step means the user explicitly picked `Proceed and apply` at Step 5's gate.
|
|
106
|
+
|
|
107
|
+
Do each sub-step in order; if any fails, stop and surface the error before continuing:
|
|
87
108
|
|
|
88
109
|
1. **Pre-create lazy directories**:
|
|
89
110
|
```bash
|
|
@@ -186,6 +207,7 @@ Print a final summary:
|
|
|
186
207
|
## Constraints
|
|
187
208
|
|
|
188
209
|
- **Steps 6 + 7 + 8 are atomic for the user.** If Step 8 fails, do not declare success at Step 9.
|
|
210
|
+
- **Step 5 is review, not setup.** Until the user explicitly picks `Proceed and apply` at Step 5's gate, the project remains in project-agnostic mode. Surfacing recommendations is not configuration; the gate prompt SHALL name the un-configured state in a full sentence so a skimming reader cannot mistake the proposal for a completion notice.
|
|
189
211
|
- **Never write `configured: true` before Step 8 passes.** A FAIL at Step 8 means the project is in a broken state; leaving `configured: true` would lie to `setup_guard` and the welcome hook in CLAUDE.md.
|
|
190
212
|
- **No silent decisions.** Every project-specific change appears in seed.md §16 so the next reader can see what diverged from baseline.
|
|
191
213
|
- **Idempotent.** Re-running on the same project produces the same §16 (modulo timestamp + run number) and passes `/audit-baseline` cleanly.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Consent Gate Grant — UserPromptSubmit
|
|
3
|
+
//
|
|
4
|
+
// JS port of consent_gate_grant.sh, adding a fourth arm for /grant-push.
|
|
5
|
+
//
|
|
6
|
+
// When the user types one of /approve-spec, /approve-swarm, /grant-commit,
|
|
7
|
+
// /grant-push, this hook fires BEFORE Claude is invoked. It writes a
|
|
8
|
+
// short-lived consent marker at .claude/state/.<gate>_grant.
|
|
9
|
+
//
|
|
10
|
+
// The marker is what makes the corresponding approval-token write succeed:
|
|
11
|
+
// the gate-specific PreToolUse guard (spec_approval_guard, swarm_approval_guard,
|
|
12
|
+
// git_commit_guard) reads the marker and allows Claude's write only if a
|
|
13
|
+
// fresh, slug-matched marker is on disk.
|
|
14
|
+
//
|
|
15
|
+
// Why the marker is unforgeable by Claude:
|
|
16
|
+
// - This hook runs on UserPromptSubmit, OUTSIDE Claude's tool boundary.
|
|
17
|
+
// - The PreToolUse guards block Claude from writing the marker file.
|
|
18
|
+
// - Markers expire after consent.gate_marker_ttl_seconds (default 120).
|
|
19
|
+
//
|
|
20
|
+
// Marker shapes:
|
|
21
|
+
// .spec_approval_grant line 1: slug · line 2: epoch · line 3: abs spec path
|
|
22
|
+
// .swarm_approval_grant line 1: slug · line 2: epoch
|
|
23
|
+
// .commit_consent_grant line 1: epoch · line 2: optional note
|
|
24
|
+
// .push_consent_grant line 1: epoch · line 2: optional note (NEW)
|
|
25
|
+
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import {
|
|
28
|
+
readPayload,
|
|
29
|
+
payloadGet,
|
|
30
|
+
canonicalSlug,
|
|
31
|
+
writeMarkerAtomic,
|
|
32
|
+
logLine,
|
|
33
|
+
CLAUDE_PROJECT_ROOT,
|
|
34
|
+
CONSENT_MARKER_SPEC,
|
|
35
|
+
CONSENT_MARKER_SWARM,
|
|
36
|
+
CONSENT_MARKER_COMMIT,
|
|
37
|
+
CONSENT_MARKER_PUSH,
|
|
38
|
+
} from './lib/common.mjs';
|
|
39
|
+
|
|
40
|
+
const HOOK = 'consent_gate_grant';
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
// Fast-path: rule out 99% of prompts before any regex parsing.
|
|
44
|
+
const payload = await readPayload();
|
|
45
|
+
const prompt = payloadGet(payload, '.prompt');
|
|
46
|
+
if (typeof prompt !== 'string' || prompt.length === 0) return;
|
|
47
|
+
if (!/\/(approve-spec|approve-swarm|grant-commit|grant-push)/.test(prompt)) return;
|
|
48
|
+
|
|
49
|
+
const firstLine = prompt.split(/\r?\n/)[0].trim();
|
|
50
|
+
const now = Math.floor(Date.now() / 1000);
|
|
51
|
+
|
|
52
|
+
let m;
|
|
53
|
+
|
|
54
|
+
m = firstLine.match(/^\/approve-spec\s+(\S+)/);
|
|
55
|
+
if (m) {
|
|
56
|
+
const arg = m[1];
|
|
57
|
+
const slug = canonicalSlug(arg);
|
|
58
|
+
let absPath;
|
|
59
|
+
if (arg.startsWith('/')) absPath = arg;
|
|
60
|
+
else if (arg.includes('/')) absPath = join(CLAUDE_PROJECT_ROOT, arg);
|
|
61
|
+
else absPath = join(CLAUDE_PROJECT_ROOT, 'docs', 'specs', `${slug}.md`);
|
|
62
|
+
if (writeMarkerAtomic(CONSENT_MARKER_SPEC, slug, String(now), absPath)) {
|
|
63
|
+
logLine(HOOK, `wrote spec_approval_grant slug=${slug} path=${absPath}`);
|
|
64
|
+
} else {
|
|
65
|
+
logLine(HOOK, `FAILED write spec_approval_grant slug=${slug}`);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
m = firstLine.match(/^\/approve-swarm\s+(\S+)/);
|
|
71
|
+
if (m) {
|
|
72
|
+
const slug = canonicalSlug(m[1]);
|
|
73
|
+
if (writeMarkerAtomic(CONSENT_MARKER_SWARM, slug, String(now))) {
|
|
74
|
+
logLine(HOOK, `wrote swarm_approval_grant slug=${slug}`);
|
|
75
|
+
} else {
|
|
76
|
+
logLine(HOOK, `FAILED write swarm_approval_grant slug=${slug}`);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
m = firstLine.match(/^\/grant-commit(\s.*)?$/);
|
|
82
|
+
if (m) {
|
|
83
|
+
const note = (m[1] || '').trim();
|
|
84
|
+
if (writeMarkerAtomic(CONSENT_MARKER_COMMIT, String(now), note)) {
|
|
85
|
+
logLine(HOOK, `wrote commit_consent_grant note=${note}`);
|
|
86
|
+
} else {
|
|
87
|
+
logLine(HOOK, `FAILED write commit_consent_grant`);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
m = firstLine.match(/^\/grant-push(\s.*)?$/);
|
|
93
|
+
if (m) {
|
|
94
|
+
const note = (m[1] || '').trim();
|
|
95
|
+
if (writeMarkerAtomic(CONSENT_MARKER_PUSH, String(now), note)) {
|
|
96
|
+
logLine(HOOK, `wrote push_consent_grant note=${note}`);
|
|
97
|
+
} else {
|
|
98
|
+
logLine(HOOK, `FAILED write push_consent_grant`);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main().catch(() => {
|
|
105
|
+
// UserPromptSubmit hook must never fail loudly — silent exit on any error.
|
|
106
|
+
process.exit(0);
|
|
107
|
+
});
|