@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.
- package/.tickets/spec/AGENTS_EXAMPLE.md +2 -0
- package/README.md +11 -0
- package/package.json +1 -1
- package/release-history.json +7 -0
- package/src/cli.js +289 -2
- package/src/lib/projections.js +2 -0
|
@@ -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
package/release-history.json
CHANGED
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
|
-
|
|
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) {
|
package/src/lib/projections.js
CHANGED
|
@@ -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
|
"",
|