@ctrl-spc/cs 0.7.4 → 0.7.6
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/daemon-lock.js +48 -0
- package/dist/daemon.js +11 -0
- package/dist/mcp.js +31 -53
- package/dist/panel3/prompt.js +35 -0
- package/dist/panel3/run.js +26 -2
- package/dist/panel3/tools.js +165 -0
- package/dist/steps.js +168 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
2023
|
-
*
|
|
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
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2080
|
-
//
|
|
2081
|
-
//
|
|
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
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
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
|
package/dist/panel3/prompt.js
CHANGED
|
@@ -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',
|
package/dist/panel3/run.js
CHANGED
|
@@ -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 = [];
|
|
@@ -2847,6 +2856,8 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
|
|
|
2847
2856
|
* normalised into a constant getter below, so there is one shape inside `run`
|
|
2848
2857
|
* and no branch anywhere near the poll.
|
|
2849
2858
|
*/
|
|
2859
|
+
/** The card states in which nobody will start again on the card's copy. */
|
|
2860
|
+
const CARD_IS_OVER = ['done', 'failed', 'stopped'];
|
|
2850
2861
|
/**
|
|
2851
2862
|
* ═══ THE CARD IS OVER, SO ITS COPY GOES — AND THE BRANCH STAYS. ═══
|
|
2852
2863
|
*
|
|
@@ -2865,7 +2876,7 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
|
|
|
2865
2876
|
* A folder no row explains is LEFT ALONE. Nothing here deletes something it
|
|
2866
2877
|
* cannot account for.
|
|
2867
2878
|
*/
|
|
2868
|
-
async function sweepFinishedWorktrees(client) {
|
|
2879
|
+
export async function sweepFinishedWorktrees(client) {
|
|
2869
2880
|
const held = worktreesOnThisMachine();
|
|
2870
2881
|
if (held.length === 0)
|
|
2871
2882
|
return;
|
|
@@ -2889,6 +2900,17 @@ async function sweepFinishedWorktrees(client) {
|
|
|
2889
2900
|
const cards = [...new Set([...cardOfFolder.values()].flat())];
|
|
2890
2901
|
if (cards.length === 0)
|
|
2891
2902
|
return;
|
|
2903
|
+
/* ═══ THE CARD IS ASKED, NOT ONLY ITS RUNS. ═══ A conversational owner is
|
|
2904
|
+
parked between wakes with `ended_at` set, so the second its last worker
|
|
2905
|
+
finishes, no run on the card is open, while the card itself still reads
|
|
2906
|
+
`working`. Reading the runs alone called that card finished and took the
|
|
2907
|
+
copy out from under the owner as it was starting
|
|
2908
|
+
(.bugs/.resolved/20260905-card-stuck-not-a-working-copy). The card's own
|
|
2909
|
+
state is the record's word for whether it is over; the runs still guard a
|
|
2910
|
+
card that is over on paper while a process of it is going. */
|
|
2911
|
+
const over = new Set((await returned(client.from('panel3_cards')
|
|
2912
|
+
.select('id, state')
|
|
2913
|
+
.in('id', cards), 'read', 'which of those cards are over')).filter((row) => CARD_IS_OVER.includes(row.state)).map((row) => row.id));
|
|
2892
2914
|
const live = new Set((await returned(client.from('panel3_runs')
|
|
2893
2915
|
.select('card_id')
|
|
2894
2916
|
.in('card_id', cards)
|
|
@@ -2896,7 +2918,9 @@ async function sweepFinishedWorktrees(client) {
|
|
|
2896
2918
|
const touched = new Set();
|
|
2897
2919
|
for (const one of held) {
|
|
2898
2920
|
const cardIds = cardOfFolder.get(one.folder) ?? [];
|
|
2899
|
-
if (cardIds.length === 0
|
|
2921
|
+
if (cardIds.length === 0)
|
|
2922
|
+
continue;
|
|
2923
|
+
if (!cardIds.every((cardId) => over.has(cardId) && !live.has(cardId)))
|
|
2900
2924
|
continue;
|
|
2901
2925
|
try {
|
|
2902
2926
|
settleCardWorktree(one.folder);
|
package/dist/panel3/tools.js
CHANGED
|
@@ -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