@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.
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/scripts/project-ideation-idempotency-harness.mjs +276 -0
- package/plugins/lisa/skills/project-ideation/SKILL.md +7 -0
- package/plugins/lisa/skills/project-ideation/examples/idempotency-verification-harness.md +56 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/src/base/scripts/project-ideation-idempotency-harness.mjs +276 -0
- package/plugins/src/base/skills/project-ideation/SKILL.md +7 -0
- 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.
|
|
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": {
|
|
@@ -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-openclaw",
|
|
3
|
-
"version": "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.
|
|
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"
|
|
@@ -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.
|