@ctrl-spc/cs 0.7.4 → 0.7.5

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.
@@ -0,0 +1,48 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { configDir } from './config.js';
4
+ import { processIsAlive } from './win-shell.js';
5
+ /**
6
+ * One `cs start` per config dir.
7
+ *
8
+ * Two daemons on one machine share one `cliv2_agents` row and take turns
9
+ * overwriting it every heartbeat. The case that produced it: `cs start` was
10
+ * running, `npm i -g` replaced dist under it, and a second `cs start` came up
11
+ * on the new code. The row then flipped between 0.7.0 and 0.7.4 every few
12
+ * seconds and the Machines popover flickered with it.
13
+ *
14
+ * Keyed on the config dir, not the machine id, so the documented two-instance
15
+ * setup (`CTRL_SPC_V2_CONFIG_DIR` plus `CTRL_SPC_V2_MACHINE_ID`) still works.
16
+ *
17
+ * ponytail: a pid file, not an OS lock. A pid recycled onto an unrelated
18
+ * process after a crash reads as "already running" until that process exits;
19
+ * `rm ~/.config/ctrl-spc-v2/daemon.pid` is the way out. Upgrade to an
20
+ * exclusive-open lock if that is ever hit in practice.
21
+ */
22
+ function lockPath() {
23
+ return join(configDir(), 'daemon.pid');
24
+ }
25
+ /** Claims the lock for this process, or returns the pid of the daemon that holds it. */
26
+ export function claimDaemonLock() {
27
+ const path = lockPath();
28
+ if (existsSync(path)) {
29
+ const pid = Number(readFileSync(path, 'utf8').trim());
30
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && processIsAlive(pid)) {
31
+ return { held: true, pid };
32
+ }
33
+ }
34
+ mkdirSync(configDir(), { recursive: true });
35
+ writeFileSync(path, String(process.pid));
36
+ return { held: false };
37
+ }
38
+ /** Removes the lock if this process wrote it. Never throws: it runs during shutdown. */
39
+ export function releaseDaemonLock() {
40
+ try {
41
+ const path = lockPath();
42
+ if (Number(readFileSync(path, 'utf8').trim()) === process.pid)
43
+ rmSync(path);
44
+ }
45
+ catch {
46
+ // no lock, or not ours
47
+ }
48
+ }
package/dist/daemon.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ensureAutostart } from './autostart.js';
2
+ import { claimDaemonLock, releaseDaemonLock } from './daemon-lock.js';
2
3
  import { liveClient, startPresence, stopPresence } from './presence.js';
3
4
  /* ═══ THE ONE IMPORT ANYTHING OUTSIDE `panel3/` MAKES INTO IT. ═══ Named in
4
5
  `.implementations/19-agent-panel-v3/conventions.md` and enforced by
@@ -28,6 +29,15 @@ import { startPanel } from './panel3/run.js';
28
29
  * starts doing the right thing with nothing else typed.
29
30
  */
30
31
  export async function runDaemon() {
32
+ /* One daemon per config dir. See daemon-lock.ts for the flicker this stops.
33
+ Exits rather than replacing the holder: under launchd's KeepAlive a
34
+ replacement would be restarted and would then replace the replacer. */
35
+ const lock = claimDaemonLock();
36
+ if (lock.held) {
37
+ console.error(`cs start is already running on this computer (pid ${lock.pid}). Stop it first to start another.`);
38
+ process.exitCode = 1;
39
+ return;
40
+ }
31
41
  ensureAutostart(); // default-on: install the login item unless the user opted out
32
42
  const { machineName, agents } = await startPresence();
33
43
  /* ═══ THE PANEL READS PRESENCE'S CLIENT, IT IS NOT HANDED A COPY OF IT. ═══
@@ -112,6 +122,7 @@ export async function runDaemon() {
112
122
  here (see `run()`). Both are best-effort and neither throws, which is why
113
123
  they can settle together. */
114
124
  await Promise.all([panel.stop(), stopPresence()]);
125
+ releaseDaemonLock();
115
126
  process.exit(code);
116
127
  }
117
128
  process.on('SIGINT', () => void shutdown());
