@mrciphersmith/keryx 0.2.153 → 0.2.155

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/dist/core.js CHANGED
@@ -11098,6 +11098,103 @@ async function readAcCriteria(cwd, dir) {
11098
11098
  }
11099
11099
  return ids;
11100
11100
  }
11101
+ var AC_LINE_PATTERN = /^(\s*)[-*]\s*(AC\d+)\s*:\s?(.*)$/i;
11102
+ var HEADING_PATTERN = /^\s*#/;
11103
+ function detectEol(content) {
11104
+ return content.includes(`\r
11105
+ `) ? `\r
11106
+ ` : `
11107
+ `;
11108
+ }
11109
+ function splitAcLines(content) {
11110
+ if (content.length === 0) {
11111
+ return [];
11112
+ }
11113
+ const lines = [];
11114
+ let index = 0;
11115
+ while (index < content.length) {
11116
+ const newlineIndex = content.indexOf(`
11117
+ `, index);
11118
+ if (newlineIndex === -1) {
11119
+ lines.push({ text: content.slice(index), eol: "" });
11120
+ break;
11121
+ }
11122
+ const hasCr = newlineIndex > index && content[newlineIndex - 1] === "\r";
11123
+ lines.push({
11124
+ text: hasCr ? content.slice(index, newlineIndex - 1) : content.slice(index, newlineIndex),
11125
+ eol: hasCr ? `\r
11126
+ ` : `
11127
+ `
11128
+ });
11129
+ index = newlineIndex + 1;
11130
+ }
11131
+ return lines;
11132
+ }
11133
+ function renderAcLines(lines) {
11134
+ return lines.map((line) => line.text + line.eol).join("");
11135
+ }
11136
+ function acBlockEnd(lines, start) {
11137
+ let end = start + 1;
11138
+ while (end < lines.length) {
11139
+ const line = lines[end];
11140
+ if (line.text.trim() === "")
11141
+ break;
11142
+ if (HEADING_PATTERN.test(line.text))
11143
+ break;
11144
+ if (AC_LINE_PATTERN.test(line.text))
11145
+ break;
11146
+ if (!/^\s/.test(line.text))
11147
+ break;
11148
+ end += 1;
11149
+ }
11150
+ return end;
11151
+ }
11152
+ async function writeAcCriterion(cwd, dir, criterion, text) {
11153
+ const file = acPath(cwd, dir);
11154
+ const content = await pathExists(file) ? await readFile2(file, "utf8") : "";
11155
+ const lines = splitAcLines(content);
11156
+ const rendered = `- ${criterion}: ${text}`;
11157
+ let previousText;
11158
+ let matchedIndex = -1;
11159
+ let lastBlockEnd = -1;
11160
+ let index = 0;
11161
+ while (index < lines.length) {
11162
+ const line = lines[index];
11163
+ const match = line.text.match(AC_LINE_PATTERN);
11164
+ if (!match?.[2]) {
11165
+ index += 1;
11166
+ continue;
11167
+ }
11168
+ const end = acBlockEnd(lines, index);
11169
+ lastBlockEnd = end;
11170
+ if (match[2].toUpperCase() === criterion) {
11171
+ if (end > index + 1) {
11172
+ throw new Error(`${criterion} spans more than one line; edit acceptance-criteria.md directly and run ` + '`keryx flow ac update <id> --reason "..."`.');
11173
+ }
11174
+ matchedIndex = index;
11175
+ previousText = (match[3] ?? "").trim();
11176
+ }
11177
+ index = end;
11178
+ }
11179
+ if (matchedIndex >= 0) {
11180
+ const existing = lines[matchedIndex];
11181
+ lines[matchedIndex] = { text: rendered, eol: existing.eol };
11182
+ } else if (lastBlockEnd >= 0) {
11183
+ const precedingIndex = lastBlockEnd - 1;
11184
+ const preceding = lines[precedingIndex];
11185
+ let newEol = preceding.eol;
11186
+ if (newEol === "") {
11187
+ const fallbackEol = detectEol(content);
11188
+ preceding.eol = fallbackEol;
11189
+ newEol = fallbackEol;
11190
+ }
11191
+ lines.splice(lastBlockEnd, 0, { text: rendered, eol: newEol });
11192
+ } else {
11193
+ lines.push({ text: rendered, eol: detectEol(content) });
11194
+ }
11195
+ await writeFileAtomic(file, renderAcLines(lines));
11196
+ return { previousText };
11197
+ }
11101
11198
  async function acChecksum(cwd, dir) {
11102
11199
  const content = await readFile2(acPath(cwd, dir), "utf8");
11103
11200
  const normalized = content.replace(/\r\n/g, `
@@ -12072,6 +12169,37 @@ async function reviewGate(input) {
12072
12169
  return { name: "review", status: verdict.status, detail: verdict.detail };
12073
12170
  }
12074
12171
 
12172
+ // src/flow/identity.ts
12173
+ function resolveSignerIdentity(input) {
12174
+ const stated = input.stated?.trim();
12175
+ if (stated) {
12176
+ return { value: stated, basis: "stated", source: "`--signed-by` flag" };
12177
+ }
12178
+ const env = input.env?.trim();
12179
+ if (env) {
12180
+ return { value: env, basis: "stated", source: "KERYX_ACTOR environment variable" };
12181
+ }
12182
+ const git = input.gitIdentity?.trim();
12183
+ if (git) {
12184
+ return {
12185
+ value: git,
12186
+ basis: "derived",
12187
+ source: "git config user.email in this checkout \u2014 this is who RAN the command, which is not " + "necessarily who signed. Pass --signed-by to record the actual signer."
12188
+ };
12189
+ }
12190
+ return {
12191
+ value: null,
12192
+ basis: "unknown",
12193
+ source: "no --signed-by flag, KERYX_ACTOR environment variable, or readable git identity"
12194
+ };
12195
+ }
12196
+ function ownerIdentity(name, source) {
12197
+ return { value: name, basis: "stated", source };
12198
+ }
12199
+ function describeIdentity(identity) {
12200
+ return `${identity.value ?? "unknown"} [${identity.basis}]`;
12201
+ }
12202
+
12075
12203
  // src/review/flow-move.ts
12076
12204
  init_fs();
12077
12205
  import { readdir as readdir4, readFile as readFile6, rename as rename2 } from "fs/promises";
@@ -12312,9 +12440,27 @@ function flowStateSchema() {
12312
12440
  review: {
12313
12441
  type: "boolean",
12314
12442
  description: "Written by `flow init`. When true, `flow complete` fails unless the flow's latest ingested review round is clean: every finding at or above the severity floor carries a terminal disposition with the evidence that disposition requires, the round ran against the PR head, no external PR comment is unanswered, and the verifier's stats are recorded."
12443
+ },
12444
+ owner: {
12445
+ type: "boolean",
12446
+ description: "Written by `flow init`. When true, `flow complete` fails the owner gate with a named reason while no owner is set, and passes it once one is. Absent on packages created before the gate existed, which report the gate `skipped` rather than `fail`."
12315
12447
  }
12316
12448
  }
12317
12449
  },
12450
+ owner: {
12451
+ $ref: "#/definitions/identity",
12452
+ description: "The human accountable for this flow, or absent if one was never set. Never inferred: only ever written by an explicit `--owner` on `flow init` or `flow owner set`, so `basis` is always `stated`."
12453
+ },
12454
+ signatures: {
12455
+ type: "array",
12456
+ description: "Append-only signing record for `flow ac confirm` and a passing `flow complete`. Absent means never signed under this field's existence.",
12457
+ items: { $ref: "#/definitions/flowSignature" }
12458
+ },
12459
+ completionAttempts: {
12460
+ type: "array",
12461
+ description: "flow 291: append-only record of every `flow complete` attempt's gate outcomes \u2014 every gate evaluated, passing, failing and skipped alike \u2014 written on every attempt from now on, pass or fail. Absent means no attempt has been recorded under this field's existence (including every flow.json written before it existed).",
12462
+ items: { $ref: "#/definitions/completionAttempt" }
12463
+ },
12318
12464
  acConfirmed: {
12319
12465
  type: "object",
12320
12466
  description: "Per-AC confirmation records keyed by AC id (e.g. AC1).",
@@ -12439,6 +12585,79 @@ function flowStateSchema() {
12439
12585
  }
12440
12586
  }
12441
12587
  }
12588
+ },
12589
+ identity: {
12590
+ type: "object",
12591
+ description: "flow 289: a claim about who is behind a value, and how it was obtained \u2014 never a proof. `basis` is `stated` (given explicitly by a flag or environment variable), `derived` (read from something like the local git configuration; NEVER promoted to stated), or `unknown` (nothing was available; `value` is then null).",
12592
+ additionalProperties: false,
12593
+ required: ["value", "basis", "source"],
12594
+ properties: {
12595
+ value: { type: ["string", "null"] },
12596
+ basis: { type: "string", enum: ["stated", "derived", "unknown"] },
12597
+ source: { type: "string", description: "Exactly where this value came from, or why there is none." }
12598
+ }
12599
+ },
12600
+ flowSignature: {
12601
+ type: "object",
12602
+ description: "flow 289: one append-only signature on an AC confirmation or a passing completion. Never mutated once written.",
12603
+ additionalProperties: true,
12604
+ required: ["at", "kind", "identity", "acChecksum"],
12605
+ properties: {
12606
+ at: { type: "string" },
12607
+ kind: { type: "string", enum: ["ac-confirm", "complete"] },
12608
+ identity: { $ref: "#/definitions/identity" },
12609
+ criterion: { type: "string", description: "The AC id signed. Present only for kind ac-confirm." },
12610
+ acChecksum: {
12611
+ type: ["string", "null"],
12612
+ description: "The acceptance-criteria checksum in force at signing time."
12613
+ },
12614
+ headCommit: {
12615
+ type: "string",
12616
+ description: "The commit the completion gates evaluated, when known. Present only for kind complete."
12617
+ }
12618
+ }
12619
+ },
12620
+ completionAttempt: {
12621
+ type: "object",
12622
+ description: "flow 291: one `flow complete` attempt, on the record \u2014 every gate it evaluated, whether the attempt passed overall, and the AC checksum in force. Never mutated once written.",
12623
+ additionalProperties: true,
12624
+ required: ["at", "gates", "passed", "acChecksum"],
12625
+ properties: {
12626
+ at: { type: "string" },
12627
+ gates: {
12628
+ type: "array",
12629
+ items: { $ref: "#/definitions/gateOutcome" }
12630
+ },
12631
+ passed: { type: "boolean" },
12632
+ acChecksum: {
12633
+ type: ["string", "null"],
12634
+ description: "The acceptance-criteria checksum in force when this attempt ran."
12635
+ }
12636
+ }
12637
+ },
12638
+ gateOutcome: {
12639
+ type: "object",
12640
+ description: "flow 291: the outcome of one gate `flow complete` evaluated during one attempt.",
12641
+ additionalProperties: true,
12642
+ required: ["name", "status", "detail"],
12643
+ properties: {
12644
+ name: {
12645
+ type: "string",
12646
+ enum: [
12647
+ "acceptance-criteria",
12648
+ "pull-request",
12649
+ "main-merge",
12650
+ "tasks",
12651
+ "health",
12652
+ "security",
12653
+ "review",
12654
+ "base-branch",
12655
+ "owner"
12656
+ ]
12657
+ },
12658
+ status: { type: "string", enum: ["pass", "fail", "skipped"] },
12659
+ detail: { type: "string" }
12660
+ }
12442
12661
  }
12443
12662
  }
12444
12663
  };
@@ -13303,6 +13522,36 @@ function recordAttempt(task, outcome, at, detail) {
13303
13522
  task.attempts = attempts;
13304
13523
  return attempts;
13305
13524
  }
13525
+ var BLANK_OWNER_MESSAGE = '--owner requires a non-blank name, e.g. --owner "Alex Smith"';
13526
+ var AC_CRITERION_NAME_PATTERN = /^AC\d+$/i;
13527
+ var AC_TEXT_SELF_PREFIX_PATTERN = /^-?\s*AC\d+\s*:/i;
13528
+ function validateCriterionName(raw) {
13529
+ const trimmed = raw.trim();
13530
+ if (!AC_CRITERION_NAME_PATTERN.test(trimmed)) {
13531
+ throw new Error(`--criterion must name "AC" followed by a number, e.g. AC7 (got "${raw}").`);
13532
+ }
13533
+ return trimmed.toUpperCase();
13534
+ }
13535
+ function validateCriterionText(raw) {
13536
+ const trimmed = raw.trim();
13537
+ if (!trimmed) {
13538
+ throw new Error("--text must not be empty.");
13539
+ }
13540
+ if (raw.includes(`
13541
+ `) || raw.includes("\r")) {
13542
+ throw new Error("--text must fit on one line (no line breaks) \u2014 acceptance-criteria.md uses one line per ACn.");
13543
+ }
13544
+ if (AC_TEXT_SELF_PREFIX_PATTERN.test(trimmed)) {
13545
+ throw new Error('--text must not carry its own "- ACn:" prefix \u2014 `flow ac update --criterion` adds that prefix itself.');
13546
+ }
13547
+ return trimmed;
13548
+ }
13549
+ function validateSingleLineReason(reason) {
13550
+ if (reason.includes(`
13551
+ `) || reason.includes("\r")) {
13552
+ throw new Error("--reason must fit on one line (no line breaks) \u2014 it is written as a single `journal.md` bullet.");
13553
+ }
13554
+ }
13306
13555
  function createFlowService(deps) {
13307
13556
  const now = () => deps.now().toISOString();
13308
13557
  async function load(cwd, id) {
@@ -13338,6 +13587,9 @@ function createFlowService(deps) {
13338
13587
  if (!input.title && !input.issue) {
13339
13588
  throw new Error('flow init requires --title "<problem>" or --issue <url>');
13340
13589
  }
13590
+ if (input.owner !== undefined && !input.owner.trim()) {
13591
+ throw new Error(BLANK_OWNER_MESSAGE);
13592
+ }
13341
13593
  const trackerReady = deps.tracker ? await deps.tracker.detect() : false;
13342
13594
  const tracker = trackerReady ? deps.tracker : null;
13343
13595
  const issueRef = input.issue && deps.tracker ? deps.tracker.parseRef(input.issue) : null;
@@ -13368,7 +13620,7 @@ function createFlowService(deps) {
13368
13620
  const createdAt = now();
13369
13621
  const flow = {
13370
13622
  schemaVersion: 1,
13371
- gates: { tasks: true, review: true },
13623
+ gates: { tasks: true, review: true, owner: true },
13372
13624
  id,
13373
13625
  slug,
13374
13626
  title,
@@ -13383,6 +13635,7 @@ function createFlowService(deps) {
13383
13635
  acConfirmed: {},
13384
13636
  pr: { url: null },
13385
13637
  ...input.baseBranch === undefined ? {} : { baseBranch: input.baseBranch },
13638
+ ...input.owner?.trim() ? { owner: ownerIdentity(input.owner.trim(), "`--owner` flag on `flow init`") } : {},
13386
13639
  tasks: DEFAULT_TASKS.map((task) => ({ ...task, status: "todo" })),
13387
13640
  history: [{ at: createdAt, event: "created" }]
13388
13641
  };
@@ -13424,6 +13677,22 @@ function createFlowService(deps) {
13424
13677
  async next({ cwd, id }) {
13425
13678
  return nextTask((await load(cwd, id)).flow.tasks);
13426
13679
  },
13680
+ async ownerSet({ cwd, id, owner, reason }) {
13681
+ if (!owner?.trim()) {
13682
+ throw new Error(BLANK_OWNER_MESSAGE);
13683
+ }
13684
+ if (!reason?.trim()) {
13685
+ throw new Error('flow owner set requires --reason "<why>"');
13686
+ }
13687
+ return mutate(cwd, id, async ({ dir, flow }) => {
13688
+ await assertAcIntact(cwd, dir, flow);
13689
+ const previous = flow.owner?.value ?? null;
13690
+ const next = ownerIdentity(owner.trim(), "`--owner` flag on `flow owner set`");
13691
+ flow.owner = next;
13692
+ const detail = `${previous ?? "not set"} -> ${next.value} (${reason.trim()})`;
13693
+ return save(cwd, dir, flow, previous === null ? "owner-set" : "owner-changed", detail);
13694
+ });
13695
+ },
13427
13696
  async freeze({ cwd, id }) {
13428
13697
  return mutate(cwd, id, async ({ dir, flow }) => {
13429
13698
  assertTransition(flow.status, "ready");
@@ -13534,7 +13803,7 @@ function createFlowService(deps) {
13534
13803
  return save(cwd, dir, flow, "task-attempt", `${task.id}: ${outcome} (attempt ${attempts.count})${detail?.trim() ? ` \u2014 ${detail.trim()}` : ""}`);
13535
13804
  });
13536
13805
  },
13537
- async acConfirm({ cwd, id, criterion, note }) {
13806
+ async acConfirm({ cwd, id, criterion, note, signedBy, signedByEnv, gitIdentity }) {
13538
13807
  return mutate(cwd, id, async ({ dir, flow }) => {
13539
13808
  await assertAcIntact(cwd, dir, flow);
13540
13809
  const known = await readAcCriteria(cwd, dir);
@@ -13542,24 +13811,50 @@ function createFlowService(deps) {
13542
13811
  if (!known.includes(target)) {
13543
13812
  throw new Error(`Unknown criterion ${target}. Known: ${known.join(", ")}`);
13544
13813
  }
13545
- flow.acConfirmed[target] = { at: now(), ...note ? { note } : {} };
13546
- return save(cwd, dir, flow, "ac-confirmed", `${target}${note ? `: ${note}` : ""}`);
13814
+ const at = now();
13815
+ flow.acConfirmed[target] = { at, ...note ? { note } : {} };
13816
+ const identity = resolveSignerIdentity({ stated: signedBy, env: signedByEnv, gitIdentity });
13817
+ flow.signatures = [
13818
+ ...flow.signatures ?? [],
13819
+ { at, kind: "ac-confirm", identity, criterion: target, acChecksum: flow.acChecksum }
13820
+ ];
13821
+ return save(cwd, dir, flow, "ac-confirmed", `${target}${note ? `: ${note}` : ""} (signed: ${describeIdentity(identity)})`);
13547
13822
  });
13548
13823
  },
13549
- async acUpdate({ cwd, id, reason }) {
13824
+ async acUpdate({ cwd, id, reason, criterion, text }) {
13550
13825
  if (!reason?.trim()) {
13551
13826
  throw new Error('flow ac update requires --reason "<why the criteria changed>"');
13552
13827
  }
13828
+ validateSingleLineReason(reason);
13829
+ if (criterion === undefined !== (text === undefined)) {
13830
+ throw new Error("flow ac update requires --criterion and --text together, or neither " + '(pass --reason "<why>" alone to re-freeze the file as already edited).');
13831
+ }
13832
+ const normalizedCriterion = criterion !== undefined ? validateCriterionName(criterion) : undefined;
13833
+ const normalizedText = text !== undefined ? validateCriterionText(text) : undefined;
13553
13834
  return mutate(cwd, id, async ({ dir, flow }) => {
13835
+ let detail = reason.trim();
13836
+ if (normalizedCriterion !== undefined && normalizedText !== undefined) {
13837
+ const known = await readAcCriteria(cwd, dir);
13838
+ const highest = known.reduce((max, ac) => Math.max(max, Number(ac.slice(2)) || 0), 0);
13839
+ const nextUnused = `AC${highest + 1}`;
13840
+ if (!known.includes(normalizedCriterion) && normalizedCriterion !== nextUnused) {
13841
+ const requestedNumber = Number(normalizedCriterion.slice(2));
13842
+ const gapNote = requestedNumber < highest ? ` ${normalizedCriterion} is a gap in the numbering \u2014 this command cannot fill a gap; edit ` + 'acceptance-criteria.md directly and re-freeze with `flow ac update <id> --reason "..."` (no --criterion/--text).' : "";
13843
+ throw new Error(`--criterion ${normalizedCriterion} is neither an existing criterion ` + `(${known.join(", ") || "none yet"}) nor the next unused one; the next number is ${nextUnused}.` + `${gapNote} Use an existing ACn to replace its text, or ${nextUnused} to add a new criterion.`);
13844
+ }
13845
+ const { previousText } = await writeAcCriterion(cwd, dir, normalizedCriterion, normalizedText);
13846
+ detail = `${normalizedCriterion}: "${previousText ?? "(new)"}" -> "${normalizedText}" (${reason.trim()})`;
13847
+ }
13554
13848
  flow.acChecksum = await acChecksum(cwd, dir);
13555
13849
  flow.acConfirmed = {};
13556
- return save(cwd, dir, flow, "ac-updated", reason);
13850
+ return save(cwd, dir, flow, "ac-updated", detail);
13557
13851
  });
13558
13852
  },
13559
13853
  async acReseal({ cwd, id, reason }) {
13560
13854
  if (!reason?.trim()) {
13561
13855
  throw new Error('flow ac reseal requires --reason "<why the checksum is stale>"');
13562
13856
  }
13857
+ validateSingleLineReason(reason);
13563
13858
  return mutate(cwd, id, async ({ dir, flow }) => {
13564
13859
  if (!flow.acChecksum) {
13565
13860
  throw new Error("flow ac reseal: the criteria are not frozen yet, so there is no checksum to re-seal.");
@@ -13604,7 +13899,15 @@ function createFlowService(deps) {
13604
13899
  return save(cwd, dir, flow, "implemented", detail);
13605
13900
  });
13606
13901
  },
13607
- async complete({ cwd, id, comment, mergedCommit }) {
13902
+ async complete({
13903
+ cwd,
13904
+ id,
13905
+ comment,
13906
+ mergedCommit,
13907
+ signedBy,
13908
+ signedByEnv,
13909
+ gitIdentity
13910
+ }) {
13608
13911
  const dir = await resolveFlowDir(cwd, id);
13609
13912
  return withFileLock(flowLockPath(cwd, dir), async () => {
13610
13913
  let flow = await readFlow(cwd, dir);
@@ -13628,6 +13931,7 @@ function createFlowService(deps) {
13628
13931
  } catch {
13629
13932
  gates.push(unevaluableGate("acceptance-criteria"));
13630
13933
  }
13934
+ let evaluatedHeadCommit = mergedCommit ?? undefined;
13631
13935
  if (mergedCommit) {
13632
13936
  const merge = deps.mainMergeGate ? await deps.mainMergeGate(cwd, mergedCommit) : await verifyCommitOnMain(cwd, mergedCommit);
13633
13937
  gates.push({ name: "main-merge", status: merge.status, detail: merge.detail });
@@ -13635,6 +13939,9 @@ function createFlowService(deps) {
13635
13939
  gates.push({ name: "pull-request", status: "fail", detail: "no PR recorded" });
13636
13940
  } else if (deps.tracker && await deps.tracker.detect()) {
13637
13941
  const pr = await deps.tracker.prStatus(flow.pr.url);
13942
+ if (typeof pr.headSha === "string" && pr.headSha !== "") {
13943
+ evaluatedHeadCommit ??= pr.headSha;
13944
+ }
13638
13945
  gates.push(pr.exists && pr.checksGreen === true ? { name: "pull-request", status: "pass", detail: "PR exists, checks green" } : {
13639
13946
  name: "pull-request",
13640
13947
  status: "fail",
@@ -13649,6 +13956,7 @@ function createFlowService(deps) {
13649
13956
  }
13650
13957
  gates.push(await baseBranchCondition(cwd, flow, mergedCommit ?? undefined, flow.pr.url && deps.tracker && await deps.tracker.detect() ? (await deps.tracker.prStatus(flow.pr.url)).baseRefName : undefined, commitContainedIn));
13651
13958
  gates.push(taskGate(flow));
13959
+ gates.push(ownerGate(flow));
13652
13960
  try {
13653
13961
  gates.push(await reviewGate({
13654
13962
  cwd,
@@ -13678,15 +13986,46 @@ function createFlowService(deps) {
13678
13986
  }
13679
13987
  }
13680
13988
  const passed = gates.every((gate) => gate.status !== "fail");
13989
+ let tamperDetail;
13990
+ try {
13991
+ await assertAcIntact(cwd, dir, flow);
13992
+ } catch (error) {
13993
+ tamperDetail = error instanceof Error ? error.message : String(error);
13994
+ }
13995
+ const recordedGates = tamperDetail === undefined ? gates : [
13996
+ ...gates,
13997
+ {
13998
+ name: "acceptance-criteria",
13999
+ status: "fail",
14000
+ detail: `acceptance criteria changed after this attempt's gates were evaluated, before it could be ` + `recorded: ${tamperDetail}`
14001
+ }
14002
+ ];
14003
+ const recordedPassed = tamperDetail === undefined && passed;
14004
+ flow.completionAttempts = [
14005
+ ...flow.completionAttempts ?? [],
14006
+ { at: now(), gates: recordedGates, passed: recordedPassed, acChecksum: flow.acChecksum }
14007
+ ];
14008
+ flow = await save(cwd, dir, flow, "completion-attempt-recorded", tamperDetail !== undefined ? `attempt ${flow.completionAttempts.length}: acceptance criteria tampered \u2014 ${tamperDetail}` : `attempt ${flow.completionAttempts.length}: ${recordedPassed ? "passed" : "failed"}`);
13681
14009
  let issueComment = null;
