@picoai/tickets 0.5.0 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,8 @@ The purpose of this bootstrap is to ensure an agent loads the canonical ticketin
16
16
  - Record `completion` metadata every time a ticket is moved to `done`.
17
17
  - When a human overrides incomplete completion gates, record that override in the ticket via `npx @picoai/tickets status --status done --acceptance-criteria ... --verification-state ... --override-by ... --override-reason ...`.
18
18
  - When logging via the CLI: use `npx @picoai/tickets log --machine` so logs are strictly structured.
19
+ - If the human asks to remove the tickets workflow from this repo, run `npx @picoai/tickets uninstall`.
20
+ - Use `npx @picoai/tickets uninstall --all` only when the human explicitly asks to delete ticket directories and logs too.
19
21
  - Respect `assignment.mode`, `agent_limits`, active advisory claims, and repo-local defaults in `.tickets/config.yml`.
20
22
 
21
23
  ### Bootstrapping TICKETS.md
package/README.md CHANGED
@@ -114,6 +114,17 @@ npx @picoai/tickets init [--examples] [--apply]
114
114
  - `--examples`: generate example tickets and logs that validate under the current spec
115
115
  - `--apply`: refresh managed `TICKETS.md`, the managed `AGENTS.md` workflow block, and repo skill content
116
116
 
117
+ ### `uninstall`
118
+
119
+ ```bash
120
+ npx @picoai/tickets uninstall [--all] [--yes]
121
+ ```
122
+
123
+ - default: remove tickets-managed workflow files while keeping ticket directories and logs under `/.tickets/<ticket-id>/`
124
+ - `--all`: also remove ticket directories and logs for a clean reset
125
+ - `--yes`: skip interactive confirmation prompts
126
+ - does not remove `@picoai/tickets` from `package.json` (run your package manager uninstall command separately)
127
+
117
128
  ### `new`
118
129
 
119
130
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@picoai/tickets",
3
- "version": "0.5.0",
3
+ "version": "0.5.5",
4
4
  "description": "Repo-native ticketing CLI and assets for Markdown-first, append-only ticket workflows.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,6 +28,13 @@
28
28
  "date": "2026-03-17",
29
29
  "channel": "latest",
30
30
  "notes": "Published to npm"
31
+ },
32
+ {
33
+ "version": "0.5.0",
34
+ "commit": "66691ef",
35
+ "date": "2026-03-17",
36
+ "channel": "latest",
37
+ "notes": "Published to npm"
31
38
  }
32
39
  ]
33
40
  }
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import readline from "node:readline/promises";
4
5
 
5
6
  import { Command } from "commander";
6
7
  import yaml from "yaml";
@@ -806,6 +807,273 @@ function upsertAgentsSection(existingContent, templateContent) {
806
807
  return replaceHeadingBlock(withoutLegacyMarkers, managedBlock, AGENTS_SECTION_HEADING, 2);
807
808
  }
808
809
 
