task-pipeline-skill 1.52.0 → 1.53.0
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/CHANGELOG.md +79 -0
- package/CONTRIBUTING.md +3 -3
- package/HOW-IT-WORKS.md +1 -1
- package/README.md +15 -1
- package/SKILL-CARD.md +1 -1
- package/bin/lib/artifact-root.js +123 -0
- package/bin/lib/migrate-artifacts.js +221 -0
- package/bin/task-pipeline.js +58 -0
- package/cursor/rules/task-pipeline.mdc +5 -5
- package/package.json +4 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/commands/task-pipeline.md +3 -3
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +2 -2
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +17 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +3 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/adoption.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +43 -14
- package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/decomposition.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +2 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +4 -4
- package/plugins/task-pipeline/skills/task-pipeline/references/learned.md +2 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/planning.md +2 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +3 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +8 -8
- package/plugins/task-pipeline/skills/task-pipeline/references/setup.md +24 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +11 -11
- package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +6 -6
- package/plugins/task-pipeline/skills/task-pipeline/templates/backlog.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/templates/brief.md +4 -4
- package/plugins/task-pipeline/skills/task-pipeline/templates/carryover.md +2 -2
- package/plugins/task-pipeline/skills/task-pipeline/templates/retro-archive.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.53.0 — the artifact root stops carrying another pack's name
|
|
4
|
+
|
|
5
|
+
The paperwork directory was called `docs/superpowers/`. The name came from an unrelated
|
|
6
|
+
pack — one whose own tests walk the same path — and `references/artifacts.md` had called
|
|
7
|
+
it "historical convention" since v0.1.0, promising that a host project *may relocate the
|
|
8
|
+
root*. Nothing kept that promise: the path was hardcoded in 24 places in the validator,
|
|
9
|
+
in the gate prose of `pipeline.json`, and in 26 CI plants.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`paths.artifacts` in `pipeline.schema.json`.** Any relative path, and it outranks
|
|
14
|
+
both discovered names. This is what turns the v0.1.0 sentence into a mechanism.
|
|
15
|
+
- **The artifact-root rule, resolved rather than spelled.** `paths.artifacts` →
|
|
16
|
+
an existing `docs/evidence/` → an existing `docs/superpowers/` → `docs/evidence/` for
|
|
17
|
+
a project that has neither. A directory is adopted only when it **carries a register**
|
|
18
|
+
(`retro.md`, `backlog.md`, `verification.md`, or a `specs/plans/briefs/retro`
|
|
19
|
+
directory): a project may keep an unrelated `docs/evidence/`, and adopting it on a
|
|
20
|
+
name match would write a run's paperwork into somebody else's folder. The answer is a
|
|
21
|
+
record — `{root, reason, legacy, leftover, collision}` — because a bare string cannot
|
|
22
|
+
say *this is the legacy name*, *records also sit over there*, or *the default landed on
|
|
23
|
+
an occupied directory*.
|
|
24
|
+
- **Two implementations, one table, compared to each other.**
|
|
25
|
+
`bin/lib/artifact-root.js` ships; `test/artifact_root.py` serves the validator;
|
|
26
|
+
`test/artifact_root_test.py` builds all seven cases as real trees and fails when the
|
|
27
|
+
two disagree. Checking each against the table alone would let them drift into two
|
|
28
|
+
readings that are both "right".
|
|
29
|
+
- **`npx task-pipeline migrate-artifacts [--dry-run]`.** Optional, always: the legacy
|
|
30
|
+
name is supported and **no run warns about it**. It moves the directory, refuses when
|
|
31
|
+
`paths.artifacts` is set rather than overriding the operator, never overwrites a
|
|
32
|
+
collision, backs up before writing — and **lists every file elsewhere that names the
|
|
33
|
+
old path without editing one of them.** Rewriting arbitrary documents in somebody's
|
|
34
|
+
repository would mean deciding for them which mentions are a path in use and which are
|
|
35
|
+
a sentence about the old name; this release got that distinction wrong in its own
|
|
36
|
+
sweep before the command was written, which is the argument for not automating it.
|
|
37
|
+
- **`/task-pipeline setup` now opens with the resolved root and why**, with all four
|
|
38
|
+
outcomes spelled out — including the one where the default lands on a directory that
|
|
39
|
+
exists and carries no register, which is a stop-and-ask rather than a write.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **The default is `docs/evidence/`**, matching the `evidence-docs` skill this plugin
|
|
44
|
+
already ships. **Nothing migrates on its own.** A project on `docs/superpowers/` keeps
|
|
45
|
+
it, forever, with no warning on any run — an upgrade is a no-op for everyone already
|
|
46
|
+
running.
|
|
47
|
+
- 24 sites in `test/validate.py` and 105 occurrences across 34 files now go through the
|
|
48
|
+
rule: doctrine writes `<artifacts>/`, templates and this repository's own statements
|
|
49
|
+
write the resolved name. **Frozen records were not touched** — a brief from March
|
|
50
|
+
describes where things were in March, and rewriting it would falsify the record.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **A guard that had lost its subject.** The prose sweep rewrote
|
|
55
|
+
`` `docs/superpowers/…` `` to `` `<artifacts>/…` `` — and the guard comparing
|
|
56
|
+
`artifacts.md`'s tables against its layout tree searched for the resolved literal, so
|
|
57
|
+
it found nothing and passed by having nothing to check. Reported by the negative
|
|
58
|
+
self-test as *does not actually fire*, not by the validator, which was green. That is
|
|
59
|
+
standing instruction #6's corollary landing in this repository's own validator one
|
|
60
|
+
release after the instruction was written; the guard is now anchored on the symbol the
|
|
61
|
+
doctrine writes, and it has been watched failing against a planted defect.
|
|
62
|
+
- **A plant that no longer landed.** The CI plant removing the layout tree anchored on a
|
|
63
|
+
bare `superpowers/` — a spelling the path sweep never matched — and said so, loudly,
|
|
64
|
+
because it asserts its own effect. Repointing the other 26 plants recovered 24 broken
|
|
65
|
+
negative self-tests.
|
|
66
|
+
- **A backup taken when nothing moved.** `migrate-artifacts` copied the legacy tree
|
|
67
|
+
before every run, so a second run against a still-colliding tree changed the tree it
|
|
68
|
+
claimed to leave alone. Found by the three-run fixture rather than by reading: the pure
|
|
69
|
+
planner was right and the command that repeats was not, which is standing instruction
|
|
70
|
+
#2 arriving in the release that cites it.
|
|
71
|
+
|
|
72
|
+
Guards: 312 → **313**. Property checks: 9 → 9. The new one reverses the precedence in
|
|
73
|
+
one of the two resolver implementations: the table still describes the correct order, so
|
|
74
|
+
a suite that merely ran both would stay green while they drifted apart on a case the
|
|
75
|
+
table never named. Two new suites join CI — the artifact-root rule (7 cases, both
|
|
76
|
+
implementations compared) and `migrate-artifacts` (7 cases, three real runs with hashes
|
|
77
|
+
compared). 26 CI plants were repointed at the new root and 24 broken negative self-tests
|
|
78
|
+
recovered; two remain unable to fire in a submodule checkout for a reason that predates
|
|
79
|
+
this release (the harness copies to `/tmp` without a resolving `.git`, and both guards
|
|
80
|
+
read git history — CI clones normally and is green).
|
|
81
|
+
|
|
3
82
|
## v1.52.0 — the three moments the run's own record could not show
|
|
4
83
|
|
|
5
84
|
### Added
|
package/CONTRIBUTING.md
CHANGED
|
@@ -371,11 +371,11 @@ seven files where thirteen state the condition, the disclosure check named five
|
|
|
371
371
|
rule and the command. Every miss was found by a reader or a sweep, never by the guard —
|
|
372
372
|
**nobody notices a corpus that is too small, because everything inside it passes.**
|
|
373
373
|
Exclusions are allowed and must each carry a reason in the code: a changelog narrates
|
|
374
|
-
old formats, `docs/
|
|
374
|
+
old formats, `docs/evidence/specs/` are point-in-time records.
|
|
375
375
|
*(guard: `a worked GATE verdict omits ` and `states the cold-retirement condition as` — both now run over corpora walked from disk, so a new surface joins by existing)*
|
|
376
376
|
|
|
377
377
|
**44. A carry-over row still `open` names a board id, and the board row names it back.**
|
|
378
|
-
`docs/
|
|
378
|
+
`docs/evidence/backlog.md` is the project's queue between runs; the ledger's `open`
|
|
379
379
|
was a home that pointed nowhere, and rows across eight ledgers sat in it — sixteen by the first, positional count, twenty-four once the check stopped reading by column. Both
|
|
380
380
|
directions are checked because they are different failures — an id nobody issued, and a
|
|
381
381
|
row traceable to nothing. The test is **position-free**: a row is open if any of its cells
|
|
@@ -387,7 +387,7 @@ design and it was wrong for the same reason.
|
|
|
387
387
|
*(guard: `with no board id` and `names no Source`)*
|
|
388
388
|
|
|
389
389
|
**45. Every shipped REQ has a verification row, and `Human` is a date or `never`.**
|
|
390
|
-
`docs/
|
|
390
|
+
`docs/evidence/verification.md` records the one thing no check can decide — whether a
|
|
391
391
|
person looked after it shipped. Both directions: a shipped REQ with no row, and a row
|
|
392
392
|
whose REQ is in no brief. It keys to the brief because eight of nine briefs carry
|
|
393
393
|
machine-readable REQ rows while ten acceptance files carry their coverage table in nearly
|
package/HOW-IT-WORKS.md
CHANGED
|
@@ -380,5 +380,5 @@ exclusion clause as their reason to refuse.
|
|
|
380
380
|
| change it | [`CONTRIBUTING.md`](CONTRIBUTING.md) — the invariants live there |
|
|
381
381
|
| why a release happened | [`CHANGELOG.md`](CHANGELOG.md) |
|
|
382
382
|
| the stage doctrine itself | `plugins/task-pipeline/skills/task-pipeline/references/` |
|
|
383
|
-
| what is open | [`docs/
|
|
383
|
+
| what is open | [`docs/evidence/backlog.md`](docs/evidence/backlog.md) |
|
|
384
384
|
| what is genuinely unresolved | [`docs/OPEN_QUESTIONS.md`](docs/OPEN_QUESTIONS.md) |
|
package/README.md
CHANGED
|
@@ -584,7 +584,7 @@ class of failure gets caught, fixed and forgotten five times, and nothing in the
|
|
|
584
584
|
pipeline notices it is the same one.
|
|
585
585
|
|
|
586
586
|
The last act of stage 10 is therefore a **retrospective**, written to
|
|
587
|
-
`docs/
|
|
587
|
+
`docs/evidence/retro.md` — **one file per project, not per run**
|
|
588
588
|
([`retrospective.md`](plugins/task-pipeline/skills/task-pipeline/references/retrospective.md)).
|
|
589
589
|
Every run **stamps and prunes** — in that order, because one retirement trigger
|
|
590
590
|
counts firings across the last five run stamps **or sixty days**, whichever comes
|
|
@@ -784,6 +784,20 @@ npx task-pipeline-skill # from the npm registry
|
|
|
784
784
|
on npm; installs the same skill + `/task-pipeline` command into `~/.claude`,
|
|
785
785
|
idempotent, `--force` to overwrite)
|
|
786
786
|
|
|
787
|
+
**Moving the paperwork off the legacy directory name — optional, always:**
|
|
788
|
+
```bash
|
|
789
|
+
npx task-pipeline-skill migrate-artifacts --dry-run # the plan, nothing written
|
|
790
|
+
npx task-pipeline-skill migrate-artifacts # do it
|
|
791
|
+
```
|
|
792
|
+
The artifact root was `docs/superpowers/` until v1.53.0 and the default is now
|
|
793
|
+
`docs/evidence/`. **A project on the old name keeps it and no run warns about it** —
|
|
794
|
+
the root is resolved (`paths.artifacts` in `pipeline.json`, else an existing
|
|
795
|
+
`docs/evidence/`, else an existing `docs/superpowers/`, else the new default), so an
|
|
796
|
+
upgrade changes nothing for a project already running. The command moves the directory,
|
|
797
|
+
never overwrites a collision, refuses when `paths.artifacts` is set rather than
|
|
798
|
+
overriding you, and **lists every other file that names the old path without editing
|
|
799
|
+
one of them** — only you can tell a path in use from a sentence about the old name.
|
|
800
|
+
|
|
787
801
|
**Cursor:** the skills CLI above with `--agent cursor`, or per project copy
|
|
788
802
|
[`cursor/rules/task-pipeline.mdc`](cursor/rules/task-pipeline.mdc) into the repo's
|
|
789
803
|
`.cursor/rules/`. Cursor has no global rules directory — use the skills CLI for a
|
package/SKILL-CARD.md
CHANGED
|
@@ -12,7 +12,7 @@ harmless.
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| **Purpose** | Runs a substantial task through ten gated delivery stages — intake grill, docs study, brainstorm, spec, plan, subagent build, tests, lint/deploy, post-deploy, docs+registers, acceptance — refusing to advance until each gate passes |
|
|
14
14
|
| **Owner** | ssheleg ([github.com/ssheleg/task-pipeline](https://github.com/ssheleg/task-pipeline)) |
|
|
15
|
-
| **Version** | 1.
|
|
15
|
+
| **Version** | 1.53.0 |
|
|
16
16
|
| **Surface** | Claude Code (filesystem skill + plugin) and the vercel `skills` CLI. **Not** uploaded to the Skills API; custom Skills do not sync across surfaces |
|
|
17
17
|
| **Dependencies** | None required. Optional: `context7` (MCP), `figma` (MCP), super-ux, agent-sync, graphify, obsidian-wiki. Every stage's doctrine ships in-repo; the one conditional requirement is super-ux for the stage-3 UX track on a user-facing task |
|
|
18
18
|
| **Evaluation status** | Suite authored, 5 categories. One recorded run, **self-observed by the author**; **zero blind runs on zero of three models** — the split, and the numbers, live in [`evals/RESULTS.md`](evals/RESULTS.md) and are computed by `evals/run.py` |
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* Where this project's run artifacts live — the shipped answer.
|
|
5
|
+
*
|
|
6
|
+
* The same ordered rule as `test/artifact_root.py`, which serves the validator.
|
|
7
|
+
* `test/artifact_root_test.py` runs both against
|
|
8
|
+
* `test/fixtures/artifact-root-cases.json` and fails when they disagree, which is
|
|
9
|
+
* what makes two implementations of one rule affordable at all.
|
|
10
|
+
*
|
|
11
|
+
* 1. `paths.artifacts` in `pipeline.json` wins outright. Any relative path.
|
|
12
|
+
* 2. otherwise the first KNOWN name that exists AND CARRIES A REGISTER.
|
|
13
|
+
* `docs/evidence/` before `docs/superpowers/`, so a partial migration
|
|
14
|
+
* resolves forward and moving one file at a time never splits a project.
|
|
15
|
+
* 3. otherwise `docs/evidence/`, the default for a project that has none.
|
|
16
|
+
*
|
|
17
|
+
* **Carrying a register is the whole difference between a root and a directory.**
|
|
18
|
+
* A project may keep an unrelated `docs/evidence/`; adopting it on bare existence
|
|
19
|
+
* would write a run's paperwork into somebody else's folder.
|
|
20
|
+
*
|
|
21
|
+
* **The answer is a record, not a string.** A bare path cannot say *this is the
|
|
22
|
+
* legacy name*, *records also sit over there*, or *the default landed on an
|
|
23
|
+
* occupied directory* — and a caller that cannot know those things writes blind.
|
|
24
|
+
*
|
|
25
|
+
* This file lives under `bin/` because that is what the package ships
|
|
26
|
+
* (`package.json` → `files`), and `bin/task-pipeline.js` is its only caller.
|
|
27
|
+
* Run directly it prints the record as JSON, which is how the Python test asks it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
/** Ordered. The new name first, so a partial migration resolves forward. */
|
|
34
|
+
const KNOWN = ['docs/evidence', 'docs/superpowers'];
|
|
35
|
+
|
|
36
|
+
/** The name this pipeline used until 2026-08-13. Supported, not deprecated. */
|
|
37
|
+
const LEGACY = 'docs/superpowers';
|
|
38
|
+
|
|
39
|
+
/** What makes a directory a root rather than a directory. Any ONE of these. */
|
|
40
|
+
const REGISTERS = ['retro.md', 'backlog.md', 'verification.md',
|
|
41
|
+
'specs', 'plans', 'briefs', 'retro'];
|
|
42
|
+
|
|
43
|
+
/** Does this directory hold any artifact this pipeline recognises? */
|
|
44
|
+
function carriesRegister(dir) {
|
|
45
|
+
let st;
|
|
46
|
+
try { st = fs.statSync(dir); } catch (e) { return false; }
|
|
47
|
+
if (!st.isDirectory()) return false;
|
|
48
|
+
return REGISTERS.some((name) => fs.existsSync(path.join(dir, name)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `paths.artifacts` from `pipeline.json`, or `null`.
|
|
53
|
+
*
|
|
54
|
+
* An unreadable or malformed config yields `null` rather than throwing: the
|
|
55
|
+
* resolver's job is to answer, and a project with a broken config still has a
|
|
56
|
+
* directory layout. The config's own validity is the schema check's business.
|
|
57
|
+
*/
|
|
58
|
+
function configured(project) {
|
|
59
|
+
let cfg;
|
|
60
|
+
try {
|
|
61
|
+
cfg = JSON.parse(fs.readFileSync(path.join(project, 'pipeline.json'), 'utf8'));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const value = cfg && cfg.paths && cfg.paths.artifacts;
|
|
66
|
+
if (typeof value === 'string' && value.trim()) {
|
|
67
|
+
return value.trim().replace(/\/+$/, '');
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `{root, reason, legacy, leftover, collision}` for a project directory.
|
|
74
|
+
* `root` is relative to `project` and posix-separated.
|
|
75
|
+
*/
|
|
76
|
+
function resolve(project) {
|
|
77
|
+
if (typeof project !== 'string' || !project) {
|
|
78
|
+
// A resolver handed nothing must not answer as though it were handed a
|
|
79
|
+
// project: standing instruction #1, in the one line where it is cheapest.
|
|
80
|
+
throw new TypeError('resolve() needs a project directory');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let root = configured(project);
|
|
84
|
+
let reason = root ? 'configured' : null;
|
|
85
|
+
|
|
86
|
+
if (!root) {
|
|
87
|
+
for (const name of KNOWN) {
|
|
88
|
+
if (carriesRegister(path.join(project, name))) {
|
|
89
|
+
root = name;
|
|
90
|
+
reason = name === LEGACY ? 'legacy' : 'found';
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!root) {
|
|
97
|
+
root = KNOWN[0];
|
|
98
|
+
reason = 'default';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `leftover` answers "what else carries records here", a different question
|
|
102
|
+
// from "which root won" — so it is computed the same way whatever chose the root.
|
|
103
|
+
const leftover = KNOWN.find(
|
|
104
|
+
(n) => n !== root && carriesRegister(path.join(project, n))
|
|
105
|
+
) || null;
|
|
106
|
+
|
|
107
|
+
// The default landing on a directory that exists but is not a root: answer,
|
|
108
|
+
// and say so, so the caller asks instead of writing into it.
|
|
109
|
+
let collision = false;
|
|
110
|
+
if (reason === 'default') {
|
|
111
|
+
const dir = path.join(project, root);
|
|
112
|
+
collision = fs.existsSync(dir) && !carriesRegister(dir);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { root, reason, legacy: reason === 'legacy', leftover, collision };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { resolve, carriesRegister, configured, KNOWN, LEGACY, REGISTERS };
|
|
119
|
+
|
|
120
|
+
if (require.main === module) {
|
|
121
|
+
const target = process.argv[2] || process.cwd();
|
|
122
|
+
process.stdout.write(JSON.stringify(resolve(target)) + '\n');
|
|
123
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* `migrate-artifacts` — move a project's paperwork off the legacy root, on request.
|
|
4
|
+
*
|
|
5
|
+
* **Nothing calls this on its own.** The resolver supports `docs/superpowers/`
|
|
6
|
+
* indefinitely and no run warns about it, so a project that never runs this command is
|
|
7
|
+
* not behind. This exists because `task-pipeline` is published: without it, a project
|
|
8
|
+
* that WANTS the new name has to do the move by hand and guess what else to touch.
|
|
9
|
+
*
|
|
10
|
+
* What it does, and the line it does not cross:
|
|
11
|
+
*
|
|
12
|
+
* - **It moves the directory.** That part is mechanical and safe to automate.
|
|
13
|
+
* - **It does not rewrite your prose.** Files elsewhere in the project that name the
|
|
14
|
+
* old path are LISTED, never edited. A command that rewrote arbitrary documents in
|
|
15
|
+
* somebody's repository would be deciding, for them, which mentions were a path in
|
|
16
|
+
* use and which were a path being discussed — the exact distinction a mechanical
|
|
17
|
+
* replace cannot make, and one this pipeline got wrong in its own sweep before
|
|
18
|
+
* shipping this file.
|
|
19
|
+
* - **A partial state is a state, not a fault.** Where both roots exist it moves what
|
|
20
|
+
* does not collide and names what does. Nothing is ever overwritten.
|
|
21
|
+
* - **A configured root is refused, not overridden.** `paths.artifacts` is the
|
|
22
|
+
* operator saying where the paperwork goes; moving it would contradict the config
|
|
23
|
+
* the resolver is about to read.
|
|
24
|
+
*
|
|
25
|
+
* The backup is a mechanism, not a habit: a copy that cannot be taken CANCELS the
|
|
26
|
+
* move. Copies land under `.task-pipeline/backups/`, and a name already taken in the
|
|
27
|
+
* same second gets a suffix — two backups inside one second were one backup once, and
|
|
28
|
+
* the second overwrote the first.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const { execFileSync } = require('child_process');
|
|
34
|
+
|
|
35
|
+
const { resolve, LEGACY, KNOWN } = require('./artifact-root.js');
|
|
36
|
+
|
|
37
|
+
const NEW = KNOWN[0];
|
|
38
|
+
|
|
39
|
+
/** Every file under `dir`, repo-relative, posix-separated. */
|
|
40
|
+
function walk(dir, base, out) {
|
|
41
|
+
out = out || [];
|
|
42
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
43
|
+
const full = path.join(dir, e.name);
|
|
44
|
+
if (e.isDirectory()) walk(full, base, out);
|
|
45
|
+
else out.push(path.relative(base, full).split(path.sep).join('/'));
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Is this a git work tree? `git mv` keeps the history a rename would drop. */
|
|
51
|
+
function inGitWorkTree(project) {
|
|
52
|
+
try {
|
|
53
|
+
execFileSync('git', ['-C', project, 'rev-parse', '--is-inside-work-tree'],
|
|
54
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] });
|
|
55
|
+
return true;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which files elsewhere in the project still name the old path.
|
|
63
|
+
*
|
|
64
|
+
* Reported, never rewritten. Skips the artifact roots themselves — a brief written in
|
|
65
|
+
* March describes where things were in March, and correcting it would falsify the
|
|
66
|
+
* record it exists to be.
|
|
67
|
+
*/
|
|
68
|
+
function mentionsElsewhere(project) {
|
|
69
|
+
const hits = [];
|
|
70
|
+
const skipDirs = new Set(['.git', 'node_modules', 'graphify-out', 'dist', 'build']);
|
|
71
|
+
const exts = ['.md', '.mdc', '.json', '.yml', '.yaml', '.sh', '.py', '.js', '.ts'];
|
|
72
|
+
const walkAll = (dir) => {
|
|
73
|
+
let entries;
|
|
74
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
const full = path.join(dir, e.name);
|
|
77
|
+
const rel = path.relative(project, full).split(path.sep).join('/');
|
|
78
|
+
if (e.isDirectory()) {
|
|
79
|
+
if (skipDirs.has(e.name)) continue;
|
|
80
|
+
if (rel === LEGACY || rel === NEW) continue; // the records themselves
|
|
81
|
+
walkAll(full);
|
|
82
|
+
} else if (exts.includes(path.extname(e.name))) {
|
|
83
|
+
let text;
|
|
84
|
+
try { text = fs.readFileSync(full, 'utf8'); } catch (err) { continue; }
|
|
85
|
+
const n = text.split(`${LEGACY}/`).length - 1;
|
|
86
|
+
if (n) hits.push({ file: rel, count: n });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
walkAll(project);
|
|
91
|
+
return hits.sort((a, b) => b.count - a.count);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What the move would do. Pure: reads the tree, writes nothing.
|
|
96
|
+
*
|
|
97
|
+
* `moves` are safe; `collisions` are files whose target already exists and which are
|
|
98
|
+
* therefore left where they are. `mentions` is the list this command refuses to edit.
|
|
99
|
+
*/
|
|
100
|
+
function plan(project) {
|
|
101
|
+
const info = resolve(project);
|
|
102
|
+
const legacyAbs = path.join(project, LEGACY);
|
|
103
|
+
|
|
104
|
+
if (info.reason === 'configured') {
|
|
105
|
+
return { action: 'refused', info,
|
|
106
|
+
why: `pipeline.json sets paths.artifacts to "${info.root}". Moving the `
|
|
107
|
+
+ 'directory would contradict the config the resolver reads first. '
|
|
108
|
+
+ 'Change or remove that key first if the move is what you want.' };
|
|
109
|
+
}
|
|
110
|
+
if (!fs.existsSync(legacyAbs)) {
|
|
111
|
+
return { action: 'nothing', info,
|
|
112
|
+
why: `no ${LEGACY}/ in this project — the root is already "${info.root}".` };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const files = walk(legacyAbs, legacyAbs);
|
|
116
|
+
const moves = [];
|
|
117
|
+
const collisions = [];
|
|
118
|
+
for (const rel of files) {
|
|
119
|
+
const target = path.join(project, NEW, rel);
|
|
120
|
+
if (fs.existsSync(target)) collisions.push(`${NEW}/${rel}`);
|
|
121
|
+
else moves.push({ from: `${LEGACY}/${rel}`, to: `${NEW}/${rel}` });
|
|
122
|
+
}
|
|
123
|
+
return { action: 'move', info, moves, collisions,
|
|
124
|
+
mentions: mentionsElsewhere(project) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A preview that shows what LEAVES, not only what arrives. */
|
|
128
|
+
function render(p) {
|
|
129
|
+
const L = [];
|
|
130
|
+
if (p.action === 'refused') { L.push(`refused: ${p.why}`); return L.join('\n'); }
|
|
131
|
+
if (p.action === 'nothing') { L.push(`nothing to do: ${p.why}`); return L.join('\n'); }
|
|
132
|
+
|
|
133
|
+
L.push(`artifact root: ${p.info.root} (${p.info.reason})`);
|
|
134
|
+
L.push('');
|
|
135
|
+
L.push(`${p.moves.length} file(s) move:`);
|
|
136
|
+
for (const m of p.moves) {
|
|
137
|
+
L.push(` - ${m.from}`); // what leaves
|
|
138
|
+
L.push(` + ${m.to}`); // and where it arrives
|
|
139
|
+
}
|
|
140
|
+
if (p.collisions.length) {
|
|
141
|
+
L.push('');
|
|
142
|
+
L.push(`${p.collisions.length} file(s) NOT moved — the target already exists and `
|
|
143
|
+
+ 'nothing here overwrites:');
|
|
144
|
+
for (const c of p.collisions) L.push(` ! ${c}`);
|
|
145
|
+
L.push(` ${LEGACY}/ stays in place for these.`);
|
|
146
|
+
}
|
|
147
|
+
if (p.mentions.length) {
|
|
148
|
+
L.push('');
|
|
149
|
+
L.push(`${p.mentions.length} file(s) elsewhere still name ${LEGACY}/ — `
|
|
150
|
+
+ 'LISTED, NOT EDITED:');
|
|
151
|
+
for (const m of p.mentions) L.push(` ? ${m.file} (${m.count})`);
|
|
152
|
+
L.push(' Some of those are paths in use and some are sentences about the old');
|
|
153
|
+
L.push(' name. Only you can tell them apart, so this command will not try.');
|
|
154
|
+
}
|
|
155
|
+
return L.join('\n');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A copy that cannot be taken cancels the move. Returns the backup directory. */
|
|
159
|
+
function backup(project, stamp) {
|
|
160
|
+
const dir = path.join(project, '.task-pipeline', 'backups');
|
|
161
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
162
|
+
let target = path.join(dir, `artifacts-${stamp}`);
|
|
163
|
+
let n = 1;
|
|
164
|
+
// A stamp resolves to the second and an agent moves faster than that: a name
|
|
165
|
+
// already taken gets a suffix rather than the previous copy's contents.
|
|
166
|
+
while (fs.existsSync(target)) target = path.join(dir, `artifacts-${stamp}-${++n}`);
|
|
167
|
+
fs.cpSync(path.join(project, LEGACY), target, { recursive: true });
|
|
168
|
+
const copied = walk(target, target).length;
|
|
169
|
+
const wanted = walk(path.join(project, LEGACY), path.join(project, LEGACY)).length;
|
|
170
|
+
if (copied !== wanted) {
|
|
171
|
+
throw new Error(`backup incomplete (${copied}/${wanted} files) — move cancelled`);
|
|
172
|
+
}
|
|
173
|
+
return target;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Do it. Returns `{plan, backupDir, moved}`. */
|
|
177
|
+
function apply(project, opts) {
|
|
178
|
+
const o = opts || {};
|
|
179
|
+
const p = plan(project);
|
|
180
|
+
if (p.action !== 'move') return { plan: p, backupDir: null, moved: 0 };
|
|
181
|
+
|
|
182
|
+
// Nothing movable is nothing to do — and a backup taken anyway would make every
|
|
183
|
+
// repeat run change the tree it claims to leave alone. Found by the three-run
|
|
184
|
+
// fixture, not by reading: the plan was right and the command that repeats was not,
|
|
185
|
+
// which is standing instruction #2 arriving in the file that cites it.
|
|
186
|
+
if (!p.moves.length) return { plan: p, backupDir: null, moved: 0 };
|
|
187
|
+
|
|
188
|
+
const backupDir = backup(project, o.stamp || 'unstamped');
|
|
189
|
+
const git = inGitWorkTree(project);
|
|
190
|
+
let moved = 0;
|
|
191
|
+
for (const m of p.moves) {
|
|
192
|
+
const from = path.join(project, m.from);
|
|
193
|
+
const to = path.join(project, m.to);
|
|
194
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
195
|
+
if (git) {
|
|
196
|
+
try {
|
|
197
|
+
execFileSync('git', ['-C', project, 'mv', m.from, m.to], { stdio: 'ignore' });
|
|
198
|
+
} catch (e) {
|
|
199
|
+
fs.renameSync(from, to); // untracked files git mv refuses
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
fs.renameSync(from, to);
|
|
203
|
+
}
|
|
204
|
+
moved += 1;
|
|
205
|
+
}
|
|
206
|
+
// The legacy directory goes only when nothing is left in it — a collision means
|
|
207
|
+
// records still live there and removing it would delete them.
|
|
208
|
+
pruneEmpty(path.join(project, LEGACY));
|
|
209
|
+
return { plan: p, backupDir, moved };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Remove empty directories, deepest first. Never removes a directory with content. */
|
|
213
|
+
function pruneEmpty(dir) {
|
|
214
|
+
if (!fs.existsSync(dir)) return;
|
|
215
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
216
|
+
if (e.isDirectory()) pruneEmpty(path.join(dir, e.name));
|
|
217
|
+
}
|
|
218
|
+
if (!fs.readdirSync(dir).length) fs.rmdirSync(dir);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { plan, render, apply, backup, mentionsElsewhere, NEW, LEGACY };
|
package/bin/task-pipeline.js
CHANGED
|
@@ -26,6 +26,14 @@ Usage:
|
|
|
26
26
|
into ~/.claude (skip existing unless --force)
|
|
27
27
|
npx task-pipeline-skill --help
|
|
28
28
|
|
|
29
|
+
npx task-pipeline-skill migrate-artifacts [--dry-run]
|
|
30
|
+
move this project's paperwork from the legacy
|
|
31
|
+
docs/superpowers/ to docs/evidence/. Optional:
|
|
32
|
+
the legacy name is supported forever and no run
|
|
33
|
+
warns about it. Moves the directory, LISTS every
|
|
34
|
+
other file that names the old path, and edits
|
|
35
|
+
none of them.
|
|
36
|
+
|
|
29
37
|
Other install paths:
|
|
30
38
|
Claude Code plugin: /plugin marketplace add ${REPO}
|
|
31
39
|
/plugin install task-pipeline@task-pipeline
|
|
@@ -87,12 +95,62 @@ function offerRouters() {
|
|
|
87
95
|
}
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
/**
|
|
99
|
+
* `migrate-artifacts` — the one verb this installer grew.
|
|
100
|
+
*
|
|
101
|
+
* Kept here rather than in a second binary because the package ships one `bin`, and a
|
|
102
|
+
* project that wants the move should not have to learn a second command name. The work
|
|
103
|
+
* itself is in `lib/migrate-artifacts.js`, which is pure up to the moment it copies.
|
|
104
|
+
*/
|
|
105
|
+
function migrateArtifacts(args) {
|
|
106
|
+
const mig = require('./lib/migrate-artifacts.js');
|
|
107
|
+
const project = process.cwd();
|
|
108
|
+
const dry = args.includes('--dry-run');
|
|
109
|
+
const unknown = args.filter((a) => a !== '--dry-run');
|
|
110
|
+
if (unknown.length) {
|
|
111
|
+
console.error(`unknown argument(s) for migrate-artifacts: ${unknown.join(' ')}`);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const p = mig.plan(project);
|
|
116
|
+
console.log(mig.render(p));
|
|
117
|
+
if (p.action === 'refused') return 3;
|
|
118
|
+
if (p.action === 'nothing') return 0;
|
|
119
|
+
if (dry) {
|
|
120
|
+
console.log('\n--dry-run: nothing was written.');
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let r;
|
|
125
|
+
try {
|
|
126
|
+
r = mig.apply(project, { stamp: new Date().toISOString().replace(/[:.]/g, '-') });
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// A copy that cannot be taken cancels the move, and says so instead of
|
|
129
|
+
// degrading to "moved anyway".
|
|
130
|
+
console.error(`\nmove cancelled: ${e.message}`);
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
console.log(`\nbackup: ${r.backupDir}`);
|
|
134
|
+
console.log(`moved: ${r.moved} file(s)`);
|
|
135
|
+
if (r.plan.collisions.length) {
|
|
136
|
+
console.log(`kept: ${r.plan.collisions.length} file(s) left in `
|
|
137
|
+
+ `${mig.LEGACY}/ — their targets already existed`);
|
|
138
|
+
}
|
|
139
|
+
if (r.plan.mentions.length) {
|
|
140
|
+
console.log(`review: ${r.plan.mentions.length} file(s) still name ${mig.LEGACY}/ `
|
|
141
|
+
+ '— listed above, none edited');
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
90
146
|
function main(argv) {
|
|
91
147
|
const args = argv.slice(2);
|
|
92
148
|
if (args.includes('--help') || args.includes('-h')) {
|
|
93
149
|
usage();
|
|
94
150
|
return 0;
|
|
95
151
|
}
|
|
152
|
+
if (args[0] === 'migrate-artifacts') return migrateArtifacts(args.slice(1));
|
|
153
|
+
|
|
96
154
|
const force = args.includes('--force');
|
|
97
155
|
const unknown = args.filter((a) => a !== '--force');
|
|
98
156
|
if (unknown.length) {
|
|
@@ -96,7 +96,7 @@ ledger. A date is the graph's own reply about itself; the lag is the only part
|
|
|
96
96
|
that says whether it describes the tree you are about to change. A graph goes stale
|
|
97
97
|
like any other source, and it **points while the code decides** — never the
|
|
98
98
|
tiebreaker.
|
|
99
|
-
**Read `docs/
|
|
99
|
+
**Read `docs/evidence/retro.md` in full if it exists** — its *standing
|
|
100
100
|
instructions* are what previous runs of this pipeline got wrong in this project, and
|
|
101
101
|
they bind this run (they are capped at ten, so reading them all is cheap). Stamp
|
|
102
102
|
each one the moment it fires: that date is the only evidence behind the
|
|
@@ -174,12 +174,12 @@ Seed a **carry-over ledger** next to the brief, append-only. Anything deferred,
|
|
|
174
174
|
dropped, or left half-done goes in the moment it's said — including implementer
|
|
175
175
|
concerns and non-blocking review findings. **Deferred out loud is forgotten.**
|
|
176
176
|
|
|
177
|
-
Read **the verification ledger** — `docs/
|
|
177
|
+
Read **the verification ledger** — `docs/evidence/verification.md` — and quote how
|
|
178
178
|
many rows sit at `never`: that is the project's standing exposure, and stage 0 is where
|
|
179
179
|
it is cheapest to look. `never` is a fact, not a failure; the count has no floor and may
|
|
180
180
|
never be given a target.
|
|
181
181
|
|
|
182
|
-
Read **the board** — `docs/
|
|
182
|
+
Read **the board** — `docs/evidence/backlog.md`, the project's work-list *between*
|
|
183
183
|
runs — and quote its open count in the brief; seed it from the template when absent. Its
|
|
184
184
|
priority is **computed, never assigned**: `prio = sev × blast + age_bonus`, with the
|
|
185
185
|
three inputs in the row so a ranking can be checked rather than trusted, and `age` moving
|
|
@@ -342,7 +342,7 @@ not authorize an outward, irreversible action — stage 7 stops and asks.
|
|
|
342
342
|
`git -C <repo> status --porcelain`, `git -C <repo> log @{u}..HEAD --oneline`.
|
|
343
343
|
The fix is two commands and the second is the one that gets forgotten:
|
|
344
344
|
`git -C <submodule> push`, then `git add <submodule> && git commit`.
|
|
345
|
-
**The last act is the retrospective** — `docs/
|
|
345
|
+
**The last act is the retrospective** — `docs/evidence/retro.md`, **one file
|
|
346
346
|
per project, not per run**. Every gate above is good at *this* run and blind
|
|
347
347
|
across runs: the same class of failure gets caught, fixed and forgotten five
|
|
348
348
|
times and nothing notices it is the same one. So, in this order —
|
|
@@ -352,7 +352,7 @@ not authorize an outward, irreversible action — stage 7 stops and asks.
|
|
|
352
352
|
|
|
353
353
|
**0. Resolve the ledger onto the board first.** Every carry-over row still
|
|
354
354
|
`open`, `unresolved`, or homed `backlog` leaves with a `B-NNN` id on
|
|
355
|
-
`docs/
|
|
355
|
+
`docs/evidence/backlog.md`, and the ledger row is updated to name it — both
|
|
356
356
|
directions, because a pointer to an id nobody issued and a board row traceable to
|
|
357
357
|
nothing are different failures. Then re-derive the board's priorities: `age` moved
|
|
358
358
|
while the run was happening.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "task-pipeline-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.53.0",
|
|
4
4
|
"description": "Full-cycle delivery pipeline for coding agents: a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine ships inside the skill — no companion plugin required. This package is the installer CLI.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"task-pipeline": "bin/task-pipeline.js"
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
"test": "python3 test/validate.py",
|
|
10
10
|
"test:negatives": "python3 test/negatives.py",
|
|
11
11
|
"test:probe": "python3 test/probe.py --self-test",
|
|
12
|
-
"test:all": "python3 test/validate.py && python3 test/negatives.py && npm run test:probe && npm run test:hooks",
|
|
13
|
-
"test:hooks": "python3 test/release_gate_test.py"
|
|
12
|
+
"test:all": "python3 test/validate.py && python3 test/negatives.py && npm run test:probe && npm run test:hooks && npm run test:artifacts",
|
|
13
|
+
"test:hooks": "python3 test/release_gate_test.py",
|
|
14
|
+
"test:artifacts": "python3 test/artifact_root_test.py && python3 test/migrate_artifacts_test.py"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"bin",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "task-pipeline",
|
|
3
3
|
"displayName": "Task Pipeline",
|
|
4
4
|
"description": "Runs a substantial task through a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, subagent build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine is built into the skill — no companion plugin required — with typed auto/manual gates, a frozen requirement spine that closes with evidence, a work board and a verification ledger that outlive a run, an exposure line naming what shipped unconfirmed, a progress rail computed from the project's own config, a loop guard whose review ceiling measures rather than stops, and stage-3 tracks for what a product does, how it sounds and how it looks. Two modes need no task: `checkup` (what is unverified) and `setup` (audit existing docs). Retro insights can publish upstream as issues, opt-in and redacted.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.53.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ssheleg",
|
|
8
8
|
"url": "https://x.com/sshlg93"
|