@akagilnc/pi-workflow-roles 0.1.2071 → 0.1.2076

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.2071",
3
+ "version": "0.1.2076",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -23,7 +23,6 @@ import {
23
23
  } from "./tool-execution-observation.ts";
24
24
  import { registerEngineDetourTool } from "./engine-detour-tool.ts";
25
25
  import { installPackageOwnedToolRegistration } from "./package-owned-tool-idle.ts";
26
- import { installWorkerGitHooks } from "./worker-submission-gates.ts";
27
26
  import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
28
27
  import { createOAuthKeepalive, type OAuthKeepaliveOptions } from "./oauth-keepalive.ts";
29
28
 
@@ -1177,10 +1176,9 @@ export function createRoleRuntimeExtension(
1177
1176
  engineDetourRegistration = undefined;
1178
1177
  }
1179
1178
  }
1180
- // Worker gates ②④ + baseline: envelope arms the worktree after role install.
1179
+ // Worker gates ①②: arm records baseline and runs private one-shot hook uninstall (ADR 0070).
1181
1180
  // Parent session feeds #216 createRecordSession so baseline/bounce survive resume.
1182
1181
  if (entry.role === "coder" || entry.role === "fixer") {
1183
- installWorkerGitHooks(ctx.cwd);
1184
1182
  if (entry.role === "coder") coder.armSubmissionGate(ctx.cwd, ctx.sessionManager);
1185
1183
  else fixer.armSubmissionGate(ctx.cwd, ctx.sessionManager);
1186
1184
  }