13682
14010
  let commented = false;
13683
- if (passed) {
14011
+ if (recordedPassed) {
13684
14012
  if (mergedCommit) {
13685
14013
  flow.merged = { commit: mergedCommit, ref: "origin/main", at: now() };
13686
14014
  }
13687
- const warnNote = healthWarnNote(gates);
14015
+ const completionIdentity = resolveSignerIdentity({ stated: signedBy, env: signedByEnv, gitIdentity });
14016
+ flow.signatures = [
14017
+ ...flow.signatures ?? [],
14018
+ {
14019
+ at: now(),
14020
+ kind: "complete",
14021
+ identity: completionIdentity,
14022
+ acChecksum: flow.acChecksum,
14023
+ ...evaluatedHeadCommit ? { headCommit: evaluatedHeadCommit } : {}
14024
+ }
14025
+ ];
14026
+ const warnNote = healthWarnNote(recordedGates);
13688
14027
  flow = await transition(cwd, dir, flow, "done", "done", warnNote ? `all gates passed (${warnNote})` : "all gates passed");
13689
- issueComment = buildIssueComment(flow, gates);
14028
+ issueComment = buildIssueComment(flow, recordedGates);
13690
14029
  if (comment && flow.source.type === "github-issue" && flow.source.ref && deps.tracker) {
13691
14030
  const ref = deps.tracker.parseRef(flow.source.ref);
13692
14031
  if (ref && await deps.tracker.detect()) {
@@ -13694,10 +14033,10 @@ function createFlowService(deps) {
13694
14033
  }
13695
14034
  }
13696
14035
  } else {
13697
- const failed = gates.filter((gate) => gate.status === "fail");
14036
+ const failed = recordedGates.filter((gate) => gate.status === "fail");
13698
14037
  flow = await transition(cwd, dir, flow, "in-progress", "completion-failed", failed.map((gate) => `${gate.name}: ${gate.detail}`).join(" | "));
13699
14038
  }
13700
- return { flow, gates, passed, issueComment, commented };
14039
+ return { flow, gates: recordedGates, passed: recordedPassed, issueComment, commented };
13701
14040
  });
