@ctrl-spc/cs 0.7.5 → 0.7.7

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.
@@ -550,6 +550,23 @@ export function levelOnePrompt(cardTitle, messages, produced, attachments = [],
550
550
  * Every child it starts, and every grandchild THEY start, would be working
551
551
  * from nothing but a responsibility sentence somebody else wrote down.
552
552
  */
553
+ export const OWNER_COMPLETION_RULES = [
554
+ 'ASSIGNMENT STATUS — THIS OVERRIDES OLDER ENDING INSTRUCTIONS IN YOUR BRIEF.',
555
+ 'A reply or process exit does not finish the assignment. Keep working through all remaining',
556
+ 'required steps and checks. Status questions never cancel work already in progress.',
557
+ 'When you yield to workers, write_report with what remains and exit; you resume with results.',
558
+ 'When no worker is active and work remains, continue it yourself or dispatch it. If you exit',
559
+ 'without declaring completion, the product starts you again to carry on.',
560
+ 'Only when the latest user assignment is fulfilled, call write_report with work_complete=true',
561
+ 'and a report explaining what fulfilled it. Check all required steps and verification first.',
562
+ 'Then give the final answer and exit. Do not declare completion for an interim status answer.',
563
+ 'A failed or skipped required step is unfinished work, even if all workers have returned.',
564
+ 'Resolve it or ask for the real required intervention; never convert it into a completion caveat.',
565
+ 'If a required user action prevents progress, use ask_question and stop. It must name the actual',
566
+ 'approval, credential or manual action needed. Never ask just because no agent is running.',
567
+ 'A handover explicitly requested by the person may finish that request without finishing a',
568
+ 'feature. Say exactly what was handed over and do not claim the unfinished feature is done.',
569
+ ].join('\n');
553
570
  export function workBrief(level, responsibility, boundary, workItemId, attachments = [], codebase) {
554
571
  /* ═══ ONLY WHEN A WORKFLOW IS ACTUALLY ON THE CARD. ═══ An owner is not
555
572
  instructed about a process that is not there, which is the rule
@@ -562,6 +579,7 @@ export function workBrief(level, responsibility, boundary, workItemId, attachmen
562
579
  ? [
563
580
  'You see one thing a person asked for through to done. Keep the whole picture and stay',
564
581
  'available to the person while the work moves.',
582
+ OWNER_COMPLETION_RULES,
565
583
  '',
566
584
  /* ═══ THE FIRST THING, EVERY TIME, AND IT IS NOT THE REPLY. ═══ Nothing
567
585
  an owner writes reaches the person until its process exits, and a turn
@@ -836,7 +854,7 @@ export const landingOutcomeContext = (landing) => {
836
854
  'They chose neither of the two answers, so nothing was merged and the work is still on '
837
855
  + `${landing.branch ?? 'its branch'}.`,
838
856
  'Read what they actually wrote and answer it. If they still want the work put back, offer the',
839
- 'ending again with `offer_ending` rather than replying: a reply finishes the card, and an',
857
+ 'ending again with `offer_ending` rather than declaring completion: an',
840
858
  'offer leaves it with them.',
841
859
  ];
842
860
  }
@@ -915,6 +933,7 @@ landing = null) {
915
933
  });
916
934
  return [
917
935
  'YOU OWN THIS CTRL+SPC CONVERSATION.',
936
+ OWNER_COMPLETION_RULES,
918
937
  'You are this conversation\'s one owner, started for one serialized activation. Speak directly to the',
919
938
  'person as CTRL+SPC. The launcher is finished and cannot answer or receive an escalation.',
920
939
  '',
@@ -133,7 +133,7 @@
133
133
  // `import type` loses its v3 header in the published `dist/`.
134
134
  import { out, returned, signedInClient } from './client.js';
135
135
  import { readableWriteError } from '../firewall.js';
136
- import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, landingOutcomeContext, presentedArtifactAnswerContext, resumePrompt, retryPrompt, standingRules, whatWasAttached, workingRules, } from './prompt.js';
136
+ import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, landingOutcomeContext, presentedArtifactAnswerContext, resumePrompt, retryPrompt, standingRules, whatWasAttached, workingRules, OWNER_COMPLETION_RULES, } from './prompt.js';
137
137
  import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, loadOutputNames, outputOf, recordBaseProtection, standingRulesFor, withAskContent, } from './show.js';
138
138
  import { forgetSecrets, redactSecrets } from './secrets.js';
139
139
  import { sayListening, stopListening } from './presence.js';
@@ -148,6 +148,7 @@ import { listCodebases } from '../codebases.js';
148
148
  import { killTree, processIsAlive } from '../win-shell.js';
149
149
  import { hostname, uptime } from 'node:os';
150
150
  import { execFileSync } from 'node:child_process';
151
+ import { existsSync } from 'node:fs';
151
152
  import { mkdir, writeFile } from 'node:fs/promises';
152
153
  import { join } from 'node:path';
153
154
  const USAGE = 'usage: run [--once]';
@@ -1030,6 +1031,14 @@ export async function picturesOnDisk(client, cardId, where, level) {
1030
1031
  .eq('card_id', cardId).order('created_at'), 'read', `the pictures on card ${cardId}`);
1031
1032
  if (images.length === 0)
1032
1033
  return [];
1034
+ /* ═══ A CWD THAT IS GONE IS A FAILURE, NOT A FOLDER TO MAKE. ═══ `recursive`
1035
+ below would rebuild a removed worktree as a plain directory holding one
1036
+ `ctrl-spc-images/`, which is exactly the folder `worktreeForCard` then
1037
+ refuses on every start (.bugs/.resolved/20260905-card-stuck-not-a-working-copy). */
1038
+ if (!existsSync(where.cwd)) {
1039
+ throw new Error('This card\'s working copy went away while its agent was starting. Send your message '
1040
+ + 'again and it is made afresh.');
1041
+ }
1033
1042
  const folder = join(gitDirOf(where.cwd) ?? where.cwd, 'ctrl-spc-images');
1034
1043
  await mkdir(folder, { recursive: true });
1035
1044
  const lines = [];
@@ -1941,8 +1950,17 @@ export function ownerContinuationPrompt(brief, report, events, children, deliver
1941
1950
  const messages = events.filter((event) => event.kind === 'user' && event.needsReply);
1942
1951
  return [
1943
1952
  'CONTINUE THE CTRL+SPC CONVERSATION YOU ALREADY OWN.',
1953
+ OWNER_COMPLETION_RULES,
1944
1954
  'Use only the new facts below as the new turn. Keep coordinating the whole card and speak as CTRL+SPC.',
1945
1955
  '',
1956
+ ...(messages.length === 0 && delivered === null ? [
1957
+ 'WHY YOU WERE STARTED AGAIN',
1958
+ 'The assignment is still open. Your previous final reply did not record completion.',
1959
+ 'Do not repeat a completion reply by itself. If the assignment is fulfilled, call',
1960
+ 'write_report with work_complete=true NOW, then give the final answer. Otherwise do the',
1961
+ 'remaining work or resolve the new worker results below. No new user message is needed.',
1962
+ '',
1963
+ ] : []),
1946
1964
  'NEW USER MESSAGES',
1947
1965
  ...(messages.length === 0
1948
1966
  ? ['None.']
@@ -2416,15 +2434,17 @@ export async function reconcileOwnerSessions(client, machineId, _machineHarness,
2416
2434
  async function killStopped(client, machineId) {
2417
2435
  const stopped = await returned(client
2418
2436
  .from('panel3_runs')
2419
- .select('id, card_id, pid, started_at, resumed_at')
2437
+ .select('id, card_id, pid, state, started_at, resumed_at')
2420
2438
  .eq('machine_id', machineId)
2421
- .in('state', ENDED_BY_THE_PERSON)
2439
+ .in('state', [...ENDED_BY_THE_PERSON, 'finished', 'failed'])
2422
2440
  .not('pid', 'is', null), 'read', 'the runs on this machine that are not coming back');
2423
2441
  if (stopped.length === 0)
2424
2442
  return;
2425
2443
  const booted = bootedAt();
2426
2444
  for (const run of stopped) {
2427
2445
  if (runProcessIsAlive(run, booted)) {
2446
+ if (!ENDED_BY_THE_PERSON.includes(run.state))
2447
+ continue;
2428
2448
  try {
2429
2449
  process.kill(run.pid);
2430
2450
  out(`stopped run ${run.id} card ${run.card_id} killed pid ${run.pid}`);
@@ -2446,11 +2466,11 @@ async function killStopped(client, machineId) {
2446
2466
  await returned(client
2447
2467
  .from('panel3_runs')
2448
2468
  .update({ pid: null })
2449
- // STILL NOT COMING BACK. Nothing starts either of these states, so
2450
- // this cannot lose a race, and saying so in the statement is what
2451
- // keeps that true if something one day does.
2469
+ // A finished owner may activate again between this read and write.
2470
+ // Only acknowledge the exact process whose exit was observed.
2452
2471
  .eq('id', run.id)
2453
- .in('state', ENDED_BY_THE_PERSON)
2472
+ .eq('pid', run.pid)
2473
+ .in('state', [...ENDED_BY_THE_PERSON, 'finished', 'failed'])
2454
2474
  .select('id'), 'clear the process id of', `run ${run.id}`);
2455
2475
  }
2456
2476
  catch (error) {
@@ -2553,7 +2573,9 @@ async function recoverStranded(client, tools, machineId, mine, hold) {
2553
2573
  await writeAnswer(client, run.id, run.card_id, null, run.process_token ?? undefined);
2554
2574
  }
2555
2575
  else {
2556
- const resumed = await activateOwner(client, tools, machineId, run.id, run.process_token, run.pid);
2576
+ // This process belonged to a previous daemon. Use the fenced recovery
2577
+ // claim, not a failed-attempt retry whose budget may already be spent.
2578
+ const resumed = await activateOwner(client, tools, machineId, run.id, run.process_token, null);
2557
2579
  if (resumed)
2558
2580
  hold(run.id, resumed.settled);
2559
2581
  }
@@ -2847,6 +2869,8 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
2847
2869
  * normalised into a constant getter below, so there is one shape inside `run`
2848
2870
  * and no branch anywhere near the poll.
2849
2871
  */
2872
+ /** The card states in which nobody will start again on the card's copy. */
2873
+ const CARD_IS_OVER = ['done', 'failed', 'stopped'];
2850
2874
  /**
2851
2875
  * ═══ THE CARD IS OVER, SO ITS COPY GOES — AND THE BRANCH STAYS. ═══
2852
2876
  *
@@ -2865,7 +2889,7 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
2865
2889
  * A folder no row explains is LEFT ALONE. Nothing here deletes something it
2866
2890
  * cannot account for.
2867
2891
  */
2868
- async function sweepFinishedWorktrees(client) {
2892
+ export async function sweepFinishedWorktrees(client) {
2869
2893
  const held = worktreesOnThisMachine();
2870
2894
  if (held.length === 0)
2871
2895
  return;
@@ -2889,14 +2913,27 @@ async function sweepFinishedWorktrees(client) {
2889
2913
  const cards = [...new Set([...cardOfFolder.values()].flat())];
2890
2914
  if (cards.length === 0)
2891
2915
  return;
2916
+ /* ═══ THE CARD IS ASKED, NOT ONLY ITS RUNS. ═══ A conversational owner is
2917
+ parked between wakes with `ended_at` set, so the second its last worker
2918
+ finishes, no run on the card is open, while the card itself still reads
2919
+ `working`. Reading the runs alone called that card finished and took the
2920
+ copy out from under the owner as it was starting
2921
+ (.bugs/.resolved/20260905-card-stuck-not-a-working-copy). The card's own
2922
+ state is the record's word for whether it is over; the runs still guard a
2923
+ card that is over on paper while a process of it is going. */
2924
+ const over = new Set((await returned(client.from('panel3_cards')
2925
+ .select('id, state')
2926
+ .in('id', cards), 'read', 'which of those cards are over')).filter((row) => CARD_IS_OVER.includes(row.state)).map((row) => row.id));
2892
2927
  const live = new Set((await returned(client.from('panel3_runs')
2893
2928
  .select('card_id')
2894
2929
  .in('card_id', cards)
2895
- .is('ended_at', null), 'read', 'which of those cards still have an agent going')).map((row) => row.card_id));
2930
+ .or('ended_at.is.null,pid.not.is.null'), 'read', 'which of those cards still have an agent going')).map((row) => row.card_id));
2896
2931
  const touched = new Set();
2897
2932
  for (const one of held) {
2898
2933
  const cardIds = cardOfFolder.get(one.folder) ?? [];
2899
- if (cardIds.length === 0 || cardIds.some((cardId) => live.has(cardId)))
2934
+ if (cardIds.length === 0)
2935
+ continue;
2936
+ if (!cardIds.every((cardId) => over.has(cardId) && !live.has(cardId)))
2900
2937
  continue;
2901
2938
  try {
2902
2939
  settleCardWorktree(one.folder);
@@ -2481,11 +2481,21 @@ const TOOLS = [
2481
2481
  description: 'Write down what is true NOW: what you have done, what you decided and why, and what is still '
2482
2482
  + 'open. It REPLACES your last report rather than adding to it, and it is what you are handed if '
2483
2483
  + 'you are respawned, so write it as the thing you would want to read to carry on. Write it as '
2484
- + 'you go, not only at the end. '
2484
+ + 'you go, not only at the end. The conversation owner MUST set work_complete=true only when '
2485
+ + 'the latest user assignment is fulfilled, including its required steps and checks. A status '
2486
+ + 'reply is not completion. This records intent; Done waits for all agents to exit. '
2485
2487
  + FIREWALL_WRITING_RULE,
2486
- input: { report: z.string().min(1) },
2487
- handler: async ({ client, runId, processToken }, args) => {
2488
- const { report } = args;
2488
+ input: { report: z.string().min(1), work_complete: z.boolean().optional() },
2489
+ handler: async ({ client, runId, processToken, isOwner }, args) => {
2490
+ const { report, work_complete } = args;
2491
+ if (work_complete) {
2492
+ if (!isOwner || processToken === undefined)
2493
+ throw new Error('Only the conversation owner can declare the assignment complete.');
2494
+ await whileRunning(client.rpc('panel3_request_completion', {
2495
+ p_run_id: runId, p_process_token: processToken, p_summary: report,
2496
+ }), runId, 'declare completion of');
2497
+ return 'Completion recorded. Give your final answer and exit. Done waits until all processes have exited.';
2498
+ }
2489
2499
  await whileRunning(processToken === undefined
2490
2500
  ? client.from('panel3_runs').update({ report }).eq('id', runId)
2491
2501
  .in('state', STILL_WRITING).is('ended_at', null).select('id')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.7.5",
4
- "description": "CTRL+SPC minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
3
+ "version": "0.7.7",
4
+ "description": "CTRL+SPC \u2014 minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"
7
7
  },