810
+ function removeHeadingBlock(content, headingText, headingLevel) {
811
+ const lines = normalizeContent(content).split("\n");
812
+ const range = findHeadingBlockRange(lines, headingText, headingLevel);
813
+ if (!range) {
814
+ return [normalizeContent(content), false];
815
+ }
816
+
817
+ const before = lines.slice(0, range.start).join("\n").trimEnd();
818
+ const after = lines.slice(range.end).join("\n").trimStart();
819
+ if (!before && !after) {
820
+ return ["", true];
821
+ }
822
+ if (!before) {
823
+ return [after, true];
824
+ }
825
+ if (!after) {
826
+ return [before, true];
827
+ }
828
+ return [`${before}\n\n${after}`, true];
829
+ }
830
+
831
+ function removeHeadingBlocks(content, headingText, headingLevel) {
832
+ let next = normalizeContent(content);
833
+ let removed = false;
834
+ while (true) {
835
+ const [updated, didRemove] = removeHeadingBlock(next, headingText, headingLevel);
836
+ if (!didRemove) {
837
+ break;
838
+ }
839
+ removed = true;
840
+ next = updated;
841
+ }
842
+ return [next, removed];
843
+ }
844
+
845
+ function detectPriorInitState(root) {
846
+ const ticketsMdPath = path.join(root, "TICKETS.md");
847
+ const ticketsRoot = path.join(root, ".tickets");
848
+ const repoConfig = path.join(ticketsRoot, "config.yml");
849
+ const repoSkill = path.join(ticketsRoot, "skills", "tickets", "SKILL.md");
850
+ const currentSpec = path.join(root, BASE_DIR, FORMAT_VERSION_URL);
851
+ const hadArtifacts =
852
+ fs.existsSync(ticketsMdPath) || fs.existsSync(repoConfig) || fs.existsSync(repoSkill) || fs.existsSync(currentSpec);
853
+
854
+ if (!hadArtifacts) {
855
+ return null;
856
+ }
857
+
858
+ if (fs.existsSync(ticketsMdPath)) {
859
+ try {
860
+ const content = fs.readFileSync(ticketsMdPath, "utf8");
861
+ const versionMatch = content.match(/^\s*-\s*written_by:\s*@picoai\/tickets@([^\s]+)\s*$/m);
862
+ if (versionMatch?.[1] === TOOL_VERSION) {
863
+ return "same";
864
+ }
865
+ } catch {
866
+ // Best-effort heuristic only.
867
+ }
868
+ }
869
+
870
+ if (fs.existsSync(currentSpec)) {
871
+ return "same";
872
+ }
873
+
874
+ return "outdated";
875
+ }
876
+
877
+ function toPosixPath(value) {
878
+ return String(value).replaceAll(path.sep, "/");
879
+ }
880
+
881
+ function displayRelativePath(root, targetPath, isDir = false) {
882
+ const relative = path.relative(root, targetPath);
883
+ const base = relative ? toPosixPath(relative) : ".";
884
+ if (isDir && !base.endsWith("/")) {
885
+ return `${base}/`;
886
+ }
887
+ return base;
888
+ }
889
+
890
+ function removePathWithLog(root, targetPath) {
891
+ if (!fs.existsSync(targetPath)) {
892
+ return false;
893
+ }
894
+ const stat = fs.lstatSync(targetPath);
895
+ fs.rmSync(targetPath, { recursive: true, force: true });
896
+ process.stdout.write(`Removed ${displayRelativePath(root, targetPath, stat.isDirectory())}\n`);
897
+ return true;
898
+ }
899
+
900
+ function removeTicketingWorkflowFromAgents(root) {
901
+ const agentsMdPath = path.join(root, "AGENTS.md");
902
+ if (!fs.existsSync(agentsMdPath)) {
903
+ return;
904
+ }
905
+
906
+ const existing = fs.readFileSync(agentsMdPath, "utf8");
907
+ let next = stripManagedSection(existing, AGENTS_LEGACY_SECTION_START, AGENTS_LEGACY_SECTION_END);
908
+ let removed = next !== normalizeContent(existing);
909
+
910
+ let removedByHeading;
911
+ [next, removedByHeading] = removeHeadingBlocks(next, AGENTS_SECTION_HEADING, 2);
912
+ removed ||= removedByHeading;
913
+ [next, removedByHeading] = removeHeadingBlocks(next, AGENTS_SECTION_HEADING, 1);
914
+ removed ||= removedByHeading;
915
+
916
+ const normalizedNext = normalizeContent(next).trim();
917
+ if (!removed) {
918
+ return;
919
+ }
920
+
921
+ if (!normalizedNext) {
922
+ fs.rmSync(agentsMdPath, { force: true });
923
+ process.stdout.write("Removed AGENTS.md\n");
924
+ return;
925
+ }
926
+
927
+ fs.writeFileSync(agentsMdPath, `${normalizedNext}\n`);
928
+ process.stdout.write("Removed Ticketing Workflow section from AGENTS.md\n");
929
+ }
930
+
931
+ function keepOnlyTicketDirectories(root) {
932
+ const ticketsRoot = path.join(root, ".tickets");
933
+ if (!fs.existsSync(ticketsRoot) || !fs.statSync(ticketsRoot).isDirectory()) {
934
+ return;
935
+ }
936
+
937
+ const entries = fs.readdirSync(ticketsRoot, { withFileTypes: true });
938
+ for (const entry of entries) {
939
+ const entryPath = path.join(ticketsRoot, entry.name);
940
+ if (entry.isDirectory() && fs.existsSync(path.join(entryPath, "ticket.md"))) {
941
+ continue;
942
+ }
943
+ removePathWithLog(root, entryPath);
944
+ }
945
+
946
+ const remaining = fs.readdirSync(ticketsRoot, { withFileTypes: true });
947
+ if (remaining.length === 0) {
948
+ fs.rmSync(ticketsRoot, { force: true });
949
+ process.stdout.write("Removed .tickets/\n");
950
+ }
951
+ }
952
+
953
+ function removeTicketsManagedFiles(root, removeAllTickets) {
954
+ removePathWithLog(root, path.join(root, "TICKETS.md"));
955
+ removeTicketingWorkflowFromAgents(root);
956
+ removePathWithLog(root, path.join(root, "AGENTS_EXAMPLE.md"));
957
+ removePathWithLog(root, path.join(root, "TICKETS.override.md"));
958
+
959
+ if (removeAllTickets) {
960
+ removePathWithLog(root, path.join(root, ".tickets"));
961
+ } else {
962
+ keepOnlyTicketDirectories(root);
963
+ }
964
+ }
965
+
966
+ async function promptYesNo(ask, prompt) {
967
+ while (true) {
968
+ const answer = (await ask(prompt)).trim().toLowerCase();
969
+ if (!answer) {
970
+ return false;
971
+ }
972
+ if (["y", "yes"].includes(answer)) {
973
+ return true;
974
+ }
975
+ if (["n", "no"].includes(answer)) {
976
+ return false;
977
+ }
978
+ process.stdout.write("Please enter y or n.\n");
979
+ }
980
+ }
981
+
982
+ async function resolveUninstallScope(options) {
983
+ if (options.yes) {
984
+ return options.all ? "all" : "keep";
985
+ }
986
+
987
+ if (process.stdin.isTTY && process.stdout.isTTY) {
988
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
989
+ try {
990
+ if (options.all) {
991
+ const confirmed = await promptYesNo(
992
+ (prompt) => rl.question(prompt),
993
+ "This operation is destructive and will remove all files created by @picoai/tickets from this repository. This action cannot be undone.\n\nProceed? [y/N]:",
994
+ );
995
+ if (!confirmed) {
996
+ process.stdout.write("Canceled. No changes made.\n");
997
+ return null;
998
+ }
999
+ return "all";
1000
+ }
1001
+
1002
+ const proceed = await promptYesNo(
1003
+ (prompt) => rl.question(prompt),
1004
+ "This operation is destructive and will remove files created by @picoai/tickets from this repository. It will keep ticket directories and logs under .tickets/<ticket-id>/ by default. This action cannot be undone.\n\nProceed? [y/N]:",
1005
+ );
1006
+ if (!proceed) {
1007
+ process.stdout.write("Canceled. No changes made.\n");
1008
+ return null;
1009
+ }
1010
+
1011
+ const deleteTickets = await promptYesNo(
1012
+ (prompt) => rl.question(prompt),
1013
+ "Remove all ticket directories and logs as well? [y/N]:",
1014
+ );
1015
+ return deleteTickets ? "all" : "keep";
1016
+ } finally {
1017
+ rl.close();
1018
+ }
1019
+ }
1020
+
1021
+ const answers = fs.readFileSync(0, "utf8").split(/\r?\n/);
1022
+ let cursor = 0;
1023
+ const askFromBuffer = async (prompt) => {
1024
+ process.stdout.write(prompt);
1025
+ if (cursor >= answers.length) {
1026
+ return "";
1027
+ }
1028
+ const value = answers[cursor];
1029
+ cursor += 1;
1030
+ return value ?? "";
1031
+ };
1032
+
1033
+ if (options.all) {
1034
+ const confirmed = await promptYesNo(
1035
+ askFromBuffer,
1036
+ "This operation is destructive and will remove all files created by @picoai/tickets from this repository. This action cannot be undone.\n\nProceed? [y/N]:",
1037
+ );
1038
+ if (!confirmed) {
1039
+ process.stdout.write("Canceled. No changes made.\n");
1040
+ return null;
1041
+ }
1042
+ return "all";
1043
+ }
1044
+
1045
+ const proceed = await promptYesNo(
1046
+ askFromBuffer,
1047
+ "This operation is destructive and will remove files created by @picoai/tickets from this repository. It will keep ticket directories and logs under .tickets/<ticket-id>/ by default. This action cannot be undone.\n\nProceed? [y/N]:",
1048
+ );
1049
+ if (!proceed) {
1050
+ process.stdout.write("Canceled. No changes made.\n");
1051
+ return null;
1052
+ }
1053
+
1054
+ const deleteTickets = await promptYesNo(askFromBuffer, "Remove all ticket directories and logs as well? [y/N]:");
1055
+ return deleteTickets ? "all" : "keep";
1056
+ }
1057
+
1058
+ async function cmdUninstall(options) {
1059
+ const scope = await resolveUninstallScope(options);
1060
+ if (!scope) {
1061
+ return 0;
1062
+ }
1063
+
1064
+ if (scope === "all") {
1065
+ process.stdout.write("Removing all files created by @picoai/tickets from this repository.\n");
1066
+ removeTicketsManagedFiles(repoRoot(), true);
1067
+ return 0;
1068
+ }
1069
+
1070
+ process.stdout.write(
1071
+ "Removing all files created by @picoai/tickets from this repository except for ticket directories and logs.\n",
1072
+ );
1073
+ removeTicketsManagedFiles(repoRoot(), false);
1074
+ return 0;
1075
+ }
1076
+
809
1077
  function applyAgentsMdSection(root, templateContent) {
810
1078
  const agentsMdPath = path.join(root, "AGENTS.md");
811
1079
  const existing = fs.existsSync(agentsMdPath) ? fs.readFileSync(agentsMdPath, "utf8") : "";
@@ -1130,8 +1398,9 @@ function generateExampleTickets() {
1130
1398
  }
1131
1399
 
1132
1400
  async function cmdInit(options) {
1133
- ensureDir(ticketsDir());
1134
1401
  const root = repoRoot();
1402
+ const priorInitState = detectPriorInitState(root);
1403
+ ensureDir(ticketsDir());
1135
1404
  const repoBaseDir = path.join(root, BASE_DIR);
1136
1405
  ensureDir(repoBaseDir);
1137
1406
  const apply = Boolean(options.apply);
@@ -1178,7 +1447,13 @@ async function cmdInit(options) {
1178
1447
  }
1179
1448
 
1180
1449
  invalidatePlanningIndex();
1181
- process.stdout.write("Initialized.\n");
1450
+ if (!apply && priorInitState === null) {
1451
+ process.stdout.write("Repo successfully initialized; run init --apply to directly update agents.md if desired.\n");
1452
+ } else if (!apply && priorInitState === "outdated") {
1453
+ process.stdout.write("Repo previously initialized, but may be outdated; run init --apply to update\n");
1454
+ } else if (!apply && priorInitState === "same") {
1455
+ process.stdout.write("Repo already initialized with this version; run init --apply to apply updates anyway\n");
1456
+ }
1182
1457
  return 0;
1183
1458
  }
1184
1459
 
@@ -2136,6 +2411,18 @@ export async function run(argv = process.argv.slice(2)) {
2136
2411
  });