13702
14041
  },
13703
14042
  async block({ cwd, id, reason }) {
@@ -13932,6 +14271,23 @@ function taskGate(flow) {
13932
14271
  }
13933
14272
  return { name: "tasks", status: "fail", detail: reasons.join("; ") };
13934
14273
  }
14274
+ function ownerGate(flow) {
14275
+ if (!flow.gates?.owner) {
14276
+ return {
14277
+ name: "owner",
14278
+ status: "skipped",
14279
+ detail: "owner gate not enabled for this package (created before the gate); " + "flows created by this keryx version opt in automatically"
14280
+ };
14281
+ }
14282
+ if (!flow.owner?.value) {
14283
+ return {
14284
+ name: "owner",
14285
+ status: "fail",
14286
+ detail: 'no owner set; run `keryx flow owner set <id> --owner "<name>" --reason "<why>"`'
14287
+ };
14288
+ }
14289
+ return { name: "owner", status: "pass", detail: `owner: ${flow.owner.value}` };
14290
+ }
13935
14291
  async function isPlaceholderAc(cwd, dir) {
13936
14292
  const content = await Bun.file(acPath(cwd, dir)).text();
13937
14293
  return content.includes("<replace with a hard, verifiable criterion");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.2.153",
3
+ "version": "0.2.155",
4
4
  "description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit",
46
46
  "typecheck:scripts": "tsc --project tsconfig.scripts.json --noEmit",
47
47
  "test": "bun test",
48
- "test:core": "bun test src/cli src/core src/shell-source-audits.test.ts src/assets/ src/capability/ src/commands/ src/contracts/ src/ctx/ src/eval/ src/flow/ src/forgetting/ src/gdgraph/ src/gdskills/ src/health/ src/job/ src/lib/ src/mcp/ src/memory/ src/metrics/ src/retention/ src/review/ src/sac/ src/security/ src/standard/ src/sync/ src/testing/ src/wiki/",
48
+ "test:core": "bun test src/cli src/core src/shell-source-audits.test.ts src/acp/ src/assets/ src/capability/ src/commands/ src/contracts/ src/ctx/ src/eval/ src/flow/ src/forgetting/ src/gdgraph/ src/gdskills/ src/governance/ src/health/ src/job/ src/lib/ src/mcp/ src/memory/ src/metrics/ src/retention/ src/review/ src/sac/ src/security/ src/standard/ src/sync/ src/testing/ src/trigger/ src/wiki/",
49
49
  "test:client:terminal": "bun test src/tui/ src/commands/shell",
50
50
  "test:client:streaming": "bun test src/harness/provider/",
51
51
  "test:client:cancel-resume": "bun test src/harness/run/ src/harness/resume/ src/harness/session/ src/session/ src/bus/ src/commands/sessions",
@@ -93,7 +93,9 @@ not done either.
93
93
  When a frozen criterion genuinely conflicts with the bar — it asks for
94
94
  something that cannot be delivered without failing a clause — the criterion is
95
95
  not quietly reworded to match what was built. It changes through
96
- `keryx flow ac update <id> --reason "<why>"`, with the conflict as the reason,
96
+ `keryx flow ac update <id> --reason "<why>"` (or `--criterion ACn --text
97
+ "<criterion>" --reason "<why>"` to rewrite the line itself), with the conflict
98
+ as the reason,
97
99
  per `.metaproject/skills/gdskills/orchestration/flow-orchestrator/SKILL.md`.
98
100
  Rewriting a criterion to describe the work is how a flow loses the only record
99
101
  of what it set out to do.
@@ -60,7 +60,7 @@ CLI-owned files:
60
60
  - task status - only through `keryx flow task done ...`.
61
61
  - task attempt counts - only through `keryx flow task attempt ...`.
62
62
  - frozen acceptance criteria changes - only through
63
- `keryx flow ac update <id> --reason "<why>"`.
63
+ `keryx flow ac update <id> --reason "<why>"` (or `--criterion ACn --text "…"`; unused arguments are refused).
64
64
 
65
65
  Agent-editable files:
66
66
 
@@ -199,7 +199,7 @@ keryx flow init --issue <url>
199
199
  or:
200
200
 
201
201
  ```bash
202
- keryx flow init --title "<short formalized problem>" --base "<branch the work must land on>"
202
+ keryx flow init --title "<short formalized problem>" --base "<branch the work must land on>" [--owner "<name>"]
203
203
  ```
204
204
 
205
205
  5. Run `keryx flow status <id>` and read the flow package.
@@ -631,7 +631,7 @@ keryx flow complete <id>
631
631
 
632
632
  Completion is allowed only after the PR merge has been confirmed. The merge
633
633
  target must be the base branch captured when the flow was created; do not
634
- silently retarget or close against another branch.
634
+ silently retarget or close against another branch. Flows created since the owner gate fail completion while no owner is set (`keryx flow owner set <id> --owner "<name>" --reason "<why>"`); `ac confirm` and `flow complete` append a signature — `--signed-by "<name>"` names the signer, otherwise `KERYX_ACTOR` or the local git identity is recorded, as a claim.
635
635
 
636
636
  If gates fail, the CLI returns the flow to `in-progress`. Add a journal note,
637
637
  create fix tasks, and repeat Phase 2.
@@ -688,7 +688,7 @@ Stop and re-read this skill if you are thinking:
688
688
  |---|---|
689
689
  | "The worker's reply reads like it finished, so the task is done." | The STATUS protocol says read the `STATUS:` line first and never infer the outcome from prose. A reply without one is `NEEDS_CONTEXT` — a confident-sounding summary is exactly what an unusable result looks like. |
690
690
  | "`DONE_WITH_CONCERNS` is still done, so I can move on." | Every concern goes into `journal.md` and gets an explicit continue-or-fix decision before `flow task done`. Concerns dropped at the task boundary are invisible by the completion report, which is where they would have mattered. |
691
- | "The acceptance criterion no longer matches what we built, so I'll reword it." | Frozen AC changes only through `keryx flow ac update <id> --reason "<why>"`. Rewriting a criterion to fit the implementation makes the flow pass a gate it actually failed, and leaves no record that it moved. |
691
+ | "The acceptance criterion no longer matches what we built, so I'll reword it." | Frozen AC changes only through `keryx flow ac update <id> --reason "<why>"`, or `--criterion ACn --text "<criterion>" --reason "<why>"` to rewrite the line itself. Rewriting a criterion to fit the implementation makes the flow pass a gate it actually failed, and leaves no record that it moved. |
692
692
  | "`flow.json` is just a file — editing one field is faster than the CLI." | `flow.json`, status transitions, task status and attempt counts are CLI-owned. A hand-written field desynchronises the durable state from the flow's own history, and the CLI's next gate check reads yours, not reality. |
693
693
  | "Tests pass and the review is clean, so I'll open the PR and complete the flow." | Phase 4 stops and asks the user how the flow should end; not every flow wants a PR. And completion requires a confirmed merge into the base branch captured at creation — not a green local run. |
694
694
  | "The worker returned BLOCKED twice — faster if I implement this task myself." | The implementer never self-accepts and the orchestrator never implements. Block the flow, escalate one concise question, then unblock and re-dispatch. Doing the work here erases the boundary the whole flow model rests on. |