pluriply 0.1.0 → 0.3.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 +28 -10
- package/bin/pluriply.js +122 -34
- package/package.json +1 -1
- package/src/hub/index.js +10 -7
- package/src/setup/clients.js +400 -32
- package/src/setup/hook-stop.js +157 -0
- package/src/setup/hooks.js +233 -0
- package/src/setup/run-setup.js +309 -41
- package/src/setup/toml-lite.js +87 -0
- package/src/shared/config.js +102 -0
- package/src/shared/identity.js +13 -2
- package/src/shared/lock.js +20 -0
- package/src/shared/mcp-register.js +25 -67
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Stop 훅 명령의 본체(스펙 §3·§5). 도구가 턴을 끝낼 때 불려 허브에 "나에게 온 것"을 묻고,
|
|
2
|
+
// 있으면 {"decision":"block","reason":…} 으로 세션이 이어서 일하게 한다. 어떤 경우에도 {} 로
|
|
3
|
+
// 조용히 끝나야 한다 — 훅이 도구를 방해하면 안 된다.
|
|
4
|
+
// 공개 미러에도 실리므로 허브·커넥터는 쓰는 순간에만 동적으로 불러온다(경계 테스트).
|
|
5
|
+
import { pluriplyHome } from "../shared/paths.js";
|
|
6
|
+
import { resolveAgentName } from "../shared/identity.js";
|
|
7
|
+
|
|
8
|
+
export const HOOK_AGENTS = ["claude-code", "codex", "antigravity"];
|
|
9
|
+
const MAX_REASON = 2000;
|
|
10
|
+
const MORE_LINE = (n) => `(+${n} more: run list_tasks)`;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 허브 hook.poll 응답을 모델이 읽을 문구로 만든다. 순수 함수.
|
|
14
|
+
* @param {{tool: string, cwd: string, channelCode: string, incoming: object[], results: object[], more: number}} r
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function formatStopReason(r) {
|
|
18
|
+
const head = `pluriply: new activity on channel ${r.channelCode} for ${r.tool} (cwd ${r.cwd}). Handle it before finishing.`;
|
|
19
|
+
const inLines = (r.incoming ?? []).map(
|
|
20
|
+
(t) => `- ${t.taskId} ${t.kind ?? "task"} from ${t.from}: "${t.summary}"`,
|
|
21
|
+
);
|
|
22
|
+
const resLines = (r.results ?? []).map(
|
|
23
|
+
(t) => `- ${t.taskId} ${t.status} by ${t.to}: "${t.summary}"`,
|
|
24
|
+
);
|
|
25
|
+
let more = r.more ?? 0;
|
|
26
|
+
const build = () => {
|
|
27
|
+
const parts = [head];
|
|
28
|
+
if (inLines.length)
|
|
29
|
+
parts.push(
|
|
30
|
+
"Incoming tasks (do the work, then submit_result — or submit_review for reviews; skip one another instance already claimed):",
|
|
31
|
+
...inLines,
|
|
32
|
+
);
|
|
33
|
+
if (resLines.length)
|
|
34
|
+
parts.push(
|
|
35
|
+
"Results of tasks you delegated (read them with get_task_result):",
|
|
36
|
+
...resLines,
|
|
37
|
+
);
|
|
38
|
+
if (more > 0) parts.push(MORE_LINE(more));
|
|
39
|
+
return parts.join("\n");
|
|
40
|
+
};
|
|
41
|
+
let text = build();
|
|
42
|
+
// 2,000자를 넘으면 뒤 항목부터 덜어내고 more 를 늘린다
|
|
43
|
+
while (text.length > MAX_REASON && inLines.length + resLines.length > 0) {
|
|
44
|
+
if (resLines.length) resLines.pop();
|
|
45
|
+
else inLines.pop();
|
|
46
|
+
more++;
|
|
47
|
+
text = build();
|
|
48
|
+
}
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {string} input @returns {object} 손상·빈 입력은 {} */
|
|
53
|
+
function parseInput(input) {
|
|
54
|
+
try {
|
|
55
|
+
const v = JSON.parse(input);
|
|
56
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
57
|
+
} catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {{agent: string, input?: string, cwd?: string, home?: string, env?: NodeJS.ProcessEnv, connect?: () => Promise<object|null>, timeoutMs?: number}} o
|
|
64
|
+
* @returns {Promise<object>} stdout 에 찍을 객체
|
|
65
|
+
*/
|
|
66
|
+
export async function runStopHook({
|
|
67
|
+
agent,
|
|
68
|
+
input = "",
|
|
69
|
+
cwd = process.cwd(),
|
|
70
|
+
home = pluriplyHome(),
|
|
71
|
+
env = process.env,
|
|
72
|
+
connect,
|
|
73
|
+
timeoutMs = 2000,
|
|
74
|
+
}) {
|
|
75
|
+
if (!HOOK_AGENTS.includes(agent)) return {};
|
|
76
|
+
// 워커 자식이 대화형 세션의 알림을 가로채면 안 된다(허브가 spawn 시 심는 표식, 스펙 §3).
|
|
77
|
+
if (env.PLURIPLY_WORKER_TASK) return {};
|
|
78
|
+
// Antigravity IDE 는 agy 훅과 같은 --agent antigravity 명령을 심으므로, IDE가 스폰한
|
|
79
|
+
// 프로세스는 antigravity-ide 전용 어댑터에게 맡기고 여기서는 조용히 빠진다.
|
|
80
|
+
if (resolveAgentName(agent, env) !== agent) return {};
|
|
81
|
+
const data = parseInput(input);
|
|
82
|
+
// stop_hook_active 는 Claude Code/Codex 만 보낸다 — antigravity 페이로드엔 없어 자연히 무시된다.
|
|
83
|
+
if (data.stop_hook_active === true) return {};
|
|
84
|
+
// Claude Code 는 세션 도중 cd 로 옮겨 다녀도 프로젝트 루트를 CLAUDE_PROJECT_DIR 로 계속
|
|
85
|
+
// 알려준다(stdin의 cwd 는 그 순간의 작업 폴더라 어긋날 수 있다) — 실기기 확인, 2026-09-16.
|
|
86
|
+
// antigravity 는 cwd 대신 workspacePaths 배열의 첫 항목을 보낸다.
|
|
87
|
+
const at =
|
|
88
|
+
agent === "claude-code" &&
|
|
89
|
+
typeof env.CLAUDE_PROJECT_DIR === "string" &&
|
|
90
|
+
env.CLAUDE_PROJECT_DIR.length > 0
|
|
91
|
+
? env.CLAUDE_PROJECT_DIR
|
|
92
|
+
: agent === "antigravity"
|
|
93
|
+
? (Array.isArray(data.workspacePaths) &&
|
|
94
|
+
typeof data.workspacePaths[0] === "string" &&
|
|
95
|
+
data.workspacePaths[0]) ||
|
|
96
|
+
cwd
|
|
97
|
+
: typeof data.cwd === "string" && data.cwd.length > 0
|
|
98
|
+
? data.cwd
|
|
99
|
+
: cwd;
|
|
100
|
+
const doConnect =
|
|
101
|
+
connect ??
|
|
102
|
+
(async () => {
|
|
103
|
+
const { connectIfLive } = await import("../connector/hub-client.js");
|
|
104
|
+
return connectIfLive({ home });
|
|
105
|
+
});
|
|
106
|
+
let client = null;
|
|
107
|
+
let timer;
|
|
108
|
+
let settled = false;
|
|
109
|
+
const deadline = new Promise((resolve) => {
|
|
110
|
+
timer = setTimeout(() => {
|
|
111
|
+
settled = true;
|
|
112
|
+
resolve(null);
|
|
113
|
+
}, timeoutMs);
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
const work = (async () => {
|
|
117
|
+
client = await doConnect();
|
|
118
|
+
if (!client) return null;
|
|
119
|
+
// 데드라인이 이미 지난 뒤에야 연결됐다: 아무도 기다리지 않는 요청을 보내는 대신
|
|
120
|
+
// 바로 닫는다(연결이 새고 늦은 응답·거부가 붕 뜨는 것을 막는다).
|
|
121
|
+
if (settled) {
|
|
122
|
+
try {
|
|
123
|
+
client.close();
|
|
124
|
+
} catch {
|
|
125
|
+
// 닫기 실패는 무시
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return client.request(
|
|
130
|
+
"hook.poll",
|
|
131
|
+
{ tool: agent, cwd: at },
|
|
132
|
+
{ timeoutMs },
|
|
133
|
+
);
|
|
134
|
+
})();
|
|
135
|
+
work.catch(() => {}); // 위 race 가 이미 끝난 뒤의 거부가 미처리로 남지 않게
|
|
136
|
+
const r = await Promise.race([work, deadline]);
|
|
137
|
+
if (!r || !r.channelCode) return {};
|
|
138
|
+
if ((r.incoming?.length ?? 0) + (r.results?.length ?? 0) === 0) return {};
|
|
139
|
+
const reason = formatStopReason({ ...r, cwd: at });
|
|
140
|
+
// antigravity 는 {decision:"continue"} 라야 멈추지 않고 reason 을 주입한다(실기기 확인).
|
|
141
|
+
// 다른 도구는 그대로 block.
|
|
142
|
+
return agent === "antigravity"
|
|
143
|
+
? { decision: "continue", reason }
|
|
144
|
+
: { decision: "block", reason };
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (process.env.PLURIPLY_HOOK_DEBUG === "1")
|
|
147
|
+
process.stderr.write(`pluriply hook: ${err.message}\n`);
|
|
148
|
+
return {};
|
|
149
|
+
} finally {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
try {
|
|
152
|
+
client?.close();
|
|
153
|
+
} catch {
|
|
154
|
+
// 닫기 실패는 무시
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Stop 훅 설치·제거 어댑터(스펙 §6). 대상 파일의 hooks.Stop[] 에 우리 그룹 하나를 병합하고,
|
|
2
|
+
// 다른 그룹(사용자·다른 도구의 훅)은 순서까지 보존한다. 쓰기는 clients.js 의 백업·원자 쓰기.
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { writeJsonAtomic } from "./clients.js";
|
|
5
|
+
|
|
6
|
+
export const HOOK_TIMEOUT_SEC = 10;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `layout` 이 없으면(claude-code·codex) 스펙 §3 의 stop-groups 모양(`hooks.Stop[].hooks[]`)을 쓴다.
|
|
10
|
+
* `layout: "named"`(antigravity) 는 이름 키 아래 **평평한** 항목 배열을 쓴다 — 실기기 확인(2026-09-16).
|
|
11
|
+
* @type {Array<{id: string, label: string, file: (env: object) => string, layout?: "named"}>}
|
|
12
|
+
*/
|
|
13
|
+
export const HOOK_CLIENTS = [
|
|
14
|
+
{
|
|
15
|
+
id: "claude-code",
|
|
16
|
+
label: "Claude Code hooks",
|
|
17
|
+
file: (env) => join(env.homeDir, ".claude", "settings.json"),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "codex",
|
|
21
|
+
label: "Codex hooks",
|
|
22
|
+
file: (env) =>
|
|
23
|
+
join(
|
|
24
|
+
env.processEnv?.CODEX_HOME || join(env.homeDir, ".codex"),
|
|
25
|
+
"hooks.json",
|
|
26
|
+
),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "antigravity",
|
|
30
|
+
label: "Antigravity hooks",
|
|
31
|
+
file: (env) => join(env.homeDir, ".gemini", "config", "hooks.json"),
|
|
32
|
+
layout: "named",
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** named 레이아웃에서 우리 항목을 담는 최상위 키 */
|
|
37
|
+
const NAMED_KEY = "pluriply";
|
|
38
|
+
|
|
39
|
+
/** @param {object} env @param {string} agent @returns {string} */
|
|
40
|
+
export function hookCommand(env, agent) {
|
|
41
|
+
return `"${env.node}" "${env.binPath}" hook stop --agent ${agent}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* command 문자열에서 우리가 심은 `--agent` 값을 뽑는다. 끝에 정확히 고정한다 — `.includes`
|
|
46
|
+
* 였다면 `--agent antigravity`가 `--agent antigravity-ide`의 접두사라 서로를 자기 것으로
|
|
47
|
+
* 착각하거나(더 긴 이름), 뒤에 다른 인자가 붙은 남의 명령까지 우리 것으로 삼켰다.
|
|
48
|
+
* @param {unknown} cmd @returns {string|undefined}
|
|
49
|
+
*/
|
|
50
|
+
function agentOf(cmd) {
|
|
51
|
+
return typeof cmd === "string"
|
|
52
|
+
? /pluriply\.js" hook stop --agent (\S+)\s*$/.exec(cmd)?.[1]
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 우리 그룹인지(stop-groups): 훅 중 하나의 --agent 값이 정확히 agent 다 */
|
|
57
|
+
function isOurs(group, agent) {
|
|
58
|
+
return (
|
|
59
|
+
Array.isArray(group?.hooks) &&
|
|
60
|
+
group.hooks.some((h) => agentOf(h?.command) === agent)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 우리 항목인지(named 레이아웃의 평평한 항목) */
|
|
65
|
+
function isOursFlat(item, agent) {
|
|
66
|
+
return agentOf(item?.command) === agent;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ourGroup(env, agent) {
|
|
70
|
+
return {
|
|
71
|
+
hooks: [
|
|
72
|
+
{
|
|
73
|
+
type: "command",
|
|
74
|
+
command: hookCommand(env, agent),
|
|
75
|
+
timeout: HOOK_TIMEOUT_SEC,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** named 레이아웃에 쓰는 평평한 항목 하나 */
|
|
82
|
+
function ourFlatItem(env, agent) {
|
|
83
|
+
return {
|
|
84
|
+
type: "command",
|
|
85
|
+
command: hookCommand(env, agent),
|
|
86
|
+
timeout: HOOK_TIMEOUT_SEC,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 문서에서 우리 Stop 항목을 찾는다. 레이아웃(stop-groups/named)에 따라 다른 모양을 읽되
|
|
92
|
+
* hookStatus 는 이 공통 반환값만 보면 되게 한다.
|
|
93
|
+
* @param {object} doc @param {object} hc @param {object} env
|
|
94
|
+
* @returns {{stopArr: object[], idx: number, mine: object|null, matches: boolean}}
|
|
95
|
+
*/
|
|
96
|
+
function readOurs(doc, hc, env) {
|
|
97
|
+
const want = hookCommand(env, hc.id);
|
|
98
|
+
if (hc.layout === "named") {
|
|
99
|
+
const stopArr = Array.isArray(doc[NAMED_KEY]?.Stop)
|
|
100
|
+
? doc[NAMED_KEY].Stop
|
|
101
|
+
: [];
|
|
102
|
+
const idx = stopArr.findIndex((h) => isOursFlat(h, hc.id));
|
|
103
|
+
const mine = idx === -1 ? null : stopArr[idx];
|
|
104
|
+
const matches =
|
|
105
|
+
mine != null &&
|
|
106
|
+
mine.type === "command" &&
|
|
107
|
+
mine.command === want &&
|
|
108
|
+
mine.timeout === HOOK_TIMEOUT_SEC;
|
|
109
|
+
return { stopArr, idx, mine, matches };
|
|
110
|
+
}
|
|
111
|
+
const stopArr = Array.isArray(doc.hooks?.Stop) ? doc.hooks.Stop : [];
|
|
112
|
+
const idx = stopArr.findIndex((g) => isOurs(g, hc.id));
|
|
113
|
+
const mine = idx === -1 ? null : stopArr[idx];
|
|
114
|
+
const matches =
|
|
115
|
+
mine != null &&
|
|
116
|
+
mine.hooks.length === 1 &&
|
|
117
|
+
mine.hooks[0].type === "command" &&
|
|
118
|
+
mine.hooks[0].command === want &&
|
|
119
|
+
mine.hooks[0].timeout === HOOK_TIMEOUT_SEC;
|
|
120
|
+
return { stopArr, idx, mine, matches };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 우리 항목을 심은/갈아 끼운 새 문서를 돌려준다(설치 전용). named 레이아웃은 `doc.pluriply` 를
|
|
125
|
+
* 통째로 새로 쓴다(우리만 쓰는 키라 병합할 다른 항목이 없다) — 다른 최상위 키는 그대로 둔다.
|
|
126
|
+
* stop-groups 는 기존처럼 그룹 하나만 심거나 갈아 끼우고 다른 그룹·다른 훅 이벤트는 보존한다.
|
|
127
|
+
* @param {object} doc @param {object} hc @param {object} env
|
|
128
|
+
* @returns {object}
|
|
129
|
+
*/
|
|
130
|
+
function writeOurs(doc, hc, env) {
|
|
131
|
+
if (hc.layout === "named") {
|
|
132
|
+
return { ...doc, [NAMED_KEY]: { Stop: [ourFlatItem(env, hc.id)] } };
|
|
133
|
+
}
|
|
134
|
+
const hooks =
|
|
135
|
+
doc.hooks && typeof doc.hooks === "object" && !Array.isArray(doc.hooks)
|
|
136
|
+
? doc.hooks
|
|
137
|
+
: {};
|
|
138
|
+
const stopArr = Array.isArray(hooks.Stop) ? hooks.Stop : [];
|
|
139
|
+
const idx = stopArr.findIndex((g) => isOurs(g, hc.id));
|
|
140
|
+
const group = ourGroup(env, hc.id);
|
|
141
|
+
const nextStop =
|
|
142
|
+
idx === -1
|
|
143
|
+
? [...stopArr, group]
|
|
144
|
+
: stopArr.map((g, j) => (j === idx ? group : g));
|
|
145
|
+
return { ...doc, hooks: { ...hooks, Stop: nextStop } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @returns {object} 파일 없음·빈 파일은 {} ; JSON 이 아니거나 객체가 아니면 throw */
|
|
149
|
+
function readDoc(env, path) {
|
|
150
|
+
if (!env.fs.existsSync(path)) return {};
|
|
151
|
+
const raw = env.fs.readFileSync(path, "utf8");
|
|
152
|
+
if (raw.trim() === "") return {};
|
|
153
|
+
const doc = JSON.parse(raw);
|
|
154
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc))
|
|
155
|
+
throw new Error("hooks file root is not an object");
|
|
156
|
+
return doc;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @returns {"present"|"stale"|"missing"|{error: string}} */
|
|
160
|
+
export function hookStatus(env, hc) {
|
|
161
|
+
const path = hc.file(env);
|
|
162
|
+
let doc;
|
|
163
|
+
try {
|
|
164
|
+
doc = readDoc(env, path);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return { error: `${path}: ${err.message}` };
|
|
167
|
+
}
|
|
168
|
+
const { mine, matches } = readOurs(doc, hc, env);
|
|
169
|
+
if (!mine) return "missing";
|
|
170
|
+
return matches ? "present" : "stale";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @returns {"present"|"updated"|"registered"|"failed"} */
|
|
174
|
+
export function installHook(env, hc) {
|
|
175
|
+
const path = hc.file(env);
|
|
176
|
+
const st = hookStatus(env, hc);
|
|
177
|
+
if (st === "present") return "present";
|
|
178
|
+
if (typeof st === "object") {
|
|
179
|
+
env.log(`cannot update ${hc.label}: ${st.error}`);
|
|
180
|
+
if (hc.layout === "named") {
|
|
181
|
+
env.log(
|
|
182
|
+
`add this to ${NAMED_KEY}.Stop in ${path} yourself: ${JSON.stringify({ Stop: [ourFlatItem(env, hc.id)] })}`,
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
env.log(
|
|
186
|
+
`add this to hooks.Stop in ${path} yourself: ${JSON.stringify(ourGroup(env, hc.id))}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return "failed";
|
|
190
|
+
}
|
|
191
|
+
const doc = readDoc(env, path);
|
|
192
|
+
const next = writeOurs(doc, hc, env);
|
|
193
|
+
env.fs.mkdirSync?.(dirname(path), { recursive: true });
|
|
194
|
+
writeJsonAtomic(env, path, next);
|
|
195
|
+
env.log(
|
|
196
|
+
`${st === "missing" ? "installed" : "updated"} pluriply Stop hook in ${hc.label} (${path}, backup ${path}.bak)`,
|
|
197
|
+
);
|
|
198
|
+
return st === "missing" ? "registered" : "updated";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** @returns {"removed"|"absent"|"failed"} */
|
|
202
|
+
export function removeHook(env, hc) {
|
|
203
|
+
const path = hc.file(env);
|
|
204
|
+
if (!env.fs.existsSync(path)) return "absent";
|
|
205
|
+
let doc;
|
|
206
|
+
try {
|
|
207
|
+
doc = readDoc(env, path);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
env.log(`cannot update ${hc.label}: ${path}: ${err.message}`);
|
|
210
|
+
return "failed";
|
|
211
|
+
}
|
|
212
|
+
if (hc.layout === "named") {
|
|
213
|
+
if (!(NAMED_KEY in doc)) return "absent";
|
|
214
|
+
const next = { ...doc };
|
|
215
|
+
delete next[NAMED_KEY];
|
|
216
|
+
writeJsonAtomic(env, path, next);
|
|
217
|
+
env.log(
|
|
218
|
+
`removed pluriply Stop hook from ${hc.label} (${path}, backup ${path}.bak)`,
|
|
219
|
+
);
|
|
220
|
+
return "removed";
|
|
221
|
+
}
|
|
222
|
+
const stop = Array.isArray(doc.hooks?.Stop) ? doc.hooks.Stop : [];
|
|
223
|
+
const rest = stop.filter((g) => !isOurs(g, hc.id));
|
|
224
|
+
if (rest.length === stop.length) return "absent";
|
|
225
|
+
const hooks = { ...doc.hooks };
|
|
226
|
+
if (rest.length) hooks.Stop = rest;
|
|
227
|
+
else delete hooks.Stop;
|
|
228
|
+
writeJsonAtomic(env, path, { ...doc, hooks });
|
|
229
|
+
env.log(
|
|
230
|
+
`removed pluriply Stop hook from ${hc.label} (${path}, backup ${path}.bak)`,
|
|
231
|
+
);
|
|
232
|
+
return "removed";
|
|
233
|
+
}
|