@comento/pota-harness 0.0.0-stage → 1.0.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/.agents/workflows/pota-doctor.md +16 -0
- package/.agents/workflows/pota-install.md +15 -0
- package/.agents/workflows/pota-memory.md +17 -0
- package/.agents/workflows/pota-plan.md +22 -0
- package/.agents/workflows/pota-review.md +16 -0
- package/.agents/workflows/pota-toolcheck.md +9 -0
- package/.agents/workflows/pota-uninstall.md +9 -0
- package/.agents/workflows/pota-upgrade.md +15 -0
- package/.agents/workflows/pota-work.md +24 -0
- package/.claude/agents/explorer.md +7 -0
- package/.claude/agents/worker.md +7 -0
- package/.claude/commands/pota-doctor.md +5 -0
- package/.claude/commands/pota-install.md +9 -0
- package/.claude/commands/pota-memory.md +5 -0
- package/.claude/commands/pota-plan.md +5 -0
- package/.claude/commands/pota-review.md +5 -0
- package/.claude/commands/pota-toolcheck.md +7 -0
- package/.claude/commands/pota-uninstall.md +5 -0
- package/.claude/commands/pota-upgrade.md +9 -0
- package/.claude/commands/pota-work.md +7 -0
- package/.codex-plugin/plugin.json +33 -0
- package/LICENSE +21 -0
- package/README.de.md +166 -0
- package/README.es.md +166 -0
- package/README.fr.md +166 -0
- package/README.ja.md +166 -0
- package/README.ko.md +184 -0
- package/README.md +191 -2
- package/README.pt.md +166 -0
- package/README.zh.md +166 -0
- package/agents/explorer.md +36 -0
- package/agents/worker.md +45 -0
- package/commands/pota-doctor.md +28 -0
- package/commands/pota-install.md +29 -0
- package/commands/pota-memory.md +17 -0
- package/commands/pota-plan.md +22 -0
- package/commands/pota-review.md +16 -0
- package/commands/pota-toolcheck.md +17 -0
- package/commands/pota-uninstall.md +27 -0
- package/commands/pota-upgrade.md +26 -0
- package/commands/pota-work.md +24 -0
- package/docs/model-guidance/README.md +67 -0
- package/docs/model-guidance/claude.md +49 -0
- package/docs/model-guidance/codex.md +68 -0
- package/hooks/hooks.json +65 -0
- package/package.json +47 -4
- package/scripts/pota +14 -0
- package/scripts/pota-auto-upgrade.mjs +342 -0
- package/scripts/pota-cleanup.mjs +194 -0
- package/scripts/pota-goal-cli.mjs +337 -0
- package/scripts/pota-goal-evidence.mjs +186 -0
- package/scripts/pota-goal-history.mjs +316 -0
- package/scripts/pota-goal-operations-evidence.mjs +213 -0
- package/scripts/pota-goal-operations-mutations.mjs +97 -0
- package/scripts/pota-goal-operations.mjs +281 -0
- package/scripts/pota-goal-policy.mjs +62 -0
- package/scripts/pota-goal-state-validation.mjs +361 -0
- package/scripts/pota-goal-state.mjs +136 -0
- package/scripts/pota-goal.mjs +69 -0
- package/scripts/pota-index.mjs +1052 -0
- package/scripts/pota-lifecycle-claude.mjs +173 -0
- package/scripts/pota-lifecycle-commands.mjs +418 -0
- package/scripts/pota-lifecycle-config.mjs +153 -0
- package/scripts/pota-lifecycle-hosts.mjs +98 -0
- package/scripts/pota-lifecycle-integrity.mjs +272 -0
- package/scripts/pota-lifecycle-layout.mjs +52 -0
- package/scripts/pota-lifecycle-lock.mjs +205 -0
- package/scripts/pota-lifecycle-mirror.mjs +83 -0
- package/scripts/pota-lifecycle-staging.mjs +156 -0
- package/scripts/pota-lifecycle-transaction-journal.mjs +244 -0
- package/scripts/pota-lifecycle-transaction-targets.mjs +119 -0
- package/scripts/pota-lifecycle-transaction.mjs +12 -0
- package/scripts/pota-lifecycle.mjs +244 -0
- package/scripts/pota-paths.mjs +190 -0
- package/scripts/pota-safe-fs.mjs +277 -0
- package/scripts/pota-state-context.mjs +281 -0
- package/scripts/pota-state-hook.mjs +26 -0
- package/scripts/pota-state-init.mjs +80 -0
- package/scripts/pota-state.mjs +245 -0
- package/scripts/pota-toolcheck-detect.mjs +229 -0
- package/scripts/pota-toolcheck-runtime.mjs +178 -0
- package/scripts/pota-toolcheck.mjs +221 -0
- package/scripts/pota-update-state.mjs +219 -0
- package/scripts/pota.mjs +200 -0
- package/skills/pota-memory/SKILL.md +52 -0
- package/skills/pota-plan/SKILL.md +66 -0
- package/skills/pota-plan/delegation.md +65 -0
- package/skills/pota-plan/heavy-path.md +28 -0
- package/skills/pota-plan/planning-rules.md +78 -0
- package/skills/pota-review/SKILL.md +56 -0
- package/skills/pota-work/SKILL.md +71 -0
- package/skills/pota-work/delegation.md +65 -0
- package/skills/pota-work/heavy-path.md +18 -0
- package/skills/pota-work/recovery.md +29 -0
- package/skills/pota-work/verification.md +79 -0
- package/templates/MEMORY.md +12 -0
- package/templates/PLAN.md +9 -0
- package/templates/domain-memory.md +22 -0
- package/templates/work-plan.md +177 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
const contextLimits = {
|
|
2
|
+
goalTitle: 64,
|
|
3
|
+
roadmapTaskId: 24,
|
|
4
|
+
taskTitle: 40,
|
|
5
|
+
roadmapWindow: 3,
|
|
6
|
+
parallelTitle: 16,
|
|
7
|
+
memoryBlock: 640,
|
|
8
|
+
planBlock: 320,
|
|
9
|
+
memoryLines: 8,
|
|
10
|
+
memoryLine: 84,
|
|
11
|
+
indexName: 32,
|
|
12
|
+
planNames: 3,
|
|
13
|
+
noticeVersion: 24,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function prependContext(result, notice) {
|
|
17
|
+
const specific = result.hookOutput?.hookSpecificOutput;
|
|
18
|
+
if (!specific) return { ...result, message: `${notice}\n${result.message}` };
|
|
19
|
+
return {
|
|
20
|
+
...result,
|
|
21
|
+
message: `${notice}\n${result.message}`,
|
|
22
|
+
hookOutput: {
|
|
23
|
+
...result.hookOutput,
|
|
24
|
+
hookSpecificOutput: {
|
|
25
|
+
...specific,
|
|
26
|
+
additionalContext: `${notice}\n${specific.additionalContext}`,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
import { currentTask } from "./pota-goal-policy.mjs";
|
|
33
|
+
|
|
34
|
+
export function contextResult({ ko, state, event, index = null }) {
|
|
35
|
+
const base = kernelBase(ko);
|
|
36
|
+
const memory = event === "session-start" ? memoryBlock(ko, index?.memory) : "";
|
|
37
|
+
const idlePolicy = event === "user-prompt-submit"
|
|
38
|
+
? (ko
|
|
39
|
+
? " 비단순 구현이나 계획된 작업 재개는 pota-plan과 plan/task를 복원·생성하세요. 대상이 확정된 Git/GitHub 상태·fetch·checkout·merge·push·PR 작업은 코드 수정·충돌 해결·파괴적 복구·릴리스/배포·설계가 필요하지 않으면 plan/goal 없이 실행하세요. 상담·설명·계획 전용도 goal 없이 처리하세요."
|
|
40
|
+
: " For non-trivial implementation or planned-work continuation, load pota-plan and restore/create its plan and task. Known-target Git/GitHub operations stay plan/goal-free unless they require code edits, conflict resolution, destructive recovery, release/deploy, or design. Consultation, explanation, and plan-only requests are also goal-free.")
|
|
41
|
+
: (ko
|
|
42
|
+
? " 중요 작업 전에 pota-plan을 로드하고 active plan과 current task를 복원하세요."
|
|
43
|
+
: " Before non-trivial work, load pota-plan and restore the active plan and current task.");
|
|
44
|
+
const failClosed = ko
|
|
45
|
+
? " active state와 그 skill·plan·required task reference가 없거나 읽을 수 없거나 일치하지 않을 때만 중단하세요."
|
|
46
|
+
: " Stop only if active state and its skill, plan, or required task ref is missing, unreadable, or inconsistent.";
|
|
47
|
+
if (state && (state.goal.status === "paused" || state.goal.status === "blocked")) {
|
|
48
|
+
return interruptedContextResult(event, ko, state, memory);
|
|
49
|
+
}
|
|
50
|
+
if (!state || state.goal.status !== "active") {
|
|
51
|
+
// Plan names only reach idle sessions: an active capsule already names its exact plan.
|
|
52
|
+
const plans = event === "session-start" ? planBlock(ko, index?.plans) : "";
|
|
53
|
+
return contextHookResult({ event, message: withBlocks(`${base}${memoryCountSentence(ko, event, index?.memory)}${idlePolicy}${failClosed}`, memory, plans) });
|
|
54
|
+
}
|
|
55
|
+
const task = currentTask(state);
|
|
56
|
+
const planId = state.goal.planId;
|
|
57
|
+
const phase = task ? "pota-work" : "pota-review";
|
|
58
|
+
const activeHeader = ko
|
|
59
|
+
? `${base} ${phase}를 로드하고 정확한 plan ${planId}${task ? "과 아래 current task" : "의 완료 기준"}를 복원하세요. 압축·재시작·handoff 뒤 다시 읽으세요.${failClosed}`
|
|
60
|
+
: `${base} Load ${phase}; restore exact plan ${planId}${task ? " and the current task below" : " completion criteria"}. Re-read after compaction/restart/handoff.${failClosed}`;
|
|
61
|
+
if (!task) {
|
|
62
|
+
return contextHookResult({
|
|
63
|
+
event,
|
|
64
|
+
message: withBlocks([activeHeader, taskRoadmap(ko, state), activeGoalCompletionMessage(ko, state)].join("\n"), memory),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const taskId = task.id;
|
|
68
|
+
const taskTitle = clipText(task.title, contextLimits.taskTitle);
|
|
69
|
+
const continuation = taskId.length > 48
|
|
70
|
+
? (ko
|
|
71
|
+
? `정확한 current task: ${taskId}. 바인딩된 증거 뒤에만 완료하세요.`
|
|
72
|
+
: `Exact task: ${taskId}. Complete after bound evidence.`)
|
|
73
|
+
: (ko
|
|
74
|
+
? `현재: ${taskId} (${taskTitle}). 바인딩된 증거 뒤에만 완료하세요.`
|
|
75
|
+
: `Current: ${taskId} (${taskTitle}). Complete only after bound evidence.`);
|
|
76
|
+
const resume = event === "user-prompt-submit"
|
|
77
|
+
? (taskId.length > 48
|
|
78
|
+
? (ko
|
|
79
|
+
? "곁가지 질문·조향 뒤 위의 정확한 task를 재개하고 최종 검토까지 진행하세요(일시정지·중단·전환 요청 제외)."
|
|
80
|
+
: "After side questions or steers, resume that exact task; advance to final review unless paused, stopped, or switched.")
|
|
81
|
+
: (ko
|
|
82
|
+
? `곁가지 질문·조향 뒤 ${taskId}를 재개하고 남은 task를 최종 검토까지 진행하세요(일시정지·중단·전환 요청 제외).`
|
|
83
|
+
: `After a side question or steer, resume ${taskId}, then advance remaining tasks to final review unless asked to pause, stop, or switch.`))
|
|
84
|
+
: "";
|
|
85
|
+
return contextHookResult({ event, message: withBlocks([activeHeader, taskRoadmap(ko, state), continuation, parallelLine(ko, state), resume].filter(Boolean).join("\n"), memory) });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Only when several tasks run at once: their ids and clipped titles, never the dependency graph.
|
|
89
|
+
function parallelLine(ko, state) {
|
|
90
|
+
const active = state.tasks.filter((task) => task.status === "active");
|
|
91
|
+
if (active.length < 2) return "";
|
|
92
|
+
const list = active.map((task) => `${clipText(task.id, contextLimits.roadmapTaskId)} (${clipText(task.title, contextLimits.parallelTitle)})`).join(", ");
|
|
93
|
+
return `${ko ? "병렬 active" : "Parallel active"}: ${list}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function kernelBase(ko) {
|
|
97
|
+
return ko ? "Pota kernel: 루트 MEMORY.md는 선택 사항이며, 있으면 읽으세요." : "Pota kernel: root MEMORY.md is optional; read it when present.";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function interruptedContextResult(event, ko, state, memory) {
|
|
101
|
+
const task = state.tasks.find((item) => item.status === "active")
|
|
102
|
+
?? state.tasks.find((item) => item.status === "pending")
|
|
103
|
+
?? state.tasks.find((item) => item.status === "blocked");
|
|
104
|
+
const taskContext = task
|
|
105
|
+
? (ko
|
|
106
|
+
? `현재 task: ${task.id} (${clipText(task.title, contextLimits.taskTitle)}), 상태 ${task.status}.`
|
|
107
|
+
: `Current task: ${task.id} (${clipText(task.title, contextLimits.taskTitle)}), status ${task.status}.`)
|
|
108
|
+
: (ko ? "현재 task: 없음." : "Current task: none.");
|
|
109
|
+
const blocker = state.goal.blocker ?? task?.blocker ?? (ko ? "없음" : "none");
|
|
110
|
+
const recovery = ko
|
|
111
|
+
? "goal 상태를 자동으로 바꾸지 마세요. 정확한 context를 복원하고 원인을 처리한 뒤 goal resume을 명시적으로 실행하고 현재 task를 이어가세요."
|
|
112
|
+
: "Do not change goal status automatically. Restore this exact context, address the interruption, then run goal resume explicitly and continue the current task.";
|
|
113
|
+
const message = ko
|
|
114
|
+
? `${kernelBase(ko)} 정확한 plan: ${state.goal.planId}. ${taskContext} Goal 상태: ${state.goal.status}. Blocker: ${blocker}. ${recovery}`
|
|
115
|
+
: `${kernelBase(ko)} Exact plan: ${state.goal.planId}. ${taskContext} Goal status: ${state.goal.status}. Blocker: ${blocker}. ${recovery}`;
|
|
116
|
+
return contextHookResult({ event, message: withBlocks(message, memory) });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function foreignSessionContextResult({ ko, event, sessionId, index = null }) {
|
|
120
|
+
const claim = typeof sessionId === "string" && sessionId.length > 0
|
|
121
|
+
? (ko
|
|
122
|
+
? ` 이 session이 같은 작업을 이어받는 경우(재개·압축·handoff·fork)에만 pota goal claim --session ${JSON.stringify(sessionId)} --root <repoRoot>로 소유권을 넘겨받고 이어가세요.`
|
|
123
|
+
: ` Only if this session continues that same work (resume, compaction, handoff, fork), take ownership with pota goal claim --session ${JSON.stringify(sessionId)} --root <repoRoot>, then continue.`)
|
|
124
|
+
: "";
|
|
125
|
+
const message = ko
|
|
126
|
+
? `Pota kernel: 루트 MEMORY.md는 선택 사항이며, 있으면 읽으세요. 저장소 goal을 다른 session이 소유합니다. pota goal status로 상태를 확인하고 소유권이 해결될 때까지 Pota 작업을 시작·수정·위임·완료하지 마세요.${claim} 이 capsule은 goal, plan, task 상세를 노출하지 않습니다.`
|
|
127
|
+
: `Pota kernel: root MEMORY.md is optional; read it when present. A repository goal is owned by another session. Inspect pota goal status; until ownership is resolved, do not start, edit, delegate, or complete Pota work.${claim} This capsule does not expose goal, plan, or task details.`;
|
|
128
|
+
// Repository notes are shared knowledge; plan names would reveal the owner's work, so they never appear here.
|
|
129
|
+
const memory = event === "session-start" ? memoryBlock(ko, index?.memory) : "";
|
|
130
|
+
return contextHookResult({ event, message: withBlocks(message, memory) });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// One SessionStart line after an auto-upgrade run (decision 8 of docs/plan/auto-upgrade-pota-home.md). Versions are
|
|
134
|
+
// clipped so the line stays within 160 characters; it is shown once and never in UserPromptSubmit.
|
|
135
|
+
export function updateNoticeLine({ ko, notice }) {
|
|
136
|
+
const version = (value) => clipText(plainText(value), contextLimits.noticeVersion);
|
|
137
|
+
const from = version(notice?.from);
|
|
138
|
+
const to = version(notice?.to);
|
|
139
|
+
if (notice?.kind === "updated") {
|
|
140
|
+
return ko
|
|
141
|
+
? `Pota 업데이트: ${from} → ${to} 자동 업그레이드되었습니다. 이 세션부터 적용됩니다. 사용자에게 한 번 알려 주세요.`
|
|
142
|
+
: `Pota update: auto-upgraded ${from} → ${to}; this session runs ${to}. Mention it to the user once.`;
|
|
143
|
+
}
|
|
144
|
+
if (notice?.kind === "failed") {
|
|
145
|
+
return ko
|
|
146
|
+
? `Pota 업데이트: ${to} 자동 업그레이드가 실패했습니다. 사용자에게 "npx @comento/pota-harness@latest upgrade" 실행 또는 "pota auto-upgrade status" 확인을 안내하세요.`
|
|
147
|
+
: `Pota update: auto-upgrade to ${to} failed. Have the user run "npx @comento/pota-harness@latest upgrade" or check "pota auto-upgrade status".`;
|
|
148
|
+
}
|
|
149
|
+
if (notice?.kind === "manual") {
|
|
150
|
+
return ko
|
|
151
|
+
? `Pota 업데이트: ${to}이 설치되었지만 Claude Code 플러그인 캐시가 갱신되지 않았습니다. 사용자에게 "claude plugin update pota@pota" 실행을 안내하세요.`
|
|
152
|
+
: `Pota update: ${to} is installed but the Claude Code plugin cache was not refreshed. Tell the user to run "claude plugin update pota@pota".`;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function withBlocks(message, ...blocks) {
|
|
158
|
+
return [message, ...blocks].filter(Boolean).join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Idle prompts only learn that notes exist; the list itself stays in SessionStart to keep the per-prompt budget.
|
|
162
|
+
function memoryCountSentence(ko, event, memory) {
|
|
163
|
+
if (event !== "user-prompt-submit" || !(memory?.count > 0)) return "";
|
|
164
|
+
return ko
|
|
165
|
+
? ` docs/memory에 노트 ${memory.count}개가 있습니다(색인: MEMORY.md). 작업에 필요한 것만 읽으세요.`
|
|
166
|
+
: ` docs/memory has ${memory.count} notes (index: MEMORY.md); read only those this task needs.`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Lists repository notes as "- name: summary" while they fit; past the line or character limit it falls back to
|
|
170
|
+
// one names-only line so every note stays discoverable, counting whatever does not fit.
|
|
171
|
+
function memoryBlock(ko, memory) {
|
|
172
|
+
if (!(memory?.count > 0)) return "";
|
|
173
|
+
const entries = Array.isArray(memory.entries) ? memory.entries : [];
|
|
174
|
+
const header = ko
|
|
175
|
+
? "저장소 메모리 — docs/memory/<name>.md (색인: MEMORY.md), 작업에 필요한 것만 읽으세요:"
|
|
176
|
+
: "Repo memory — docs/memory/<name>.md (index: MEMORY.md); read only what the task needs:";
|
|
177
|
+
const drift = memory.drift ? (ko ? "(색인이 오래됨: pota memory index 실행)" : "(index out of date: run pota memory index)") : "";
|
|
178
|
+
const name = (entry) => clipText(plainText(entry.name), contextLimits.indexName);
|
|
179
|
+
if (entries.length >= memory.count && entries.length <= contextLimits.memoryLines) {
|
|
180
|
+
const lines = entries.map((entry) => clipText(entry.summary ? `- ${name(entry)}: ${plainText(entry.summary)}` : `- ${name(entry)}`, contextLimits.memoryLine));
|
|
181
|
+
const block = withBlocks(header, ...lines, drift);
|
|
182
|
+
if (block.length <= contextLimits.memoryBlock) return block;
|
|
183
|
+
}
|
|
184
|
+
const more = (count) => (count > 0 ? (ko ? ` 외 ${count}개(MEMORY.md 참조)` : ` +${count} more in MEMORY.md`) : "");
|
|
185
|
+
const budget = contextLimits.memoryBlock - header.length - 1 - (drift ? drift.length + 1 : 0);
|
|
186
|
+
let names = "";
|
|
187
|
+
let shown = 0;
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
const candidate = shown === 0 ? name(entry) : `${names}, ${name(entry)}`;
|
|
190
|
+
if (candidate.length + more(memory.count - shown - 1).length > budget) break;
|
|
191
|
+
names = candidate;
|
|
192
|
+
shown += 1;
|
|
193
|
+
}
|
|
194
|
+
return withBlocks(header, `${names}${more(memory.count - shown)}`.trim(), drift);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// One line for idle sessions. Over budget it drops latest names from the end, then reduces active names to a count.
|
|
198
|
+
function planBlock(ko, plans) {
|
|
199
|
+
if (!plans) return "";
|
|
200
|
+
const archive = plans.archive ? (ko ? ", 이전 항목: docs/plan/ARCHIVE.md" : ", older: docs/plan/ARCHIVE.md") : "";
|
|
201
|
+
const head = ko
|
|
202
|
+
? `계획 — docs/plan/<name>.md (색인: PLAN.md${archive}): 파일 ${plans.files}개`
|
|
203
|
+
: `Plans — docs/plan/<name>.md (index: PLAN.md${archive}): ${plans.files} files`;
|
|
204
|
+
const close = ko ? " 비슷한 작업을 계획하기 전에 관련 계획을 확인하세요." : " Check related plans before planning similar work.";
|
|
205
|
+
const names = (list) => (Array.isArray(list) ? list : []).slice(0, contextLimits.planNames).map((item) => clipText(plainText(item), contextLimits.indexName));
|
|
206
|
+
const active = names(plans.active);
|
|
207
|
+
const latest = names(plans.latest);
|
|
208
|
+
const activeLabel = ko ? "진행 중" : "active";
|
|
209
|
+
const latestLabel = ko ? "최근 완료" : "latest completed";
|
|
210
|
+
const render = (activePart, latestNames) => {
|
|
211
|
+
const parts = [head, activePart, latestNames.length > 0 ? `${latestLabel}: ${latestNames.join(", ")}` : ""];
|
|
212
|
+
return `${parts.filter(Boolean).join("; ")}.${close}`;
|
|
213
|
+
};
|
|
214
|
+
const activeNames = active.length > 0 ? `${activeLabel}: ${active.join(", ")}` : "";
|
|
215
|
+
const activeCount = active.length > 0 ? `${activeLabel}: ${plans.active.length}` : "";
|
|
216
|
+
const candidates = [
|
|
217
|
+
...Array.from({ length: latest.length + 1 }, (_, dropped) => render(activeNames, latest.slice(0, latest.length - dropped))),
|
|
218
|
+
render(activeCount, []),
|
|
219
|
+
];
|
|
220
|
+
return candidates.find((line) => line.length <= contextLimits.planBlock) ?? render("", []);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function taskRoadmap(ko, state) {
|
|
224
|
+
const formatTask = (task, index) => `${index + 1}. ${clipText(task.id, contextLimits.roadmapTaskId)} [${task.status}] ${clipText(task.title, contextLimits.taskTitle)}`;
|
|
225
|
+
if (state.tasks.length <= contextLimits.roadmapWindow) {
|
|
226
|
+
return `${ko ? "작업 단계" : "Work steps"}:\n${state.tasks.map(formatTask).join("\n")}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const currentIndex = Math.max(0, state.tasks.findIndex((task) => task.status === "active" || task.status === "pending"));
|
|
230
|
+
const counts = Object.fromEntries([...TASK_STATUS_NAMES].map((status) => [status, state.tasks.filter((task) => task.status === status).length]));
|
|
231
|
+
const countSummary = Object.entries(counts).filter(([, count]) => count > 0).map(([status, count]) => `${status} ${count}`).join(", ");
|
|
232
|
+
const omitted = state.tasks.length - 1;
|
|
233
|
+
const header = ko
|
|
234
|
+
? `작업 단계 (${state.tasks.length}개; ${countSummary})`
|
|
235
|
+
: `Work steps (${state.tasks.length}; ${countSummary})`;
|
|
236
|
+
const omission = ko ? `… ${omitted}개 단계 생략` : `… ${omitted} steps omitted`;
|
|
237
|
+
const currentPosition = currentIndex + 1;
|
|
238
|
+
const upcoming = state.tasks.slice(currentIndex + 1).filter((task) => task.status === "pending").slice(0, 2)
|
|
239
|
+
.map((task) => clipText(task.id, contextLimits.roadmapTaskId));
|
|
240
|
+
const next = upcoming.length > 0 ? `; ${ko ? "다음" : "next"} ${upcoming.join(", ")}` : "";
|
|
241
|
+
return `${header}. ${ko ? `현재 위치 ${currentPosition}` : `Current position ${currentPosition}`}; ${omission}${next}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function contextHookResult({ event, message }) {
|
|
245
|
+
return {
|
|
246
|
+
status: 0,
|
|
247
|
+
message,
|
|
248
|
+
hookOutput: {
|
|
249
|
+
hookSpecificOutput: {
|
|
250
|
+
hookEventName: event === "session-start" ? "SessionStart" : "UserPromptSubmit",
|
|
251
|
+
additionalContext: message,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function activeGoalCompletionMessage(ko, state) {
|
|
258
|
+
const goalTitle = clipText(state.goal.title, contextLimits.goalTitle);
|
|
259
|
+
return ko
|
|
260
|
+
? `활성 목표 "${goalTitle}"의 모든 task가 완료되었습니다. 완료 기준을 확인하고 명시적으로 complete 상태로 전이하세요.`
|
|
261
|
+
: `All tasks for active goal "${goalTitle}" are complete. Verify criteria and explicitly transition the goal to completed.`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const TASK_STATUS_NAMES = ["active", "pending", "blocked", "completed"];
|
|
265
|
+
|
|
266
|
+
// Clips by UTF-16 length, the unit of the character budgets, without splitting a surrogate pair.
|
|
267
|
+
function clipText(value, maxLength) {
|
|
268
|
+
const text = String(value ?? "");
|
|
269
|
+
if (text.length <= maxLength) return text;
|
|
270
|
+
let end = maxLength - 1;
|
|
271
|
+
if (/[\uD800-\uDBFF]/.test(text[end - 1] ?? "")) end -= 1;
|
|
272
|
+
return `${text.slice(0, end)}…`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Note names, summaries, and plan names are repository text: control characters and line separators become spaces
|
|
276
|
+
// so they cannot start a new line in the capsule or hide text from the reader.
|
|
277
|
+
const UNSAFE_TEXT = /[\u0000-\u001f\u007f-\u009f\u{2028}\u{2029}]/gu;
|
|
278
|
+
|
|
279
|
+
function plainText(value) {
|
|
280
|
+
return String(value ?? "").replace(UNSAFE_TEXT, " ");
|
|
281
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function continuationReason(reason, payload, { progressed = false } = {}) {
|
|
2
|
+
if (!payload?.stop_hook_active || progressed) return reason;
|
|
3
|
+
return `${reason} This turn was already continued without new Pota state progress, so the loop guard allows this stop. The goal is still active and incomplete: resume the current task on the next prompt; do not treat it as finished or deferred.`;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// stop_hook_active alone would allow every second stop. Block again whenever the goal state advanced
|
|
7
|
+
// since the previous continuation, so a steered turn keeps driving the goal; allow only a no-progress repeat.
|
|
8
|
+
export function stopHookOutput(gate, payload, { progressed = false } = {}) {
|
|
9
|
+
if (!gate.block) return { continue: true };
|
|
10
|
+
const reason = continuationReason(gate.reason, payload, { progressed });
|
|
11
|
+
if (payload?.stop_hook_active && !progressed) {
|
|
12
|
+
return { continue: true, systemMessage: reason };
|
|
13
|
+
}
|
|
14
|
+
return { decision: "block", reason };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function stopAllowedMessage({ ko, state, event }) {
|
|
18
|
+
if (state?.goal?.status) {
|
|
19
|
+
return ko
|
|
20
|
+
? `Pota 종료 게이트: 목표 상태가 ${state.goal.status}이므로 종료를 허용합니다.`
|
|
21
|
+
: `Pota stop gate: goal status is ${state.goal.status}; allowing stop.`;
|
|
22
|
+
}
|
|
23
|
+
return ko
|
|
24
|
+
? `${event === "subagent-stop" ? "서브에이전트" : "작업"} 종료 게이트: 활성 Pota 목표가 없어 종료를 허용합니다.`
|
|
25
|
+
: `${event === "subagent-stop" ? "Subagent" : "Turn"} stop gate: no active Pota goal; allowing stop.`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { assertNoSymlinkComponents, safeMkdir, safeWriteFile } from "./pota-safe-fs.mjs";
|
|
4
|
+
|
|
5
|
+
const memoryTemplate = `# MEMORY
|
|
6
|
+
|
|
7
|
+
Index of repository knowledge. Each line under Domain Knowledge locates one note in \`docs/memory/\`; the knowledge itself lives in the note. Maintain with \`pota memory index\` and \`pota memory check\`.
|
|
8
|
+
|
|
9
|
+
## Domain Knowledge
|
|
10
|
+
|
|
11
|
+
## Policy
|
|
12
|
+
|
|
13
|
+
- Prefer precise repository exploration before implementation.
|
|
14
|
+
- Preserve proper nouns, file names, variable names, service names, alert names, MCP tool names, and agent names exactly as written.
|
|
15
|
+
- Read only the detailed memory files needed for the current task.
|
|
16
|
+
- Write user-visible responses and generated or updated documentation, plans, and memory artifacts in the OS locale unless the user asks for another language.
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const memoryTemplateKo = `# MEMORY
|
|
20
|
+
|
|
21
|
+
저장소 지식의 색인입니다. Domain Knowledge 아래의 각 줄은 \`docs/memory/\`의 노트 하나를 가리키며, 지식 자체는 노트에 있습니다. \`pota memory index\`와 \`pota memory check\`로 관리합니다.
|
|
22
|
+
|
|
23
|
+
## Domain Knowledge
|
|
24
|
+
|
|
25
|
+
## Policy
|
|
26
|
+
|
|
27
|
+
- 구현 전에는 저장소를 정확히 탐색합니다.
|
|
28
|
+
- 고유명사, 파일 이름, 변수 이름, 서비스 이름, 알림 이름, MCP 도구 이름, 에이전트 이름은 쓰인 그대로 보존합니다.
|
|
29
|
+
- 현재 작업에 필요한 상세 메모리 파일만 읽습니다.
|
|
30
|
+
- 사용자가 다른 언어를 요청하지 않는 한, 사용자에게 보이는 응답과 생성 또는 갱신하는 문서, 계획, 메모리 산출물은 OS locale 언어로 작성합니다.
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const planTemplate = `# PLAN
|
|
34
|
+
|
|
35
|
+
Index of work plans. Each line locates one plan in \`docs/plan/\`; details and evidence live in the plan. Planning rules live in the \`pota-plan\` skill. Maintain with \`pota plan index\` and \`pota plan check\`.
|
|
36
|
+
|
|
37
|
+
## Active Plans
|
|
38
|
+
|
|
39
|
+
## Completed Plans
|
|
40
|
+
|
|
41
|
+
Newest first; one line per plan. \`pota plan index --archive\` moves older entries to \`docs/plan/ARCHIVE.md\`.
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const planTemplateKo = `# PLAN
|
|
45
|
+
|
|
46
|
+
작업 계획의 색인입니다. 각 줄은 \`docs/plan/\`의 계획 하나를 가리키며, 상세 내용과 증거는 계획에 있습니다. 계획 규칙은 \`pota-plan\` 스킬에 있습니다. \`pota plan index\`와 \`pota plan check\`로 관리합니다.
|
|
47
|
+
|
|
48
|
+
## Active Plans
|
|
49
|
+
|
|
50
|
+
## Completed Plans
|
|
51
|
+
|
|
52
|
+
최신 항목이 위에 오며 계획당 한 줄입니다. \`pota plan index --archive\`는 오래된 항목을 \`docs/plan/ARCHIVE.md\`로 옮깁니다.
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
export const INDEX_TEMPLATES = Object.freeze({
|
|
56
|
+
memory: Object.freeze({ en: memoryTemplate, ko: memoryTemplateKo }),
|
|
57
|
+
plan: Object.freeze({ en: planTemplate, ko: planTemplateKo }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export async function initializeProject(root, ko) {
|
|
61
|
+
await safeMkdir(root, "docs/memory");
|
|
62
|
+
await safeMkdir(root, "docs/plan");
|
|
63
|
+
await writeIfMissing(root, join(root, "MEMORY.md"), ko ? memoryTemplateKo : memoryTemplate);
|
|
64
|
+
await writeIfMissing(root, join(root, "PLAN.md"), ko ? planTemplateKo : planTemplate);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function writeIfMissing(root, path, content) {
|
|
68
|
+
await assertNoSymlinkComponents(root, path);
|
|
69
|
+
try {
|
|
70
|
+
await readFile(path, "utf8");
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error?.code !== "ENOENT") throw error;
|
|
73
|
+
try {
|
|
74
|
+
await safeWriteFile(root, path, content, { encoding: "utf8", flag: "wx" });
|
|
75
|
+
} catch (writeError) {
|
|
76
|
+
if (writeError?.code !== "EEXIST") throw writeError;
|
|
77
|
+
await assertNoSymlinkComponents(root, path, { allowMissing: false });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import { contextResult, foreignSessionContextResult, prependContext, updateNoticeLine } from "./pota-state-context.mjs";
|
|
5
|
+
import { continuationReason, stopAllowedMessage, stopHookOutput } from "./pota-state-hook.mjs";
|
|
6
|
+
import { initializeProject } from "./pota-state-init.mjs";
|
|
7
|
+
import { evaluateStop, isGoalOwnedBySession, reconcileWorktreeState } from "./pota-goal.mjs";
|
|
8
|
+
import { readHookGuard, readStopGuard, writeHookGuard, writeStopGuard } from "./pota-goal-state.mjs";
|
|
9
|
+
import { hookIndexSnapshot } from "./pota-index.mjs";
|
|
10
|
+
import { resolvePluginRoot, resolveRepoRoot } from "./pota-paths.mjs";
|
|
11
|
+
import { sessionStartUpdate } from "./pota-update-state.mjs";
|
|
12
|
+
|
|
13
|
+
const recoverableGoalStatuses = new Set(["active", "paused", "blocked"]);
|
|
14
|
+
|
|
15
|
+
if (isCliEntry()) {
|
|
16
|
+
const event = process.argv[2] ?? "manual";
|
|
17
|
+
const payload = await readHookInput();
|
|
18
|
+
const result = isHookEvent(event)
|
|
19
|
+
? await runStateResult(event, { payload }).catch((error) => hookFailureResult(event, error))
|
|
20
|
+
: await runStateResult(event, { payload });
|
|
21
|
+
if (isHookEvent(event)) {
|
|
22
|
+
console.log(JSON.stringify(result.hookOutput ?? {}));
|
|
23
|
+
} else {
|
|
24
|
+
console.log(result.message);
|
|
25
|
+
}
|
|
26
|
+
if (result.status !== 0) process.exitCode = result.status;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runState(event = "manual", options = {}) {
|
|
30
|
+
return (await runStateResult(event, options)).message;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function runStateResult(event = "manual", {
|
|
34
|
+
root,
|
|
35
|
+
locale = localeValue(),
|
|
36
|
+
payload,
|
|
37
|
+
update = {},
|
|
38
|
+
} = {}) {
|
|
39
|
+
const resolvedRoot = resolveRoot(root, payload);
|
|
40
|
+
const ko = locale.toLowerCase().startsWith("ko");
|
|
41
|
+
|
|
42
|
+
if (event === "manual" || event === "init") {
|
|
43
|
+
await initializeProject(resolvedRoot, ko);
|
|
44
|
+
return {
|
|
45
|
+
status: 0,
|
|
46
|
+
message: ko
|
|
47
|
+
? "Pota가 MEMORY.md, PLAN.md, docs/memory, docs/plan을 초기화했습니다."
|
|
48
|
+
: "Pota initialized MEMORY.md, PLAN.md, docs/memory, and docs/plan.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (event === "session-start" || event === "user-prompt-submit") {
|
|
53
|
+
const result = await promptContextResult(event, { ko, resolvedRoot, payload });
|
|
54
|
+
return event === "session-start" ? withUpdateNotice(result, ko, update) : result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (event === "post-tool-use") {
|
|
58
|
+
// Only remind while an owned goal is active: an unconditional reminder after every edit accumulates in the
|
|
59
|
+
// conversation prefix, raising cached-read cost and bringing compaction (a full cache reset) sooner.
|
|
60
|
+
const { state } = await reconcileWorktreeState({ root: resolvedRoot });
|
|
61
|
+
if (state?.goal?.status !== "active" || !isGoalOwnedBySession(state, payload?.session_id)) {
|
|
62
|
+
return { status: 0, message: "", hookOutput: {} };
|
|
63
|
+
}
|
|
64
|
+
// Remind once per goal, task, and session; repeating it after every edit only grows the cached prefix.
|
|
65
|
+
const reminderKey = {
|
|
66
|
+
goalId: state.goal.id,
|
|
67
|
+
taskId: state.tasks.find((task) => task.status === "active")?.id ?? null,
|
|
68
|
+
sessionId: payload?.session_id ?? null,
|
|
69
|
+
};
|
|
70
|
+
const previousReminder = await readHookGuard(resolvedRoot, "reminder");
|
|
71
|
+
if (previousReminder?.goalId === reminderKey.goalId
|
|
72
|
+
&& previousReminder?.taskId === reminderKey.taskId
|
|
73
|
+
&& previousReminder?.sessionId === reminderKey.sessionId) {
|
|
74
|
+
return { status: 0, message: "", hookOutput: {} };
|
|
75
|
+
}
|
|
76
|
+
await writeHookGuard(resolvedRoot, "reminder", reminderKey);
|
|
77
|
+
const message = ko
|
|
78
|
+
? "Pota 점검: 외부 상태 변경에는 dry-run/check 증거 기록이 필요합니다. 활성 목표가 있다면 현재 task에 성공 증거를 기록하세요."
|
|
79
|
+
: "Pota check: external-state changes need dry-run/check evidence. When a goal is active, record successful evidence for its current task.";
|
|
80
|
+
return {
|
|
81
|
+
status: 0,
|
|
82
|
+
message,
|
|
83
|
+
hookOutput: {
|
|
84
|
+
hookSpecificOutput: {
|
|
85
|
+
hookEventName: "PostToolUse",
|
|
86
|
+
additionalContext: message,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (event === "stop" || event === "subagent-stop") {
|
|
93
|
+
const { state } = await reconcileWorktreeState({ root: resolvedRoot });
|
|
94
|
+
if (!isGoalOwnedBySession(state, payload?.session_id)) return { status: 0, message: "", hookOutput: {} };
|
|
95
|
+
const gate = evaluateStop(state, {
|
|
96
|
+
subagent: event === "subagent-stop",
|
|
97
|
+
agentId: payload?.agent_id,
|
|
98
|
+
});
|
|
99
|
+
const progressed = gate.block ? await recordStopProgress(resolvedRoot, state, event, payload) : false;
|
|
100
|
+
const hookOutput = stopHookOutput(gate, payload, { progressed });
|
|
101
|
+
return {
|
|
102
|
+
status: 0,
|
|
103
|
+
message: gate.block ? continuationReason(gate.reason, payload, { progressed }) : stopAllowedMessage({ ko, state, event }),
|
|
104
|
+
hookOutput,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
status: 0,
|
|
110
|
+
message: ko ? "Pota: 지원하지 않는 상태 이벤트를 건너뛰었습니다." : "Pota: skipped an unsupported state event.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function promptContextResult(event, { ko, resolvedRoot, payload }) {
|
|
115
|
+
const reconciled = await reconcileWorktreeState({ root: resolvedRoot });
|
|
116
|
+
const state = reconciled.state;
|
|
117
|
+
const recoverable = recoverableGoalStatuses.has(state?.goal?.status);
|
|
118
|
+
const index = await readHookIndex(resolvedRoot, event, recoverable);
|
|
119
|
+
if (recoverable && !isGoalOwnedBySession(state, payload?.session_id)) {
|
|
120
|
+
return foreignSessionContextResult({ ko, event, sessionId: payload?.session_id, root: resolvedRoot, index });
|
|
121
|
+
}
|
|
122
|
+
const context = contextResult({ ko, state, event, index });
|
|
123
|
+
// SessionStart (also after compaction) carries a literal CLI prefix so agents do not rebuild or store it in a
|
|
124
|
+
// shell variable, which does not survive between tool calls.
|
|
125
|
+
const result = event === "session-start" ? appendContext(context, cliPrefixLine()) : context;
|
|
126
|
+
if (!reconciled.removed) return result;
|
|
127
|
+
const notice = ko
|
|
128
|
+
? "Pota worktree 점검: 현재 worktree에 속하지 않는 stale Pota 상태를 제거했습니다."
|
|
129
|
+
: "Pota worktree check: removed stale Pota state that did not belong to this worktree.";
|
|
130
|
+
return prependContext(result, notice);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function recordStopProgress(root, state, event, payload) {
|
|
134
|
+
const scope = event === "subagent-stop" ? `subagent:${payload?.agent_id ?? ""}` : "turn";
|
|
135
|
+
// Parallel workers share this guard: keep one entry per scope, and measure a worker's progress by its own task
|
|
136
|
+
// so sibling writes (which bump the goal revision) do not look like progress.
|
|
137
|
+
const assigned = event === "subagent-stop"
|
|
138
|
+
? state.tasks.find((task) => task.status === "active" && task.assignedAgentId === payload?.agent_id)
|
|
139
|
+
: null;
|
|
140
|
+
const marker = assigned ? `${assigned.id}:${assigned.status}:${assigned.updatedAt}` : state.revision;
|
|
141
|
+
const previous = await readStopGuard(root);
|
|
142
|
+
const scopes = previous?.goalId === state.goal.id && previous?.scopes && typeof previous.scopes === "object" ? previous.scopes : {};
|
|
143
|
+
const progressed = scopes[scope] !== marker;
|
|
144
|
+
if (progressed) {
|
|
145
|
+
const entries = Object.entries({ ...scopes, [scope]: marker }).slice(-16);
|
|
146
|
+
await writeStopGuard(root, { goalId: state.goal.id, scopes: Object.fromEntries(entries) });
|
|
147
|
+
}
|
|
148
|
+
return progressed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// SessionStart lists repository notes (and the plan index while idle); an idle prompt only counts notes. Prompts during
|
|
152
|
+
// an active, paused, blocked, or foreign goal read nothing, since their capsule has no budget left.
|
|
153
|
+
async function readHookIndex(root, event, recoverable) {
|
|
154
|
+
if (event === "session-start") return hookIndexSnapshot({ root, memory: "list", plans: !recoverable });
|
|
155
|
+
if (!recoverable) return hookIndexSnapshot({ root, memory: "count", plans: false });
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Only the CLI path: goal commands resolve the repository root themselves, and a second absolute path would let
|
|
160
|
+
// long install or checkout paths push the SessionStart capsule past its budget. An installed copy names the canonical
|
|
161
|
+
// runtime from its locator, since an upgrade can remove a host cache copy (Codex) while the session still runs.
|
|
162
|
+
function cliPrefixLine() {
|
|
163
|
+
const cli = installedCanonicalCli() ?? fileURLToPath(new URL("./pota.mjs", import.meta.url));
|
|
164
|
+
return `Pota CLI: node ${JSON.stringify(cli)} goal <command>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function installedCanonicalCli() {
|
|
168
|
+
try {
|
|
169
|
+
const locator = JSON.parse(readFileSync(new URL("../.pota-runtime.json", import.meta.url), "utf8"));
|
|
170
|
+
return locator?.plugin === "pota" && typeof locator.entrypoints?.cli === "string" ? locator.entrypoints.cli : null;
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function appendContext(result, line) {
|
|
177
|
+
const specific = result.hookOutput?.hookSpecificOutput;
|
|
178
|
+
return {
|
|
179
|
+
...result,
|
|
180
|
+
message: `${result.message}\n${line}`,
|
|
181
|
+
hookOutput: specific
|
|
182
|
+
? { ...result.hookOutput, hookSpecificOutput: { ...specific, additionalContext: `${specific.additionalContext}\n${line}` } }
|
|
183
|
+
: result.hookOutput,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The auto-upgrade step runs after the capsule is built and every error in it is swallowed, so it never changes the
|
|
188
|
+
// capsule, the exit code, or the "could not read goal state" failure. A notice goes last (after the CLI line), keeping
|
|
189
|
+
// the kernel-first prefix, and is mirrored in systemMessage for the user.
|
|
190
|
+
function withUpdateNotice(result, ko, { env = process.env, now = new Date(), spawn, pluginRoot = resolvePluginRoot(import.meta.url) } = {}) {
|
|
191
|
+
let line = null;
|
|
192
|
+
try {
|
|
193
|
+
const notice = sessionStartUpdate({ env, now, spawn, pluginRoot });
|
|
194
|
+
line = notice ? updateNoticeLine({ ko, notice }) : null;
|
|
195
|
+
} catch {
|
|
196
|
+
line = null;
|
|
197
|
+
}
|
|
198
|
+
if (!line) return result;
|
|
199
|
+
const appended = appendContext(result, line);
|
|
200
|
+
return { ...appended, hookOutput: { ...appended.hookOutput, systemMessage: line } };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function hookFailureResult(event, error) {
|
|
204
|
+
const detail = String(error?.message ?? error).split("\n")[0].slice(0, 300);
|
|
205
|
+
const message = `Pota ${event} hook could not read goal state: ${detail}. Run pota goal status to inspect it; do not treat an active goal as finished.`;
|
|
206
|
+
return { status: 0, message, hookOutput: { systemMessage: message } };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveRoot(root, payload) {
|
|
210
|
+
return resolveRepoRoot({
|
|
211
|
+
explicitRoot: root ?? null,
|
|
212
|
+
hookCwd: payload?.cwd ?? null,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function readHookInput() {
|
|
217
|
+
if (process.stdin.isTTY) return undefined;
|
|
218
|
+
let input = "";
|
|
219
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
220
|
+
if (input.trim().length === 0) return undefined;
|
|
221
|
+
try {
|
|
222
|
+
const value = JSON.parse(input);
|
|
223
|
+
return value && typeof value === "object" ? value : undefined;
|
|
224
|
+
} catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isHookEvent(event) {
|
|
230
|
+
return ["session-start", "user-prompt-submit", "post-tool-use", "stop", "subagent-stop"].includes(event);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function localeValue() {
|
|
234
|
+
return [process.env.LC_ALL, process.env.LC_MESSAGES, process.env.LANG]
|
|
235
|
+
.find((value) => typeof value === "string" && value.length > 0) ?? Intl.DateTimeFormat().resolvedOptions().locale;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isCliEntry() {
|
|
239
|
+
if (!process.argv[1]) return false;
|
|
240
|
+
try {
|
|
241
|
+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
|
242
|
+
} catch {
|
|
243
|
+
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
244
|
+
}
|
|
245
|
+
}
|