@ctrl-spc/cs 0.7.10 → 0.7.12
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/codebases.js +26 -0
- package/dist/mcp.js +94 -357
- package/dist/panel3/checkout.js +9 -32
- package/dist/panel3/codex-models.js +96 -0
- package/dist/panel3/coordinator.js +15 -0
- package/dist/panel3/prompt.js +28 -8
- package/dist/panel3/run.js +163 -253
- package/dist/panel3/spawn.js +24 -15
- package/dist/panel3/tools.js +207 -58
- package/dist/product-tools.js +533 -0
- package/dist/workflow-tool-mentions.js +12 -0
- package/dist/workflows.js +21 -7
- package/package.json +2 -2
package/dist/product-tools.js
CHANGED
|
@@ -76,6 +76,83 @@ export async function placeWorkItemHandler(client, args) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
79
|
+
export async function listArtifactFoldersHandler(client, workItemId) {
|
|
80
|
+
try {
|
|
81
|
+
if (!UUID_RE.test(workItemId))
|
|
82
|
+
return errorResult('Use a valid work item ID.');
|
|
83
|
+
const task = await must(client.from('tasks').select('id').eq('id', workItemId).maybeSingle());
|
|
84
|
+
if (!task)
|
|
85
|
+
return errorResult('Work item not found or inaccessible.');
|
|
86
|
+
const artifacts = await must(client.from('artifacts')
|
|
87
|
+
.select('folder_name').eq('task_id', workItemId).is('deleted_at', null));
|
|
88
|
+
const counts = new Map();
|
|
89
|
+
for (const row of artifacts ?? [])
|
|
90
|
+
if (row.folder_name)
|
|
91
|
+
counts.set(row.folder_name, (counts.get(row.folder_name) ?? 0) + 1);
|
|
92
|
+
return textResult({ work_item_id: workItemId, folders: [...counts].sort(([a], [b]) => a.localeCompare(b))
|
|
93
|
+
.map(([name, artifact_count]) => ({ name, artifact_count })), unfiled_count: (artifacts ?? []).filter(row => !row.folder_name).length });
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return errorResult(`Could not list artifact folders: ${errorMessage(error)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export async function setArtifactFolderHandler(client, artifactId, folderName) {
|
|
100
|
+
try {
|
|
101
|
+
if (!UUID_RE.test(artifactId))
|
|
102
|
+
return errorResult('Use a valid artifact ID.');
|
|
103
|
+
if (folderName !== null && (typeof folderName !== 'string' || folderName.length > 80))
|
|
104
|
+
return errorResult('Folder names must be 80 characters or fewer; use null for Unfiled.');
|
|
105
|
+
const artifact = await must(client.from('artifacts').update({ folder_name: folderName })
|
|
106
|
+
.eq('id', artifactId).is('deleted_at', null).select('id,task_id,title,folder_name,revision').maybeSingle());
|
|
107
|
+
if (!artifact)
|
|
108
|
+
return errorResult('Artifact not found or you do not have permission to move it.');
|
|
109
|
+
return textResult(artifact);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return errorResult(`Could not set artifact folder: ${errorMessage(error)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export async function workItemDependencyHandler(client, action, args) {
|
|
116
|
+
try {
|
|
117
|
+
const id = args.work_item_id, blockerId = args.depends_on_work_item_id;
|
|
118
|
+
if (!UUID_RE.test(id) || (action !== 'list' && (!blockerId || !UUID_RE.test(blockerId)))) {
|
|
119
|
+
return errorResult('Use valid work item IDs from list_tasks. No dependency was changed.');
|
|
120
|
+
}
|
|
121
|
+
const task = await must(client.from('tasks').select('id,project_id,archived_at,is_idea').eq('id', id).maybeSingle());
|
|
122
|
+
if (!task)
|
|
123
|
+
return errorResult('Work item not found or inaccessible. No dependency was changed.');
|
|
124
|
+
if (action === 'add') {
|
|
125
|
+
const blocker = await must(client.from('tasks').select('id,project_id,archived_at,is_idea').eq('id', blockerId).maybeSingle());
|
|
126
|
+
if (!blocker || blocker.project_id !== task.project_id)
|
|
127
|
+
return errorResult('Dependencies must connect accessible work items in the same project.');
|
|
128
|
+
if (task.archived_at || blocker.archived_at || task.is_idea || blocker.is_idea)
|
|
129
|
+
return errorResult('Choose active work items, not archived items or Product Ideas.');
|
|
130
|
+
if (id === blockerId)
|
|
131
|
+
return errorResult('A work item cannot depend on itself.');
|
|
132
|
+
const { error } = await client.from('task_dependencies').insert({ task_id: id, depends_on_task_id: blockerId });
|
|
133
|
+
if (error && error.code !== '23505')
|
|
134
|
+
throw new Error(error.message);
|
|
135
|
+
}
|
|
136
|
+
else if (action === 'remove') {
|
|
137
|
+
await must(client.from('task_dependencies').delete().match({ task_id: id, depends_on_task_id: blockerId }));
|
|
138
|
+
}
|
|
139
|
+
const [blockedBy, blocks] = await Promise.all([
|
|
140
|
+
must(client.from('task_dependencies')
|
|
141
|
+
.select('blocker:tasks!task_dependencies_depends_on_task_id_fkey(id,name,status)').eq('task_id', id)),
|
|
142
|
+
must(client.from('task_dependencies')
|
|
143
|
+
.select('dependent:tasks!task_dependencies_task_id_fkey(id,name,status)').eq('depends_on_task_id', id)),
|
|
144
|
+
]);
|
|
145
|
+
if (action === 'remove' && blockedBy?.some(row => row.blocker.id === blockerId)) {
|
|
146
|
+
return errorResult('You do not have permission to remove this dependency.');
|
|
147
|
+
}
|
|
148
|
+
return textResult({ work_item_id: id, blocked_by: (blockedBy ?? []).map(row => row.blocker),
|
|
149
|
+
blocks: (blocks ?? []).map(row => row.dependent),
|
|
150
|
+
waiting: (blockedBy ?? []).some(row => row.blocker.status !== 'done') });
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
return errorResult(`Could not ${action} work item dependency: ${errorMessage(error)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
79
156
|
export async function resolveFeedbackHandler(client, session, args) {
|
|
80
157
|
try {
|
|
81
158
|
if (!session) {
|
|
@@ -440,3 +517,459 @@ export function canonicalArgsHash(args) {
|
|
|
440
517
|
};
|
|
441
518
|
return createHash('sha256').update(JSON.stringify(canonical(args))).digest('hex');
|
|
442
519
|
}
|
|
520
|
+
/* ---------------------------------------------------------------------------
|
|
521
|
+
* Agile skeleton (feature 32, Slice 1) — `create_epic` / `create_sprint`,
|
|
522
|
+
* beside the grown `create_task` above.
|
|
523
|
+
*
|
|
524
|
+
* The agent lays out the WHOLE skeleton from one feature ask: an epic (what
|
|
525
|
+
* for), work items under it, sprints (ordered batches). Step −1 rulings
|
|
526
|
+
* (ux.md, 2026-07-30): DIRECT WRITE as the user under RLS — a skeleton of
|
|
527
|
+
* backlog items is un-started work, closer to an idea than an edit, so no
|
|
528
|
+
* propose queue; SMALL ORTHOGONAL TOOLS, not a composite draft pass;
|
|
529
|
+
* IDEMPOTENCE = read-before-write plus a duplicate-name refusal that teaches
|
|
530
|
+
* extending the existing structure instead of minting a parallel one.
|
|
531
|
+
*
|
|
532
|
+
* THE CALIBRATION RULE LIVES IN THE DESCRIPTIONS (teach-in-the-description
|
|
533
|
+
* doctrine): agents dramatically overestimate how long building takes because
|
|
534
|
+
* their training is saturated with human engineering timelines. The
|
|
535
|
+
* descriptions state the bias outright, size sprints as ordered batches of
|
|
536
|
+
* agent-executable work, and forbid duration estimates on items unless the
|
|
537
|
+
* user asked. Nothing here stores an estimate — there is no column for one,
|
|
538
|
+
* deliberately.
|
|
539
|
+
*
|
|
540
|
+
* GRANTS, verified against the seeded rows rather than assumed: members hold
|
|
541
|
+
* ('member','epic','create') (20260727080000 §4) and ('member','sprint',
|
|
542
|
+
* 'create') (20260708000000 init.sql §9), and no later migration revokes
|
|
543
|
+
* either — so the epics_insert / sprints_insert RLS policies already admit
|
|
544
|
+
* every org member and NO new grants migration is needed. A denial for some
|
|
545
|
+
* future role surfaces through the normal error path.
|
|
546
|
+
*
|
|
547
|
+
* NO cliv2_agent_outputs attribution row is written for an epic or a sprint:
|
|
548
|
+
* the kind check is ('artifact','comment','task','decision') and widening it
|
|
549
|
+
* is a migration this slice does not need — the skeleton's provenance is
|
|
550
|
+
* legible through its work items, which create_task already attributes.
|
|
551
|
+
* ------------------------------------------------------------------------- */
|
|
552
|
+
/** Date args are refused by shape HERE (the resolveClient idiom) so a
|
|
553
|
+
* malformed date gets a clean tool error instead of a Postgres cast failure
|
|
554
|
+
* after other work happened. */
|
|
555
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
556
|
+
/** The project a skeleton tool writes into, resolved by id: UUID-shape-checked
|
|
557
|
+
* before the read, and a miss (no such project, or one outside the caller's
|
|
558
|
+
* orgs — RLS makes those indistinguishable on purpose) refused by id. */
|
|
559
|
+
async function resolveSkeletonProject(client, projectIdArg, tool) {
|
|
560
|
+
const projectId = typeof projectIdArg === 'string' ? projectIdArg.trim() : '';
|
|
561
|
+
if (!projectId || !UUID_RE.test(projectId)) {
|
|
562
|
+
return {
|
|
563
|
+
ok: false,
|
|
564
|
+
error: errorResult(`${tool}: "${projectId}" is not a project id. Call list_tasks to see your projects and their ids. ` +
|
|
565
|
+
'Nothing was created.'),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const row = await must(client.from('projects').select('id, name').eq('id', projectId).maybeSingle());
|
|
569
|
+
if (!row) {
|
|
570
|
+
return {
|
|
571
|
+
ok: false,
|
|
572
|
+
error: errorResult(`${tool}: no project found for id "${projectId}". Nothing was created.`),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
return { ok: true, value: row };
|
|
576
|
+
}
|
|
577
|
+
/** Refuse a malformed date arg by name; `undefined` passes (the arg is
|
|
578
|
+
* optional everywhere it appears). */
|
|
579
|
+
function validDateArg(tool, field, value) {
|
|
580
|
+
if (value === undefined)
|
|
581
|
+
return null;
|
|
582
|
+
if (!ISO_DATE_RE.test(value)) {
|
|
583
|
+
return errorResult(`${tool}: ${field} must be an ISO date (YYYY-MM-DD), got "${value}". Nothing was created.`);
|
|
584
|
+
}
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Create an Epic in a project — writes public.epics as the user (RLS
|
|
589
|
+
* epics_insert, the 'epic'/'create' grant every member holds).
|
|
590
|
+
*
|
|
591
|
+
* THE DUPLICATE-NAME REFUSAL IS THE IDEMPOTENCE MECHANISM (Step −1 ruling 4):
|
|
592
|
+
* a re-run, or a "feature ABC v2" ask, must EXTEND the existing epic, not
|
|
593
|
+
* mint a second one — 24c's updating-beats-creating rule applied to
|
|
594
|
+
* structure. The match is case-insensitive on the trimmed name, against LIVE
|
|
595
|
+
* epics only: an archived epic has left the picker and can take no new work,
|
|
596
|
+
* so a fresh epic re-using its name is a legitimate new start, not a
|
|
597
|
+
* duplicate. The refusal lists the project's live epics so it doubles as the
|
|
598
|
+
* read surface the skeleton pass otherwise lacks (no epic-listing tool ships
|
|
599
|
+
* in this slice).
|
|
600
|
+
*/
|
|
601
|
+
export async function createEpicHandler(client, args) {
|
|
602
|
+
try {
|
|
603
|
+
const project = await resolveSkeletonProject(client, args.project_id, 'create_epic');
|
|
604
|
+
if (!project.ok)
|
|
605
|
+
return project.error;
|
|
606
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
607
|
+
if (!name)
|
|
608
|
+
return errorResult('create_epic requires a non-empty name. Nothing was created.');
|
|
609
|
+
for (const [field, value] of [
|
|
610
|
+
['start_date', args.start_date],
|
|
611
|
+
['target_date', args.target_date],
|
|
612
|
+
]) {
|
|
613
|
+
const refused = validDateArg('create_epic', field, value);
|
|
614
|
+
if (refused)
|
|
615
|
+
return refused;
|
|
616
|
+
}
|
|
617
|
+
// Mirrors the DB's epics_target_not_before_start check, refused where the
|
|
618
|
+
// agent can fix it instead of as a constraint violation.
|
|
619
|
+
if (args.start_date && args.target_date && args.target_date < args.start_date) {
|
|
620
|
+
return errorResult(`create_epic: target_date (${args.target_date}) is before start_date (${args.start_date}). ` +
|
|
621
|
+
'Nothing was created.');
|
|
622
|
+
}
|
|
623
|
+
const epics = (await must(client.from('epics').select('id, name, archived_at').eq('project_id', project.value.id))) ?? [];
|
|
624
|
+
const live = epics.filter((epic) => epic.archived_at === null);
|
|
625
|
+
const duplicate = live.find((epic) => epic.name.trim().toLowerCase() === name.toLowerCase());
|
|
626
|
+
if (duplicate) {
|
|
627
|
+
const listing = live.map((epic) => `"${epic.name}" (${epic.id})`).join(', ');
|
|
628
|
+
return errorResult(`create_epic: an epic named "${duplicate.name}" already exists in "${project.value.name}" ` +
|
|
629
|
+
`(id ${duplicate.id}). Do not create a duplicate — EXTEND the existing epic: create the new ` +
|
|
630
|
+
`work items with epic_id ${duplicate.id}, and reshape what already hangs under it. A re-run ` +
|
|
631
|
+
'or a "v2" ask extends the existing skeleton; it never mints a second one. Live epics in ' +
|
|
632
|
+
`this project: ${listing}. Nothing was created.`);
|
|
633
|
+
}
|
|
634
|
+
const epic = await must(client
|
|
635
|
+
.from('epics')
|
|
636
|
+
.insert({
|
|
637
|
+
project_id: project.value.id,
|
|
638
|
+
name,
|
|
639
|
+
...(args.description?.trim() ? { description: args.description.trim() } : {}),
|
|
640
|
+
...(args.start_date ? { start_date: args.start_date } : {}),
|
|
641
|
+
...(args.target_date ? { target_date: args.target_date } : {}),
|
|
642
|
+
})
|
|
643
|
+
.select('id, project_id, name, description, start_date, target_date, created_at')
|
|
644
|
+
.single());
|
|
645
|
+
if (!epic)
|
|
646
|
+
throw new Error('Epic insert returned no row.');
|
|
647
|
+
return textResult({
|
|
648
|
+
epic,
|
|
649
|
+
note: 'Created as an empty epic — no work has started and none is declared. Place work items under ' +
|
|
650
|
+
"it with create_task's epic_id, sequence them with create_sprint + sprint_id, and add NO " +
|
|
651
|
+
'duration estimates to anything unless the user asked: sequencing is the value.',
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
return errorResult(`create_epic failed: ${errorMessage(err)}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Create a Sprint in a project — writes public.sprints as the user (RLS
|
|
660
|
+
* sprints_insert, the 'sprint'/'create' grant every member holds). Dates are
|
|
661
|
+
* optional (28 made the columns nullable): for agent-executed work a sprint is
|
|
662
|
+
* an ORDERED BATCH, not a time-box, and an invented fortnight is exactly the
|
|
663
|
+
* human-timeline bias this feature exists to counter.
|
|
664
|
+
*
|
|
665
|
+
* Same duplicate-name posture as create_epic, for the same reason: a re-run
|
|
666
|
+
* must extend the existing sprint sequence, never mint a parallel one.
|
|
667
|
+
*/
|
|
668
|
+
export async function createSprintHandler(client, args) {
|
|
669
|
+
try {
|
|
670
|
+
const project = await resolveSkeletonProject(client, args.project_id, 'create_sprint');
|
|
671
|
+
if (!project.ok)
|
|
672
|
+
return project.error;
|
|
673
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
674
|
+
if (!name)
|
|
675
|
+
return errorResult('create_sprint requires a non-empty name. Nothing was created.');
|
|
676
|
+
for (const [field, value] of [
|
|
677
|
+
['start_date', args.start_date],
|
|
678
|
+
['end_date', args.end_date],
|
|
679
|
+
]) {
|
|
680
|
+
const refused = validDateArg('create_sprint', field, value);
|
|
681
|
+
if (refused)
|
|
682
|
+
return refused;
|
|
683
|
+
}
|
|
684
|
+
// Mirrors the sprints table's own start <= end check (init.sql), refused
|
|
685
|
+
// where the agent can fix it.
|
|
686
|
+
if (args.start_date && args.end_date && args.end_date < args.start_date) {
|
|
687
|
+
return errorResult(`create_sprint: end_date (${args.end_date}) is before start_date (${args.start_date}). ` +
|
|
688
|
+
'Nothing was created.');
|
|
689
|
+
}
|
|
690
|
+
const sprints = (await must(client.from('sprints').select('id, name, archived_at').eq('project_id', project.value.id))) ?? [];
|
|
691
|
+
const live = sprints.filter((sprint) => sprint.archived_at === null);
|
|
692
|
+
const duplicate = live.find((sprint) => sprint.name.trim().toLowerCase() === name.toLowerCase());
|
|
693
|
+
if (duplicate) {
|
|
694
|
+
const listing = live.map((sprint) => `"${sprint.name}" (${sprint.id})`).join(', ');
|
|
695
|
+
return errorResult(`create_sprint: a sprint named "${duplicate.name}" already exists in "${project.value.name}" ` +
|
|
696
|
+
`(id ${duplicate.id}). Reuse it — place items into it with create_task's sprint_id — or name ` +
|
|
697
|
+
'the NEXT batch in the sequence; a re-run extends the existing sprint sequence, it never ' +
|
|
698
|
+
`mints a parallel one. Live sprints in this project: ${listing}. Nothing was created.`);
|
|
699
|
+
}
|
|
700
|
+
const sprint = await must(client
|
|
701
|
+
.from('sprints')
|
|
702
|
+
.insert({
|
|
703
|
+
project_id: project.value.id,
|
|
704
|
+
name,
|
|
705
|
+
...(args.description !== undefined ? { description: args.description.trim() } : {}),
|
|
706
|
+
...(args.start_date ? { start_date: args.start_date } : {}),
|
|
707
|
+
...(args.end_date ? { end_date: args.end_date } : {}),
|
|
708
|
+
})
|
|
709
|
+
.select('id, project_id, name, description, start_date, end_date, created_at')
|
|
710
|
+
.single());
|
|
711
|
+
if (!sprint)
|
|
712
|
+
throw new Error('Sprint insert returned no row.');
|
|
713
|
+
return textResult({
|
|
714
|
+
sprint,
|
|
715
|
+
note: 'Created. Sprints for agent-executed work are ordered batches: the first holds what unblocks ' +
|
|
716
|
+
"everything else, each sized to what agents deliver in a session or a day. Place items with " +
|
|
717
|
+
"create_task's sprint_id, and add NO duration estimates unless the user asked.",
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
catch (err) {
|
|
721
|
+
return errorResult(`create_sprint failed: ${errorMessage(err)}`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const STRUCTURE_TABLE = { epic: 'epics', sprint: 'sprints' };
|
|
725
|
+
/**
|
|
726
|
+
* WHAT STRUCTURE THIS PROJECT HAS — the read that made "read before you write"
|
|
727
|
+
* possible (I13).
|
|
728
|
+
*
|
|
729
|
+
* LIVE ONES BY DEFAULT, because that is what "what exists" means to an agent
|
|
730
|
+
* about to add to it: an archived epic is not something to extend, and listing
|
|
731
|
+
* it invites a duplicate-name refusal the agent cannot act on. `include_archived`
|
|
732
|
+
* is deliberately NOT offered — nothing in this phase's Gherkin needs it, and
|
|
733
|
+
* YAGNI beats a flag with no caller.
|
|
734
|
+
*
|
|
735
|
+
* BOTH KINDS IN ONE CALL. They are always wanted together, and two tools would
|
|
736
|
+
* mean two round trips for the one question an agent actually has.
|
|
737
|
+
*/
|
|
738
|
+
export async function listStructureHandler(client, args) {
|
|
739
|
+
try {
|
|
740
|
+
const project = await resolveSkeletonProject(client, args.project_id, 'list_structure');
|
|
741
|
+
if (!project.ok)
|
|
742
|
+
return project.error;
|
|
743
|
+
const [epics, sprints] = await Promise.all([
|
|
744
|
+
must(client
|
|
745
|
+
.from('epics')
|
|
746
|
+
.select('id, name, description, start_date, target_date, created_at')
|
|
747
|
+
.eq('project_id', project.value.id)
|
|
748
|
+
.is('archived_at', null)
|
|
749
|
+
.order('created_at', { ascending: true })),
|
|
750
|
+
must(client
|
|
751
|
+
.from('sprints')
|
|
752
|
+
.select('id, name, description, start_date, end_date, created_at')
|
|
753
|
+
.eq('project_id', project.value.id)
|
|
754
|
+
.is('archived_at', null)
|
|
755
|
+
.order('created_at', { ascending: true })),
|
|
756
|
+
]);
|
|
757
|
+
return textResult({
|
|
758
|
+
project: project.value,
|
|
759
|
+
epics: epics ?? [],
|
|
760
|
+
sprints: sprints ?? [],
|
|
761
|
+
note: 'Live epics and sprints only. Extend these rather than creating a near-duplicate — a ' +
|
|
762
|
+
'case-insensitive name match is refused. Place a work item with place_work_item, and see ' +
|
|
763
|
+
"where items already sit in list_tasks's epic and sprint fields.",
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
return errorResult(`list_structure failed: ${errorMessage(err)}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* RENAME OR ARCHIVE an epic or a sprint (I13).
|
|
772
|
+
*
|
|
773
|
+
* THE ARCHIVE RULE IS THE DATABASE'S, NOT THIS FUNCTION'S. A trigger refuses
|
|
774
|
+
* archiving anything that still has unfinished work
|
|
775
|
+
* (`app.guard_epic_archive` / `app.guard_sprint_archive`, SQLSTATE PT423). This
|
|
776
|
+
* does not pre-check it — a client-side check would read a snapshot of a table
|
|
777
|
+
* this write does not touch, so it can be stale, and duplicating the rule means
|
|
778
|
+
* two places to keep in step. It translates the refusal instead.
|
|
779
|
+
*
|
|
780
|
+
* THE MACHINE TOKEN NEVER REACHES THE AGENT. The raise's message is
|
|
781
|
+
* `epic_archive_blocked`, which the shipped web UI is explicit must never be
|
|
782
|
+
* rendered; the SAME rule applies to a tool result, which is read by an agent
|
|
783
|
+
* that will repeat it to the user. Keyed off SQLSTATE, exactly as
|
|
784
|
+
* `archiveErrorMessage` in ProjectSettings.tsx does, and the sentence says what
|
|
785
|
+
* would unblock it — the Gherkin asserts all three.
|
|
786
|
+
*/
|
|
787
|
+
export async function updateStructureHandler(client, args) {
|
|
788
|
+
try {
|
|
789
|
+
const kind = typeof args.kind === 'string' ? args.kind.trim().toLowerCase() : '';
|
|
790
|
+
if (kind !== 'epic' && kind !== 'sprint') {
|
|
791
|
+
return errorResult(`update_structure: kind must be "epic" or "sprint", got "${args.kind ?? ''}". Nothing was changed.`);
|
|
792
|
+
}
|
|
793
|
+
const table = STRUCTURE_TABLE[kind];
|
|
794
|
+
const id = typeof args.id === 'string' ? args.id.trim() : '';
|
|
795
|
+
if (!id || !UUID_RE.test(id)) {
|
|
796
|
+
return errorResult(`update_structure: "${id}" is not a ${kind} id. Call list_structure to see them. Nothing was changed.`);
|
|
797
|
+
}
|
|
798
|
+
const name = typeof args.name === 'string' ? args.name.trim() : undefined;
|
|
799
|
+
if (args.name !== undefined && !name) {
|
|
800
|
+
return errorResult(`update_structure: name cannot be blank. Nothing was changed.`);
|
|
801
|
+
}
|
|
802
|
+
if (name === undefined && args.archived === undefined && args.description === undefined
|
|
803
|
+
&& args.start_date === undefined && args.end_date === undefined && args.target_date === undefined) {
|
|
804
|
+
return errorResult('update_structure: pass a name, description, date, or archived setting — there is nothing to change. ' +
|
|
805
|
+
'Nothing was changed.');
|
|
806
|
+
}
|
|
807
|
+
const current = await must(client.from(table).select('*').eq('id', id).maybeSingle());
|
|
808
|
+
if (!current) {
|
|
809
|
+
return errorResult(`update_structure: no ${kind} found for id "${id}". Nothing was changed.`);
|
|
810
|
+
}
|
|
811
|
+
/* THE SAME DUPLICATE-NAME RULE THE CREATE TOOLS ENFORCE. Without it, rename
|
|
812
|
+
is a way around the refusal `create_epic` gives — two live epics with one
|
|
813
|
+
name, reached by creating under a throwaway name and renaming. Scoped to
|
|
814
|
+
LIVE rows in the same project, and it ignores the row being renamed so
|
|
815
|
+
that setting a name to itself is not a conflict. */
|
|
816
|
+
if (name !== undefined && name.toLowerCase() !== current.name.trim().toLowerCase()) {
|
|
817
|
+
const siblings = (await must(client
|
|
818
|
+
.from(table)
|
|
819
|
+
.select('id, name')
|
|
820
|
+
.eq('project_id', current.project_id)
|
|
821
|
+
.is('archived_at', null))) ?? [];
|
|
822
|
+
const clash = siblings.find((row) => row.id !== id && row.name.trim().toLowerCase() === name.toLowerCase());
|
|
823
|
+
if (clash) {
|
|
824
|
+
return errorResult(`update_structure: a live ${kind} named "${clash.name}" already exists in this project ` +
|
|
825
|
+
`(id ${clash.id}). Use that one, or pick a different name. Nothing was changed.`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (kind === 'epic' && args.end_date !== undefined)
|
|
829
|
+
return errorResult('Use target_date for an epic.');
|
|
830
|
+
if (kind === 'sprint' && args.target_date !== undefined)
|
|
831
|
+
return errorResult('Use end_date for a sprint.');
|
|
832
|
+
const patch = {};
|
|
833
|
+
if (args.description !== undefined)
|
|
834
|
+
patch.description = args.description.trim();
|
|
835
|
+
for (const field of ['start_date', 'end_date', 'target_date']) {
|
|
836
|
+
if (args[field] === undefined)
|
|
837
|
+
continue;
|
|
838
|
+
const refusal = validDateArg('update_structure', field, args[field] ?? undefined);
|
|
839
|
+
if (refusal)
|
|
840
|
+
return refusal;
|
|
841
|
+
patch[field] = args[field];
|
|
842
|
+
}
|
|
843
|
+
if (name !== undefined)
|
|
844
|
+
patch.name = name;
|
|
845
|
+
/* THE DATABASE OWNS THE TIMESTAMP. The guard trigger coalesces whatever is
|
|
846
|
+
sent on the archiving transition to `now()`, so this sends a marker
|
|
847
|
+
instant rather than pretending to choose one; restoring sends null, which
|
|
848
|
+
the trigger leaves alone. */
|
|
849
|
+
if (args.archived !== undefined)
|
|
850
|
+
patch.archived_at = args.archived ? new Date().toISOString() : null;
|
|
851
|
+
const { data, error } = await client
|
|
852
|
+
.from(table)
|
|
853
|
+
.update(patch)
|
|
854
|
+
.eq('id', id)
|
|
855
|
+
.select('*')
|
|
856
|
+
.maybeSingle();
|
|
857
|
+
if (error) {
|
|
858
|
+
/* PT423 — the archive guard. The sentence is OURS: the raise's message is
|
|
859
|
+
a machine token (`epic_archive_blocked`) and must never be repeated to
|
|
860
|
+
the user. The database's own `hint` says the same thing, but keying off
|
|
861
|
+
the stable SQLSTATE rather than parsing prose is what the web does. */
|
|
862
|
+
if (error.code === 'PT423') {
|
|
863
|
+
return errorResult(`update_structure: this ${kind} still has work items that are not done, so it cannot be ` +
|
|
864
|
+
`archived. Finish them or move them to another ${kind} first, then archive it. ` +
|
|
865
|
+
'Nothing was changed.');
|
|
866
|
+
}
|
|
867
|
+
if (error.code === '23514')
|
|
868
|
+
return errorResult('The end or target date must not be before the start date. Nothing was changed.');
|
|
869
|
+
throw new Error(error.message);
|
|
870
|
+
}
|
|
871
|
+
/* RLS REFUSING A WRITE RETURNS NO ERROR AND NO ROW — the silent-failure
|
|
872
|
+
shape this repo has been bitten by before. Reported as a refusal rather
|
|
873
|
+
than as success. */
|
|
874
|
+
if (!data) {
|
|
875
|
+
return errorResult(`update_structure: the ${kind} could not be changed — it may have been removed, or you may ` +
|
|
876
|
+
'not have permission. Nothing was changed.');
|
|
877
|
+
}
|
|
878
|
+
return textResult({
|
|
879
|
+
[kind]: data,
|
|
880
|
+
note: args.archived
|
|
881
|
+
? `Archived. Its work items survive and keep their other placements.`
|
|
882
|
+
: 'Updated.',
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
catch (err) {
|
|
886
|
+
return errorResult(`update_structure failed: ${errorMessage(err)}`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
async function artifactStructure(client, kind, id) {
|
|
890
|
+
if ((kind !== 'epic' && kind !== 'sprint') || !UUID_RE.test(id))
|
|
891
|
+
throw new Error('Choose an epic or sprint and its valid ID.');
|
|
892
|
+
const parent = await must(client.from(kind === 'epic' ? 'epics' : 'sprints').select('id,name,project_id,archived_at').eq('id', id).maybeSingle());
|
|
893
|
+
if (!parent)
|
|
894
|
+
throw new Error('The epic or sprint is missing or inaccessible.');
|
|
895
|
+
return parent;
|
|
896
|
+
}
|
|
897
|
+
export async function listStructureArtifactsHandler(client, kind, structureId) {
|
|
898
|
+
try {
|
|
899
|
+
const parent = await artifactStructure(client, kind, structureId);
|
|
900
|
+
const artifacts = await must(client.from('structure_artifacts').select('id,title,type,format,revision,created_at,updated_at')
|
|
901
|
+
.eq(kind === 'epic' ? 'epic_id' : 'sprint_id', structureId).is('deleted_at', null).order('created_at'));
|
|
902
|
+
return textResult({ structure: parent, artifacts });
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
return errorResult(`Could not list artifacts: ${errorMessage(error)}`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
export async function getStructureArtifactHandler(client, artifactId) {
|
|
909
|
+
try {
|
|
910
|
+
if (!UUID_RE.test(artifactId))
|
|
911
|
+
throw new Error('Use a valid artifact ID.');
|
|
912
|
+
const artifact = await must(client.from('structure_artifacts').select('*').eq('id', artifactId).is('deleted_at', null).maybeSingle());
|
|
913
|
+
if (!artifact)
|
|
914
|
+
throw new Error('Artifact is missing or inaccessible.');
|
|
915
|
+
return textResult(artifact);
|
|
916
|
+
}
|
|
917
|
+
catch (error) {
|
|
918
|
+
return errorResult(`Could not read artifact: ${errorMessage(error)}`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
export async function createStructureArtifactHandler(client, userId, args) {
|
|
922
|
+
try {
|
|
923
|
+
const parent = await artifactStructure(client, args.kind, args.structure_id);
|
|
924
|
+
if (parent.archived_at)
|
|
925
|
+
throw new Error('Restore the epic or sprint before adding artifacts.');
|
|
926
|
+
if (!args.title.trim() || !args.content.trim())
|
|
927
|
+
throw new Error('Give the artifact a title and content.');
|
|
928
|
+
const artifact = await must(client.from('structure_artifacts').insert({ project_id: parent.project_id,
|
|
929
|
+
[args.kind === 'epic' ? 'epic_id' : 'sprint_id']: parent.id, title: args.title.trim(), type: args.type,
|
|
930
|
+
format: args.format ?? 'md', content: args.content, created_by: userId }).select('*').single());
|
|
931
|
+
if (!artifact)
|
|
932
|
+
throw new Error('Artifact was not created.');
|
|
933
|
+
return textResult({ artifact, structure: parent, kind: args.kind });
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
return errorResult(`Could not create artifact: ${errorMessage(error)}`);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
export async function updateStructureArtifactHandler(client, args) {
|
|
940
|
+
try {
|
|
941
|
+
if (!UUID_RE.test(args.artifact_id) || !Number.isInteger(args.expected_revision) || args.expected_revision < 1)
|
|
942
|
+
throw new Error('Read the artifact first and pass its current revision.');
|
|
943
|
+
const patch = {};
|
|
944
|
+
for (const key of ['title', 'content', 'type', 'format'])
|
|
945
|
+
if (args[key] !== undefined)
|
|
946
|
+
patch[key] = args[key];
|
|
947
|
+
if (args.archived !== undefined)
|
|
948
|
+
patch.deleted_at = args.archived ? new Date().toISOString() : null;
|
|
949
|
+
if (!Object.keys(patch).length)
|
|
950
|
+
throw new Error('Pass at least one field to change.');
|
|
951
|
+
const artifact = await must(client.from('structure_artifacts').update(patch).eq('id', args.artifact_id)
|
|
952
|
+
.eq('revision', args.expected_revision).select('*').maybeSingle());
|
|
953
|
+
if (!artifact)
|
|
954
|
+
throw new Error('Artifact changed, is missing, or you cannot edit it. Read it again before retrying; nothing was overwritten.');
|
|
955
|
+
return textResult(artifact);
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
return errorResult(`Could not update artifact: ${errorMessage(error)}`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
export async function searchAgentCardsHandler(client, args) {
|
|
962
|
+
try {
|
|
963
|
+
const limit = Math.max(1, Math.min(args.limit ?? 100, 1000)), offset = Math.max(args.offset ?? 0, 0);
|
|
964
|
+
const cards = await must(client.rpc('panel3_search_cards', {
|
|
965
|
+
p_query: args.query ?? '', p_work_item_id: args.work_item_id ?? null,
|
|
966
|
+
p_archived: args.archived ?? null, p_limit: limit, p_offset: offset,
|
|
967
|
+
}));
|
|
968
|
+
if (!cards)
|
|
969
|
+
throw new Error('Search returned no result. Try again.');
|
|
970
|
+
return textResult({ cards, next_offset: cards.length === limit ? offset + limit : null });
|
|
971
|
+
}
|
|
972
|
+
catch (error) {
|
|
973
|
+
return errorResult(`Could not search agent cards: ${errorMessage(error)}`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
@@ -21,6 +21,17 @@ export const WORKFLOW_TOOLS = [
|
|
|
21
21
|
{ id: 'create_product_idea', name: 'Create Product Idea', detail: 'Capture future work without starting implementation.', alwaysAllowed: false, advanced: false },
|
|
22
22
|
{ id: 'create_epic', name: 'Create Epic', detail: 'Group related work items in a project.', alwaysAllowed: false, advanced: true },
|
|
23
23
|
{ id: 'create_sprint', name: 'Create Sprint', detail: 'Create an ordered batch of work.', alwaysAllowed: false, advanced: true },
|
|
24
|
+
{ id: 'list_structure', name: 'Read Epics and Sprints', detail: 'Read descriptions and dates for project planning.', alwaysAllowed: true, advanced: true },
|
|
25
|
+
{ id: 'update_structure', name: 'Edit Epic or Sprint', detail: 'Update descriptions, dates, names, or archive state.', alwaysAllowed: false, advanced: true },
|
|
26
|
+
{ id: 'list_structure_artifacts', name: 'Read Epic or Sprint Artifacts', detail: 'Find artifacts attached to an epic or sprint.', alwaysAllowed: true, advanced: true },
|
|
27
|
+
{ id: 'get_structure_artifact', name: 'Read Epic or Sprint Artifact', detail: 'Read its content and revision.', alwaysAllowed: true, advanced: true },
|
|
28
|
+
{ id: 'create_structure_artifact', name: 'Create Epic or Sprint Artifact', detail: 'Write an artifact directly on an epic or sprint.', alwaysAllowed: false, advanced: true },
|
|
29
|
+
{ id: 'update_structure_artifact', name: 'Edit Epic or Sprint Artifact', detail: 'Revise or remove an artifact using its current revision.', alwaysAllowed: false, advanced: true },
|
|
30
|
+
{ id: 'list_artifact_folders', name: 'Read Artifact Folders', detail: 'Find existing folders on a work item.', alwaysAllowed: true, advanced: true },
|
|
31
|
+
{ id: 'set_artifact_folder', name: 'Set Artifact Folder', detail: 'File an artifact in one folder or move it to Unfiled.', alwaysAllowed: false, advanced: true },
|
|
32
|
+
{ id: 'list_work_item_dependencies', name: 'Read Dependencies', detail: 'See what blocks a work item and what it blocks.', alwaysAllowed: true, advanced: true },
|
|
33
|
+
{ id: 'add_work_item_dependency', name: 'Add Dependency', detail: 'Make a work item wait for another to finish.', alwaysAllowed: false, advanced: true },
|
|
34
|
+
{ id: 'remove_work_item_dependency', name: 'Remove Dependency', detail: 'Remove a work item dependency.', alwaysAllowed: false, advanced: true },
|
|
24
35
|
{ id: 'place_work_item', name: 'Move Work Item', detail: 'Place work in an epic or sprint, or remove that placement.', alwaysAllowed: false, advanced: true },
|
|
25
36
|
{ id: 'reorder_backlog', name: 'Reorder Backlog', detail: 'Change the order in which backlog work is picked up.', alwaysAllowed: false, advanced: true },
|
|
26
37
|
{ id: 'propose_project_context', name: 'Propose Project Context', detail: 'Submit project guidance for review in Project settings.', alwaysAllowed: false, advanced: true },
|
|
@@ -112,6 +123,7 @@ export function authorToolMentions(body) {
|
|
|
112
123
|
}
|
|
113
124
|
export const WORKFLOW_AUTHORING_TEACHING = 'When the user asks you to create a workflow, build it in the library; do not merely describe it or execute it. ' +
|
|
114
125
|
'Use ask_question for missing requirements that materially change the workflow, then continue after the answer. ' +
|
|
126
|
+
'If the user requests plain prose or no tool mentions, write plain Markdown only: do not add tool links, permissions, or an approval template. ' +
|
|
115
127
|
'Encode every requested tool use and permission as a Markdown mention in the stage body. ' +
|
|
116
128
|
'Use compact links: [@Create Artifact](ctrl-spc://tool/create_artifact?approval=not-required) to write a plan without asking; ' +
|
|
117
129
|
'[@Ask Question](ctrl-spc://tool/ask_question?approval=not-required) to review its output; ' +
|
package/dist/workflows.js
CHANGED
|
@@ -144,6 +144,7 @@ export async function buildWorkflow(client, input) {
|
|
|
144
144
|
prose.push([`stage ${i + 1} name`, stage.name], [`stage ${i + 1} description`, stage.description], [`stage ${i + 1} body`, stage.body]);
|
|
145
145
|
});
|
|
146
146
|
input.exits.forEach((exit, i) => prose.push([`exit ${i + 1} condition`, exit.condition]));
|
|
147
|
+
input.branches?.forEach((branch, i) => prose.push([`branch ${i + 1} condition`, branch.when]));
|
|
147
148
|
for (const [field, value] of prose) {
|
|
148
149
|
const token = absolutePathToken(value);
|
|
149
150
|
if (token) {
|
|
@@ -158,8 +159,9 @@ export async function buildWorkflow(client, input) {
|
|
|
158
159
|
p_description: input.description,
|
|
159
160
|
p_stages: input.stages,
|
|
160
161
|
p_exits: input.exits,
|
|
161
|
-
p_branches: [],
|
|
162
|
+
p_branches: (input.branches ?? []).map(branch => ({ when: branch.when, runs_workflow_id: branch.runsWorkflowId })),
|
|
162
163
|
p_from_agent: input.fromAgent,
|
|
164
|
+
...(input.ending === undefined ? {} : { p_ending: input.ending }),
|
|
163
165
|
});
|
|
164
166
|
if (error)
|
|
165
167
|
throw new Error(`could not create workflow ${input.name}: ${error.message}`);
|
|
@@ -239,11 +241,8 @@ export async function rewordStage(client, input) {
|
|
|
239
241
|
* behalf. Its refusals come back verbatim behind one sentence naming what was
|
|
240
242
|
* being edited.
|
|
241
243
|
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* `readWorkflow` read a moment before. A branch written between that read and
|
|
245
|
-
* this call is lost, which is the posture the web's `replaceStructure` records
|
|
246
|
-
* for itself.
|
|
244
|
+
* Branches are the complete replacement list. Callers preserve the current
|
|
245
|
+
* list when no branch change was requested, matching the web editor.
|
|
247
246
|
*
|
|
248
247
|
* ═══ NULL NAME OR DESCRIPTION MEANS UNCHANGED, ═══ which is the RPC's own
|
|
249
248
|
* contract: an agent adding a stage does not restate a name it is not touching.
|
|
@@ -257,6 +256,7 @@ export async function editWorkflow(client, input) {
|
|
|
257
256
|
prose.push([`stage ${i + 1} name`, stage.name], [`stage ${i + 1} description`, stage.description], [`stage ${i + 1} body`, stage.body]);
|
|
258
257
|
});
|
|
259
258
|
input.exits.forEach((exit, i) => prose.push([`exit ${i + 1} condition`, exit.condition]));
|
|
259
|
+
input.branches.forEach((branch, i) => prose.push([`branch ${i + 1} condition`, branch.when]));
|
|
260
260
|
for (const [field, value] of prose) {
|
|
261
261
|
const token = value === null ? null : absolutePathToken(value);
|
|
262
262
|
if (token) {
|
|
@@ -273,6 +273,7 @@ export async function editWorkflow(client, input) {
|
|
|
273
273
|
p_exits: input.exits,
|
|
274
274
|
p_branches: input.branches.map((branch) => ({ when: branch.when, runs_workflow_id: branch.runsWorkflowId })),
|
|
275
275
|
p_from_agent: input.fromAgent,
|
|
276
|
+
...(input.ending === undefined ? {} : { p_ending: input.ending }),
|
|
276
277
|
});
|
|
277
278
|
if (error)
|
|
278
279
|
throw new Error(`could not edit the workflow: ${error.message}`);
|
|
@@ -323,7 +324,7 @@ export async function validateWorkflowToolTarget(client, workItemId, tool, args)
|
|
|
323
324
|
if (targetId && targetId !== workItemId)
|
|
324
325
|
throw new Error('The tool mention belongs to the current workflow work item. Nothing was written.');
|
|
325
326
|
}
|
|
326
|
-
if (tool === 'update_artifact') {
|
|
327
|
+
if (tool === 'update_artifact' || tool === 'set_artifact_folder') {
|
|
327
328
|
const artifacts = await read(client.from('artifacts').select('task_id')
|
|
328
329
|
.eq('id', args.artifact_id ?? args.id), 'the artifact work item');
|
|
329
330
|
if (artifacts.length !== 1 || artifacts[0].task_id !== workItemId)
|
|
@@ -343,6 +344,19 @@ export async function validateWorkflowToolTarget(client, workItemId, tool, args)
|
|
|
343
344
|
throw new Error('Feedback is missing or outside the workflow work item. Nothing was written.');
|
|
344
345
|
}
|
|
345
346
|
}
|
|
347
|
+
if (tool === 'update_structure' || tool === 'create_structure_artifact' || tool === 'list_structure_artifacts'
|
|
348
|
+
|| tool === 'get_structure_artifact' || tool === 'update_structure_artifact') {
|
|
349
|
+
const artifactAction = tool === 'get_structure_artifact' || tool === 'update_structure_artifact';
|
|
350
|
+
const table = artifactAction ? 'structure_artifacts' : args.kind === 'epic' ? 'epics' : args.kind === 'sprint' ? 'sprints' : null;
|
|
351
|
+
if (!table)
|
|
352
|
+
throw new Error('Choose an epic or sprint. Nothing was written.');
|
|
353
|
+
const [source, destination] = await Promise.all([
|
|
354
|
+
read(client.from('tasks').select('project_id').eq('id', workItemId), 'the workflow project'),
|
|
355
|
+
read(client.from(table).select('project_id').eq('id', artifactAction ? args.artifact_id : args.structure_id ?? args.id), 'the destination project'),
|
|
356
|
+
]);
|
|
357
|
+
if (source.length !== 1 || destination.length !== 1 || source[0].project_id !== destination[0].project_id)
|
|
358
|
+
throw new Error('This action is outside the workflow project. Nothing was written.');
|
|
359
|
+
}
|
|
346
360
|
if (args.project_id || (targetId && targetId !== workItemId)) {
|
|
347
361
|
const items = await read(client.from('tasks').select('id, project_id')
|
|
348
362
|
.in('id', [...new Set([workItemId, ...(targetId ? [String(targetId)] : [])])]), 'the workflow project');
|