@codyswann/lisa 2.107.2 → 2.108.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.
Files changed (25) hide show
  1. package/package.json +1 -1
  2. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  3. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  4. package/plugins/lisa/scripts/project-ideation-idempotency-harness.mjs +276 -0
  5. package/plugins/lisa/skills/project-ideation/SKILL.md +7 -0
  6. package/plugins/lisa/skills/project-ideation/examples/idempotency-verification-harness.md +56 -0
  7. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  8. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  9. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  23. package/plugins/src/base/scripts/project-ideation-idempotency-harness.mjs +276 -0
  24. package/plugins/src/base/skills/project-ideation/SKILL.md +7 -0
  25. package/plugins/src/base/skills/project-ideation/examples/idempotency-verification-harness.md +56 -0
package/package.json CHANGED
@@ -82,7 +82,7 @@
82
82
  "lodash": ">=4.18.1"
83
83
  },
84
84
  "name": "@codyswann/lisa",
85
- "version": "2.107.2",
85
+ "version": "2.108.0",
86
86
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
87
87
  "main": "dist/index.js",
88
88
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic verification harness for project-ideation PRD dedupe.
4
+ *
5
+ * The harness runs a caller-supplied deterministic project-ideation command
6
+ * twice, checks that exactly one open GitHub PRD contains the expected marker,
7
+ * then optionally removes the automation memory file and verifies a third run
8
+ * recreates memory without creating a duplicate PRD.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
12
+ import { dirname, resolve } from "node:path";
13
+ import { spawnSync } from "node:child_process";
14
+
15
+ const args = parseArgs(process.argv.slice(2));
16
+
17
+ if (args.help) {
18
+ printUsage();
19
+ process.exit(0);
20
+ }
21
+
22
+ const repo = requireValue(args.repo, "--repo");
23
+ const marker = requireValue(args.marker, "--marker");
24
+ const command = requireValue(args.command, "--command");
25
+ const memoryFile = args.memoryFile ? resolve(args.memoryFile) : null;
26
+
27
+ runCommand("first ideation run", command);
28
+ const first = assertSingleOpenMarker(repo, marker, "after first run");
29
+
30
+ runCommand("second ideation run", command);
31
+ const second = assertSingleOpenMarker(repo, marker, "after second run");
32
+
33
+ let missingMemory = null;
34
+ if (memoryFile) {
35
+ missingMemory = runMissingMemoryVariant(repo, marker, command, memoryFile);
36
+ }
37
+
38
+ console.log(
39
+ JSON.stringify(
40
+ {
41
+ repo,
42
+ marker,
43
+ first,
44
+ second,
45
+ missingMemory,
46
+ verdict: "PASS",
47
+ },
48
+ null,
49
+ 2
50
+ )
51
+ );
52
+
53
+ /**
54
+ * @param {string[]} argv
55
+ * @returns {Record<string, string | boolean>}
56
+ */
57
+ function parseArgs(argv) {
58
+ const parsed = {};
59
+
60
+ for (let index = 0; index < argv.length; index += 1) {
61
+ const token = argv[index];
62
+ if (token === "--help" || token === "-h") {
63
+ parsed.help = true;
64
+ continue;
65
+ }
66
+ if (!token.startsWith("--")) {
67
+ fail(`Unexpected positional argument: ${token}`);
68
+ }
69
+
70
+ const eqIndex = token.indexOf("=");
71
+ if (eqIndex !== -1) {
72
+ parsed[token.slice(2, eqIndex)] = token.slice(eqIndex + 1);
73
+ continue;
74
+ }
75
+
76
+ const key = token.slice(2);
77
+ const value = argv[index + 1];
78
+ if (!value || value.startsWith("--")) {
79
+ fail(`Missing value for --${key}`);
80
+ }
81
+ parsed[key] = value;
82
+ index += 1;
83
+ }
84
+
85
+ return parsed;
86
+ }
87
+
88
+ /**
89
+ * @param {Record<string, string | boolean>} input
90
+ * @param {string} key
91
+ * @returns {string}
92
+ */
93
+ function requireValue(input, key) {
94
+ const normalized = key.replace(/^--/, "");
95
+ const value = input[normalized];
96
+ if (typeof value !== "string" || value.trim().length === 0) {
97
+ fail(`${key} is required`);
98
+ }
99
+ return value.trim();
100
+ }
101
+
102
+ /**
103
+ * @param {string} label
104
+ * @param {string} command
105
+ */
106
+ function runCommand(label, command) {
107
+ const result = spawnSync(command, {
108
+ shell: true,
109
+ stdio: "inherit",
110
+ env: process.env,
111
+ });
112
+
113
+ if (result.status !== 0) {
114
+ fail(`${label} failed with exit code ${String(result.status)}`);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * @param {string} repo
120
+ * @param {string} marker
121
+ * @param {string} phase
122
+ * @returns {{ readonly count: number, readonly issue: Record<string, any> }}
123
+ */
124
+ function assertSingleOpenMarker(repo, marker, phase) {
125
+ const issues = queryOpenIssuesByMarker(repo, marker);
126
+ if (issues.length !== 1) {
127
+ fail(
128
+ `Expected exactly one open issue containing marker ${JSON.stringify(marker)} ${phase}, found ${issues.length}`
129
+ );
130
+ }
131
+
132
+ return {
133
+ count: issues.length,
134
+ issue: {
135
+ number: issues[0].number,
136
+ title: issues[0].title,
137
+ url: issues[0].url,
138
+ labels: normalizeLabels(issues[0].labels),
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * @param {string} repo
145
+ * @param {string} marker
146
+ * @returns {readonly Record<string, any>[]}
147
+ */
148
+ function queryOpenIssuesByMarker(repo, marker) {
149
+ const search = runJson("gh", [
150
+ "issue",
151
+ "list",
152
+ "--repo",
153
+ repo,
154
+ "--state",
155
+ "open",
156
+ "--search",
157
+ `"${marker}" in:body`,
158
+ "--limit",
159
+ "100",
160
+ "--json",
161
+ "number,title,url,labels",
162
+ ]);
163
+
164
+ if (Array.isArray(search) && search.length > 0) {
165
+ return search;
166
+ }
167
+
168
+ const fallback = runJson("gh", [
169
+ "issue",
170
+ "list",
171
+ "--repo",
172
+ repo,
173
+ "--state",
174
+ "open",
175
+ "--limit",
176
+ "1000",
177
+ "--json",
178
+ "number,title,url,labels,body",
179
+ ]);
180
+
181
+ if (!Array.isArray(fallback)) {
182
+ return [];
183
+ }
184
+
185
+ return fallback.filter(issue => String(issue.body ?? "").includes(marker));
186
+ }
187
+
188
+ /**
189
+ * @param {string} repo
190
+ * @param {string} marker
191
+ * @param {string} command
192
+ * @param {string} memoryFile
193
+ * @returns {{ readonly count: number, readonly issue: Record<string, any>, readonly memoryRecreated: boolean }}
194
+ */
195
+ function runMissingMemoryVariant(repo, marker, command, memoryFile) {
196
+ const backup = `${memoryFile}.project-ideation-idempotency-backup`;
197
+ rmSync(backup, { force: true });
198
+
199
+ if (existsSync(memoryFile)) {
200
+ mkdirSync(dirname(backup), { recursive: true });
201
+ renameSync(memoryFile, backup);
202
+ }
203
+
204
+ try {
205
+ runCommand("missing-memory ideation run", command);
206
+ const result = assertSingleOpenMarker(
207
+ repo,
208
+ marker,
209
+ "after missing-memory run"
210
+ );
211
+ return {
212
+ ...result,
213
+ memoryRecreated: existsSync(memoryFile),
214
+ };
215
+ } finally {
216
+ if (existsSync(backup)) {
217
+ rmSync(memoryFile, { force: true });
218
+ mkdirSync(dirname(memoryFile), { recursive: true });
219
+ renameSync(backup, memoryFile);
220
+ }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * @param {string} command
226
+ * @param {readonly string[]} argv
227
+ * @returns {any}
228
+ */
229
+ function runJson(command, argv) {
230
+ const result = spawnSync(command, argv, {
231
+ encoding: "utf8",
232
+ stdio: ["ignore", "pipe", "pipe"],
233
+ });
234
+
235
+ if (result.status !== 0) {
236
+ fail(`${command} ${argv.join(" ")} failed:\n${result.stderr}`);
237
+ }
238
+
239
+ try {
240
+ return JSON.parse(result.stdout);
241
+ } catch (error) {
242
+ fail(`Could not parse JSON from ${command}: ${error.message}`);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * @param {readonly any[] | undefined} labels
248
+ * @returns {readonly string[]}
249
+ */
250
+ function normalizeLabels(labels) {
251
+ return (Array.isArray(labels) ? labels : [])
252
+ .map(label => (typeof label === "string" ? label : label?.name))
253
+ .filter(Boolean);
254
+ }
255
+
256
+ function printUsage() {
257
+ console.log(`Usage:
258
+ node plugins/lisa/scripts/project-ideation-idempotency-harness.mjs \\
259
+ --repo CodySwannGT/lisa \\
260
+ --marker "[lisa-project-ideation] idea=<stable-key>" \\
261
+ --command "codex exec '/lisa:project-ideation ./fixtures/project-ideation-idempotency prd_ready=true max_prds=1'" \\
262
+ --memory-file "$CODEX_HOME/automations/<automation_id>/memory.md"
263
+
264
+ The command must be deterministic: it should select the same idea and marker on
265
+ each run. When --memory-file is provided, the harness temporarily moves it aside
266
+ for the missing-memory variant and restores the original file afterward.`);
267
+ }
268
+
269
+ /**
270
+ * @param {string} message
271
+ * @returns {never}
272
+ */
273
+ function fail(message) {
274
+ console.error(message);
275
+ process.exit(1);
276
+ }
@@ -25,6 +25,10 @@ cannot verify yourself is noise — demote it honestly.
25
25
  creates **one** PRD (the single top-ranked idea), because `lisa:research` is a heavy full flow.
26
26
  `max_prds=3` creates the top three; `max_prds=all` creates one per build-ready idea. Discovery
27
27
  Spikes and Rejected ideas are never turned into PRDs regardless of `max_prds`.
28
+ - **`fixture=<path>`** (optional, verification-only) — a deterministic host-project fixture used
29
+ for idempotency verification. When present, read the fixture before ranking and honor its declared
30
+ single persona, single idea, existing-fit anchor, and expected dedupe marker. Do not use this
31
+ parameter for normal ideation runs.
28
32
 
29
33
  ## When to use
30
34
 
@@ -232,3 +236,6 @@ Use the markdown examples in `examples/` as shape references for the idea report
232
236
  requirements.
233
237
  - `unavailable-data-rejection.md` — naming missing private/paid/unavailable sources when demoting.
234
238
  - `evidence-card-format.md` — the required evidence fields every Practical Idea card must carry.
239
+ - `idempotency-verification-harness.md` — deterministic fixture and script procedure proving that
240
+ repeated `prd_ready=true` ideation keeps the open GitHub marker count at one, including the
241
+ missing-memory rerun variant.
@@ -0,0 +1,56 @@
1
+ # Project Ideation Idempotency Verification Harness
2
+
3
+ Use this documented harness when validating that `project-ideation prd_ready=true` reuses an
4
+ existing GitHub PRD by stable marker instead of creating duplicates.
5
+
6
+ ## Deterministic fixture
7
+
8
+ Create an isolated fixture project with only one evidence-backed persona and one build-ready idea.
9
+ The fixture should make the selected marker predictable:
10
+
11
+ - Repo identity: `CodySwannGT/lisa`
12
+ - Persona key: `queue-automation-operators`
13
+ - Idea name: `Exploratory PRD run ledger`
14
+ - Existing-fit anchor: `plugins/src/base/skills/project-ideation/SKILL.md`
15
+ - Expected marker: `[lisa-project-ideation] idea=codyswanngt-lisa-exploratory-prd-run-ledger-queue-automation-operators-plugins-lisa-skills-project-ideation-skill-md`
16
+
17
+ The fixture may be a tiny directory with:
18
+
19
+ - `README.md` naming queue automation operators and their goals.
20
+ - `.lisa.config.json` pointing `source` and `tracker` to GitHub.
21
+ - `PROJECT_IDEATION_FIXTURE.md` listing the single persona, the single idea, and the expected
22
+ marker above.
23
+
24
+ Invoke project ideation against that fixture with `prd_ready=true max_prds=1`. The fixture is valid
25
+ only if the idea report selects the single expected idea and the PRD summary reports the expected
26
+ marker.
27
+
28
+ ## Scripted verification
29
+
30
+ Run the shipped harness after choosing the deterministic command for your runtime:
31
+
32
+ ```bash
33
+ node plugins/lisa/scripts/project-ideation-idempotency-harness.mjs \
34
+ --repo CodySwannGT/lisa \
35
+ --marker "[lisa-project-ideation] idea=codyswanngt-lisa-exploratory-prd-run-ledger-queue-automation-operators-plugins-lisa-skills-project-ideation-skill-md" \
36
+ --command "codex exec '/lisa:project-ideation ./fixtures/project-ideation-idempotency prd_ready=true max_prds=1'" \
37
+ --memory-file "$CODEX_HOME/automations/<automation_id>/memory.md"
38
+ ```
39
+
40
+ The harness performs the acceptance check in three phases:
41
+
42
+ 1. Run the deterministic ideation command once, then query open GitHub issues for the marker and
43
+ require exactly one match.
44
+ 2. Run the same command again, then require the open marker count to remain one.
45
+ 3. Move the automation memory file aside, run the command again, require the marker count to remain
46
+ one, and require the memory file to be recreated. The original memory file is restored at the end.
47
+
48
+ ## Expected evidence
49
+
50
+ Capture the harness JSON output as:
51
+
52
+ - `[EVIDENCE: marker-count-one]` from the first and second marker-count checks.
53
+ - `[EVIDENCE: memory-recreated-after-rerun]` from the missing-memory variant.
54
+
55
+ The run passes only when all reported counts are `1`, the issue URL is the same across phases, and
56
+ `memoryRecreated` is `true` when `--memory-file` is supplied.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "NestJS-specific skills and migration write-protection hooks.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.107.2",
3
+ "version": "2.108.0",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic verification harness for project-ideation PRD dedupe.
4
+ *
5
+ * The harness runs a caller-supplied deterministic project-ideation command
6
+ * twice, checks that exactly one open GitHub PRD contains the expected marker,
7
+ * then optionally removes the automation memory file and verifies a third run
8
+ * recreates memory without creating a duplicate PRD.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
12
+ import { dirname, resolve } from "node:path";
13
+ import { spawnSync } from "node:child_process";
14
+
15
+ const args = parseArgs(process.argv.slice(2));
16
+
17
+ if (args.help) {
18
+ printUsage();
19
+ process.exit(0);
20
+ }
21
+
22
+ const repo = requireValue(args.repo, "--repo");
23
+ const marker = requireValue(args.marker, "--marker");
24
+ const command = requireValue(args.command, "--command");
25
+ const memoryFile = args.memoryFile ? resolve(args.memoryFile) : null;
26
+
27
+ runCommand("first ideation run", command);
28
+ const first = assertSingleOpenMarker(repo, marker, "after first run");
29
+
30
+ runCommand("second ideation run", command);
31
+ const second = assertSingleOpenMarker(repo, marker, "after second run");
32
+
33
+ let missingMemory = null;
34
+ if (memoryFile) {
35
+ missingMemory = runMissingMemoryVariant(repo, marker, command, memoryFile);
36
+ }
37
+
38
+ console.log(
39
+ JSON.stringify(
40
+ {
41
+ repo,
42
+ marker,
43
+ first,
44
+ second,
45
+ missingMemory,
46
+ verdict: "PASS",
47
+ },
48
+ null,
49
+ 2
50
+ )
51
+ );
52
+
53
+ /**
54
+ * @param {string[]} argv
55
+ * @returns {Record<string, string | boolean>}
56
+ */
57
+ function parseArgs(argv) {
58
+ const parsed = {};
59
+
60
+ for (let index = 0; index < argv.length; index += 1) {
61
+ const token = argv[index];
62
+ if (token === "--help" || token === "-h") {
63
+ parsed.help = true;
64
+ continue;
65
+ }
66
+ if (!token.startsWith("--")) {
67
+ fail(`Unexpected positional argument: ${token}`);
68
+ }
69
+
70
+ const eqIndex = token.indexOf("=");
71
+ if (eqIndex !== -1) {
72
+ parsed[token.slice(2, eqIndex)] = token.slice(eqIndex + 1);
73
+ continue;
74
+ }
75
+
76
+ const key = token.slice(2);
77
+ const value = argv[index + 1];
78
+ if (!value || value.startsWith("--")) {
79
+ fail(`Missing value for --${key}`);
80
+ }
81
+ parsed[key] = value;
82
+ index += 1;
83
+ }
84
+
85
+ return parsed;
86
+ }
87
+
88
+ /**
89
+ * @param {Record<string, string | boolean>} input
90
+ * @param {string} key
91
+ * @returns {string}
92
+ */
93
+ function requireValue(input, key) {
94
+ const normalized = key.replace(/^--/, "");
95
+ const value = input[normalized];
96
+ if (typeof value !== "string" || value.trim().length === 0) {
97
+ fail(`${key} is required`);
98
+ }
99
+ return value.trim();
100
+ }
101
+
102
+ /**
103
+ * @param {string} label
104
+ * @param {string} command
105
+ */
106
+ function runCommand(label, command) {
107
+ const result = spawnSync(command, {
108
+ shell: true,
109
+ stdio: "inherit",
110
+ env: process.env,
111
+ });
112
+
113
+ if (result.status !== 0) {
114
+ fail(`${label} failed with exit code ${String(result.status)}`);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * @param {string} repo
120
+ * @param {string} marker
121
+ * @param {string} phase
122
+ * @returns {{ readonly count: number, readonly issue: Record<string, any> }}
123
+ */
124
+ function assertSingleOpenMarker(repo, marker, phase) {
125
+ const issues = queryOpenIssuesByMarker(repo, marker);
126
+ if (issues.length !== 1) {
127
+ fail(
128
+ `Expected exactly one open issue containing marker ${JSON.stringify(marker)} ${phase}, found ${issues.length}`
129
+ );
130
+ }
131
+
132
+ return {
133
+ count: issues.length,
134
+ issue: {
135
+ number: issues[0].number,
136
+ title: issues[0].title,
137
+ url: issues[0].url,
138
+ labels: normalizeLabels(issues[0].labels),
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * @param {string} repo
145
+ * @param {string} marker
146
+ * @returns {readonly Record<string, any>[]}
147
+ */
148
+ function queryOpenIssuesByMarker(repo, marker) {
149
+ const search = runJson("gh", [
150
+ "issue",
151
+ "list",
152
+ "--repo",
153
+ repo,
154
+ "--state",
155
+ "open",
156
+ "--search",
157
+ `"${marker}" in:body`,
158
+ "--limit",
159
+ "100",
160
+ "--json",
161
+ "number,title,url,labels",
162
+ ]);
163
+
164
+ if (Array.isArray(search) && search.length > 0) {
165
+ return search;
166
+ }
167
+
168
+ const fallback = runJson("gh", [
169
+ "issue",
170
+ "list",
171
+ "--repo",
172
+ repo,
173
+ "--state",
174
+ "open",
175
+ "--limit",
176
+ "1000",
177
+ "--json",
178
+ "number,title,url,labels,body",
179
+ ]);
180
+
181
+ if (!Array.isArray(fallback)) {
182
+ return [];
183
+ }
184
+
185
+ return fallback.filter(issue => String(issue.body ?? "").includes(marker));
186
+ }
187
+
188
+ /**
189
+ * @param {string} repo
190
+ * @param {string} marker
191
+ * @param {string} command
192
+ * @param {string} memoryFile
193
+ * @returns {{ readonly count: number, readonly issue: Record<string, any>, readonly memoryRecreated: boolean }}
194
+ */
195
+ function runMissingMemoryVariant(repo, marker, command, memoryFile) {
196
+ const backup = `${memoryFile}.project-ideation-idempotency-backup`;
197
+ rmSync(backup, { force: true });
198
+
199
+ if (existsSync(memoryFile)) {
200
+ mkdirSync(dirname(backup), { recursive: true });
201
+ renameSync(memoryFile, backup);
202
+ }
203
+
204
+ try {
205
+ runCommand("missing-memory ideation run", command);
206
+ const result = assertSingleOpenMarker(
207
+ repo,
208
+ marker,
209
+ "after missing-memory run"
210
+ );
211
+ return {
212
+ ...result,
213
+ memoryRecreated: existsSync(memoryFile),
214
+ };
215
+ } finally {
216
+ if (existsSync(backup)) {
217
+ rmSync(memoryFile, { force: true });
218
+ mkdirSync(dirname(memoryFile), { recursive: true });
219
+ renameSync(backup, memoryFile);
220
+ }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * @param {string} command
226
+ * @param {readonly string[]} argv
227
+ * @returns {any}
228
+ */
229
+ function runJson(command, argv) {
230
+ const result = spawnSync(command, argv, {
231
+ encoding: "utf8",
232
+ stdio: ["ignore", "pipe", "pipe"],
233
+ });
234
+
235
+ if (result.status !== 0) {
236
+ fail(`${command} ${argv.join(" ")} failed:\n${result.stderr}`);
237
+ }
238
+
239
+ try {
240
+ return JSON.parse(result.stdout);
241
+ } catch (error) {
242
+ fail(`Could not parse JSON from ${command}: ${error.message}`);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * @param {readonly any[] | undefined} labels
248
+ * @returns {readonly string[]}
249
+ */
250
+ function normalizeLabels(labels) {
251
+ return (Array.isArray(labels) ? labels : [])
252
+ .map(label => (typeof label === "string" ? label : label?.name))
253
+ .filter(Boolean);
254
+ }
255
+
256
+ function printUsage() {
257
+ console.log(`Usage:
258
+ node plugins/lisa/scripts/project-ideation-idempotency-harness.mjs \\
259
+ --repo CodySwannGT/lisa \\
260
+ --marker "[lisa-project-ideation] idea=<stable-key>" \\
261
+ --command "codex exec '/lisa:project-ideation ./fixtures/project-ideation-idempotency prd_ready=true max_prds=1'" \\
262
+ --memory-file "$CODEX_HOME/automations/<automation_id>/memory.md"
263
+
264
+ The command must be deterministic: it should select the same idea and marker on
265
+ each run. When --memory-file is provided, the harness temporarily moves it aside
266
+ for the missing-memory variant and restores the original file afterward.`);
267
+ }
268
+
269
+ /**
270
+ * @param {string} message
271
+ * @returns {never}
272
+ */
273
+ function fail(message) {
274
+ console.error(message);
275
+ process.exit(1);
276
+ }
@@ -25,6 +25,10 @@ cannot verify yourself is noise — demote it honestly.
25
25
  creates **one** PRD (the single top-ranked idea), because `lisa:research` is a heavy full flow.
26
26
  `max_prds=3` creates the top three; `max_prds=all` creates one per build-ready idea. Discovery
27
27
  Spikes and Rejected ideas are never turned into PRDs regardless of `max_prds`.
28
+ - **`fixture=<path>`** (optional, verification-only) — a deterministic host-project fixture used
29
+ for idempotency verification. When present, read the fixture before ranking and honor its declared
30
+ single persona, single idea, existing-fit anchor, and expected dedupe marker. Do not use this
31
+ parameter for normal ideation runs.
28
32
 
29
33
  ## When to use
30
34
 
@@ -232,3 +236,6 @@ Use the markdown examples in `examples/` as shape references for the idea report
232
236
  requirements.
233
237
  - `unavailable-data-rejection.md` — naming missing private/paid/unavailable sources when demoting.
234
238
  - `evidence-card-format.md` — the required evidence fields every Practical Idea card must carry.
239
+ - `idempotency-verification-harness.md` — deterministic fixture and script procedure proving that
240
+ repeated `prd_ready=true` ideation keeps the open GitHub marker count at one, including the
241
+ missing-memory rerun variant.
@@ -0,0 +1,56 @@
1
+ # Project Ideation Idempotency Verification Harness
2
+
3
+ Use this documented harness when validating that `project-ideation prd_ready=true` reuses an
4
+ existing GitHub PRD by stable marker instead of creating duplicates.
5
+
6
+ ## Deterministic fixture
7
+
8
+ Create an isolated fixture project with only one evidence-backed persona and one build-ready idea.
9
+ The fixture should make the selected marker predictable:
10
+
11
+ - Repo identity: `CodySwannGT/lisa`
12
+ - Persona key: `queue-automation-operators`
13
+ - Idea name: `Exploratory PRD run ledger`
14
+ - Existing-fit anchor: `plugins/src/base/skills/project-ideation/SKILL.md`
15
+ - Expected marker: `[lisa-project-ideation] idea=codyswanngt-lisa-exploratory-prd-run-ledger-queue-automation-operators-plugins-lisa-skills-project-ideation-skill-md`
16
+
17
+ The fixture may be a tiny directory with:
18
+
19
+ - `README.md` naming queue automation operators and their goals.
20
+ - `.lisa.config.json` pointing `source` and `tracker` to GitHub.
21
+ - `PROJECT_IDEATION_FIXTURE.md` listing the single persona, the single idea, and the expected
22
+ marker above.
23
+
24
+ Invoke project ideation against that fixture with `prd_ready=true max_prds=1`. The fixture is valid
25
+ only if the idea report selects the single expected idea and the PRD summary reports the expected
26
+ marker.
27
+
28
+ ## Scripted verification
29
+
30
+ Run the shipped harness after choosing the deterministic command for your runtime:
31
+
32
+ ```bash
33
+ node plugins/lisa/scripts/project-ideation-idempotency-harness.mjs \
34
+ --repo CodySwannGT/lisa \
35
+ --marker "[lisa-project-ideation] idea=codyswanngt-lisa-exploratory-prd-run-ledger-queue-automation-operators-plugins-lisa-skills-project-ideation-skill-md" \
36
+ --command "codex exec '/lisa:project-ideation ./fixtures/project-ideation-idempotency prd_ready=true max_prds=1'" \
37
+ --memory-file "$CODEX_HOME/automations/<automation_id>/memory.md"
38
+ ```
39
+
40
+ The harness performs the acceptance check in three phases:
41
+
42
+ 1. Run the deterministic ideation command once, then query open GitHub issues for the marker and
43
+ require exactly one match.
44
+ 2. Run the same command again, then require the open marker count to remain one.
45
+ 3. Move the automation memory file aside, run the command again, require the marker count to remain
46
+ one, and require the memory file to be recreated. The original memory file is restored at the end.
47
+
48
+ ## Expected evidence
49
+
50
+ Capture the harness JSON output as:
51
+
52
+ - `[EVIDENCE: marker-count-one]` from the first and second marker-count checks.
53
+ - `[EVIDENCE: memory-recreated-after-rerun]` from the missing-memory variant.
54
+
55
+ The run passes only when all reported counts are `1`, the issue URL is the same across phases, and
56
+ `memoryRecreated` is `true` when `--memory-file` is supplied.