@afokapu/atdd-flow 0.3.63 → 0.3.64

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.
@@ -6,7 +6,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, recordMailActivation, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
7
7
  import { paths, readYaml, type Seat } from "../../src/core";
8
8
  import { activationNotice, diagnoseMailActivation, expectsNativeMail } from "../../src/mail-activation-notice";
9
- import { type NoticeState, createNoticeState, refreshDelivered, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
9
+ import { type NoticeState, createNoticeState, refreshDelivered, sendSettledDirective, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
10
10
  import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
11
11
  import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
12
12
  import { contextHandoverThreshold, readHandoverReplacement } from "../../src/handover";
@@ -382,7 +382,11 @@ export default function (pi: ExtensionAPI) {
382
382
  return message ? { message: message as typeof event.message } : undefined;
383
383
  });
384
384
  // Nothing queued survives a settled run, so a pending flag left by a cleared queue cannot suppress future notices.
385
- pi.on("agent_settled", () => { notices.pending = false; });
385
+ pi.on("agent_settled", async () => {
386
+ notices.pending = false;
387
+ // Run completion is not task completion; one bounded continuation per unchanged directive.
388
+ await sendSettledDirective(host, root, seat, notices);
389
+ });
386
390
  pi.on("turn_end", async (_event, ctx) => {
387
391
  if (handoverPrompted) return;
388
392
  const percent = ctx.getContextUsage()?.percent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.63",
3
+ "version": "0.3.64",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -19,6 +19,7 @@ import { multiplexer } from "./multiplexer";
19
19
  import * as cleanup from "./ephemeral-resources";
20
20
  import * as ownerAlerts from "./owner-escalations";
21
21
  import { recoveryFor } from "./recovery-bundle";
22
+ import { reconcileOnce } from "./reconcile";
22
23
  import { recordHandover, requestHandoverReplacement, resumedHandoverReplacement, showHandover } from "./handover";
23
24
 
24
25
  const usage = `atdd-flow — filesystem-first agent seats and tasks
@@ -65,6 +66,7 @@ Usage:
65
66
  atdd-flow task open <project> <task-id>
66
67
  atdd-flow cleanup status <project>
67
68
  atdd-flow cleanup checklist <project> <task-id>
69
+ atdd-flow reconcile --once [--dry-run]
68
70
  atdd-flow owner-alert scan --by <coordinator-or-main> [--re-notify-after-ms <milliseconds>]
69
71
  atdd-flow owner-alert deliver|acknowledge|execute|resolve <alert-id> --by <address> [--note <text>|--option <id>|--reason <text>]
70
72
  atdd-flow thread start --with <address,...> --subject <text> [--task <project/task-id>]
@@ -166,6 +168,12 @@ async function main() {
166
168
  if (subcommand === "checklist") return console.log(await cleanup.checklist(root, { project: required(projectName, "project"), task: required(taskId, "task id") }));
167
169
  throw new Error("Use `atdd-flow cleanup status <project>` or `atdd-flow cleanup checklist <project> <task-id>`.");
168
170
  },
171
+ reconcile: async () => {
172
+ if (!rest.includes("--once") || rest.some((value) => value !== "--once" && value !== "--dry-run"))
173
+ throw new Error("Use `atdd-flow reconcile --once [--dry-run]`.");
174
+ process.stdout.write(Bun.YAML.stringify(await reconcileOnce(root, { dryRun: rest.includes("--dry-run") })));
175
+ return;
176
+ },
169
177
  "owner-alert": async () => {
170
178
  const [subcommand, alertId, ...tail] = rest;
171
179
  if (subcommand === "scan") {
@@ -14,6 +14,8 @@ export type NoticeState = {
14
14
  /** A next-action follow-up has been queued and not yet delivered. */
15
15
  pending: boolean;
16
16
  lastDelivered?: string;
17
+ /** One automatic post-settlement continuation per unchanged directive. */
18
+ settledNudged?: string;
17
19
  /** Blocker notices already delivered this session, keyed by task and blocker text. */
18
20
  blockers: Set<string>;
19
21
  /** Mail ids already notified this session. */
@@ -50,19 +52,46 @@ export async function sendTaskDirective(host: NoticeHost, root: string, seat: st
50
52
  }
51
53
  }
52
54
 
55
+ /** A settled model turn is not task completion. Give one additional nudge for a
56
+ * still-actionable directive, but never loop on unchanged task state. */
57
+ export async function sendSettledDirective(host: NoticeHost, root: string, seat: string, state: NoticeState) {
58
+ if (state.pending) return;
59
+ try {
60
+ const directive = await nextDirective(root, seat);
61
+ if (!directive?.command) return; // Waiting for an authority or dependency is not work for this seat.
62
+ const key = directiveKey(directive);
63
+ if (key !== state.lastDelivered) return sendTaskDirective(host, root, seat, state);
64
+ if (state.settledNudged === key) return;
65
+ state.settledNudged = key;
66
+ state.pending = true;
67
+ host.sendMessage({
68
+ customType: "atdd-flow-next-action",
69
+ content: directiveNotice(seat, directive),
70
+ display: true,
71
+ details: { ...directiveDetails(directive), continuation: true },
72
+ }, { triggerTurn: true, deliverAs: "followUp" });
73
+ } catch { state.pending = false; }
74
+ }
75
+
53
76
  /** Re-derives a held next-action follow-up when Pi delivers it; a queued message cannot be dropped, so it is replaced by the shortest truthful text. */
54
77
  async function currentDirective(root: string, seat: string, state: NoticeState, message: NoticeMessage): Promise<NoticeMessage> {
55
78
  state.pending = false;
56
79
  const directive = await nextDirective(root, seat).catch(() => undefined);
57
80
  if (!directive) {
58
81
  state.lastDelivered = undefined;
82
+ state.settledNudged = undefined;
59
83
  return { ...message, content: `SYSTEM: next-action notice for ${seat} withdrawn: task state has no actionable step. Task state outranks mail.`, details: { authority: "task", lifecycle: "NONE" } };
60
84
  }
61
85
  const key = directiveKey(directive);
62
86
  if (key === state.lastDelivered) {
87
+ if (message.details?.continuation === true && directive.command) {
88
+ return { ...message, content: `${directiveNotice(seat, directive)} The task remains open. Continue or escalate a real blocker; do not stop merely because this turn ended.`,
89
+ details: { ...directiveDetails(directive), continuation: true } };
90
+ }
63
91
  return { ...message, content: `SYSTEM: next action for ${seat} unchanged on ${directive.project}/${directive.task} [${directive.lifecycle}]; see the previous notice.`, details: directiveDetails(directive) };
64
92
  }
65
93
  state.lastDelivered = key;
94
+ state.settledNudged = undefined;
66
95
  if (directive.lifecycle === "BLOCKED") state.blockers.add(key);
67
96
  return { ...message, content: directiveNotice(seat, directive), details: directiveDetails(directive) };
68
97
  }
@@ -0,0 +1,151 @@
1
+ // URN: component:flow:e012:reconcile:backend:application
2
+ // Tested-By:
3
+ // - test:flow:continuation:E012-UNIT-001
4
+ // Runtime: bun
5
+ // Purpose: Wake idle owners of existing actionable Flow obligations.
6
+ import { createHash } from "node:crypto";
7
+ import { mkdir, readdir } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { notify } from "./adapters";
10
+ import { type HerdrLocator, type Seat, atomicYaml, paths, readYaml, runOutput } from "./core";
11
+ import { type TaskDirective, directiveNotice, nextDirective } from "./tasks";
12
+ import { isRuntimeStateStale, readRuntimeState, withOwnedRuntimeState } from "./runtime-state";
13
+
14
+ type Outcome = "woke" | "dry_run" | "cooldown" | "exhausted" | "inactive" | "unverified" | "busy" | "replaced" | "wake_failed";
15
+ export type Reconciliation = { seat: string; task: string; outcome: Outcome };
16
+ export type ReconcileOptions = {
17
+ now?: Date;
18
+ dryRun?: boolean;
19
+ cooldownMs?: number;
20
+ maxNudges?: number;
21
+ inspect?: (locator: HerdrLocator, seat: string) => Promise<string>;
22
+ wake?: (locator: HerdrLocator, notice: string) => Promise<void>;
23
+ };
24
+
25
+ type Attempt = { schema: "atdd-flow/continuation-attempt/v1"; key: string; at: string; count: number };
26
+ type Target = { owner: Seat; directive: TaskDirective; locator: HerdrLocator; token: string };
27
+ type Settings = Required<Pick<ReconcileOptions, "now" | "cooldownMs" | "maxNudges" | "inspect" | "wake">> & { dryRun: boolean };
28
+
29
+ const agentName = (address: string) => `flow-${address.replace(/[^a-z0-9_-]/gi, "-").toLowerCase()}`.slice(0, 32);
30
+ const fingerprint = (target: Target) =>
31
+ createHash("sha256").update(JSON.stringify({ directive: target.directive, session: target.owner.runtime?.pi_session, owner: target.token })).digest("hex");
32
+ const attemptFile = (root: string, address: string) =>
33
+ join(root, ".atdd-flow", "continuation", `${encodeURIComponent(address)}.yaml`);
34
+
35
+ /** Read Herdr status without treating missing or mismatched identities as healthy.
36
+ * The live host is an observation surface, never a seat registry.
37
+ */
38
+ async function inspectHerdr(locator: HerdrLocator, address: string) {
39
+ const raw = JSON.parse(await runOutput(["herdr", "--session", locator.session, "agent", "get", agentName(address)]));
40
+ const envelope = raw?.result ?? raw;
41
+ const agent = envelope?.agent ?? envelope;
42
+ if (agent?.name !== agentName(address) || agent?.pane_id !== locator.pane) return "unverified";
43
+ return String(agent?.agent_status ?? agent?.status ?? agent?.state ?? "unverified").toLowerCase();
44
+ }
45
+
46
+ /** Discover durable seat identity from the existing Desk directories.
47
+ * No second fleet registry is created or synchronized.
48
+ */
49
+ async function seats(root: string): Promise<Seat[]> {
50
+ const found: Seat[] = [];
51
+ for (const project of await readdir(paths(root).work, { withFileTypes: true })) {
52
+ if (!project.isDirectory()) continue;
53
+ for (const local of await readdir(paths(root).seats(project.name), { withFileTypes: true }).catch(() => [])) {
54
+ if (!local.isDirectory()) continue;
55
+ found.push(await readYaml<Seat>(join(paths(root).seats(project.name), local.name, "seat.yaml")));
56
+ }
57
+ }
58
+ return found;
59
+ }
60
+
61
+ function validateLimits(cooldownMs: number, maxNudges: number) {
62
+ if (!Number.isFinite(cooldownMs) || cooldownMs < 0) throw new Error("Invalid continuation cooldown.");
63
+ if (!Number.isInteger(maxNudges) || maxNudges < 1) throw new Error("Invalid maximum nudge count.");
64
+ }
65
+
66
+ function settings(options: ReconcileOptions): Settings {
67
+ const cooldownMs = options.cooldownMs ?? 5 * 60_000;
68
+ const maxNudges = options.maxNudges ?? 2;
69
+ validateLimits(cooldownMs, maxNudges);
70
+ return {
71
+ now: options.now ?? new Date(),
72
+ dryRun: options.dryRun ?? false,
73
+ cooldownMs, maxNudges,
74
+ inspect: options.inspect ?? inspectHerdr,
75
+ wake: options.wake ?? ((locator, notice) => notify("herdr", locator.pane, notice, locator.session)),
76
+ };
77
+ }
78
+
79
+ const outcome = (owner: Seat, directive: TaskDirective, status: Outcome): Reconciliation =>
80
+ ({ seat: owner.address, task: `${directive.project}/${directive.task}`, outcome: status });
81
+
82
+ /** An actionable target also needs a live, matching runtime observation.
83
+ * This deliberately refuses an unknown or stale session rather than guessing.
84
+ */
85
+ async function target(root: string, owner: Seat, directive: TaskDirective, at: Date): Promise<Target | undefined> {
86
+ const runtime = owner.runtime;
87
+ const locator = runtime?.addresses.herdr;
88
+ if (runtime?.application !== "herdr" || !locator || typeof locator === "string") return;
89
+ const active = await readRuntimeState(root, owner.address);
90
+ if (!active) return;
91
+ if (isRuntimeStateStale(active, 60_000, at)) return;
92
+ if (runtime.pi_session && active.pi_session !== runtime.pi_session) return;
93
+ return { owner, directive, locator, token: active.owner_token };
94
+ }
95
+
96
+ /** The per-seat retry memo is advisory: changing work or replacing an agent
97
+ * starts a new key, while unchanged instructions have bounded retries.
98
+ */
99
+ function attemptDisposition(previous: Attempt | undefined, key: string, config: Settings): Outcome | undefined {
100
+ if (previous?.schema !== "atdd-flow/continuation-attempt/v1") return;
101
+ if (previous.key !== key) return;
102
+ if (previous.count >= config.maxNudges) return "exhausted";
103
+ if (config.now.getTime() - Date.parse(previous.at) < config.cooldownMs) return "cooldown";
104
+ }
105
+
106
+ /** Write the attempt before contacting Herdr, so a transient host failure
107
+ * cannot turn repeated reconciliation into a prompt storm.
108
+ */
109
+ async function wakeOnce(root: string, candidate: Target, config: Settings): Promise<Outcome> {
110
+ const file = attemptFile(root, candidate.owner.address);
111
+ const key = fingerprint(candidate);
112
+ const previous = await readYaml<Attempt>(file).catch(() => undefined);
113
+ const suppressed = attemptDisposition(previous, key, config);
114
+ if (suppressed) return suppressed;
115
+ const count = previous?.key === key ? previous.count + 1 : 1;
116
+ await mkdir(join(root, ".atdd-flow", "continuation"), { recursive: true });
117
+ await atomicYaml(file, { schema: "atdd-flow/continuation-attempt/v1", key, at: config.now.toISOString(), count } satisfies Attempt);
118
+ try {
119
+ await config.wake(candidate.locator, `${directiveNotice(candidate.owner.address, candidate.directive)} The task remains open. Continue within your authority; escalate a real blocker rather than ending the obligation.`);
120
+ return "woke";
121
+ } catch { return "wake_failed"; }
122
+ }
123
+
124
+ async function reconcileSeat(root: string, owner: Seat, config: Settings): Promise<Reconciliation | undefined> {
125
+ if (owner.retired) return;
126
+ const directive = await nextDirective(root, owner.address);
127
+ // A directive without a command is a legitimate wait on another actor.
128
+ if (!directive?.command) return;
129
+ const candidate = await target(root, owner, directive, config.now);
130
+ if (!candidate) return outcome(owner, directive, "inactive");
131
+ let status: string;
132
+ try { status = await config.inspect(candidate.locator, owner.address); }
133
+ catch { return outcome(owner, directive, "unverified"); }
134
+ if (status === "unverified") return outcome(owner, directive, "unverified");
135
+ if (status !== "idle" && status !== "done") return outcome(owner, directive, "busy");
136
+ if (config.dryRun) return outcome(owner, directive, "dry_run");
137
+ // Serialize with a live runtime's token-fenced wake; a replacement cannot be prompted.
138
+ const result = await withOwnedRuntimeState(root, owner.address, candidate.token, () => wakeOnce(root, candidate, config));
139
+ return outcome(owner, directive, result.owned ? result.value : "replaced");
140
+ }
141
+
142
+ /** One-shot, advisory fleet reconciliation. Task state remains the sole work authority. */
143
+ export async function reconcileOnce(root: string, options: ReconcileOptions = {}): Promise<Reconciliation[]> {
144
+ const config = settings(options);
145
+ const results: Reconciliation[] = [];
146
+ for (const owner of await seats(root)) {
147
+ const result = await reconcileSeat(root, owner, config);
148
+ if (result) results.push(result);
149
+ }
150
+ return results;
151
+ }