package/dist/mcp.js CHANGED
@@ -2019,11 +2019,8 @@ async function resolvePlacementTarget(client, table, noun, id, projectId) {
2019
2019
  * `epic_id` / `sprint_id` since 20260727080000 — with the fresh row's
2020
2020
  * revision, so the CAS contract is honored rather than bypassed.
2021
2021
  *
2022
- * Sequencing fix: the create RPC's own position (min-1, top of backlog) is
2023
- * right for a single human quick-add but inverts an agent-authored sequence,
2024
- * so the placement pass runs for EVERY creation and moves the task to the
2025
- * project's max(position)+1 — creation order equals board order. The
2026
- * `position` key rides the same save_task_if_current p_changes whitelist.
2022
+ * The create_task RPC appends at the bottom of the backlog, so creation order
2023
+ * equals board order with no follow-up write.
2027
2024
  */
2028
2025
  export async function createTaskHandler(client, userId, currentSession, args,
2029
2026
  /** 18c Slice 6: the request THIS CONNECTION is working. See
@@ -2044,25 +2041,6 @@ connectionTodoId = null) {
2044
2041
  if (!sprint.ok)
2045
2042
  return sprint.error;
2046
2043
  }
2047
- // Feature 32 sequencing: the create_task RPC assigns position = min-1 (top
2048
- // of backlog — right for a single human quick-add), so N sequential agent
2049
- // creates would render NEWEST-FIRST on the board, inverting the skeleton's
2050
- // sequence. Read the project's current max position BEFORE the create so
2051
- // the placement pass below can APPEND the fresh task at max+1 — for every
2052
- // creation, placed or not, so creation order equals board order.
2053
- // (save_task_if_current's p_changes whitelist accepts `position`.) A
2054
- // concurrent-agent race on max+1 costs at worst an adjacent swap.
2055
- const maxPositionRow = await must(client
2056
- .from('tasks')
2057
- .select('position')
2058
- .eq('project_id', args.project_id)
2059
- .is('archived_at', null)
2060
- .order('position', { ascending: false })
2061
- .limit(1)
2062
- .maybeSingle());
2063
- const nextPosition = (typeof maxPositionRow?.position === 'number' && Number.isFinite(maxPositionRow.position)
2064
- ? maxPositionRow.position
2065
- : 0) + 1;
2066
2044
  const taskId = await must(client.rpc('create_task', {
2067
2045
  p_project: args.project_id,
2068
2046
  p_name: args.name,
@@ -2074,37 +2052,37 @@ connectionTodoId = null) {
2074
2052
  let task = await fetchTask(client, taskId);
2075
2053
  if (!task)
2076
2054
  throw new Error('Created task could not be re-fetched.');
2077
- // Place the fresh task on the board: append it at the project's max+1
2078
- // position (EVERY create see the sequencing note above), plus its
2079
- // epic/sprint when given. The task row is COMMITTED by this point, so a
2080
- // placement failure is surfaced as a warning on the real result —
2081
- // reporting it as a failed create would make the agent retry and
2082
- // duplicate the task (the create_product_idea read-back lesson).
2055
+ // Place the fresh task in its epic/sprint when either was given. The task
2056
+ // row is COMMITTED by this point, so a placement failure is surfaced as a
2057
+ // warning on the real result: reporting it as a failed create would make
2058
+ // the agent retry and duplicate the task (the create_product_idea
2059
+ // read-back lesson).
2083
2060
  let placementWarning;
2084
- try {
2085
- const revision = task.revision;
2086
- if (typeof revision !== 'number' || !Number.isInteger(revision) || revision < 1) {
2087
- throw new Error('the created task carried no usable revision');
2061
+ if (args.epic_id !== undefined || args.sprint_id !== undefined) {
2062
+ try {
2063
+ const revision = task.revision;
2064
+ if (typeof revision !== 'number' || !Number.isInteger(revision) || revision < 1) {
2065
+ throw new Error('the created task carried no usable revision');
2066
+ }
2067
+ await must(client.rpc('save_task_if_current', {
2068
+ p_task_id: taskId,
2069
+ p_expected_revision: revision,
2070
+ p_changes: {
2071
+ ...(args.epic_id !== undefined ? { epic_id: args.epic_id } : {}),
2072
+ ...(args.sprint_id !== undefined ? { sprint_id: args.sprint_id } : {}),
2073
+ },
2074
+ p_tag_ids: null,
2075
+ p_reorder: false,
2076
+ p_before_task_id: null,
2077
+ }));
2078
+ task = (await fetchTask(client, taskId)) ?? task;
2079
+ }
2080
+ catch (err) {
2081
+ placementWarning =
2082
+ `The task was created (id ${taskId}) but placing it on the board (its epic/sprint) failed: ` +
2083
+ `${errorMessage(err)}. Do NOT create the task again; report the failed placement, ` +
2084
+ 'a human can place it from the board.';
2088
2085
  }
2089
- await must(client.rpc('save_task_if_current', {
2090
- p_task_id: taskId,
2091
- p_expected_revision: revision,
2092
- p_changes: {
2093
- position: nextPosition,
2094
- ...(args.epic_id !== undefined ? { epic_id: args.epic_id } : {}),
2095
- ...(args.sprint_id !== undefined ? { sprint_id: args.sprint_id } : {}),
2096
- },
2097
- p_tag_ids: null,
2098
- p_reorder: false,
2099
- p_before_task_id: null,
2100
- }));
2101
- task = (await fetchTask(client, taskId)) ?? task;
2102
- }
2103
- catch (err) {
2104
- placementWarning =
2105
- `The task was created (id ${taskId}) but placing it on the board (its position/epic/sprint) failed: ` +
2106
- `${errorMessage(err)}. Do NOT create the task again — report the failed placement; ` +
2107
- 'a human can place it from the board.';
2108
2086
  }
2109
2087
  // D2a attribution: if this connection has an open session, record that the
2110
2088
  // session produced this NEW task — one cliv2_agent_outputs row referencing the
@@ -664,6 +664,22 @@ export function workBrief(level, responsibility, boundary, workItemId, attachmen
664
664
  'first stage only, and do not start the next stage until the one before it has come',
665
665
  'back. When you send somebody for a stage, put that stage\'s own document into what you',
666
666
  'give them, in full: they cannot read the workflow themselves.',
667
+ /* ═══ 38-panel3-steps: THE STAGES LAND ON THE WORK ITEM, WHERE THE
668
+ PERSON WATCHES. ═══ Without these sentences a panel run follows a
669
+ workflow and the work item's Steps section stays empty: nothing
670
+ starts the workflow on the item and nothing writes a step. The
671
+ owner starts it once, hands each worker the work item, and reads
672
+ the record before sending the next stage, because "the stage is
673
+ finished" is what `up_next` moving on means and nothing else
674
+ enforces the order. */
675
+ 'Before you send anybody for the first stage, call `start_workflow` with the workflow and',
676
+ 'the work item, once: it puts the stages on the work item, where the person watches. Pass',
677
+ '`work_item_id` when you send somebody for a stage. When somebody comes back, call',
678
+ '`list_steps` before you send the next stage. If `up_next` still names the stage you sent',
679
+ 'them for, that stage is not finished: send somebody back for what is left, or ask the',
680
+ 'person. Send the next stage only when `up_next` has moved on. A stage you work yourself',
681
+ 'gets the same record: `list_steps`, then `create_step` for each unit of work, then',
682
+ '`update_step` as you go.',
667
683
  ]
668
684
  : []),