2137
2412
  });
2138
2413
 
2414
+ program
2415
+ .command("uninstall")
2416
+ .description("Remove @picoai/tickets managed files from this repository")
2417
+ .option("--all", "Also remove ticket directories and logs under .tickets/")
2418
+ .option("--yes", "Run non-interactively with confirmation accepted")
2419
+ .action(async (options) => {
2420
+ process.exitCode = await cmdUninstall({
2421
+ all: Boolean(options.all),
2422
+ yes: Boolean(options.yes),
2423
+ });
2424
+ });
2425
+
2139
2426
  try {
2140
2427
  await program.parseAsync(argv, { from: "user" });
2141
2428
  } catch (error) {
@@ -37,6 +37,8 @@ export function renderRepoSkill(profile = loadDefaultProfile()) {
37
37
  "- Record `completion` metadata every time a ticket is moved to `done`.",
38
38
  "- When a human overrides incomplete completion gates, record the exception through `npx @picoai/tickets status --status done --acceptance-criteria ... --verification-state ... --override-by ... --override-reason ...` so `ticket.md` and the status log both reflect it.",
39
39
  "- Use `npx @picoai/tickets status`, `log`, `claim`, `plan`, and `graph` instead of editing derived state manually.",
40
+ "- If a human asks to remove the tickets workflow from the repo, run `npx @picoai/tickets uninstall`.",
41
+ "- Use `npx @picoai/tickets uninstall --all` only when the human explicitly asks to delete ticket directories and logs too.",
40
42
  "- When humans use terms like feature, phase, milestone, roadmap, or repo-specific equivalents, translate them through `.tickets/config.yml` and then call the generic CLI fields.",
41
43
  "- Respect repo overrides in `.tickets/config.yml` and any narrative guidance in `TICKETS.override.md` if present.",
42
44
  "",