@ctrl-spc/cs 0.7.9 → 0.7.11

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.
@@ -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 },
package/dist/workflows.js CHANGED
@@ -323,7 +323,7 @@ export async function validateWorkflowToolTarget(client, workItemId, tool, args)
323
323
  if (targetId && targetId !== workItemId)
324
324
  throw new Error('The tool mention belongs to the current workflow work item. Nothing was written.');
325
325
  }
326
- if (tool === 'update_artifact') {
326
+ if (tool === 'update_artifact' || tool === 'set_artifact_folder') {
327
327
  const artifacts = await read(client.from('artifacts').select('task_id')
328
328
  .eq('id', args.artifact_id ?? args.id), 'the artifact work item');
329
329
  if (artifacts.length !== 1 || artifacts[0].task_id !== workItemId)
@@ -343,6 +343,19 @@ export async function validateWorkflowToolTarget(client, workItemId, tool, args)
343
343
  throw new Error('Feedback is missing or outside the workflow work item. Nothing was written.');
344
344
  }
345
345
  }
346
+ if (tool === 'update_structure' || tool === 'create_structure_artifact' || tool === 'list_structure_artifacts'
347
+ || tool === 'get_structure_artifact' || tool === 'update_structure_artifact') {
348
+ const artifactAction = tool === 'get_structure_artifact' || tool === 'update_structure_artifact';
349
+ const table = artifactAction ? 'structure_artifacts' : args.kind === 'epic' ? 'epics' : args.kind === 'sprint' ? 'sprints' : null;
350
+ if (!table)
351
+ throw new Error('Choose an epic or sprint. Nothing was written.');
352
+ const [source, destination] = await Promise.all([
353
+ read(client.from('tasks').select('project_id').eq('id', workItemId), 'the workflow project'),
354
+ read(client.from(table).select('project_id').eq('id', artifactAction ? args.artifact_id : args.structure_id ?? args.id), 'the destination project'),
355
+ ]);
356
+ if (source.length !== 1 || destination.length !== 1 || source[0].project_id !== destination[0].project_id)
357
+ throw new Error('This action is outside the workflow project. Nothing was written.');
358
+ }
346
359
  if (args.project_id || (targetId && targetId !== workItemId)) {
347
360
  const items = await read(client.from('tasks').select('id, project_id')
348
361
  .in('id', [...new Set([workItemId, ...(targetId ? [String(targetId)] : [])])]), 'the workflow project');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
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"