@astrosheep/keiyaku 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +73 -56
  2. package/build/.tsbuildinfo +1 -1
  3. package/build/agents/call-terms_v2.js +103 -0
  4. package/build/agents/harness/activity-values.js +54 -0
  5. package/build/agents/harness/event-channel.js +55 -20
  6. package/build/agents/harness/events.js +47 -1
  7. package/build/agents/harness/execution-handle.js +292 -61
  8. package/build/agents/harness/index.js +103 -121
  9. package/build/agents/harness/pump.js +36 -47
  10. package/build/agents/harness/runtime.js +3 -0
  11. package/build/agents/launch-snapshot_v2.js +348 -0
  12. package/build/agents/opencode-sdk.js +42 -169
  13. package/build/agents/providers/claude-agent-sdk.js +240 -79
  14. package/build/agents/providers/codex-app-server.js +280 -72
  15. package/build/agents/providers/codex-sdk.js +159 -41
  16. package/build/agents/providers/event-type-tail.js +16 -0
  17. package/build/agents/providers/opencode-sdk.js +165 -59
  18. package/build/agents/providers/pi.js +254 -42
  19. package/build/agents/selector_v2.js +122 -0
  20. package/build/cli/commands/akuma.js +120 -42
  21. package/build/cli/completion.js +6 -7
  22. package/build/cli/flags.js +36 -10
  23. package/build/cli/help.js +42 -26
  24. package/build/cli/index.js +225 -93
  25. package/build/cli/parse.js +194 -75
  26. package/build/cli/projection-address.js +11 -0
  27. package/build/cli/render/address.js +88 -0
  28. package/build/cli/render/arc.js +10 -10
  29. package/build/cli/render/call.js +59 -0
  30. package/build/cli/render/compact-text.js +3 -0
  31. package/build/cli/render/errors.js +4 -2
  32. package/build/cli/render/format.js +6 -0
  33. package/build/cli/render/line-width.js +70 -0
  34. package/build/cli/render/misc.js +7 -4
  35. package/build/cli/render/petition.js +4 -3
  36. package/build/cli/render/projection-activity.js +201 -0
  37. package/build/cli/render/shared.js +46 -12
  38. package/build/cli/render/status.js +59 -10
  39. package/build/cli/render/tool-ledger-rollup.js +74 -0
  40. package/build/cli/render/tool-presentation.js +97 -0
  41. package/build/cli/render/wait.js +229 -0
  42. package/build/cli/skills-install.js +42 -24
  43. package/build/cli/subagent-guard.js +1 -1
  44. package/build/config/akuma-loader_v2.js +222 -0
  45. package/build/config/env-keys.js +0 -4
  46. package/build/config/env.js +0 -6
  47. package/build/config/settings.js +325 -12
  48. package/build/core/addressing.js +224 -0
  49. package/build/core/amend.js +30 -47
  50. package/build/core/arc.js +68 -60
  51. package/build/core/bind.js +105 -25
  52. package/build/core/call-persist.js +69 -0
  53. package/build/core/call.js +658 -0
  54. package/build/core/claim.js +72 -44
  55. package/build/core/command-io.js +19 -19
  56. package/build/core/context.js +11 -36
  57. package/build/core/draft.js +2 -3
  58. package/build/core/entry.js +101 -6
  59. package/build/core/execution-coordinate.js +62 -0
  60. package/build/core/execution-pact.js +141 -0
  61. package/build/core/forfeit.js +2 -3
  62. package/build/core/hints.js +10 -10
  63. package/build/core/ids.js +6 -0
  64. package/build/core/ledger.js +16 -1
  65. package/build/core/petition-claim-gates.js +14 -12
  66. package/build/core/petition-claim.js +1 -0
  67. package/build/core/petition-run.js +6 -2
  68. package/build/core/petition.js +63 -21
  69. package/build/core/places.js +106 -13
  70. package/build/core/projection/generation.js +412 -0
  71. package/build/core/projection/heart.js +316 -0
  72. package/build/core/projection/identity.js +81 -0
  73. package/build/core/projection/leash.js +87 -0
  74. package/build/core/projection/mint.js +154 -0
  75. package/build/core/projection-activity.js +219 -0
  76. package/build/core/projection-coordinate.js +35 -0
  77. package/build/core/projection-core.js +69 -463
  78. package/build/core/projection-generation-continuation.js +31 -0
  79. package/build/core/projection-generation-execution.js +148 -0
  80. package/build/core/projection-generation-identity.js +17 -0
  81. package/build/core/projection-generation-launcher.js +90 -0
  82. package/build/core/projection-generation-process.js +43 -0
  83. package/build/core/projection-generation-runner.js +144 -0
  84. package/build/core/projection-generation-runtime.js +15 -0
  85. package/build/core/projection-generation-store.js +707 -0
  86. package/build/core/projection-identity.js +11 -0
  87. package/build/core/projection-life-observer.js +87 -0
  88. package/build/core/projection-life-protocol.js +95 -0
  89. package/build/core/projection-mint.js +96 -24
  90. package/build/core/projection-runner-lock.js +230 -0
  91. package/build/core/projection-status.js +207 -107
  92. package/build/core/projection-tells.js +401 -0
  93. package/build/core/projection-wait.js +155 -0
  94. package/build/core/projection-wake.js +109 -231
  95. package/build/core/queue_v2.js +342 -0
  96. package/build/core/registry.js +6 -3
  97. package/build/core/render.js +63 -1
  98. package/build/core/renew.js +80 -53
  99. package/build/core/scope.js +186 -137
  100. package/build/core/seal.js +16 -19
  101. package/build/core/status.js +88 -25
  102. package/build/core/stored-agent-event.js +101 -0
  103. package/build/core/transcripts.js +214 -228
  104. package/build/core/verdict.js +27 -15
  105. package/build/core/worktree-bootstrap.js +187 -0
  106. package/build/core/worktree-path.js +72 -18
  107. package/build/flow-error.js +4 -6
  108. package/build/generated/version.js +1 -1
  109. package/build/git/branches.js +6 -1
  110. package/build/index.js +8 -5
  111. package/build/keiyaku.js +1 -3
  112. package/package.json +6 -5
  113. package/skills/keiyaku/SKILL.md +6 -47
  114. package/skills/keiyaku-akuma/SKILL.md +75 -0
  115. package/build/agents/effective-policy.js +0 -32
  116. package/build/agents/index.js +0 -114
  117. package/build/agents/selector.js +0 -28
  118. package/build/cli/render/summon.js +0 -17
  119. package/build/cli/render/tell.js +0 -11
  120. package/build/config/akuma-loader.js +0 -268
  121. package/build/core/queue.js +0 -73
  122. package/build/core/summon-persist.js +0 -73
  123. package/build/core/summon.js +0 -377
