@antoneeo/mkt-agentic-sdlc-skill 0.3.0 → 0.4.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/README.md +4 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/scripts/init.js +31 -10
- package/scripts/lib.js +233 -183
- package/skills/mkt-agentic-sdlc/SKILL.md +4 -1
- package/skills/mkt-agentic-sdlc/research.md +7 -1
- package/skills/mkt-agentic-sdlc/review.md +12 -0
- package/skills/mkt-agentic-sdlc/scripts/mkt_check.py +46 -2
- package/skills/mkt-agentic-sdlc/scripts/sdlc_core.py +199 -3
- package/skills/mkt-agentic-sdlc/templates.md +87 -34
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-08-03
|
|
4
|
+
|
|
5
|
+
F-028 — several people on one project. `templates.md` had claimed the workstream
|
|
6
|
+
registry was "Parallel-safe by construction" since F-019, and nothing had ever exercised
|
|
7
|
+
it. Two workstreams opened from one base conflict **twice in one file**: on the row
|
|
8
|
+
insert, and on the file-global `Date:` header. Row-level ownership cannot save a file
|
|
9
|
+
that has a file-level field.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **`audit/handoff.md` is now GENERATED** from one `HANDOFF_[unit].md` per open
|
|
13
|
+
workstream, whose frontmatter IS the row. Two writers on two workstreams touch two
|
|
14
|
+
different files, and the `Date:` header is derived — from the newest `updated:` VALUE
|
|
15
|
+
in the sources, never a filesystem timestamp (git does not preserve mtimes, and an
|
|
16
|
+
mtime-derived header would make the file regenerate differently in every fresh clone).
|
|
17
|
+
- **`HANDOFF_[unit].md` is now written for every OPEN workstream**, with or without
|
|
18
|
+
volatile state — no file, no row. It still carries the resume logistics, and deleting
|
|
19
|
+
it at closure *is* what removes the row. The DRY boundary against the ANALYSIS Diary
|
|
20
|
+
is restated in the template, because what used to keep narrative out of that file was
|
|
21
|
+
its rarity, and the rarity is gone.
|
|
22
|
+
- Project-wide notes move to their own source, `audit/project_notes.md`, so generating
|
|
23
|
+
the registry cannot destroy notes that belong to no workstream.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **`validate` errors when the registry disagrees with its sources** — which is what
|
|
27
|
+
turns a merge resolved carelessly from permanent into loud. Resolution is mechanical:
|
|
28
|
+
re-run `index`. The generated view can still conflict; the authored truth does not.
|
|
29
|
+
- **`index` refuses to write while anything in the file is unaccounted for**, and names
|
|
30
|
+
it. Converting one row at a time is the state that loses the others, so conversion is
|
|
31
|
+
per project. A project with no sources is untouched and sees no new finding.
|
|
32
|
+
- **`init` writes a `.gitattributes` stanza** giving the append-only review log
|
|
33
|
+
`merge=union` — a **built-in** driver, unlike `merge=ours`, which silently does
|
|
34
|
+
nothing until every clone runs `git config` and leaves the file wrong even then.
|
|
35
|
+
Create-only; a user's own `.gitattributes` is never clobbered.
|
|
36
|
+
- `scripts/test_merge_safety.py` (shared, ×3): the experiment that found the defect,
|
|
37
|
+
kept as a regression test — two workstreams from one base, merged, with the assertion
|
|
38
|
+
that only the generated view may conflict and that regenerating loses no state. Plus
|
|
39
|
+
the mixed-state, fresh-mtime, ordering, cap and duplicate-id guards, each
|
|
40
|
+
mutation-verified.
|
|
41
|
+
- A warning when two files claim the same workstream: the collision this design does
|
|
42
|
+
**not** fix (two people opening the same work under two names) must not pass as two
|
|
43
|
+
ordinary rows.
|
|
44
|
+
|
|
45
|
+
The mechanism is files and a generator: **it works with no VCS at all.** The
|
|
46
|
+
`.gitattributes` stanza is defence in depth — without it the outcome is today's, one
|
|
47
|
+
conflict resolved by hand, never a lost row.
|
|
48
|
+
|
|
49
|
+
### Upgrading
|
|
50
|
+
Nothing happens until you convert: with no `HANDOFF_*.md` sources, `index` and
|
|
51
|
+
`validate` behave exactly as before. When you convert, convert the whole registry at
|
|
52
|
+
once — `index` will tell you what is still unaccounted for.
|
|
53
|
+
|
|
54
|
+
## 0.3.1 — 2026-08-02
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
- **The multi-lens routing note announced this skill as the code lens.** `init.js` wrote
|
|
58
|
+
the row for the CURRENT lens as a hardcoded literal copied from the code distribution,
|
|
59
|
+
so a project initialised with `mkt-sdlc-init` listed `agentic-sdlc` twice and never
|
|
60
|
+
named `mkt-agentic-sdlc`. Both the self row and the sibling table are now derived from
|
|
61
|
+
the shared `routing.md` lens table. Found by a cold-agent field test.
|
|
62
|
+
- **`FACT` was the way past the evidence rule.** `BENCHMARK` owes a source URL and
|
|
63
|
+
`ASSUMPTION` a range; `FACT` owed nothing, so a researched observation classed FACT
|
|
64
|
+
with source "see research/VOC.md" entered the ledger unsourced. `mkt_check.py` now
|
|
65
|
+
errors on a FACT with no source, or one pointing at a document of the engagement,
|
|
66
|
+
while exempting a cell that names the client as origin. `research.md` states the two
|
|
67
|
+
legal origins.
|
|
68
|
+
- `review.md` (shared spine): a review verdict travels as the reviewer's final output.
|
|
69
|
+
|
|
70
|
+
## 0.3.0 — 2026-08-01
|
|
71
|
+
|
|
72
|
+
- **One shared core, three lenses.** This skill is now built from the family's spine
|
|
73
|
+
instead of a copy of it: triage, the Vision gate, the review gates, the guide router,
|
|
74
|
+
question discipline and the validator core are byte-identical with
|
|
75
|
+
`@antoneeo/agentic-sdlc-skill` and `@antoneeo/kb-agentic-skill`, and a drift guard
|
|
76
|
+
verifies it on every build. `routing.md` decides which lens owns a unit of work when
|
|
77
|
+
more than one is installed.
|
|
78
|
+
- `mkt_check.py` forwards every spine subcommand it does not intercept by iterating the
|
|
79
|
+
spine's parser — the hand-copied command tuple used to drop `migrate` while `SKILL.md`
|
|
80
|
+
documented it.
|
|
81
|
+
|
|
3
82
|
## 0.2.1 — 2026-07-09
|
|
4
83
|
|
|
5
84
|
- **Install DX fix**: README + the generated project protocol pointer no longer present `mkt-sdlc-install-skill` as a required second step. `npm i -g` alone installs the skill (the `postinstall` runs the installer); the manual command is documented as a fallback via `npx mkt-sdlc-install-skill` for setups that block install scripts, with an explicit note that the bare command is only on PATH after a global install. Prevents the "command not recognized" error after a local `npm i`.
|
package/README.md
CHANGED
|
@@ -47,6 +47,10 @@ npx mkt-sdlc-init # creates mkt_docs/ + protocol pointers
|
|
|
47
47
|
| E3 Full Plan | complete marketing plan | full SOSTAC workflow, 9 phases, all gates |
|
|
48
48
|
| Research Spike | time-boxed market question | `mkt_docs/spikes/RESEARCH_[topic].md` |
|
|
49
49
|
|
|
50
|
+
## Several people, one project
|
|
51
|
+
|
|
52
|
+
The workstream registry (`audit/handoff.md`) is **generated** from one file per open workstream, so two people opening or closing two workstreams on two branches edit two different files and their merge is clean. Row-per-workstream alone was not enough — a file-global `Date:` header defeats row-level ownership — so the header is derived from the sources and no writer touches it. The generated view can still conflict; that conflict is resolved by re-running `index`, never by hand, and `validate` refuses CLEAN until the file matches its sources. The append-only review log gets `merge=union` (a built-in driver, no per-clone configuration). It all works with no VCS at all: it is files and a generator.
|
|
53
|
+
|
|
50
54
|
## Modes
|
|
51
55
|
|
|
52
56
|
- **Standalone** — everything on the filesystem under `mkt_docs/`.
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mkt-agentic-sdlc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Evidence-First marketing planning protocol with engagement triage, SOSTAC workflow, evidence ledger and optional devPNT integration.",
|
|
5
5
|
"author": "Antonio Pinto (https://github.com/Antoneeo)"
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antoneeo/mkt-agentic-sdlc-skill",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Evidence-First marketing planning protocol for Claude Code, Gemini CLI, Google Antigravity and Codex: engagement triage, SOSTAC workflow, evidence ledger, mechanical validator and optional devPNT integration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
package/scripts/init.js
CHANGED
|
@@ -3,14 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { execSync } = require('child_process');
|
|
6
|
-
const { SKILL_SOURCE, CLIENTS, clientDetected, skillTarget, loadTemplates, templateFor } = require('./lib');
|
|
7
|
-
|
|
8
|
-
// Sibling lenses of the same family: one shared core, one `ai_docs/` tree, a different
|
|
9
|
-
// fidelity discipline each. Keyed by the installed skill directory name.
|
|
10
|
-
const SIBLING_LENSES = {
|
|
11
|
-
'kb-agentic': 'knowledge',
|
|
12
|
-
'agentic-sdlc': 'code',
|
|
13
|
-
};
|
|
6
|
+
const { SKILL_SOURCE, INSTALLED_SKILL_NAME, SELF_LENS, SIBLING_LENSES, CLIENTS, clientDetected, skillTarget, loadTemplates, templateFor } = require('./lib');
|
|
14
7
|
|
|
15
8
|
const cwd = process.cwd();
|
|
16
9
|
|
|
@@ -110,6 +103,34 @@ const writeIfNotExists = (relPath, content, description) => {
|
|
|
110
103
|
|
|
111
104
|
seedFiles.forEach(([relPath, content]) => writeIfNotExists(relPath, content));
|
|
112
105
|
|
|
106
|
+
const EOL = String.fromCharCode(10);
|
|
107
|
+
// 5b. Merge hygiene for the append-only review log (F-028, defence in depth).
|
|
108
|
+
// `merge=union` is a BUILT-IN driver: no per-clone `git config`, unlike
|
|
109
|
+
// `merge=ours`, which silently does nothing until every clone configures it.
|
|
110
|
+
// Rows are date-stamped, so interleaving them loses no information and their
|
|
111
|
+
// order carries none. Create-only, and appended rather than rewritten: a user's
|
|
112
|
+
// own .gitattributes is never clobbered.
|
|
113
|
+
const gitattributes = () => {
|
|
114
|
+
const marker = 'mkt_docs/audit/reviews/REVIEW_LOG.md';
|
|
115
|
+
const stanza = [
|
|
116
|
+
'',
|
|
117
|
+
'# agentic-sdlc: the review log is append-only and its rows are date-stamped,',
|
|
118
|
+
'# so a union merge keeps both sides instead of asking a human to pick one.',
|
|
119
|
+
marker + ' merge=union',
|
|
120
|
+
'',
|
|
121
|
+
].join(EOL);
|
|
122
|
+
const filePath = path.join(cwd, '.gitattributes');
|
|
123
|
+
if (!fs.existsSync(path.join(cwd, '.git'))) return; // not a git repo: nothing to do
|
|
124
|
+
const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
125
|
+
if (current.includes(marker)) {
|
|
126
|
+
console.log('SKIP .gitattributes already covers the review log.');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
fs.writeFileSync(filePath, current + stanza, 'utf8');
|
|
130
|
+
console.log('OK .gitattributes: review log set to merge=union (concurrent reviews merge cleanly).');
|
|
131
|
+
};
|
|
132
|
+
gitattributes();
|
|
133
|
+
|
|
113
134
|
// 6. Client discovery and protocol pointers
|
|
114
135
|
console.log('\n--- Environment Analysis ---');
|
|
115
136
|
|
|
@@ -159,7 +180,7 @@ if (siblings.size > 0) {
|
|
|
159
180
|
This project has more than one lens of the Agentic SDLC family installed:
|
|
160
181
|
|
|
161
182
|
${list}
|
|
162
|
-
- \`
|
|
183
|
+
- \`${INSTALLED_SKILL_NAME}\` — the **${SELF_LENS}** lens
|
|
163
184
|
|
|
164
185
|
One \`ai_docs/\` tree, one project default (\`default_domain:\` in \`ai_docs/README.md\`),
|
|
165
186
|
one lens per unit of work. Before acting on any L2, L3 or Spike, run the domain router
|
|
@@ -186,7 +207,7 @@ Delete it once the merge is done.
|
|
|
186
207
|
`, 'multi-lens routing note');
|
|
187
208
|
if (wrote && protocolPreexisting) {
|
|
188
209
|
console.log(' ⚠️ A protocol pointer already existed and was NOT overwritten.');
|
|
189
|
-
console.log(
|
|
210
|
+
console.log(` Merge the ${SELF_LENS}-lens ladder from AGENTIC_MULTI_LENS.md into it by hand.`);
|
|
190
211
|
}
|
|
191
212
|
}
|
|
192
213
|
|
package/scripts/lib.js
CHANGED
|
@@ -1,183 +1,233 @@
|
|
|
1
|
-
// Shared helpers for the Marketing Agentic SDLC npm scripts (init / postinstall / preuninstall).
|
|
2
|
-
// Single source for client detection and skill-target paths: init and postinstall
|
|
3
|
-
// must never disagree on what "Claude Code is installed" means.
|
|
4
|
-
|
|
5
|
-
const fs = require('fs');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const os = require('os');
|
|
8
|
-
const { execSync } = require('child_process');
|
|
9
|
-
|
|
10
|
-
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
11
|
-
const SKILL_SOURCE = path.join(PACKAGE_ROOT, 'skills', 'mkt-agentic-sdlc');
|
|
12
|
-
const TEMPLATES_PATH = path.join(SKILL_SOURCE, 'templates.md');
|
|
13
|
-
// Kept for backward compatibility with this package's own scripts; the
|
|
14
|
-
// authoritative value is INSTALLED_SKILL_NAME, derived from SKILL.md below.
|
|
15
|
-
// The directory name each client loads the skill from. Derived from the manifest,
|
|
16
|
-
// never hard-coded by a consumer: three distributions share these scripts, and a
|
|
17
|
-
// literal here is how a copy-fork starts installing under its sibling's name.
|
|
18
|
-
const INSTALLED_SKILL_NAME = (() => {
|
|
19
|
-
const m = fs.readFileSync(path.join(SKILL_SOURCE, 'SKILL.md'), 'utf8').match(/^name:\s*(\S+)/m);
|
|
20
|
-
if (!m) throw new Error(`SKILL.md carries no 'name:' field: ${SKILL_SOURCE}`);
|
|
21
|
-
return m[1];
|
|
22
|
-
})();
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
{
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
1
|
+
// Shared helpers for the Marketing Agentic SDLC npm scripts (init / postinstall / preuninstall).
|
|
2
|
+
// Single source for client detection and skill-target paths: init and postinstall
|
|
3
|
+
// must never disagree on what "Claude Code is installed" means.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
11
|
+
const SKILL_SOURCE = path.join(PACKAGE_ROOT, 'skills', 'mkt-agentic-sdlc');
|
|
12
|
+
const TEMPLATES_PATH = path.join(SKILL_SOURCE, 'templates.md');
|
|
13
|
+
// Kept for backward compatibility with this package's own scripts; the
|
|
14
|
+
// authoritative value is INSTALLED_SKILL_NAME, derived from SKILL.md below.
|
|
15
|
+
// The directory name each client loads the skill from. Derived from the manifest,
|
|
16
|
+
// never hard-coded by a consumer: three distributions share these scripts, and a
|
|
17
|
+
// literal here is how a copy-fork starts installing under its sibling's name.
|
|
18
|
+
const INSTALLED_SKILL_NAME = (() => {
|
|
19
|
+
const m = fs.readFileSync(path.join(SKILL_SOURCE, 'SKILL.md'), 'utf8').match(/^name:\s*(\S+)/m);
|
|
20
|
+
if (!m) throw new Error(`SKILL.md carries no 'name:' field: ${SKILL_SOURCE}`);
|
|
21
|
+
return m[1];
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
// The family's lens table, read from the shared `routing.md` rather than restated
|
|
25
|
+
// here. Same reason as INSTALLED_SKILL_NAME above, and the same failure it prevents:
|
|
26
|
+
// BOTH the row for this lens and the rows for its siblings used to be literals copied
|
|
27
|
+
// between distributions, so kb and mkt wrote a multi-lens note announcing themselves
|
|
28
|
+
// as the code lens. routing.md is one of the byte-identical shared files, so this
|
|
29
|
+
// lookup cannot drift between distributions.
|
|
30
|
+
// Lazy on purpose: preuninstall.js needs INSTALLED_SKILL_NAME and nothing else, and
|
|
31
|
+
// must keep working on an installation damaged badly enough to have lost routing.md.
|
|
32
|
+
function lensTable() {
|
|
33
|
+
const routing = path.join(SKILL_SOURCE, 'routing.md');
|
|
34
|
+
if (!fs.existsSync(routing)) throw new Error(`routing.md missing from the skill: ${SKILL_SOURCE}`);
|
|
35
|
+
// Parsed as a table, not matched with a regex: a mis-escaped pattern matches the
|
|
36
|
+
// empty string and yields undefined instead of throwing, which is how this lookup
|
|
37
|
+
// failed the first time it was written.
|
|
38
|
+
const table = new Map();
|
|
39
|
+
for (const line of fs.readFileSync(routing, 'utf8').split(/\r?\n/)) {
|
|
40
|
+
const cells = line.split('|').map((cell) => cell.trim());
|
|
41
|
+
if (cells.length < 4) continue;
|
|
42
|
+
const [, lens, skill] = cells;
|
|
43
|
+
if (!/^[a-z]+$/.test(lens) || !/^`[a-z0-9-]+`$/.test(skill)) continue;
|
|
44
|
+
const name = skill.slice(1, -1);
|
|
45
|
+
// A duplicate row would otherwise be won silently by whichever came first, and a
|
|
46
|
+
// wrong routing.md propagates byte-identically to every distribution.
|
|
47
|
+
if (table.has(name)) throw new Error(`routing.md lists '${name}' more than once`);
|
|
48
|
+
table.set(name, lens);
|
|
49
|
+
}
|
|
50
|
+
if (!table.size) throw new Error(`routing.md carries no lens table: ${routing}`);
|
|
51
|
+
return table;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function selfLens() {
|
|
55
|
+
const lens = lensTable().get(INSTALLED_SKILL_NAME);
|
|
56
|
+
if (!lens) throw new Error(`routing.md has no lens row for '${INSTALLED_SKILL_NAME}'`);
|
|
57
|
+
return lens;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Sibling lenses of the same family: one shared core, one docs tree, a different
|
|
61
|
+
// fidelity discipline each. Keyed by the installed skill directory name.
|
|
62
|
+
function siblingLenses() {
|
|
63
|
+
const table = lensTable();
|
|
64
|
+
selfLens(); // this lens must be in the table too
|
|
65
|
+
table.delete(INSTALLED_SKILL_NAME);
|
|
66
|
+
return Object.fromEntries(table);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// One entry per supported AI client. `home` may be overridden by an env var
|
|
70
|
+
// (Claude Desktop / portable installs); presence of the home dir counts as
|
|
71
|
+
// detection even when the CLI is not on PATH.
|
|
72
|
+
|
|
73
|
+
// One entry per supported AI client. `home` may be overridden by an env var
|
|
74
|
+
// (Claude Desktop / portable installs); presence of the home dir counts as
|
|
75
|
+
// detection even when the CLI is not on PATH.
|
|
76
|
+
const CLIENTS = [
|
|
77
|
+
{
|
|
78
|
+
key: 'claude',
|
|
79
|
+
label: 'Claude Code',
|
|
80
|
+
cmd: 'claude',
|
|
81
|
+
home: process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'),
|
|
82
|
+
envVar: 'CLAUDE_CONFIG_DIR',
|
|
83
|
+
reload: 'Restart Claude Code to load it. Invoke via Skill tool as "mkt-agentic-sdlc".',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
key: 'gemini',
|
|
87
|
+
label: 'Gemini CLI',
|
|
88
|
+
cmd: 'gemini',
|
|
89
|
+
home: process.env.GEMINI_HOME || path.join(os.homedir(), '.gemini'),
|
|
90
|
+
envVar: 'GEMINI_HOME',
|
|
91
|
+
reload: 'Run "gemini skills reload" or restart Gemini CLI to load it.',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: 'codex',
|
|
95
|
+
label: 'Codex AI',
|
|
96
|
+
cmd: 'codex',
|
|
97
|
+
home: process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
98
|
+
envVar: 'CODEX_HOME',
|
|
99
|
+
reload: 'Restart Codex to load it. Invoke it as "$mkt-agentic-sdlc" or by asking for the Marketing SDLC skill.',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
// Google Antigravity 2.0 discovers global agent skills under
|
|
103
|
+
// ~/.gemini/config/skills/ -- the SAME home the legacy Gemini CLI claims.
|
|
104
|
+
// To avoid a shared-home double-install, this entry sets:
|
|
105
|
+
// - skillsSubdir 'config/skills': distinct target from gemini's ~/.gemini/skills
|
|
106
|
+
// - homeMarker on ~/.gemini/config/skills: detection never fires on bare
|
|
107
|
+
// ~/.gemini (which every Antigravity user has); only the Antigravity skills
|
|
108
|
+
// dir, the `agy` CLI, or ANTIGRAVITY_HOME count as "Antigravity installed".
|
|
109
|
+
key: 'antigravity',
|
|
110
|
+
label: 'Google Antigravity',
|
|
111
|
+
cmd: 'agy',
|
|
112
|
+
home: process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
|
|
113
|
+
envVar: 'ANTIGRAVITY_HOME',
|
|
114
|
+
skillsSubdir: 'config/skills',
|
|
115
|
+
homeMarker: path.join(
|
|
116
|
+
process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
|
|
117
|
+
'config',
|
|
118
|
+
'skills',
|
|
119
|
+
),
|
|
120
|
+
reload: 'Restart Antigravity, or run "agy skills reload", to load it. Invoke by asking for the Marketing SDLC skill.',
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
function commandExists(cmd) {
|
|
125
|
+
try {
|
|
126
|
+
execSync(`${cmd} --version`, { stdio: 'ignore' });
|
|
127
|
+
return true;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function clientDetected(client) {
|
|
134
|
+
// An entry may override the fs-existence probe with a `homeMarker` (a more
|
|
135
|
+
// specific path than the bare home) so two clients sharing a home dir do not
|
|
136
|
+
// both fire on its mere existence. Entries without a marker check `home`
|
|
137
|
+
// exactly as before (backward-compatible).
|
|
138
|
+
const homePathToCheck = client.homeMarker || client.home;
|
|
139
|
+
return commandExists(client.cmd)
|
|
140
|
+
|| Boolean(process.env[client.envVar])
|
|
141
|
+
|| fs.existsSync(homePathToCheck);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function skillTarget(client) {
|
|
145
|
+
// An entry may override the default `skills` sub-path with `skillsSubdir`
|
|
146
|
+
// (split on '/' to keep cross-platform path.join correctness). Entries
|
|
147
|
+
// without it resolve to <home>/skills/mkt-agentic-sdlc exactly as before.
|
|
148
|
+
const subdir = client.skillsSubdir ? client.skillsSubdir.split('/') : ['skills'];
|
|
149
|
+
return path.join(client.home, ...subdir, INSTALLED_SKILL_NAME);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function copyRecursive(src, dest) {
|
|
153
|
+
if (typeof fs.cpSync === 'function') {
|
|
154
|
+
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Fallback for Node < 16.7
|
|
158
|
+
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
159
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
160
|
+
const s = path.join(src, entry.name);
|
|
161
|
+
const d = path.join(dest, entry.name);
|
|
162
|
+
if (entry.isDirectory()) copyRecursive(s, d);
|
|
163
|
+
else fs.copyFileSync(s, d);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse templates.md into { headingText: [fencedBlock, ...] }.
|
|
169
|
+
* Templates are single-sourced there: the init script must extract them
|
|
170
|
+
* instead of carrying its own inline copies (which historically drifted).
|
|
171
|
+
*/
|
|
172
|
+
function loadTemplates() {
|
|
173
|
+
const text = fs.readFileSync(TEMPLATES_PATH, 'utf8');
|
|
174
|
+
const lines = text.split(/\r?\n/);
|
|
175
|
+
const sections = {};
|
|
176
|
+
let heading = null;
|
|
177
|
+
let block = null;
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
const h = line.match(/^##\s+(.*)$/);
|
|
180
|
+
if (h && block === null) {
|
|
181
|
+
heading = h[1].trim();
|
|
182
|
+
sections[heading] = sections[heading] || [];
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (/^```/.test(line)) {
|
|
186
|
+
if (block === null) {
|
|
187
|
+
block = [];
|
|
188
|
+
} else {
|
|
189
|
+
if (heading) sections[heading].push(block.join('\n') + '\n');
|
|
190
|
+
block = null;
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (block !== null) block.push(line);
|
|
195
|
+
}
|
|
196
|
+
return sections;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Return the Nth fenced block of the section whose heading contains `needle`.
|
|
201
|
+
* Throws with a clear message when missing: writing a wrong or empty
|
|
202
|
+
* boilerplate silently would be worse than failing the init.
|
|
203
|
+
*/
|
|
204
|
+
function templateFor(sections, needle, index = 0) {
|
|
205
|
+
const heading = Object.keys(sections).find((h) => h.includes(needle));
|
|
206
|
+
const blocks = heading ? sections[heading] : undefined;
|
|
207
|
+
if (!blocks || !blocks[index]) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Template section containing "${needle}" (block ${index}) not found in ${TEMPLATES_PATH}. ` +
|
|
210
|
+
'The package is corrupted or templates.md was restructured: fix templates.md, do not improvise content.'
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return blocks[index];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
INSTALLED_SKILL_NAME,
|
|
218
|
+
PACKAGE_ROOT,
|
|
219
|
+
SKILL_SOURCE,
|
|
220
|
+
TEMPLATES_PATH,
|
|
221
|
+
CLIENTS,
|
|
222
|
+
commandExists,
|
|
223
|
+
clientDetected,
|
|
224
|
+
skillTarget,
|
|
225
|
+
copyRecursive,
|
|
226
|
+
loadTemplates,
|
|
227
|
+
templateFor,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Lazy: reading routing.md is deferred to the consumer that actually asks.
|
|
231
|
+
Object.defineProperty(module.exports, 'SELF_LENS', { enumerable: true, get: selfLens });
|
|
232
|
+
Object.defineProperty(module.exports, 'SIBLING_LENSES', { enumerable: true, get: siblingLenses });
|
|
233
|
+
|
|
@@ -66,7 +66,10 @@ One event, one destination: when the trigger fires and the document does not exi
|
|
|
66
66
|
|
|
67
67
|
| Document | Write trigger | Phase |
|
|
68
68
|
|---|---|---|
|
|
69
|
-
| `audit/handoff.md` (workstream registry) |
|
|
69
|
+
| `audit/handoff.md` (workstream registry) | **Never by hand — generated by `mkt_check.py index` from the `HANDOFF_*.md` sources**, and `validate` errors when the two disagree. Regenerate at every closure and at session end; the `Date:` header is derived, so no writer touches it. Inventory for lookup, never a work board. | 9 / session end |
|
|
70
|
+
| `audit/HANDOFF_[engagement].md` | **One per OPEN engagement, with or without volatile state** — it is the authored home of that engagement's registry row (frontmatter `workstream`/`level`/`branch`/`status`/`since`/`next`/`details`/`updated`), so no file means no row. DELETED at closure — deleting it *is* removing the row. | 9 / session end |
|
|
71
|
+
| `audit/project_notes.md` | A note true for the whole engagement portfolio rather than one engagement. Appended verbatim to the generated registry, so regenerating cannot destroy it. | 9 / session end |
|
|
72
|
+
| `audit/handoff.md` — converting an existing project | **Lazily, at the first write — and then ALL AT ONCE.** Converting one row at a time is the state that loses the others, so `index` refuses to write while anything in the file is unaccounted for and names it. | 9 / session end |
|
|
70
73
|
| `audit/HANDOFF_[engagement].md` | Session ends with that engagement unfinished AND there is volatile resume state. Ephemeral, deleted at closure. | 8 / 9 / session end |
|
|
71
74
|
| `audit/reviews/REVIEW_LOG.md` | Every completed review — when and what to write is `review.md`; schema is `templates.md`. | 6 / 9 |
|
|
72
75
|
| `reference/GUIDE_[topic].md` | Origin+purpose test (`guides.md`), or a proactive proposal the user accepted. | 8 / 9 |
|
|
@@ -16,7 +16,13 @@ piece of evidence:
|
|
|
16
16
|
numbers as `[EV-nn]`.
|
|
17
17
|
- **Class** (exactly one):
|
|
18
18
|
- `FACT` — user input or primary data (their analytics, their CRM export).
|
|
19
|
-
Source = "user, Wave 1" or the data file.
|
|
19
|
+
Source = "user, Wave 1" or the data file. **Only these two origins.** An
|
|
20
|
+
observation *you* gathered — a forum quote, a competitor's page, a review
|
|
21
|
+
site — is a `BENCHMARK` however solid it looks, and owes its URL. FACT is
|
|
22
|
+
the one class the URL rule cannot reach, so classing research as FACT is
|
|
23
|
+
the way an unsourced number gets in wearing the strongest label; the
|
|
24
|
+
validator now rejects a FACT whose Source names one of your own documents
|
|
25
|
+
or says "see X".
|
|
20
26
|
- `BENCHMARK` — external number. Source URL **and** publication date
|
|
21
27
|
mandatory. The Source cell must carry the actual external URL, NOT a
|
|
22
28
|
pointer to one of your own research documents that aggregates it ("see
|
|
@@ -85,6 +85,14 @@ When you hand work to a reviewer (human or agent), give them:
|
|
|
85
85
|
should have been. The design reviewer checks that every threat surface the change
|
|
86
86
|
touches has a matching security requirement.
|
|
87
87
|
|
|
88
|
+
**The verdict travels back as the reviewer's own final output** — the text it
|
|
89
|
+
returns when it finishes, nothing else. A reviewer that tries to message the
|
|
90
|
+
requester mid-run depends on a delivery channel it cannot verify (a subagent
|
|
91
|
+
addressed by agent TYPE rather than by session gets no such channel, and the
|
|
92
|
+
attempt fails silently); a requester that waits for such a message stalls
|
|
93
|
+
holding a verdict that already exists. State the return form when you request
|
|
94
|
+
the review, and read the verdict where it actually arrives.
|
|
95
|
+
|
|
88
96
|
Never ask a reviewer to "review my session" or "review what I just did"
|
|
89
97
|
without the artifacts above — that forces them to reconstruct scope from
|
|
90
98
|
conversation instead of reviewing the change itself. Say which finding
|
|
@@ -109,6 +117,10 @@ When you are the reviewer:
|
|
|
109
117
|
|
|
110
118
|
- Verify claims against the real source, not against the diff's own
|
|
111
119
|
description of itself.
|
|
120
|
+
- **Your verdict is your final output.** Deliver findings and verdict as the text
|
|
121
|
+
you return when you finish — never only through a message to the requester, a
|
|
122
|
+
channel you cannot verify and which fails silently when it is not there
|
|
123
|
+
(see `## Requesting`).
|
|
112
124
|
- Cite evidence as `file:line` for every finding — a finding without a
|
|
113
125
|
location is not actionable.
|
|
114
126
|
- Keep severity honest: do not inflate a style preference to a blocker, and
|
|
@@ -53,7 +53,7 @@ except ImportError as exc: # pragma: no cover - exercised by the copied-file te
|
|
|
53
53
|
# has migrated to the family's single `ai_docs/` tree is reachable from here too.
|
|
54
54
|
DOMAIN = "marketing"
|
|
55
55
|
MKT_DOCS_DIR = "mkt_docs"
|
|
56
|
-
sdlc_core.set_entry_point(DOMAIN, provides=("marketing",))
|
|
56
|
+
sdlc_core.set_entry_point(DOMAIN, provides=("marketing",), script="mkt_check.py")
|
|
57
57
|
sdlc_core.set_docs_dir(MKT_DOCS_DIR)
|
|
58
58
|
|
|
59
59
|
sdlc_core.set_profile(
|
|
@@ -84,6 +84,18 @@ VALID_STATUS = ("CURRENT", "SUPERSEDED", "DRAFT", "DEPRECATED")
|
|
|
84
84
|
LEDGER_CLASSES = ("FACT", "BENCHMARK", "ASSUMPTION")
|
|
85
85
|
CONFIDENCE_VALUES = ("HIGH", "MED", "MEDIUM", "LOW")
|
|
86
86
|
EV_REF = re.compile(r"\[(EV-\d+)\]")
|
|
87
|
+
# A source that points INSIDE this engagement instead of at the world: a document of
|
|
88
|
+
# OUR OWN under a canonical dir, or the "see X" form. Only FACT can carry it today,
|
|
89
|
+
# because FACT is the one class the URL rule does not reach.
|
|
90
|
+
INTERNAL_SOURCE_RE = re.compile(
|
|
91
|
+
r"(?:\b(?:mkt_docs|ai_docs|research|strategy|tactics|deliverables|vision)/\S*\.md\b"
|
|
92
|
+
r"|\b(?:see|vedi|cfr\.?)\s)", re.IGNORECASE)
|
|
93
|
+
# The two origins research.md sanctions for a FACT. A cell that opens by naming the
|
|
94
|
+
# client is exempt: their own primary data may well be a Markdown file they handed
|
|
95
|
+
# over, and telling its author to "reclassify as BENCHMARK and add a URL" would be
|
|
96
|
+
# advice to misclassify.
|
|
97
|
+
CLIENT_ORIGIN_RE = re.compile(
|
|
98
|
+
r"^\s*(?:user|client|owner|utente|cliente|titolare|proprietario)\b", re.IGNORECASE)
|
|
87
99
|
OBJECTIVE_HEADING = re.compile(r"^###\s+(O\d+)\b", re.MULTILINE)
|
|
88
100
|
OBJECTIVE_TOKEN = re.compile(r"\bO\d+\b")
|
|
89
101
|
EXAMPLE_MARKER = "(example)"
|
|
@@ -290,7 +302,10 @@ def cmd_index(root):
|
|
|
290
302
|
out = sdlc_core.ai_path(root) / "INDEX.md"
|
|
291
303
|
out.write_text(content, encoding="utf-8")
|
|
292
304
|
print(f"[OK] wrote {out.relative_to(root).as_posix()}")
|
|
293
|
-
|
|
305
|
+
# F-028: the workstream registry is generated here too. This entry point does
|
|
306
|
+
# not reuse the spine's cmd_index, so wiring it explicitly is what keeps the
|
|
307
|
+
# doctrine ("generated by mkt_check.py index") from being false in this lens.
|
|
308
|
+
return sdlc_core.rc_registry(root)
|
|
294
309
|
|
|
295
310
|
|
|
296
311
|
# ---------------------------------------------------------------- validate
|
|
@@ -298,6 +313,21 @@ def cmd_index(root):
|
|
|
298
313
|
def run_validate(root):
|
|
299
314
|
rep = Report()
|
|
300
315
|
docs = sdlc_core.ai_path(root)
|
|
316
|
+
# F-028: the generated registry against its HANDOFF_*.md sources. Gated on
|
|
317
|
+
# source presence, so a project that has not converted sees nothing new.
|
|
318
|
+
workstreams = sdlc_core.list_workstreams(root)
|
|
319
|
+
if workstreams:
|
|
320
|
+
hand = docs / "audit" / "handoff.md"
|
|
321
|
+
if not hand.is_file():
|
|
322
|
+
rep.error("audit/handoff.md missing while HANDOFF_*.md sources exist: "
|
|
323
|
+
"run 'mkt_check.py index'")
|
|
324
|
+
elif sdlc_core.norm_text(read_text(hand)) != sdlc_core.norm_text(sdlc_core.build_registry(root)):
|
|
325
|
+
rep.error("audit/handoff.md not aligned with its HANDOFF_*.md sources: "
|
|
326
|
+
"run 'mkt_check.py index'. A merge resolved by hand is exactly "
|
|
327
|
+
"what this catches")
|
|
328
|
+
if len(workstreams) > sdlc_core.REGISTRY_CAP:
|
|
329
|
+
rep.warn(f"{len(workstreams)} open workstreams: the registry is meant to "
|
|
330
|
+
f"stay under {sdlc_core.REGISTRY_CAP}")
|
|
301
331
|
known = {p.name for p in docs.rglob("*.md")}
|
|
302
332
|
for p in canonical_files(root):
|
|
303
333
|
if p.name == "INDEX.md":
|
|
@@ -370,6 +400,20 @@ def run_ledger(root):
|
|
|
370
400
|
rep.error(f"ledger {rid}: BENCHMARK without a source URL")
|
|
371
401
|
if not date:
|
|
372
402
|
rep.error(f"ledger {rid}: BENCHMARK without a date")
|
|
403
|
+
if cls == "FACT" and not source:
|
|
404
|
+
rep.error(f"ledger {rid}: FACT without a source -- name the origin "
|
|
405
|
+
"('user, Wave 1' or the client's data file)")
|
|
406
|
+
if (cls == "FACT" and source and not CLIENT_ORIGIN_RE.match(source)
|
|
407
|
+
and INTERNAL_SOURCE_RE.search(source)):
|
|
408
|
+
# FACT means "the client told me" or "the client's own primary data" — the
|
|
409
|
+
# one class that legitimately has no URL. Pointing it at another document
|
|
410
|
+
# of this engagement is how a researched observation gets in without the
|
|
411
|
+
# URL its class would owe: the row cites a file that cites the source, the
|
|
412
|
+
# validator resolves nothing, and "see VOC.md" is exactly the form the
|
|
413
|
+
# BENCHMARK rule rejects. Observed in a cold-agent field test, 2026-08-02.
|
|
414
|
+
rep.error(f"ledger {rid}: FACT sourced to an internal document ('{source}') -- "
|
|
415
|
+
"a FACT is what the client stated or their own primary data; an "
|
|
416
|
+
"observation gathered by research is a BENCHMARK and owes its URL")
|
|
373
417
|
if cls == "ASSUMPTION":
|
|
374
418
|
if confidence not in CONFIDENCE_VALUES:
|
|
375
419
|
rep.error(f"ledger {rid}: ASSUMPTION without confidence (HIGH|MED|LOW)")
|
|
@@ -215,7 +215,11 @@ DEFAULT_DOMAIN = "code"
|
|
|
215
215
|
PORTABLE_CHECKS = {}
|
|
216
216
|
# Which check namespaces this distribution actually carries. Set by the entry point;
|
|
217
217
|
# a `checks:` entry outside it WARNS visibly rather than passing silently.
|
|
218
|
-
_ENTRY_POINT = {"domain": DEFAULT_DOMAIN, "provides": ()}
|
|
218
|
+
_ENTRY_POINT = {"domain": DEFAULT_DOMAIN, "provides": (), "script": "sdlc_check.py"}
|
|
219
|
+
# "script" is what generated headers tell the reader to RUN. Declared, never
|
|
220
|
+
# derived from sys.argv: the generated bytes must not depend on how the command
|
|
221
|
+
# was invoked, or the alignment check would fail on the invocation instead of on
|
|
222
|
+
# the content.
|
|
219
223
|
|
|
220
224
|
|
|
221
225
|
# --- distribution profile ----------------------------------------------------
|
|
@@ -292,10 +296,17 @@ def portable_check(name):
|
|
|
292
296
|
return register
|
|
293
297
|
|
|
294
298
|
|
|
295
|
-
def set_entry_point(domain, provides=()):
|
|
299
|
+
def set_entry_point(domain, provides=(), script=None):
|
|
296
300
|
"""Declare which domain this distribution is and which check namespaces it ships."""
|
|
297
301
|
_ENTRY_POINT["domain"] = domain
|
|
298
302
|
_ENTRY_POINT["provides"] = tuple(provides)
|
|
303
|
+
if script:
|
|
304
|
+
_ENTRY_POINT["script"] = script
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def entry_script():
|
|
308
|
+
"""The command name a generated header tells the reader to run."""
|
|
309
|
+
return _ENTRY_POINT["script"]
|
|
299
310
|
|
|
300
311
|
|
|
301
312
|
def project_default_domain(root):
|
|
@@ -727,6 +738,142 @@ def build_manifest(root):
|
|
|
727
738
|
return "\n".join(lines).rstrip() + "\n"
|
|
728
739
|
|
|
729
740
|
|
|
741
|
+
# ------------------------------------------------- workstream registry (F-028)
|
|
742
|
+
# audit/handoff.md is GENERATED from one source file per open workstream, so two
|
|
743
|
+
# writers working two workstreams touch two different files. Row-per-workstream
|
|
744
|
+
# alone was not enough -- F-019 had that and the file still conflicted twice,
|
|
745
|
+
# because a file-global `Date:` header defeats row-level ownership. The header is
|
|
746
|
+
# now DERIVED, so no writer touches it.
|
|
747
|
+
|
|
748
|
+
REGISTRY_COLUMNS = ("Workstream", "Level", "Branch", "Status", "Since",
|
|
749
|
+
"Next step", "Details")
|
|
750
|
+
REGISTRY_KEYS = ("workstream", "level", "branch", "status", "since", "next")
|
|
751
|
+
REGISTRY_CAP = 20 # a signal, never a truncation (see cmd_index)
|
|
752
|
+
PROJECT_NOTES = "project_notes.md" # NOT handoff_notes.md: the HANDOFF_*.md
|
|
753
|
+
# glob is case-insensitive on Windows, and that name would be collected as a
|
|
754
|
+
# source. The trap is real; the name is the fix.
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def registry_header():
|
|
758
|
+
return (f"<!-- GENERATED by {entry_script()} index - do not edit by hand. "
|
|
759
|
+
f"Source of truth: the HANDOFF_*.md files in {docs_dir()}/audit/. -->")
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def list_workstreams(root):
|
|
763
|
+
"""[(path, meta)] for audit/HANDOFF_*.md carrying registry frontmatter.
|
|
764
|
+
|
|
765
|
+
Opt-in by the presence of `workstream:` -- a project whose handoff is still
|
|
766
|
+
hand-written has no sources, so nothing generates and nothing errors (the
|
|
767
|
+
F-019 migration lesson). Sorted by workstream id: the alignment check is a
|
|
768
|
+
byte comparison, and glob order differs across filesystems."""
|
|
769
|
+
aud = ai_path(root, "audit")
|
|
770
|
+
out = []
|
|
771
|
+
if not aud.is_dir():
|
|
772
|
+
return out
|
|
773
|
+
for p in sorted(aud.glob("HANDOFF_*.md")):
|
|
774
|
+
meta = load_frontmatter(read_text(p).splitlines())
|
|
775
|
+
if str(meta.get("workstream") or "").strip():
|
|
776
|
+
out.append((p, meta))
|
|
777
|
+
out.sort(key=lambda pm: (str(pm[1].get("workstream")).strip().lower(), pm[0].name))
|
|
778
|
+
return out
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _registry_cell(value):
|
|
782
|
+
text = str(value if value is not None else "").strip()
|
|
783
|
+
return text.replace("|", "\\|") or "-"
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def build_registry(root):
|
|
787
|
+
"""The generated registry, or "" when there is no source to build it from.
|
|
788
|
+
|
|
789
|
+
Deterministic by construction: sorted rows, and a `Date:` taken from the
|
|
790
|
+
newest `updated:` VALUE written inside the sources -- never a filesystem
|
|
791
|
+
timestamp. Git does not preserve mtimes, so an mtime-derived header would
|
|
792
|
+
regenerate differently in every fresh clone and the alignment check would
|
|
793
|
+
fire on a tree nobody touched."""
|
|
794
|
+
rows = list_workstreams(root)
|
|
795
|
+
if not rows:
|
|
796
|
+
return ""
|
|
797
|
+
stamp = max(str(m.get("updated") or m.get("since") or "").strip() or "0000-00-00"
|
|
798
|
+
for _p, m in rows)
|
|
799
|
+
lines = ["# Handoff — workstream registry",
|
|
800
|
+
f"Date: {stamp} (UTC)",
|
|
801
|
+
"",
|
|
802
|
+
registry_header(),
|
|
803
|
+
"",
|
|
804
|
+
"| " + " | ".join(REGISTRY_COLUMNS) + " |",
|
|
805
|
+
"|" + "---|" * len(REGISTRY_COLUMNS)]
|
|
806
|
+
for p, meta in rows:
|
|
807
|
+
extra = str(meta.get("details") or "").strip()
|
|
808
|
+
details = f"{p.name} · {extra}" if extra else p.name
|
|
809
|
+
cells = [_registry_cell(meta.get(k)) for k in REGISTRY_KEYS]
|
|
810
|
+
lines.append("| " + " | ".join(cells) + f" | {_registry_cell(details)} |")
|
|
811
|
+
notes = ai_path(root, "audit", PROJECT_NOTES)
|
|
812
|
+
if notes.is_file():
|
|
813
|
+
body = read_text(notes).strip()
|
|
814
|
+
if body:
|
|
815
|
+
lines += ["", "## Project-wide notes", "", body]
|
|
816
|
+
return "\n".join(lines) + "\n"
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def parse_registry_rows(text):
|
|
820
|
+
"""Workstream ids in a registry table, hand-written or generated."""
|
|
821
|
+
ids = []
|
|
822
|
+
for line in text.splitlines():
|
|
823
|
+
s = line.strip()
|
|
824
|
+
if not s.startswith("|") or set(s) <= {"|", "-", " ", ":"}:
|
|
825
|
+
continue
|
|
826
|
+
first = s.strip("|").split("|")[0].strip()
|
|
827
|
+
if first and first.lower() != "workstream":
|
|
828
|
+
ids.append(first)
|
|
829
|
+
return ids
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def registry_conversion_blockers(root):
|
|
833
|
+
"""What stops `index` from writing over a hand-written handoff.md.
|
|
834
|
+
|
|
835
|
+
The mixed state is the trap this exists for: converting one row at a time
|
|
836
|
+
leaves a project at one source file and five hand-written rows, and
|
|
837
|
+
regenerating from the one source DELETES the other five -- silently, in the
|
|
838
|
+
file whose whole purpose is not losing them. So conversion is per project.
|
|
839
|
+
Empty list = writing is safe."""
|
|
840
|
+
hand = ai_path(root, "audit", "handoff.md")
|
|
841
|
+
if not hand.is_file():
|
|
842
|
+
return []
|
|
843
|
+
text = read_text(hand)
|
|
844
|
+
if "GENERATED by sdlc_check.py index" in text:
|
|
845
|
+
return [] # already ours
|
|
846
|
+
blockers = []
|
|
847
|
+
known = {str(m.get("workstream")).strip() for _p, m in list_workstreams(root)}
|
|
848
|
+
orphans = [r for r in parse_registry_rows(text) if r not in known]
|
|
849
|
+
if orphans:
|
|
850
|
+
blockers.append("rows no HANDOFF_*.md accounts for: " + ", ".join(orphans))
|
|
851
|
+
# Everything else in the file must have a home too, or it is lost on write:
|
|
852
|
+
# a pre-1.17 narrative handoff carries no table at all, so orphan rows alone
|
|
853
|
+
# would not notice it.
|
|
854
|
+
notes_ok = ai_path(root, "audit", PROJECT_NOTES).is_file()
|
|
855
|
+
leftovers, in_notes = [], False
|
|
856
|
+
for line in text.splitlines():
|
|
857
|
+
s = line.strip()
|
|
858
|
+
if not s or s.startswith("|") or s.startswith("<!--"):
|
|
859
|
+
continue
|
|
860
|
+
if s.startswith("# ") or re.match(r"^(?:Date|Data):", s):
|
|
861
|
+
continue
|
|
862
|
+
if re.match(r"^##\s+Project-wide notes\s*$", s):
|
|
863
|
+
in_notes = True
|
|
864
|
+
continue
|
|
865
|
+
if s.startswith("## "):
|
|
866
|
+
in_notes = False
|
|
867
|
+
if in_notes and notes_ok:
|
|
868
|
+
continue
|
|
869
|
+
leftovers.append(s)
|
|
870
|
+
if leftovers:
|
|
871
|
+
blockers.append("content outside the table with nowhere to go (%d line(s), first: %r) "
|
|
872
|
+
"-- project-wide notes belong in audit/%s"
|
|
873
|
+
% (len(leftovers), leftovers[0][:60], PROJECT_NOTES))
|
|
874
|
+
return blockers
|
|
875
|
+
|
|
876
|
+
|
|
730
877
|
def list_guides(root):
|
|
731
878
|
"""[(rel_to_ai_docs, path, meta, text)] for ai_docs/reference/GUIDE_*.md."""
|
|
732
879
|
ref = ai_path(root, "reference")
|
|
@@ -821,6 +968,30 @@ def cmd_index(root):
|
|
|
821
968
|
"a comprehension map (`source_kind: code`) -- see `guides.md`.\n",
|
|
822
969
|
encoding="utf-8")
|
|
823
970
|
print(f"[ok] guide router regenerated (empty stub): {gidx}")
|
|
971
|
+
return max(rc_registry(root), 0)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def rc_registry(root):
|
|
975
|
+
"""Write the generated workstream registry, or refuse and say why (F-028)."""
|
|
976
|
+
ws = list_workstreams(root)
|
|
977
|
+
if not ws:
|
|
978
|
+
return 0 # no sources: a hand-written handoff is untouched
|
|
979
|
+
blockers = registry_conversion_blockers(root)
|
|
980
|
+
hand = ai_path(root, "audit", "handoff.md")
|
|
981
|
+
if blockers:
|
|
982
|
+
print(f"[ERROR] {docs_dir()}/audit/handoff.md NOT regenerated -- it still holds "
|
|
983
|
+
"state no source accounts for:")
|
|
984
|
+
for b in blockers:
|
|
985
|
+
print(f" - {b}")
|
|
986
|
+
print(" Convert the whole registry at once (templates.md): converting "
|
|
987
|
+
"one row at a time is the state that loses the others.")
|
|
988
|
+
return 1
|
|
989
|
+
hand.parent.mkdir(parents=True, exist_ok=True)
|
|
990
|
+
hand.write_text(build_registry(root), encoding="utf-8")
|
|
991
|
+
print(f"[ok] workstream registry regenerated: {hand}")
|
|
992
|
+
if len(ws) > REGISTRY_CAP:
|
|
993
|
+
print(f"[warn] {len(ws)} open workstreams: the registry is meant to stay under "
|
|
994
|
+
f"{REGISTRY_CAP}. Nothing was truncated -- closing one is the fix.")
|
|
824
995
|
return 0
|
|
825
996
|
|
|
826
997
|
|
|
@@ -1257,8 +1428,33 @@ def cmd_validate(root, strict=False, hybrid=False):
|
|
|
1257
1428
|
elif norm_text(read_text(gidx)) != norm_text(build_guide_index(root)):
|
|
1258
1429
|
errors.append(f"{docs_dir()}/reference/INDEX.md not aligned with the guides: run 'sdlc_check.py index'")
|
|
1259
1430
|
|
|
1260
|
-
# Handoff: header and freshness
|
|
1431
|
+
# Handoff: alignment with its sources (F-028), then header and freshness
|
|
1261
1432
|
hand = ai / "audit" / "handoff.md"
|
|
1433
|
+
workstreams = list_workstreams(root)
|
|
1434
|
+
if workstreams:
|
|
1435
|
+
if not hand.is_file():
|
|
1436
|
+
errors.append(f"{docs_dir()}/audit/handoff.md missing while HANDOFF_*.md sources "
|
|
1437
|
+
"exist: the registry is the only place a cold agent sees the open "
|
|
1438
|
+
"workstreams -- run 'sdlc_check.py index'")
|
|
1439
|
+
elif norm_text(read_text(hand)) != norm_text(build_registry(root)):
|
|
1440
|
+
errors.append(f"{docs_dir()}/audit/handoff.md not aligned with its HANDOFF_*.md "
|
|
1441
|
+
"sources: run 'sdlc_check.py index'. A merge resolved by hand is "
|
|
1442
|
+
"exactly what this catches")
|
|
1443
|
+
if len(workstreams) > REGISTRY_CAP:
|
|
1444
|
+
warnings.append(f"{len(workstreams)} open workstreams: the registry is meant to "
|
|
1445
|
+
f"stay under {REGISTRY_CAP}")
|
|
1446
|
+
# Two files claiming one workstream is the collision this design does NOT
|
|
1447
|
+
# fix (two people opening the same work under different file names). It
|
|
1448
|
+
# would otherwise show up as two identical-looking rows and nothing else.
|
|
1449
|
+
seen = {}
|
|
1450
|
+
for p, meta in workstreams:
|
|
1451
|
+
wid = str(meta.get("workstream")).strip()
|
|
1452
|
+
if wid in seen:
|
|
1453
|
+
warnings.append(f"{docs_dir()}/audit/{p.name} and {seen[wid]} both claim "
|
|
1454
|
+
f"workstream '{wid}': the registry shows two rows for one "
|
|
1455
|
+
"workstream — decide which file owns it")
|
|
1456
|
+
else:
|
|
1457
|
+
seen[wid] = p.name
|
|
1262
1458
|
if hand.is_file():
|
|
1263
1459
|
m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
|
|
1264
1460
|
if not m:
|
|
@@ -38,7 +38,7 @@ index`, never edit by hand).
|
|
|
38
38
|
1. `vision/MKT_VISION.md` — why this plan exists (check its Status first).
|
|
39
39
|
2. `research/evidence_ledger.md` — the single source for every number.
|
|
40
40
|
3. `strategy/STRATEGY.md` — the approved strategy (if present).
|
|
41
|
-
4. `audit/handoff.md` — where work stopped last session (
|
|
41
|
+
4. `audit/handoff.md` — where work stopped last session (generated from the `HANDOFF_*.md` beside it; never edited by hand).
|
|
42
42
|
|
|
43
43
|
Directory purposes: `vision/` (marketing vision and principles), `research/`
|
|
44
44
|
(evidence ledger and research outputs, discovery-by-grep), `strategy/`
|
|
@@ -446,36 +446,50 @@ Agent: Claude
|
|
|
446
446
|
<!-- gates passed? open review findings? ledger rows pending sources? -->
|
|
447
447
|
```
|
|
448
448
|
|
|
449
|
-
## audit/handoff.md (under the docs root) — the workstream registry
|
|
450
|
-
|
|
451
|
-
One row per OPEN workstream
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
449
|
+
## audit/handoff.md (under the docs root) — the workstream registry (GENERATED)
|
|
450
|
+
|
|
451
|
+
One row per OPEN workstream. **Never written by hand**: `sdlc_check.py index` builds
|
|
452
|
+
it from the `HANDOFF_[engagement].md` files, and `validate` errors when the two disagree.
|
|
453
|
+
It is an **inventory for lookup** (like the generated manifest), not a work board: no
|
|
454
|
+
assignment, no due dates, no execution ordering, no holder.
|
|
455
|
+
|
|
456
|
+
**Why generated, and not just one row per workstream.** Row-per-workstream alone was
|
|
457
|
+
tried and was not enough: two workstreams opened from one base still conflicted twice
|
|
458
|
+
in this file, because a file-global `Date:` header defeats row-level ownership no
|
|
459
|
+
matter how few rows each writer touches. So the truth moved into the per-workstream
|
|
460
|
+
file, and the header is derived (the newest `updated:` in the sources — a value, never
|
|
461
|
+
a filesystem timestamp, which git does not preserve). Two writers on two workstreams
|
|
462
|
+
now touch two different files. The generated view can still conflict at merge; that
|
|
463
|
+
conflict is resolved **mechanically** by re-running `index`, never by hand, and
|
|
464
|
+
`validate` refuses CLEAN until the file matches its sources.
|
|
465
|
+
|
|
466
|
+
Project-wide notes have their own source, `audit/project_notes.md`, appended
|
|
467
|
+
verbatim under `## Project-wide notes`. (Not `handoff_notes.md`: the `HANDOFF_*.md`
|
|
468
|
+
glob is case-insensitive on Windows and would collect it as a workstream.)
|
|
469
|
+
|
|
470
|
+
**Converting an existing project** — lazily, at the first write, and **all at once**.
|
|
471
|
+
Converting one row at a time is the state that loses the others: the next `index`
|
|
472
|
+
would regenerate from the one source and drop the rest. `index` refuses to write while
|
|
473
|
+
anything in the file is unaccounted for, and names it. A pre-1.17 narrative handoff
|
|
474
|
+
(`## Active features` / `## Next step` / `## Session notes`) is the same conversion:
|
|
475
|
+
each bullet becomes a `HANDOFF_[engagement].md`, `## Session notes` becomes
|
|
476
|
+
`project_notes.md`. A project with no sources yet is not touched and reports nothing —
|
|
477
|
+
migrating a repository nobody is working on buys nothing.
|
|
467
478
|
|
|
468
479
|
```markdown
|
|
469
480
|
# Handoff — workstream registry
|
|
470
481
|
Date: 2026-06-11 (UTC)
|
|
471
482
|
|
|
483
|
+
<!-- GENERATED by sdlc_check.py index - do not edit by hand. Source of truth: the HANDOFF_*.md files in ai_docs/audit/. -->
|
|
484
|
+
|
|
472
485
|
| Workstream | Level | Branch | Status | Since | Next step | Details |
|
|
473
486
|
|---|---|---|---|---|---|---|
|
|
474
487
|
| F-001 SSO login | L3 | feature/sso-login | PROGRESS | 2026-06-10 | wire callback tests | HANDOFF_login_sso.md · ANALYSIS_login_sso.md |
|
|
475
|
-
| F-002 Audit refresh | L3 | feature/audit | PAUSED | 2026-06-02 | resume at Phase 4 |
|
|
488
|
+
| F-002 Audit refresh | L3 | feature/audit | PAUSED | 2026-06-02 | resume at Phase 4 | HANDOFF_audit_refresh.md · ANALYSIS_audit_refresh.md |
|
|
476
489
|
|
|
477
490
|
## Project-wide notes
|
|
478
|
-
|
|
491
|
+
|
|
492
|
+
<!-- from audit/project_notes.md: release pending, environment quirks that affect everyone -->
|
|
479
493
|
```
|
|
480
494
|
|
|
481
495
|
## audit/reviews/REVIEW_LOG.md (under the docs root)
|
|
@@ -510,21 +524,47 @@ records the realization actually used — fresh subagent, one-shot client run, o
|
|
|
510
524
|
honest; writing nothing, or implying independence you did not have, is the failure
|
|
511
525
|
this column exists to prevent. `findings_real` is how many raised findings survived
|
|
512
526
|
triage: over time it is the only evidence of whether the gate earns its cost.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
527
|
+
Concurrent reviews: `init` writes a `.gitattributes` stanza giving this file
|
|
528
|
+
`merge=union` — a **built-in** driver (no per-clone `git config`, unlike
|
|
529
|
+
`merge=ours`, which silently does nothing until every clone configures it).
|
|
530
|
+
Rows are date-stamped and their order carries no meaning, so a union merge keeps
|
|
531
|
+
both sides instead of asking a human to choose. It is defence in depth: without
|
|
532
|
+
git, or without the stanza, the outcome is today's — one conflict you resolve by
|
|
533
|
+
hand, never a lost row.
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
## audit/HANDOFF_[engagement].md (under the docs root) — one open workstream, its own file
|
|
537
|
+
|
|
538
|
+
**The authored home of that workstream's registry row**, and the only one: the
|
|
539
|
+
registry is generated from these files. **One exists for every OPEN workstream**, with
|
|
540
|
+
or without volatile state — a workstream whose file is missing has no row, and a
|
|
541
|
+
workstream with no row is invisible to the next cold agent. **DELETED at the feature's
|
|
542
|
+
closure**, in the same step that flips the ANALYSIS to COMPLETED: deleting it *is*
|
|
543
|
+
removing the row.
|
|
544
|
+
|
|
545
|
+
**The DRY boundary, restated because the file is no longer rare.** What used to keep
|
|
546
|
+
narrative out of it was that it barely existed; now it always does. So: the ANALYSIS
|
|
547
|
+
Diary keeps **what happened and why** (decisions, state of the work — survives
|
|
548
|
+
forever), and this file keeps **the row plus the resume logistics** — how to pick the work back up (branch,
|
|
549
|
+
worktree, uncommitted state, the next concrete command — worthless once resumed).
|
|
550
|
+
Prose that would still be worth reading after closure is in the wrong file, because
|
|
551
|
+
this one is deleted.
|
|
552
|
+
|
|
553
|
+
The frontmatter IS the row. `workstream:` is what marks the file as a source: without
|
|
554
|
+
it the file is still a perfectly good volatile note, and nothing generates.
|
|
523
555
|
|
|
524
556
|
```markdown
|
|
557
|
+
---
|
|
558
|
+
workstream: F-001 SSO login
|
|
559
|
+
level: L3
|
|
560
|
+
branch: feature/sso-login (worktree ../wt-sso)
|
|
561
|
+
status: PROGRESS
|
|
562
|
+
since: 2026-06-10
|
|
563
|
+
next: wire the callback tests
|
|
564
|
+
details: ANALYSIS_login_sso.md
|
|
565
|
+
updated: 2026-06-11
|
|
566
|
+
---
|
|
525
567
|
# HANDOFF: [engagement] (ephemeral — deleted at closure)
|
|
526
|
-
Updated: 2026-06-11 (UTC)
|
|
527
|
-
Branch: feature/sso-login (worktree ../wt-sso)
|
|
528
568
|
|
|
529
569
|
## Resume state
|
|
530
570
|
<!-- uncommitted files, half-run migrations, env vars, running services -->
|
|
@@ -536,6 +576,19 @@ Branch: feature/sso-login (worktree ../wt-sso)
|
|
|
536
576
|
<!-- traps discovered this session that bite on resume (locks, CRLF, flaky test) -->
|
|
537
577
|
```
|
|
538
578
|
|
|
579
|
+
`updated:` is the date this file last changed, and the newest one across all sources
|
|
580
|
+
becomes the registry's `Date:` header — which is why no writer ever edits that header
|
|
581
|
+
and why two concurrent writers no longer collide on it. `details:` holds the *other*
|
|
582
|
+
pointers (the ANALYSIS, a review log entry); the generator prepends this file's own
|
|
583
|
+
name, so nothing points at itself by hand.
|
|
584
|
+
|
|
585
|
+
## audit/project_notes.md — the registry's project-wide notes (source)
|
|
586
|
+
|
|
587
|
+
Plain lines, no frontmatter, appended verbatim to the generated registry under
|
|
588
|
+
`## Project-wide notes`. Release pending, environment quirks, anything true for
|
|
589
|
+
everyone rather than for one workstream. It exists so that generating the registry
|
|
590
|
+
cannot destroy notes that belong to no workstream.
|
|
591
|
+
|
|
539
592
|
## strategic/architecture.md and existing_features.md (under the docs root)
|
|
540
593
|
|
|
541
594
|
Canonical docs: they open with the header (`description:`/`status:`) so they enter the `INDEX.md` manifest cleanly.
|
|
@@ -605,7 +658,7 @@ Must-reads for this project, in order. The full manifest of canonical docs is
|
|
|
605
658
|
1. `reference/INDEX.md` — the guide router: which guide already governs the work you are about to do (generated).
|
|
606
659
|
2. `vision/project_vision.md` — why the project exists (check its Status first).
|
|
607
660
|
3. `strategic/architecture.md` — how it is built.
|
|
608
|
-
4. `audit/handoff.md` — where work stopped last session (
|
|
661
|
+
4. `audit/handoff.md` — where work stopped last session (generated from the `HANDOFF_*.md` beside it; never edited by hand).
|
|
609
662
|
|
|
610
663
|
Directory purposes: `vision/` (project direction), `strategic/` (architecture and
|
|
611
664
|
feature catalog), `reference/` (operative guides), `solutions/` (per-feature
|