669
685
  'What you have already sent somebody to do is written down, and you are handed that list',
@@ -685,6 +701,25 @@ export function workBrief(level, responsibility, boundary, workItemId, attachmen
685
701
  '',
686
702
  'Somebody else may be working in this same copy of the codebase at the same time as you, on',
687
703
  'a different piece. Stay inside what you were given and leave the rest of it alone.',
704
+ /* ═══ 38-panel3-steps: A WORKER'S STAGE IS WRITTEN DOWN AS STEPS. ═══
705
+ Printed only when a workflow is on the card, like the owner's clause
706
+ above. The wording is the one v2's `start_workflow` reminder already
707
+ uses, which a walked agent follows: decompose before working, tick as
708
+ you go, only done moves the record on. It names no sequencing rule,
709
+ because a worker sends nobody. */
710
+ ...(hasWorkflow
711
+ ? [
712
+ '',
713
+ 'THIS WORK FOLLOWS A WORKFLOW, AND ITS STEPS ARE WRITTEN DOWN WHERE THE PERSON WATCHES.',
714
+ 'Your stage is the first unfinished one on the work item: call `list_steps` with the work',
715
+ 'item id and read `up_next`. Before you do the stage\'s work, record its steps with',
716
+ '`create_step`, one per unit of work, in order. Then `update_step` each to `in_progress`',
717
+ 'when you pick it up and `done` when it is finished; only done lets the next one start. If',
718
+ 'you cannot go on without an answer, `update_step` the step you are on to `blocked` with a',
719
+ '`next_action` saying what you need, then `escalate`, then stop. Never create a second step',
720
+ 'to report on the first.',
721
+ ]
722
+ : []),
688
723
  ]),