@@ -29,6 +29,7 @@ import {
29
29
  import {
30
30
  createWorkerSubmissionGate,
31
31
  WorkerCommitReminderError,
32
+ WorkerPrefixReminderError,
32
33
  WorkerUnfinishedReasonReminderError,
33
34
  } from "./worker-submission-gates.ts";
34
35
 
@@ -189,6 +190,7 @@ function assertAcceptableThroughHost(
189
190
  } catch (error) {
190
191
  if (
191
192
  error instanceof WorkerCommitReminderError ||
193
+ error instanceof WorkerPrefixReminderError ||
192
194
  error instanceof WorkerUnfinishedReasonReminderError
193
195
  ) {
194
196
  throw error;
@@ -1,12 +1,6 @@
1
- /** #242 worker gates ①②④. durability: ADR 0065/#216 createRecordSession only — no appendCustomEntry bypass / parallel ledger. */
1
+ /** #242/#369 worker gates ①② at submission seam. Durability: ADR 0065 createRecordSession only. */
2
2
  import { execFileSync } from "node:child_process";
3
- import {
4
- chmodSync,
5
- existsSync,
6
- mkdirSync,
7
- readFileSync,
8
- writeFileSync,
9
- } from "node:fs";
3
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, rmSync } from "node:fs";
10
4
  import { resolve } from "node:path";
11
5
  import { SessionManager } from "@earendil-works/pi-coding-agent";
12
6
 
@@ -16,22 +10,36 @@ import {
16
10
  WORKER_SUBMISSION_GATE_KIND,
17
11
  } from "./sitian-record-entry.ts";
18
12
 
19
- export const WORKER_COMMIT_SUBJECT_PREFIX = "ak-roles:";
20
- /** Sitian kind for gate ① durable baseline / bounce records (single path segment, not a destination). */
21
13
  export const WORKER_SUBMISSION_GATE_RECORD_KIND = WORKER_SUBMISSION_GATE_KIND;
22
14
  export const WORKER_COMMIT_BASELINE_ENTRY_TYPE = "commit-baseline";
23
15
  export const WORKER_COMMIT_REMINDER_BOUNCE_ENTRY_TYPE = "commit-reminder-bounce";
16
+ export const WORKER_PREFIX_REMINDER_BOUNCE_ENTRY_TYPE = "prefix-reminder-bounce";
24
17
 
25
18
  const DONE = new Set(["completed", "partially_completed"]);
26
- /** Marker: own-package hooks are reloadable across HOOK body changes; foreign hooks refuse. */
19
+ /** Historical package hook ownership marker uninstall criterion only. */
27
20
  const HOOK_MARKER = "ak-roles: worker-submission-gates reference-transaction";
21
+ const HOOKS_DIR = "ak-roles-hooks";
22
+ const HOOK_FILE = "reference-transaction";
23
+ /** Open platform-prefix domain (constitution #10) — not a closed singleton. */
24
+ const PLATFORM_PREFIX = /^[A-Za-z][A-Za-z0-9_-]*:/;
25
+ const UNFINISHED_REASON_BOUNCE_LIMIT = 2;
28
26
 
29
27
  export class WorkerCommitReminderError extends Error {
30
28
  readonly code = "worker_commit_reminder" as const;
31
- constructor() { super("未观察到 commit"); this.name = "WorkerCommitReminderError"; }
29
+ constructor() {
30
+ super("未观察到 commit");
31
+ this.name = "WorkerCommitReminderError";
32
+ }
33
+ }
34
+
35
+ export class WorkerPrefixReminderError extends Error {
36
+ readonly code = "worker_prefix_reminder" as const;
37
+ constructor() {
38
+ super("观察到缺前缀 commit,请重写后再交");
39
+ this.name = "WorkerPrefixReminderError";
40
+ }
32
41
  }
33
42
 
34
- /** #292 unfinished reason solicitation — same bounce shape as gate ①; in-session only. */
35
43
  export class WorkerUnfinishedReasonReminderError extends Error {
36
44
  readonly code = "worker_unfinished_reason_reminder" as const;
37
45
  constructor() {
@@ -40,18 +48,6 @@ export class WorkerUnfinishedReasonReminderError extends Error {
40
48
  }
41
49
  }
42
50
 
43
- const UNFINISHED_REASON_BOUNCE_LIMIT = 2;
44
-
45
- function unfinishedReasonPresent(details?: unknown): boolean {
46
- if (typeof details !== "object" || details === null) return false;
47
- try {
48
- const reason = (details as { reason?: unknown }).reason;
49
- return typeof reason === "string" && reason.trim().length > 0;
50
- } catch {
51
- return false;
52
- }
53
- }
54
-
55
51
  export type WorkerSubmissionGateParent = RecordSessionParent;
56
52
 
57
53
  function git(cwd: string, args: string[]): string {
@@ -68,125 +64,127 @@ function gitFile(file: string, args: string[]): string {
68
64
  }).trim();
69
65
  }
70
66
 
71
- // ② each newly-created commit (incl. empty subject); ban non-fast-forward.
72
- // Scoped by install: worktree-local core.hooksPath only the armed tree.
73
- const HOOK = `#!/bin/sh
74
- # ${HOOK_MARKER}
75
- [ "$1" = prepared ] || exit 0
76
- prefix=${WORKER_COMMIT_SUBJECT_PREFIX}
77
- while read -r old new ref; do
78
- case $ref in refs/heads/*|HEAD) ;; *) continue ;; esac
79
- [ -n "$new" ] && [ -n "$(printf %s "$new" | tr -d 0)" ] || continue
80
- if [ -n "$old" ] && [ -n "$(printf %s "$old" | tr -d 0)" ]; then
81
- git merge-base --is-ancestor "$old" "$new" 2>/dev/null || { echo "ak-roles: rejected non-fast-forward update of $ref (no amend/rebase/reset)" >&2; exit 1; }
82
- fi
83
- for commit in $(git rev-list "$new" --not --all 2>/dev/null); do
84
- subj=$(git log -1 --format=%s "$commit")
85
- case $subj in "$prefix"*) ;; *) echo "ak-roles: commit subject must start with $prefix (got: $subj)" >&2; exit 1 ;; esac
86
- done
87
- done
88
- `;
67
+ function statusOf(error: unknown): unknown {
68
+ return typeof error === "object" && error !== null && "status" in error
69
+ ? (error as { status: unknown }).status
70
+ : undefined;
71
+ }
89
72
 
90
- /**
91
- * Bind ②④ to this worktree only — private hooksPath + worktree config.
92
- * Never leave shared common config in a state that bricks sibling/main trees
93
- * (git requires core.bare/core.worktree moved out of common when worktreeConfig is on).
94
- */
95
- export function installWorkerGitHooks(cwd: string): void {
96
- // Fail closed before any shared-config write: bare host / non-work-tree is not armable.
97
- let inside: string;
73
+ function tryGetAll(file: string, key: string): string[] {
74
+ if (!existsSync(file)) return [];
98
75
  try {
99
- inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
76
+ const out = gitFile(file, ["--get-all", key]);
77
+ return out.length === 0 ? [] : out.split("\n");
100
78
  } catch (error) {
101
- throw new Error(
102
- `ak-roles: refusing worker hooks install outside a git work tree: ${
103
- error instanceof Error ? error.message : String(error)
104
- }`,
105
- );
106
- }
107
- if (inside !== "true") {
108
- throw new Error("ak-roles: refusing worker hooks install outside a git work tree");
79
+ // --get-all exit 1 = absent; other failures stay loud.
80
+ if (statusOf(error) !== 1) throw error;
81
+ return [];
109
82
  }
83
+ }
110
84
 
111
- const commonDir = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
112
- const commonConfig = resolve(commonDir, "config");
113
- const mainWorktreeConfig = resolve(commonDir, "config.worktree");
85
+ /** True only when the file exists and carries the historical package marker.
86
+ * Read failures propagate never disguised as "not owned". */
87
+ function ownedHook(path: string): boolean {
88
+ if (!existsSync(path)) return false;
89
+ return readFileSync(path, "utf8").includes(HOOK_MARKER);
90
+ }
114
91
 
115
- // Snapshot worktree-only keys still sitting in common config (git docs: must move on enable).
116
- let bareInCommon = false;
117
- let worktreeInCommon: string | undefined;
118
- try { bareInCommon = gitFile(commonConfig, ["--get", "core.bare"]) === "true"; } catch { /* unset */ }
119
- try { worktreeInCommon = gitFile(commonConfig, ["--get", "core.worktree"]); } catch { /* unset */ }
92
+ /** Escape a hooksPath value for git config --unset value-pattern (POSIX ERE). */
93
+ function escapeGitConfigValueRegex(value: string): string {
94
+ return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
95
+ }
120
96
 
121
- // Skip shared write when already enabled in *this* repo — concurrent sibling activations
122
- // otherwise race on .git/config.lock (#267). Scope must be --local (common config): a
123
- // global/system true must not skip the repo's first enable. Value must use Git bool
124
- // semantics (true/yes/on/1), not a literal "true" compare. First enable still writes;
125
- // real write failures still throw. Only --get exit 1 means unset; other failures stay loud.
126
- let worktreeConfigEnabled = false;
127
- try {
128
- worktreeConfigEnabled =
129
- git(cwd, ["config", "--local", "--bool", "--get", "extensions.worktreeConfig"]) === "true";
130
- } catch (error) {
131
- const status =
132
- typeof error === "object" && error !== null && "status" in error
133
- ? (error as { status: unknown }).status
134
- : undefined;
135
- if (status !== 1) throw error;
136
- }
137
- if (!worktreeConfigEnabled) {
138
- git(cwd, ["config", "extensions.worktreeConfig", "true"]);
97
+ /**
98
+ * Remove only package-owned core.hooksPath values; keep every foreign value.
99
+ * --unset without a value-pattern exits 5 for both "absent" and "multi-value",
100
+ * so multi-valued keys must be addressed per matching value.
101
+ */
102
+ function unsetOwnedHooksPath(file: string): string[] {
103
+ const owned: string[] = [];
104
+ for (const value of tryGetAll(file, "core.hooksPath")) {
105
+ if (!ownedHook(resolve(value, HOOK_FILE))) continue;
106
+ try {
107
+ // --unset-all + exact value-pattern drops every duplicate owned copy; foreign stays.
108
+ gitFile(file, [
109
+ "--unset-all",
110
+ "core.hooksPath",
111
+ `^${escapeGitConfigValueRegex(value)}$`,
112
+ ]);
113
+ } catch (error) {
114
+ // 5 = this specific value already absent (not multi-value ambiguity).
115
+ if (statusOf(error) !== 5) throw error;
116
+ }
117
+ owned.push(value);
139
118
  }
119
+ return owned;
120
+ }
140
121
 
141
- // Migrate immediately so sibling trees never observe bare-in-common under worktreeConfig.
142
- if (bareInCommon) {
143
- try { gitFile(commonConfig, ["--unset", "core.bare"]); } catch { /* raced */ }
144
- gitFile(mainWorktreeConfig, ["core.bare", "true"]);
145
- }
146
- if (worktreeInCommon !== undefined) {
147
- try { gitFile(commonConfig, ["--unset", "core.worktree"]); } catch { /* raced */ }
148
- gitFile(mainWorktreeConfig, ["core.worktree", worktreeInCommon]);
122
+ /** Delete only the package-owned hook file; rmdir solely when empty. */
123
+ function rmOwnedDir(dir: string): void {
124
+ const hookPath = resolve(dir, HOOK_FILE);
125
+ if (!ownedHook(hookPath)) return;
126
+ rmSync(hookPath, { force: true });
127
+ if (existsSync(dir) && readdirSync(dir).length === 0) rmdirSync(dir);
128
+ }
129
+
130
+ function linkedGitDirs(commonDir: string): string[] {
131
+ const root = resolve(commonDir, "worktrees");
132
+ if (!existsSync(root)) return [];
133
+ // Enumeration/lstat failures propagate — never skip a linked admin dir silently.
134
+ // lstat does not follow: symlink entries are not directories and stay out of range.
135
+ return readdirSync(root)
136
+ .map((name) => resolve(root, name))
137
+ .filter((dir) => lstatSync(dir).isDirectory());
138
+ }
139
+
140
+ /**
141
+ * ADR 0070 §4 — private one-shot uninstall on arm.
142
+ * Range: current repo + enumerable worktree admin dirs. Owned hooksPath/files only.
143
+ * Never rolls back extensions.worktreeConfig / migrated bare|worktree / foreign hooksPath.
144
+ */
145
+ function uninstallPackageWorkerHooks(cwd: string): void {
146
+ let inside: string;
147
+ try {
148
+ inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
149
+ } catch {
150
+ return;
149
151
  }
152
+ if (inside !== "true") return;
150
153
 
151
- const gitDir = git(cwd, ["rev-parse", "--path-format=absolute", "--git-dir"]);
152
- const dir = resolve(gitDir, "ak-roles-hooks");
153
- const path = resolve(dir, "reference-transaction");
154
- if (existsSync(path)) {
155
- const existing = readFileSync(path, "utf8");
156
- // Own-package marker → reload OK (HOOK body may change across versions).
157
- // Foreign same-name hook → fail closed, never overwrite.
158
- if (!existing.includes(HOOK_MARKER)) {
159
- throw new Error("ak-roles: refusing to overwrite existing reference-transaction hook");
160
- }
154
+ const commonDir = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
155
+ // Unset owned hooksPath before deleting the marker file (ownership check needs it).
156
+ const clear = (configFile: string): void => {
157
+ for (const hooks of unsetOwnedHooksPath(configFile)) rmOwnedDir(hooks);
158
+ };
159
+ clear(resolve(commonDir, "config"));
160
+ clear(resolve(commonDir, "config.worktree"));
161
+ rmOwnedDir(resolve(commonDir, HOOKS_DIR));
162
+ const legacy = resolve(commonDir, "hooks", HOOK_FILE);
163
+ if (ownedHook(legacy)) rmSync(legacy, { force: true });
164
+ for (const gitDir of linkedGitDirs(commonDir)) {
165
+ clear(resolve(gitDir, "config.worktree"));
166
+ rmOwnedDir(resolve(gitDir, HOOKS_DIR));
161
167
  }
162
- mkdirSync(dir, { recursive: true });
163
- writeFileSync(path, HOOK, "utf8");
164
- chmodSync(path, 0o755);
165
- git(cwd, ["config", "--worktree", "core.hooksPath", dir]);
166
168
  }
167
169
 
168
170
  function isRecord(value: unknown): value is Record<string, unknown> {
169
171
  return typeof value === "object" && value !== null && !Array.isArray(value);
170
172
  }
171
173
 
172
- /**
173
- * Open gate record via sitian entry only — resume/lifecycle live in createRecordSession.
174
- * Gate consumes the returned session; no nest scan, unlink, or peer reopen.
175
- */
176
- function openGateRecord(cwd: string, parent?: WorkerSubmissionGateParent): SessionManager {
177
- return createRecordSession({
178
- cwd,
179
- kind: WORKER_SUBMISSION_GATE_RECORD_KIND,
180
- ...(parent === undefined ? {} : { parent }),
181
- });
174
+ function unfinishedReasonPresent(details?: unknown): boolean {
175
+ if (typeof details !== "object" || details === null) return false;
176
+ const reason = (details as { reason?: unknown }).reason;
177
+ return typeof reason === "string" && reason.trim().length > 0;
182
178
  }
183
179
 
184
180
  function readGateState(session: SessionManager): {
185
181
  baseline: string | null | undefined;
186
182
  reminded: boolean;
183
+ prefixReminded: boolean;
187
184
  } {
188
185
  let baseline: string | null | undefined;
189
186
  let reminded = false;
187
+ let prefixReminded = false;
190
188
  for (const entry of session.getEntries()) {
191
189
  if (entry.type !== "custom") continue;
192
190
  if (entry.customType === WORKER_COMMIT_BASELINE_ENTRY_TYPE) {
@@ -196,9 +194,44 @@ function readGateState(session: SessionManager): {
196
194
  }
197
195
  } else if (entry.customType === WORKER_COMMIT_REMINDER_BOUNCE_ENTRY_TYPE) {
198
196
  reminded = true;
197
+ } else if (entry.customType === WORKER_PREFIX_REMINDER_BOUNCE_ENTRY_TYPE) {
198
+ prefixReminded = true;
199
199
  }
200
200
  }
201
- return { baseline, reminded };
201
+ return { baseline, reminded, prefixReminded };
202
+ }
203
+
204
+ function isAncestor(cwd: string, ancestor: string, descendant: string): boolean {
205
+ try {
206
+ git(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]);
207
+ return true;
208
+ } catch (error) {
209
+ if (statusOf(error) === 1) return false;
210
+ throw error;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Reliable window (ADR 0070). null = unreliable tip-SHA baseline; [] = empty.
216
+ * Structured git-log fields only — never parse a shell command string.
217
+ */
218
+ function reliableWindow(
219
+ cwd: string,
220
+ baseline: string | null,
221
+ head: string,
222
+ ): ReadonlyArray<{ subject: string; merge: boolean }> | null {
223
+ if (baseline !== null && !isAncestor(cwd, baseline, head)) return null;
224
+ const range = baseline === null ? head : `${baseline}..${head}`;
225
+ const raw = git(cwd, ["log", "--format=%P%x1e%s", range]);
226
+ if (raw.length === 0) return [];
227
+ return raw.split("\n").flatMap((line) => {
228
+ const sep = line.indexOf("\x1e");
229
+ if (sep < 0) return [];
230
+ return [{
231
+ subject: line.slice(sep + 1),
232
+ merge: line.slice(0, sep).trim().includes(" "),
233
+ }];
234
+ });
202
235
  }
203
236
 
204
237
  export function createWorkerSubmissionGate(): {
@@ -208,37 +241,42 @@ export function createWorkerSubmissionGate(): {
208
241
  let baseline: string | null | undefined;
209
242
  let root: string | undefined;
210
243
  let reminded = false;
244
+ let prefixReminded = false;
211
245
  let unfinishedReasonBounces = 0;
212
246
  let record: SessionManager | undefined;
213
- // null = unborn HEAD only; any other git failure throws (no swallow).
214
247
  const head = (cwd: string): string | null => {
215
- try { return git(cwd, ["rev-parse", "HEAD"]); }
216
- catch {
217
- git(cwd, ["rev-parse", "--git-dir"]); // surface real git/repo failures
248
+ try {
249
+ return git(cwd, ["rev-parse", "HEAD"]);
250
+ } catch {
251
+ git(cwd, ["rev-parse", "--git-dir"]); // surface real git failures
218
252
  return null;
219
253
  }
220
254
  };
221
255
  return {
222
256
  arm(cwd, parent) {
257
+ uninstallPackageWorkerHooks(cwd);
223
258
  root = cwd;
224
- record = openGateRecord(cwd, parent);
259
+ record = createRecordSession({
260
+ cwd,
261
+ kind: WORKER_SUBMISSION_GATE_RECORD_KIND,
262
+ ...(parent === undefined ? {} : { parent }),
263
+ });
225
264
  const prior = readGateState(record);
226
265
  if (prior.baseline !== undefined) {
227
- // Cross-resume: keep first-arm baseline and any prior bounce (no second false bounce).
228
266
  baseline = prior.baseline;
229
267
  reminded = prior.reminded;
268
+ prefixReminded = prior.prefixReminded;
230
269
  return;
231
270
  }
232
271
  baseline = head(cwd);
233
272
  reminded = false;
234
- // First arm writes baseline through the sitian-created session (auditor pattern).
273
+ prefixReminded = false;
235
274
  record.appendCustomEntry(WORKER_COMMIT_BASELINE_ENTRY_TYPE, {
236
275
  version: 1,
237
276
  head: baseline,
238
277
  });
239
278
  },
240
279
  assertAcceptable(status, details) {
241
- // #292: unfinished without a non-blank reason → in-session bounce (max 2), then accept.
242
280
  if (status === "unfinished" && !unfinishedReasonPresent(details)) {
243
281
  if (unfinishedReasonBounces < UNFINISHED_REASON_BOUNCE_LIMIT) {
244
282
  unfinishedReasonBounces += 1;
@@ -247,14 +285,29 @@ export function createWorkerSubmissionGate(): {
247
285
  }
248
286
  if (baseline === undefined || root === undefined || !DONE.has(status)) return;
249
287
  const now = head(root);
250
- if ((now !== null && (baseline === null || now !== baseline)) || reminded) {
288
+ const headMoved = now !== null && (baseline === null || now !== baseline);
289
+
290
+ // Gate ① — forgetfulness reminder (ADR 0066; behavior unchanged).
291
+ if (!headMoved && !reminded) {
251
292
  reminded = true;
252
- return;
293
+ record?.appendCustomEntry(WORKER_COMMIT_REMINDER_BOUNCE_ENTRY_TYPE, { version: 1 });
294
+ throw new WorkerCommitReminderError();
253
295
  }
254
296
  reminded = true;
255
- // Durable bounce once per run — resume must not re-fire the same reminder.
256
- record?.appendCustomEntry(WORKER_COMMIT_REMINDER_BOUNCE_ENTRY_TYPE, { version: 1 });
257
- throw new WorkerCommitReminderError();
297
+
298
+ // Gate open platform-prefix soft reminder (ADR 0070).
299
+ if (prefixReminded || now === null) return;
300
+ const window = reliableWindow(root, baseline, now);
301
+ if (
302
+ window === null ||
303
+ window.length === 0 ||
304
+ !window.some((c) => !c.merge && !PLATFORM_PREFIX.test(c.subject))
305
+ ) {
306
+ return;
307
+ }
308
+ prefixReminded = true;
309
+ record?.appendCustomEntry(WORKER_PREFIX_REMINDER_BOUNCE_ENTRY_TYPE, { version: 1 });
310
+ throw new WorkerPrefixReminderError();
258
311
  },
259
312
  };
260
313
  }