@@ -1,4 +1,8 @@
1
- const ILLEGAL_PATTERN_TOKENS = ["!", "{", "}", "?", "[", "]"];
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { createGit, wrapGitError } from "../git/core.js";
2
6
  export class ScopePatternError extends Error {
3
7
  code = "INVALID_SCOPE_PATTERN";
4
8
  constructor(message) {
@@ -6,158 +10,203 @@ export class ScopePatternError extends Error {
6
10
  this.name = "ScopePatternError";
7
11
  }
8
12
  }
9
- function normalizeSeparators(value) {
10
- return value.replace(/\\/g, "/").replace(/\/+/g, "/");
11
- }
12
- function assertAllowedPattern(pattern) {
13
- const found = ILLEGAL_PATTERN_TOKENS.find((token) => pattern.includes(token));
14
- if (found) {
15
- throw new ScopePatternError(`scope pattern '${pattern}' uses unsupported token '${found}'`);
13
+ /**
14
+ * Gitignore trailing spaces are ignored unless escaped with a backslash.
15
+ * Preserve `\ ` (and longer odd-backslash runs) while stripping only unescaped
16
+ * trailing SPACE characters. Tabs and other trailing whitespace stay as written.
17
+ */
18
+ function stripUnescapedTrailingSpaces(value) {
19
+ let end = value.length;
20
+ while (end > 0 && value[end - 1] === " ") {
21
+ let backslashes = 0;
22
+ for (let index = end - 2; index >= 0 && value[index] === "\\"; index -= 1) {
23
+ backslashes += 1;
24
+ }
25
+ if (backslashes % 2 === 1)
26
+ break;
27
+ end -= 1;
16
28
  }
29
+ return value.slice(0, end);
30
+ }
31
+ /**
32
+ * Drop cosmetic framing whitespace without destroying escaped trailing spaces.
33
+ * Leading whitespace is always cosmetic for scope entry; trailing SPACE follows
34
+ * gitignore escape rules.
35
+ */
36
+ function canonicalizeScopePatternInput(pattern) {
37
+ return stripUnescapedTrailingSpaces(pattern.replace(/^\s+/, ""));
17
38
  }
39
+ /** True when a raw Scope Add / list line has no pattern after gitignore blank rules. */
40
+ export function isBlankScopePatternLine(line) {
41
+ return canonicalizeScopePatternInput(line).length === 0;
42
+ }
43
+ /**
44
+ * Canonicalize a scope gitignore pattern for exclude matching and persistence.
45
+ * Strips only a cosmetic leading `./` (including after `!`); preserves meaningful
46
+ * leading `/` anchoring, gitignore backslash escapes, and escaped trailing spaces.
47
+ */
18
48
  export function normalizeScopePattern(pattern) {
19
- const trimmed = normalizeSeparators(pattern.trim()).replace(/^\.\/+/, "").replace(/^\/+/, "");
20
- if (!trimmed) {
21
- throw new ScopePatternError("scope pattern cannot be empty");
49
+ if (pattern.includes("\0") || pattern.includes("\n") || pattern.includes("\r")) {
50
+ throw new ScopePatternError("scope pattern must fit on one line and cannot contain NUL");
22
51
  }
23
- assertAllowedPattern(trimmed);
24
- if (trimmed.endsWith("/")) {
25
- return `${trimmed}**`;
52
+ const trimmed = canonicalizeScopePatternInput(pattern);
53
+ if (!trimmed)
54
+ throw new ScopePatternError("scope pattern cannot be empty");
55
+ const negative = trimmed.startsWith("!");
56
+ let body = negative ? trimmed.slice(1) : trimmed;
57
+ while (body.startsWith("./")) {
58
+ body = body.slice(2);
26
59
  }
27
- return trimmed;
60
+ if (!body)
61
+ throw new ScopePatternError("scope pattern cannot be empty");
62
+ return negative ? `!${body}` : body;
28
63
  }
29
- export function normalizeScopePath(path) {
30
- const normalized = normalizeSeparators(path.trim()).replace(/^\.\/+/, "").replace(/^\/+/, "");
31
- if (!normalized) {
32
- throw new ScopePatternError("scope path cannot be empty");
64
+ /**
65
+ * Normalize a path to Git diff form for exact waiver comparison:
66
+ * either slash convention, collapse separators, and strip cosmetic `./` or `/`.
67
+ * Leading and trailing spaces are significant path bytes and must remain unchanged.
68
+ */
69
+ export function normalizeScopePath(filePath) {
70
+ let normalized = filePath.replace(/\\/g, "/").replace(/\/+/g, "/");
71
+ while (normalized.startsWith("./")) {
72
+ normalized = normalized.slice(2);
33
73
  }
74
+ normalized = normalized.replace(/^\/+/, "");
75
+ if (!normalized || normalized === ".")
76
+ throw new ScopePatternError("scope path cannot be empty");
77
+ if (normalized.includes("\0"))
78
+ throw new ScopePatternError("scope path cannot contain NUL");
34
79
  return normalized;
35
80
  }
36
- function splitPattern(pattern) {
37
- return normalizeScopePattern(pattern).split("/");
81
+ function runCheckIgnore(gitDir, workTree, cwd, paths) {
82
+ return new Promise((resolve, reject) => {
83
+ const child = spawn("git", [
84
+ `--git-dir=${gitDir}`,
85
+ `--work-tree=${workTree}`,
86
+ "check-ignore",
87
+ "--no-index",
88
+ "-z",
89
+ "--stdin",
90
+ ], {
91
+ cwd,
92
+ env: {
93
+ ...process.env,
94
+ GIT_TERMINAL_PROMPT: "0",
95
+ GCM_INTERACTIVE: "Never",
96
+ GIT_CONFIG_GLOBAL: "/dev/null",
97
+ GIT_CONFIG_NOSYSTEM: "1",
98
+ LC_ALL: "C",
99
+ },
100
+ stdio: ["pipe", "pipe", "pipe"],
101
+ });
102
+ const stdout = [];
103
+ const stderr = [];
104
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
105
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
106
+ child.stdin.on("error", reject);
107
+ child.on("error", reject);
108
+ child.on("close", (code) => {
109
+ if (code !== 0 && code !== 1) {
110
+ reject(wrapGitError("check-ignore --no-index -z --stdin", {
111
+ message: `git check-ignore exited with status ${code ?? "unknown"}`,
112
+ stderr: Buffer.concat(stderr).toString("utf8"),
113
+ stdout: Buffer.concat(stdout).toString("utf8"),
114
+ }, cwd));
115
+ return;
116
+ }
117
+ resolve(new Set(Buffer.concat(stdout)
118
+ .toString("utf8")
119
+ .split("\0")
120
+ .filter(Boolean)));
121
+ });
122
+ child.stdin.end(`${paths.join("\0")}\0`);
123
+ });
38
124
  }
39
- function splitPath(path) {
40
- return normalizeScopePath(path).split("/");
125
+ export async function pathsMatchingScope(cwd, patterns, filePaths) {
126
+ const normalizedPatterns = patterns.map(normalizeScopePattern);
127
+ const normalizedPaths = filePaths.map(normalizeScopePath);
128
+ if (normalizedPatterns.length === 0 || normalizedPaths.length === 0)
129
+ return new Set();
130
+ const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "keiyaku-scope-"));
131
+ const gitDir = path.join(temporaryRoot, "scope.git");
132
+ const workTree = path.join(temporaryRoot, "worktree");
133
+ try {
134
+ await Promise.all([fs.mkdir(gitDir), fs.mkdir(workTree)]);
135
+ await createGit(gitDir).init(true);
136
+ await fs.writeFile(path.join(gitDir, "info", "exclude"), `${normalizedPatterns.join("\n")}\n`, "utf8");
137
+ return await runCheckIgnore(gitDir, workTree, cwd, normalizedPaths);
138
+ }
139
+ finally {
140
+ await fs.rm(temporaryRoot, { recursive: true, force: true });
141
+ }
142
+ }
143
+ export function isNegativeScopePattern(pattern) {
144
+ return normalizeScopePattern(pattern).startsWith("!");
145
+ }
146
+ /**
147
+ * Parse only the subset whose path language can be proved statically: literal
148
+ * segments, `*` segments, and `**` segments. Anything using richer gitignore
149
+ * syntax is left to Git and produces no advisory warning.
150
+ */
151
+ function staticScopeSegments(pattern) {
152
+ const positive = normalizeScopePattern(pattern);
153
+ if (positive.startsWith("!") || positive.startsWith("#"))
154
+ return null;
155
+ const body = positive.startsWith("/") ? positive.slice(1) : positive;
156
+ if (!body || body.endsWith("/"))
157
+ return null;
158
+ const parsed = [];
159
+ for (const segment of body.split("/")) {
160
+ if (!segment || segment === "." || segment === "..")
161
+ return null;
162
+ if (segment === "*")
163
+ parsed.push({ kind: "one" });
164
+ else if (segment === "**")
165
+ parsed.push({ kind: "many" });
166
+ else if (/[*?\[\]\\]/.test(segment))
167
+ return null;
168
+ else
169
+ parsed.push({ kind: "literal", value: segment });
170
+ }
171
+ return parsed;
41
172
  }
42
- function segmentMatchesPattern(patternSegment, pathSegment) {
43
- if (patternSegment === "**")
173
+ function ordinarySegmentsIntersect(a, b) {
174
+ if (a.kind === "many" || b.kind === "many")
44
175
  return true;
45
- if (patternSegment === "*")
176
+ if (a.kind === "one" || b.kind === "one")
46
177
  return true;
47
- let regexSource = "^";
48
- for (const char of patternSegment) {
49
- if (char === "*") {
50
- regexSource += ".*";
51
- continue;
52
- }
53
- regexSource += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
54
- }
55
- regexSource += "$";
56
- return new RegExp(regexSource).test(pathSegment);
178
+ return a.value === b.value;
57
179
  }
58
- function segmentPatternsOverlap(a, b) {
59
- const memo = new Map();
60
- const visit = (aIndex, bIndex) => {
180
+ /** Exact non-empty intersection for the finite segment-language subset above. */
181
+ function staticSegmentLanguagesOverlap(a, b) {
182
+ const pending = [[0, 0]];
183
+ const seen = new Set();
184
+ while (pending.length > 0) {
185
+ const [aIndex, bIndex] = pending.pop();
61
186
  const key = `${aIndex}:${bIndex}`;
62
- const cached = memo.get(key);
63
- if (cached !== undefined)
64
- return cached;
65
- let result = false;
66
- if (aIndex === a.length && bIndex === b.length) {
67
- result = true;
68
- }
69
- else if (aIndex < a.length && a[aIndex] === "*") {
70
- result =
71
- visit(aIndex + 1, bIndex) ||
72
- (bIndex < b.length && visit(aIndex, bIndex + 1));
73
- }
74
- else if (bIndex < b.length && b[bIndex] === "*") {
75
- result =
76
- visit(aIndex, bIndex + 1) ||
77
- (aIndex < a.length && visit(aIndex + 1, bIndex));
78
- }
79
- else if (aIndex < a.length && bIndex < b.length && a[aIndex] === b[bIndex]) {
80
- result = visit(aIndex + 1, bIndex + 1);
81
- }
82
- memo.set(key, result);
83
- return result;
84
- };
85
- return visit(0, 0);
86
- }
87
- function canPatternMatchEmpty(segments, index) {
88
- for (let cursor = index; cursor < segments.length; cursor += 1) {
89
- if (segments[cursor] !== "**")
90
- return false;
187
+ if (seen.has(key))
188
+ continue;
189
+ seen.add(key);
190
+ if (aIndex === a.length && bIndex === b.length)
191
+ return true;
192
+ const aSegment = a[aIndex];
193
+ const bSegment = b[bIndex];
194
+ if (aSegment?.kind === "many")
195
+ pending.push([aIndex + 1, bIndex]);
196
+ if (bSegment?.kind === "many")
197
+ pending.push([aIndex, bIndex + 1]);
198
+ if (!aSegment || !bSegment || !ordinarySegmentsIntersect(aSegment, bSegment))
199
+ continue;
200
+ pending.push([
201
+ aSegment.kind === "many" ? aIndex : aIndex + 1,
202
+ bSegment.kind === "many" ? bIndex : bIndex + 1,
203
+ ]);
91
204
  }
92
- return true;
93
- }
94
- export function pathMatches(pattern, path) {
95
- const patternSegments = splitPattern(pattern);
96
- const pathSegments = splitPath(path);
97
- const memo = new Map();
98
- const visit = (patternIndex, pathIndex) => {
99
- const key = `${patternIndex}:${pathIndex}`;
100
- const cached = memo.get(key);
101
- if (cached !== undefined)
102
- return cached;
103
- let result = false;
104
- if (patternIndex === patternSegments.length && pathIndex === pathSegments.length) {
105
- result = true;
106
- }
107
- else if (patternIndex === patternSegments.length) {
108
- result = false;
109
- }
110
- else if (patternSegments[patternIndex] === "**") {
111
- result =
112
- visit(patternIndex + 1, pathIndex) ||
113
- (pathIndex < pathSegments.length && visit(patternIndex, pathIndex + 1));
114
- }
115
- else if (pathIndex < pathSegments.length) {
116
- result =
117
- segmentMatchesPattern(patternSegments[patternIndex], pathSegments[pathIndex]) &&
118
- visit(patternIndex + 1, pathIndex + 1);
119
- }
120
- memo.set(key, result);
121
- return result;
122
- };
123
- return visit(0, 0);
205
+ return false;
124
206
  }
207
+ /** Advisory only: negative patterns are exclusions, and are intentionally ignored here. */
125
208
  export function patternsOverlap(a, b) {
126
- const aSegments = splitPattern(a);
127
- const bSegments = splitPattern(b);
128
- const memo = new Map();
129
- const visit = (aIndex, bIndex) => {
130
- const key = `${aIndex}:${bIndex}`;
131
- const cached = memo.get(key);
132
- if (cached !== undefined)
133
- return cached;
134
- let result = false;
135
- if (aIndex === aSegments.length && bIndex === bSegments.length) {
136
- result = true;
137
- }
138
- else if (aIndex === aSegments.length) {
139
- result = canPatternMatchEmpty(bSegments, bIndex);
140
- }
141
- else if (bIndex === bSegments.length) {
142
- result = canPatternMatchEmpty(aSegments, aIndex);
143
- }
144
- else if (aSegments[aIndex] === "**") {
145
- result =
146
- visit(aIndex + 1, bIndex) ||
147
- (bSegments[bIndex] !== "**" && visit(aIndex, bIndex + 1));
148
- }
149
- else if (bSegments[bIndex] === "**") {
150
- result =
151
- visit(aIndex, bIndex + 1) ||
152
- (aSegments[aIndex] !== "**" && visit(aIndex + 1, bIndex));
153
- }
154
- else {
155
- result =
156
- segmentPatternsOverlap(aSegments[aIndex], bSegments[bIndex]) &&
157
- visit(aIndex + 1, bIndex + 1);
158
- }
159
- memo.set(key, result);
160
- return result;
161
- };
162
- return visit(0, 0);
209
+ const aSegments = staticScopeSegments(a);
210
+ const bSegments = staticScopeSegments(b);
211
+ return aSegments !== null && bSegments !== null && staticSegmentLanguagesOverlap(aSegments, bSegments);
163
212
  }
@@ -5,7 +5,7 @@ import { createGit, wrapGitError } from "../git/core.js";
5
5
  import { KEIYAKU_FILE } from "../keiyaku.js";
6
6
  import { activeLedgerRef, appendWithRetry, readLedger } from "./ledger.js";
7
7
  import { renderContractView } from "./render.js";
8
- import { pathMatches } from "./scope.js";
8
+ import { normalizeScopePath, pathsMatchingScope } from "./scope.js";
9
9
  import { deriveContractState, isTerminalState } from "./status.js";
10
10
  function findBind(entries) {
11
11
  const bind = entries.find((entry) => entry.kind === "bind");
@@ -46,14 +46,8 @@ export function effectiveScope(entries) {
46
46
  for (const entry of entries) {
47
47
  if (entry.kind !== "amend" || !entry.data.scopeDelta)
48
48
  continue;
49
- const remove = new Set(entry.data.scopeDelta.remove);
50
- for (let index = scope.length - 1; index >= 0; index -= 1) {
51
- if (remove.has(scope[index]))
52
- scope.splice(index, 1);
53
- }
54
49
  for (const pattern of entry.data.scopeDelta.add) {
55
- if (!scope.includes(pattern))
56
- scope.push(pattern);
50
+ scope.push(pattern);
57
51
  }
58
52
  }
59
53
  return scope;
@@ -82,9 +76,8 @@ async function revList(cwd, from, to) {
82
76
  async function changedPaths(cwd, from, to) {
83
77
  const git = createGit(cwd);
84
78
  try {
85
- return (await git.raw(["diff", "--name-only", `${from}..${to}`]))
86
- .split(/\r?\n/)
87
- .map((line) => line.trim())
79
+ return (await git.raw(["diff", "--name-only", "-z", `${from}..${to}`]))
80
+ .split("\0")
88
81
  .filter(Boolean);
89
82
  }
90
83
  catch (error) {
@@ -109,8 +102,10 @@ async function diffstat(cwd, from, to) {
109
102
  throw wrapGitError(`diff --numstat ${from}..${to}`, error, cwd);
110
103
  }
111
104
  }
112
- function scopeViolations(paths, scope) {
113
- return paths.filter((changedPath) => changedPath !== KEIYAKU_FILE && !scope.some((pattern) => pathMatches(pattern, changedPath)));
105
+ async function scopeViolations(cwd, paths, scope) {
106
+ const auditedPaths = paths.filter((changedPath) => changedPath !== KEIYAKU_FILE);
107
+ const matched = await pathsMatchingScope(cwd, scope, auditedPaths);
108
+ return auditedPaths.filter((changedPath) => !matched.has(changedPath));
114
109
  }
115
110
  function assertOperable(contractId, entries) {
116
111
  const state = deriveContractState(entries);
@@ -121,7 +116,7 @@ function assertOperable(contractId, entries) {
121
116
  throw new FlowError("INTERNAL_STATE", `contract ${contractId} cannot seal from state ${state.state}`);
122
117
  }
123
118
  }
124
- export async function captureSeal(cwd, entries, head = "HEAD") {
119
+ export async function captureSeal(cwd, entries, head = "HEAD", waivePaths = []) {
125
120
  const open = findOpenArc(entries);
126
121
  if (!open)
127
122
  return null;
@@ -133,9 +128,11 @@ export async function captureSeal(cwd, entries, head = "HEAD") {
133
128
  diffstat(cwd, oldFence, newFence),
134
129
  ]);
135
130
  const scope = effectiveScope(entries);
136
- const violations = scopeViolations(paths, scope);
137
- if (violations.length > 0) {
138
- throw new FlowError("INTERNAL_STATE", `scope audit failed for contract ${open.contract}: paths ${violations.join(", ")} outside ${scope.join(", ")}`);
131
+ const violations = await scopeViolations(cwd, paths, scope);
132
+ const waivedPaths = new Set(waivePaths.map(normalizeScopePath));
133
+ const unwaived = violations.filter((violation) => !waivedPaths.has(violation));
134
+ if (unwaived.length > 0) {
135
+ throw new FlowError("INTERNAL_STATE", `scope audit failed for contract ${open.contract}: paths ${unwaived.join(", ")} outside ${scope.join(", ")}`);
139
136
  }
140
137
  return {
141
138
  arc: open.data.arc,
@@ -143,7 +140,7 @@ export async function captureSeal(cwd, entries, head = "HEAD") {
143
140
  newFence,
144
141
  receipts,
145
142
  diffstat: stat,
146
- scopeAudit: { violations, waived: false },
143
+ scopeAudit: { violations, waived: violations.length > 0 },
147
144
  };
148
145
  }
149
146
  async function renderUpdatedContract(ledgerCwd, renderCwd, contractId) {
@@ -159,7 +156,7 @@ export async function sealOpenArc(input) {
159
156
  throw new FlowError("INTERNAL_STATE", `contract ${input.contractId} not found or not active`);
160
157
  }
161
158
  assertOperable(input.contractId, ledger.entries);
162
- const capture = input.capture ?? await captureSeal(input.cwd, ledger.entries);
159
+ const capture = input.capture ?? await captureSeal(input.cwd, ledger.entries, "HEAD", input.waivePaths);
163
160
  if (!capture)
164
161
  return null;
165
162
  const at = new Date(input.nowMs).toISOString();
@@ -3,12 +3,22 @@ import * as path from "node:path";
3
3
  import { assertSettingsKnobUsable, loadKeiyakuSettings, } from "../config/settings.js";
4
4
  import { FlowError } from "../flow-error.js";
5
5
  import { SETTINGS_FILE } from "../keiyaku.js";
6
+ import { isGitRepo } from "../git/branches.js";
6
7
  import { createGit, errorContainsAnyPattern, MISSING_HEAD_PATTERNS, wrapGitError } from "../git/core.js";
7
- import { readLedger } from "./ledger.js";
8
+ import { fullBranchRefSchema } from "./entry.js";
9
+ import { listContractIds, readLedger } from "./ledger.js";
8
10
  import { readProjectionStatusBoard } from "./projection-status.js";
9
- import { queuePositionsFromEntries, queuedContracts, readQueue } from "./queue.js";
11
+ import { deriveQueueReadModel, queueMembership, } from "./queue_v2.js";
10
12
  import { findOpenArc, getMaxArc, receiptsAfterFence } from "./seal.js";
11
13
  import { stableRepoRoot } from "./worktree-path.js";
14
+ /** Prefix bare branch names and validate the full ref. Sole status/bind normalizer. */
15
+ export function normalizeTargetRef(raw) {
16
+ const ref = raw.startsWith("refs/heads/") ? raw : `refs/heads/${raw}`;
17
+ if (!fullBranchRefSchema.safeParse(ref).success) {
18
+ throw new FlowError("INVALID_TARGET", `invalid target ref '${raw}' — pass a branch name or refs/heads/<name>`);
19
+ }
20
+ return ref;
21
+ }
12
22
  function transitionFromEntry(current, entry) {
13
23
  if (current === "claimed" || current === "forfeited" || current === "corrupt") {
14
24
  return "corrupt";
@@ -16,6 +26,9 @@ function transitionFromEntry(current, entry) {
16
26
  switch (entry.kind) {
17
27
  case "bind":
18
28
  return "corrupt";
29
+ case "bootstrap":
30
+ // Placement legality is owned exclusively by deriveContractState.
31
+ return "corrupt";
19
32
  case "arc":
20
33
  return current === "bound" || current === "active" ? "active" : "corrupt";
21
34
  case "seal":
@@ -41,9 +54,20 @@ export function deriveContractState(entries) {
41
54
  if (entries.length === 0 || entries[0]?.kind !== "bind") {
42
55
  return { state: "corrupt", at: 0, reason: "ledger must start with bind" };
43
56
  }
57
+ const bind = entries[0];
44
58
  let state = "bound";
45
59
  for (let index = 1; index < entries.length; index += 1) {
46
60
  const entry = entries[index];
61
+ if (entry.kind === "bootstrap") {
62
+ if (index !== 1 || state !== "bound") {
63
+ return { state: "corrupt", at: index + 1, reason: "bootstrap must immediately follow bind" };
64
+ }
65
+ if (!bind.data.bootstrap) {
66
+ return { state: "corrupt", at: index + 1, reason: "bootstrap without bind plan" };
67
+ }
68
+ // Legal bootstrap is a durable fact and leaves the derived state bound.
69
+ continue;
70
+ }
47
71
  const next = transitionFromEntry(state, entry);
48
72
  if (next === "corrupt") {
49
73
  return { state: "corrupt", at: index + 1, reason: `invalid ${entry.kind} after ${state}` };
@@ -52,6 +76,14 @@ export function deriveContractState(entries) {
52
76
  }
53
77
  return { state };
54
78
  }
79
+ export function deriveBootstrapDiagnostics(entries) {
80
+ const bind = entries.find((entry) => entry.kind === "bind");
81
+ if (!bind?.data.bootstrap)
82
+ return [];
83
+ if (entries.some((entry) => entry.kind === "bootstrap"))
84
+ return [];
85
+ return [{ kind: "plan-without-result" }];
86
+ }
55
87
  export function isTerminalState(state) {
56
88
  return state.state === "claimed" || state.state === "forfeited";
57
89
  }
@@ -79,20 +111,16 @@ export function latestBase(entries) {
79
111
  }
80
112
  return entries.find((entry) => entry.kind === "bind")?.data.base;
81
113
  }
82
- export async function listContractIds(cwd) {
83
- const git = createGit(cwd);
84
- const refPrefix = "refs/keiyaku/active/";
85
- try {
86
- const output = await git.raw(["for-each-ref", "--format=%(refname)", refPrefix]);
87
- return output
88
- .split(/\r?\n/)
89
- .map((line) => line.trim())
90
- .filter((line) => line.startsWith(refPrefix))
91
- .map((ref) => ref.slice(refPrefix.length));
92
- }
93
- catch (error) {
94
- throw wrapGitError(`for-each-ref ${refPrefix}`, error, cwd);
95
- }
114
+ /** Commission settlement target from the bind entry. Schema guarantees refs/heads/<name>. */
115
+ export function commissionTarget(entries) {
116
+ const bind = findBindEntry(entries);
117
+ if (!bind)
118
+ return undefined;
119
+ const ref = bind.data.target;
120
+ return {
121
+ ref,
122
+ branch: ref.slice("refs/heads/".length),
123
+ };
96
124
  }
97
125
  async function resolveCommit(cwd, ref) {
98
126
  const git = createGit(cwd);
@@ -366,8 +394,19 @@ async function computeClaimReconciliation(cwd, defaultBranch) {
366
394
  }
367
395
  return { defaultBranch: defaultBranch.branch, head: defaultBranch.head, unclaimedSegments: segments, receiptIssues: issues };
368
396
  }
369
- export async function readKanshiBoard(cwd, now) {
397
+ export async function readKanshiBoard(cwd, now, opts) {
398
+ const targetFilter = opts?.target ?? null;
370
399
  const loadedSettings = await loadKeiyakuSettings(cwd);
400
+ if (!(await isGitRepo(cwd))) {
401
+ return {
402
+ contracts: [],
403
+ projections: null,
404
+ queueLength: 0,
405
+ diseases: loadedSettings.diseases,
406
+ claimReconciliation: null,
407
+ targetFilter,
408
+ };
409
+ }
371
410
  // Status is a pure reader: use the same resolver, but retain its ability to
372
411
  // open when the defaultBranch knob itself is diseased.
373
412
  let defaultBranch = null;
@@ -377,16 +416,16 @@ export async function readKanshiBoard(cwd, now) {
377
416
  catch {
378
417
  defaultBranch = null;
379
418
  }
380
- const [contractIds, queueEntries] = await Promise.all([listContractIds(cwd), readQueue(cwd)]);
419
+ const contractIds = await listContractIds(cwd);
381
420
  const terminalById = new Map();
382
421
  const ledgers = [];
383
- const queued = queuedContracts(queueEntries);
384
- const queuePositions = queuePositionsFromEntries(queueEntries);
422
+ const readableLedgers = [];
385
423
  for (const id of contractIds) {
386
424
  try {
387
425
  const ledger = await readLedger(cwd, id);
388
426
  if (ledger) {
389
427
  ledgers.push(ledger);
428
+ readableLedgers.push(ledger);
390
429
  terminalById.set(id, isTerminalState(deriveContractState(ledger.entries)));
391
430
  }
392
431
  }
@@ -394,9 +433,15 @@ export async function readKanshiBoard(cwd, now) {
394
433
  ledgers.push({ id, error: error instanceof Error ? error : new Error(String(error)) });
395
434
  }
396
435
  }
436
+ const queueModel = deriveQueueReadModel(readableLedgers);
437
+ const boardQueueLength = queueModel.targetQueues.reduce((sum, queue) => sum + queue.ordered.length, 0);
397
438
  const contracts = [];
439
+ // Readable-ledger commission targets retained for post-assembly filter only.
440
+ const derivedTargetById = new Map();
398
441
  for (const item of ledgers) {
399
442
  if ("error" in item) {
443
+ // Unreadable ledgers have no derivable target and stay under every filter.
444
+ derivedTargetById.set(item.id, undefined);
400
445
  contracts.push({
401
446
  id: item.id,
402
447
  slug: contractSlugFromId(item.id),
@@ -408,10 +453,13 @@ export async function readKanshiBoard(cwd, now) {
408
453
  baseDrift: { state: "unknown", label: "n/a (corrupt ledger)" },
409
454
  after: [],
410
455
  corruptReason: item.error.message,
456
+ bootstrapDiagnostics: [],
457
+ queueDiagnostics: [],
411
458
  });
412
459
  continue;
413
460
  }
414
461
  const bind = findBindEntry(item.entries);
462
+ derivedTargetById.set(item.contractId, commissionTarget(item.entries)?.ref);
415
463
  const base = latestBase(item.entries);
416
464
  const state = deriveContractState(item.entries);
417
465
  if (isTerminalState(state))
@@ -442,15 +490,18 @@ export async function readKanshiBoard(cwd, now) {
442
490
  const sealForecast = openArc ? `seals a${openArc.data.arc} (${receiptCount} receipts), ` : "";
443
491
  nextStep.hint = `run: keiyaku @${item.contractId} renew — ${sealForecast}follows ${baseDrift.defaultBranch} ▲${baseDrift.behindBy}`;
444
492
  }
493
+ const membership = queueMembership(queueModel, item.contractId);
494
+ const queuePosition = membership?.position ?? null;
495
+ const targetQueueLength = membership?.queueLength ?? 0;
445
496
  contracts.push({
446
497
  id: item.contractId,
447
498
  slug: contractSlugFromId(item.contractId),
448
499
  state,
449
- queuePosition: queuePositions.get(item.contractId) ?? null,
500
+ queuePosition,
450
501
  queueHeadStalled: isQueueHeadStall({
451
502
  entries: item.entries,
452
- queuePosition: queuePositions.get(item.contractId) ?? null,
453
- queueLength: queued.length,
503
+ queuePosition,
504
+ queueLength: targetQueueLength,
454
505
  state,
455
506
  }),
456
507
  arcs: getMaxArc(item.entries),
@@ -461,16 +512,28 @@ export async function readKanshiBoard(cwd, now) {
461
512
  base,
462
513
  baseDrift,
463
514
  after,
515
+ bootstrapDiagnostics: deriveBootstrapDiagnostics(item.entries),
516
+ queueDiagnostics: membership ? [...membership.diagnostics] : [],
464
517
  });
465
518
  }
519
+ const filteredContracts = targetFilter === null
520
+ ? contracts
521
+ : contracts.filter((contract) => {
522
+ const derived = derivedTargetById.get(contract.id);
523
+ return derived === undefined || derived === targetFilter;
524
+ });
525
+ const queueLength = targetFilter === null
526
+ ? boardQueueLength
527
+ : (queueModel.targetQueues.find((queue) => queue.target === targetFilter)?.ordered.length ?? 0);
466
528
  const claimReconciliation = await computeClaimReconciliation(cwd, defaultBranch);
467
529
  return {
468
- contracts,
530
+ contracts: filteredContracts,
469
531
  projections: readProjectionStatusBoard(cwd, now),
470
- queueLength: queued.length,
532
+ queueLength,
471
533
  defaultBranch: defaultBranch?.branch,
472
534
  defaultHead: defaultBranch?.head,
473
535
  diseases: loadedSettings.diseases,
474
536
  claimReconciliation,
537
+ targetFilter,
475
538
  };
476
539
  }