689
724
  '',
690
725
  'WHAT YOU OWN',
@@ -127,6 +127,12 @@ import { workBrief } from './prompt.js';
127
127
  import { harness } from './spawn.js';
128
128
  import { listCodebases } from '../codebases.js';
129
129
  import { buildWorkflow, duplicateWorkflow, editWorkflow, readWorkflow, rewordStage } from '../workflows.js';
130
+ /* ═══ 38-panel3-steps: THE FIFTH NEUTRAL MODULE, AND A DECISION LIKE THE OTHERS.
131
+ ═══ Stages and steps are the work item's own record, shared by both
132
+ generations and read by the web's Steps section. `steps.ts` owns the rows so
133
+ nothing here names a `cliv2_` table; conventions.md's enumeration and
134
+ `panel3-isolation.contract.test.mjs` were amended with it. */
135
+ import { STEP_STATUSES, createStep, listSteps, stageById, startWorkflowOnItem, stepById, updateStep, } from '../steps.js';
130
136
  /* ═══ A THIRD GENERATION-NEUTRAL MODULE, AND IT IS A DECISION. ═══
131
137
  conventions.md enumerates what `panel3/` may reach outside itself and says
132
138
  "and nothing else", so this line amends that enumeration rather than slipping
@@ -706,6 +712,32 @@ function zeroBasedExits(exits, stageCount) {
706
712
  // read against the table it came from without hunting. `levels` on each entry is
707
713
  // the whole of the per-level rule; there is no second place where a level is
708
714
  // granted or taken away.
715
+ /** 38-panel3-steps: the work item must be one this card carries. The person's
716
+ * attachments are the authority on what a run may write to, exactly as
717
+ * `attach_image_artifact` reads them before keeping a picture. */
718
+ async function carriedWorkItem(caller, workItemId, consequence, attached) {
719
+ attached ??= await loadAttachments(caller.client, caller.cardId);
720
+ if (!attached.some((item) => item.kind === 'work_item' && item.ref_id === workItemId)) {
721
+ throw new Error(`NOTHING WAS WRITTEN: this card does not carry work item ${workItemId}, so ${consequence}. `
722
+ + 'get_my_brief_and_report and what was attached name the work item you are on.');
723
+ }
724
+ }
725
+ /** The stage a step write may land in: a real row, anchored to a work item this
726
+ * card carries. A stage anchored to a panel request has no Steps section to
727
+ * show it, so it is refused by name and the agent is pointed at the card. */
728
+ async function stageForWriting(caller, stageId, consequence) {
729
+ const stage = await stageById(caller.client, stageId);
730
+ if (!stage) {
731
+ throw new Error(`NOTHING WAS WRITTEN: there is no stage ${stageId}, or it is not yours. list_steps names them.`);
732
+ }
733
+ if (!stage.workItemId) {
734
+ throw new Error(`NOTHING WAS WRITTEN: stage ${stageId} belongs to a panel request rather than a work item, and `
735
+ + 'steps are a WORK ITEM\'s record. What you are doing already goes on the card: call '
736
+ + 'report_activity. If this work deserves a work item of its own, ask for one.');
737
+ }
738
+ await carriedWorkItem(caller, stage.workItemId, consequence);
739
+ return stage;
740
+ }
709
741
  const TOOLS = [
710
742
  // ── Read the record ──────────────────────────────────────────────────────
711
743
  {
@@ -1253,6 +1285,139 @@ const TOOLS = [
1253
1285
  + 'as the original, so reword_stage on either rewords both.';
1254
1286
  },
1255
1287
  },
1288
+ // ── Steps: the work item's own record of a workflow run ──────────────────
1289
+ /* ═══ 38-panel3-steps: A PANEL RUN THAT FOLLOWS A WORKFLOW WRITES THE WORK
1290
+ ITEM'S STEPS. ═══ The Steps section, its tables, its live refresh and the
1291
+ board card's progress chip all existed; what did not exist was a writer on
1292
+ this side. v2's own step tools refuse anything but a v2 session and gate on
1293
+ v2's scope contract, which a panel run never has, so these four are served
1294
+ here over the neutral module.
1295
+
1296
+ THE GATE IS THE CARD'S ATTACHMENTS, exactly as `attach_image_artifact`
1297
+ gates a picture: the person pointed this conversation at a work item, and
1298
+ that row is the authority on what a run may write to. A stage anchored to
1299
+ a panel request rather than a work item is refused outright, because the
1300
+ Steps section that would show it lives on a work item (decision 1 on the
1301
+ work item's spec). Provenance is copied from the stage by the module, never
1302
+ chosen here.
1303
+
1304
+ WHO HOLDS WHAT. `start_workflow` is the owner's, like `get_workflow`: only
1305
+ level 2 sees the attachment list and reads the process. The three that
1306
+ write and read steps are levels 2 and 3, because a worker owns one stage
1307
+ and the owner may work a record-only stage itself. Level 1 launches and
1308
+ exits and holds none of them. */
1309
+ {
1310
+ name: 'start_workflow',
1311
+ levels: [2],
1312
+ description: 'Put the attached workflow\'s stages on the attached work item, once, before you send anybody '
1313
+ + 'for the first stage. The person watches progress on the work item, so a workflow that is '
1314
+ + 'not started there is a workflow they cannot see. Call it again and nothing is written twice.',
1315
+ input: {
1316
+ workflow_id: z.string().uuid().describe('The id printed beside the workflow in what was attached.'),
1317
+ work_item_id: z.string().uuid().describe('The id printed beside the work item in what was attached.'),
1318
+ },
1319
+ handler: async (caller, args) => {
1320
+ const { workflow_id, work_item_id } = args;
1321
+ const attached = await loadAttachments(caller.client, caller.cardId);
1322
+ if (!attached.some((item) => item.kind === 'workflow' && item.ref_id === workflow_id)) {
1323
+ throw new Error(`NOTHING WAS WRITTEN: this card does not carry workflow ${workflow_id}. Use the id printed `
1324
+ + 'beside the workflow in what was attached.');
1325
+ }
1326
+ await carriedWorkItem(caller, work_item_id, 'a workflow cannot be started on it', attached);
1327
+ const workflow = await readWorkflow(caller.client, workflow_id);
1328
+ const result = await startWorkflowOnItem(caller.client, work_item_id, workflow_id);
1329
+ return result.started
1330
+ ? `The ${result.stages} stages of ${workflow.name} are on work item ${work_item_id}. Work them in `
1331
+ + 'order: send somebody for stage 1, and nobody for stage 2 until list_steps shows stage 1\'s '
1332
+ + 'steps are all done.'
1333
+ : `${workflow.name} is already on work item ${work_item_id} (${result.stages} stages); nothing was `
1334
+ + 'written twice. Call list_steps to see where it is.';
1335
+ },
1336
+ },
1337
+ {
1338
+ name: 'list_steps',
1339
+ levels: [2, 3],
1340
+ description: 'The stages on a work item, the steps recorded inside each, and UP NEXT: the one thing that '
1341
+ + 'should happen next, computed by the product and read by the person too. "decompose" means '
1342
+ + 'the named stage has no steps yet and writing them is the next action; "step" names the one '
1343
+ + 'to work; "none" means nothing is waiting. Read it before you record or send anything.',
1344
+ input: {
1345
+ work_item_id: z.string().uuid().describe('The work item, from what was attached or your brief.'),
1346
+ },
1347
+ handler: async (caller, args) => {
1348
+ const { work_item_id } = args;
1349
+ await carriedWorkItem(caller, work_item_id, 'its steps cannot be read from here');
1350
+ const spine = await listSteps(caller.client, work_item_id);
1351
+ if (spine.stages.length === 0) {
1352
+ return `Work item ${work_item_id} has no stages. Nothing has been started on it.`;
1353
+ }
1354
+ return [
1355
+ `STAGES ${spine.stages.length} on work item ${work_item_id}`,
1356
+ ...spine.stages.flatMap((stage, i) => {
1357
+ const done = stage.steps.filter((step) => step.status === 'done').length;
1358
+ const summary = stage.steps.length === 0 ? 'no steps yet' : `${done} of ${stage.steps.length} done`;
1359
+ return [
1360
+ '',
1361
+ `${i + 1}. ${stage.title} id ${stage.id} ${stage.sourceKind} · ${stage.sourceLabel} ${summary}`,
1362
+ ...stage.steps.map((step, j) => ` ${i + 1}.${j + 1} ${step.title} [${step.status}] id ${step.id}\n`
1363
+ + ` next: ${step.nextAction}`),
1364
+ ];
1365
+ }),
1366
+ '',
1367
+ 'UP NEXT',
1368
+ JSON.stringify(spine.upNext, null, 2),
1369
+ ].join('\n');
1370
+ },
1371
+ },
1372
+ {
1373
+ name: 'create_step',
1374
+ levels: [2, 3],
1375
+ description: 'Record one step inside a stage of the work item, before you do it. A step is a resumable unit '
1376
+ + 'of work: its charter says why it exists and what done looks like, its next action is the one '
1377
+ + 'literal thing to do first, and its position orders it inside the stage. Record every step '
1378
+ + 'of a stage before working the first; the person reads these as the run moves. '
1379
+ + FIREWALL_WRITING_RULE,
1380
+ input: {
1381
+ stage_id: z.string().uuid().describe('The stage this step belongs to, from list_steps.'),
1382
+ title: z.string().min(1).describe('A few words, as the person will read them in the list.'),
1383
+ charter: z.string().min(1).describe('Why the step exists and how you would know it is done.'),
1384
+ next_action: z.string().min(1).describe('The one literal thing to do first. Repo-relative paths only.'),
1385
+ position: z.number().int().describe('Order inside the stage, from 1.'),
1386
+ },
1387
+ handler: async (caller, args) => {
1388
+ const { stage_id, title, charter, next_action, position } = args;
1389
+ const stage = await stageForWriting(caller, stage_id, 'a step cannot be recorded under it');
1390
+ const { id } = await createStep(caller.client, stage, { title, charter, nextAction: next_action, position });
1391
+ return `Recorded step ${position} "${title}" under ${stage.title}, id ${id}, status pending. Call `
1392
+ + 'update_step with status in_progress when you pick it up.';
1393
+ },
1394
+ },
1395
+ {
1396
+ name: 'update_step',
1397
+ levels: [2, 3],
1398
+ description: 'Change a step you or somebody else recorded: in_progress when you pick it up, done when it is '
1399
+ + 'finished, blocked with a next_action naming what you need when you cannot go on. Rewrite '
1400
+ + 'next_action as it changes. Only done lets the next step start. A step\'s stage, position and '
1401
+ + 'source cannot change; nothing is written when nothing would change. '
1402
+ + FIREWALL_WRITING_RULE,
1403
+ input: {
1404
+ step_id: z.string().uuid().describe('The step, from list_steps or create_step.'),
1405
+ title: z.string().optional(),
1406
+ charter: z.string().optional(),
1407
+ next_action: z.string().optional(),
1408
+ status: z.enum(STEP_STATUSES).optional(),
1409
+ },
1410
+ handler: async (caller, args) => {
1411
+ const { step_id, title, charter, next_action, status } = args;
1412
+ const step = await stepById(caller.client, step_id);
1413
+ if (!step) {
1414
+ throw new Error(`NOTHING WAS WRITTEN: there is no step ${step_id}, or it is not yours. list_steps names them.`);
1415
+ }
1416
+ await stageForWriting(caller, step.stageId, 'its step cannot be changed');
1417
+ const result = await updateStep(caller.client, step, { title, charter, nextAction: next_action, status });
1418
+ return `Updated ${result.updated.join(', ')} on "${title ?? step.title}"; status is now ${status ?? step.status}.`;
1419
+ },
1420
+ },
1256
1421
  {
1257
1422
  name: 'get_credential',
1258
1423
  /* ═══ LEVELS 2 AND 3, AND THAT IS A CORRECTION TO ux.md's OWN TABLE. ═══
package/dist/steps.js ADDED
@@ -0,0 +1,168 @@
1
+ import { absolutePathToken } from './local-paths.js';
2
+ export const STEP_STATUSES = ['pending', 'in_progress', 'blocked', 'interrupted', 'done'];
3
+ const STAGE_COLUMNS = 'id, work_item_id, title, position, source_kind, source_label, source_ref, source_workflow_ref';
4
+ const STEP_COLUMNS = 'id, stage_id, title, charter, next_action, status, position, source_kind, source_label, source_ref';
5
+ async function read(query, subject) {
6
+ const { data, error } = await query;
7
+ if (error)
8
+ throw new Error(`could not read ${subject}: ${error.message}`);
9
+ return data ?? [];
10
+ }
11
+ const stageOf = (row) => ({
12
+ id: row.id,
13
+ workItemId: row.work_item_id,
14
+ title: row.title,
15
+ position: row.position,
16
+ sourceKind: row.source_kind,
17
+ sourceLabel: row.source_label,
18
+ sourceRef: row.source_ref,
19
+ sourceWorkflowRef: row.source_workflow_ref,
20
+ });
21
+ const stepOf = (row) => ({
22
+ id: row.id,
23
+ stageId: row.stage_id,
24
+ title: row.title,
25
+ charter: row.charter,
26
+ nextAction: row.next_action,
27
+ status: row.status,
28
+ position: row.position,
29
+ sourceKind: row.source_kind,
30
+ sourceLabel: row.source_label,
31
+ sourceRef: row.source_ref,
32
+ });
33
+ /** Refuse prose that carries an absolute local path, before it is written. */
34
+ function sweep(action, prose) {
35
+ for (const [field, value] of prose) {
36
+ const token = value === undefined ? null : absolutePathToken(value);
37
+ if (token) {
38
+ throw new Error(`could not ${action}: ${field} contains an absolute local path ("${token}"). It is rendered `
39
+ + 'by hosted browser JS and must never leave this machine; name files repo-relative. '
40
+ + 'Nothing was written.');
41
+ }
42
+ }
43
+ }
44
+ /** The stages on one work item, in the order they are worked. */
45
+ export async function stagesOnItem(client, workItemId) {
46
+ const rows = await read(client.from('cliv2_stages').select(STAGE_COLUMNS).eq('work_item_id', workItemId)
47
+ .order('position', { ascending: true }).order('created_at', { ascending: true }), `the stages of work item ${workItemId}`);
48
+ return rows.map(stageOf);
49
+ }
50
+ /** One stage by id, or null when there is no such row or it is not the caller's. */
51
+ export async function stageById(client, stageId) {
52
+ const rows = await read(client.from('cliv2_stages').select(STAGE_COLUMNS).eq('id', stageId), `stage ${stageId}`);
53
+ return rows.length === 0 ? null : stageOf(rows[0]);
54
+ }
55
+ /** One step by id, or null when there is no such row or it is not the caller's. */
56
+ export async function stepById(client, stepId) {
57
+ const rows = await read(client.from('cliv2_steps').select(STEP_COLUMNS).eq('id', stepId), `step ${stepId}`);
58
+ return rows.length === 0 ? null : stepOf(rows[0]);
59
+ }
60
+ /**
61
+ * Put a workflow's stages on a work item, once.
62
+ *
63
+ * `cliv2_start_workflow` is the one guard: it refuses a workflow already on
64
+ * the item, atomically, with "already started". That refusal is turned into a
65
+ * calm `{ started: false }` here, because an owner restarted at a stage
66
+ * boundary calls this again and must not read its own earlier success as a
67
+ * failure. Any other refusal comes back verbatim.
68
+ */
69
+ export async function startWorkflowOnItem(client, workItemId, workflowId) {
70
+ const { data, error } = await client.rpc('cliv2_start_workflow', {
71
+ p_work_item_id: workItemId,
72
+ p_workflow_id: workflowId,
73
+ });
74
+ if (error) {
75
+ if (/already started/i.test(error.message)) {
76
+ const existing = (await stagesOnItem(client, workItemId))
77
+ .filter((stage) => stage.sourceWorkflowRef === workflowId);
78
+ return { started: false, stages: existing.length };
79
+ }
80
+ throw new Error(`could not start workflow ${workflowId} on work item ${workItemId}: ${error.message}. `
81
+ + 'Nothing was written.');
82
+ }
83
+ return { started: true, stages: Number(data) };
84
+ }
85
+ /** The whole spine of one work item plus the one shared "up next". */
86
+ export async function listSteps(client, workItemId) {
87
+ const stages = await stagesOnItem(client, workItemId);
88
+ const steps = stages.length === 0 ? [] : await read(client.from('cliv2_steps').select(STEP_COLUMNS).in('stage_id', stages.map((stage) => stage.id))
89
+ .order('position', { ascending: true }).order('created_at', { ascending: true }), `the steps of work item ${workItemId}`);
90
+ const { data: upNext, error } = await client.rpc('cliv2_steps_up_next', { p_work_item_id: workItemId });
91
+ if (error)
92
+ throw new Error(`could not read what is up next on work item ${workItemId}: ${error.message}`);
93
+ return {
94
+ stages: stages.map((stage) => ({
95
+ ...stage,
96
+ steps: steps.filter((row) => row.stage_id === stage.id).map(stepOf),
97
+ })),
98
+ upNext,
99
+ };
100
+ }
101
+ /**
102
+ * Record one step inside a stage. Provenance is COPIED FROM THE STAGE, never
103
+ * chosen by the caller: a step under a workflow stage says which workflow, and
104
+ * a step under a plan stage says which plan, and an agent cannot mislabel one.
105
+ */
106
+ export async function createStep(client, stage, input) {
107
+ const action = `record the step "${input.title}"`;
108
+ for (const [field, value] of [['title', input.title], ['charter', input.charter], ['next_action', input.nextAction]]) {
109
+ if (value.trim() === '')
110
+ throw new Error(`could not ${action}: ${field} is blank. Nothing was written.`);
111
+ }
112
+ if (!Number.isInteger(input.position)) {
113
+ throw new Error(`could not ${action}: position must be a whole number. Nothing was written.`);
114
+ }
115
+ sweep(action, [['title', input.title], ['charter', input.charter], ['next_action', input.nextAction]]);
116
+ const rows = await read(client.from('cliv2_steps').insert({
117
+ stage_id: stage.id,
118
+ title: input.title,
119
+ charter: input.charter,
120
+ next_action: input.nextAction,
121
+ position: input.position,
122
+ source_kind: stage.sourceKind,
123
+ source_label: stage.sourceLabel,
124
+ source_ref: stage.sourceRef,
125
+ }).select('id'), `the step just written under stage ${stage.id}`);
126
+ if (rows.length === 0)
127
+ throw new Error(`could not ${action}: the write returned no row.`);
128
+ return { id: rows[0].id };
129
+ }
130
+ /**
131
+ * Change a step's living fields. The database grants update on exactly these
132
+ * four columns, so a stage, position or provenance change is refused there
133
+ * whatever a caller sends; this refuses the two things the database cannot see:
134
+ * a call that changes nothing, and a blank string offered as a way to clear.
135
+ */
136
+ export async function updateStep(client, step, changes) {
137
+ const action = `update the step "${step.title}"`;
138
+ const patch = {};
139
+ const updated = [];
140
+ const consider = (field, next, current) => {
141
+ if (next === undefined)
142
+ return;
143
+ if (next.trim() === '') {
144
+ throw new Error(`could not ${action}: ${field} is blank. A field cannot be cleared; nothing was written.`);
145
+ }
146
+ if (next === current)
147
+ return;
148
+ patch[field] = next;
149
+ updated.push(field);
150
+ };
151
+ if (changes.status !== undefined && !STEP_STATUSES.includes(changes.status)) {
152
+ throw new Error(`could not ${action}: "${changes.status}" is not a status. Use one of ${STEP_STATUSES.join(', ')}. `
153
+ + 'Nothing was written.');
154
+ }
155
+ consider('title', changes.title, step.title);
156
+ consider('charter', changes.charter, step.charter);
157
+ consider('next_action', changes.nextAction, step.nextAction);
158
+ consider('status', changes.status, step.status);
159
+ if (updated.length === 0) {
160
+ throw new Error(`could not ${action}: nothing would change. Nothing was written.`);
161
+ }
162
+ sweep(action, [['title', changes.title], ['charter', changes.charter], ['next_action', changes.nextAction]]);
163
+ const rows = await read(client.from('cliv2_steps').update(patch).eq('id', step.id).select('id'), `the step just updated (${step.id})`);
164
+ if (rows.length === 0) {
165
+ throw new Error(`could not ${action}: the update matched no row, so nothing was written.`);
166
+ }
167
+ return { id: step.id, updated: updated.sort() };
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"