@chief-clancy/plan 0.4.2 → 0.5.1
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/README.md +1 -1
- package/bin/plan.js +80 -36
- package/dist/installer/install.d.ts +4 -0
- package/dist/installer/install.d.ts.map +1 -1
- package/dist/installer/install.js +37 -2
- package/dist/installer/install.js.map +1 -1
- package/package.json +4 -1
- package/src/commands/approve-plan.md +3 -1
- package/src/workflows/approve-plan.md +353 -8
- package/src/workflows/plan.md +6 -1
- package/src/workflows/workflows.test.ts +658 -2
|
@@ -863,8 +863,16 @@ describe('approve-plan local marker (Step 4a)', () => {
|
|
|
863
863
|
);
|
|
864
864
|
});
|
|
865
865
|
|
|
866
|
-
it('after writing the marker, Step 4a
|
|
867
|
-
|
|
866
|
+
it('after writing the marker, Step 4a continues to Step 4c (PR 9 — was Step 7 in PR 7b)', () => {
|
|
867
|
+
// PR 7b's "jump to Step 7" was rewritten in PR 9 slice 1 / DA H2 fix:
|
|
868
|
+
// the post-marker tail now hands off to Step 4c (Optional board push),
|
|
869
|
+
// which is best-effort and gates on board credentials. Steps 5/5b/6
|
|
870
|
+
// remain unreachable in plan-file stem mode regardless.
|
|
871
|
+
const afterMarkerIdx = content.indexOf('### After writing the marker');
|
|
872
|
+
const tailEnd = content.indexOf('---', afterMarkerIdx);
|
|
873
|
+
const tail = content.slice(afterMarkerIdx, tailEnd);
|
|
874
|
+
expect(tail).toMatch(/Step 4c/);
|
|
875
|
+
expect(tail).toMatch(/Steps 5, 5b, and 6[^.]*skipped/i);
|
|
868
876
|
});
|
|
869
877
|
});
|
|
870
878
|
|
|
@@ -1006,3 +1014,651 @@ describe('approve-plan board mode preserved unchanged', () => {
|
|
|
1006
1014
|
expect(content).toContain('Notion');
|
|
1007
1015
|
});
|
|
1008
1016
|
});
|
|
1017
|
+
|
|
1018
|
+
// PR 9 — Slice 0/6: drift-prevention for the duplicated push curl blocks.
|
|
1019
|
+
// Slice 0 declared the start/end anchors in both files. Slice 6 promotes
|
|
1020
|
+
// this suite to a byte-equality check between the wrapped regions: the
|
|
1021
|
+
// canonical curl blocks live in plan.md Step 5b; approve-plan.md Step 4c
|
|
1022
|
+
// holds an identical duplicate.
|
|
1023
|
+
describe('approve-plan board push drift anchors (PR 9 Slice 0/6)', () => {
|
|
1024
|
+
const planContent = readFileSync(new URL('plan.md', import.meta.url), 'utf8');
|
|
1025
|
+
const approveContent = readFileSync(
|
|
1026
|
+
new URL('approve-plan.md', import.meta.url),
|
|
1027
|
+
'utf8',
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
const startAnchor = '<!-- curl-blocks:approve-plan-push:start -->';
|
|
1031
|
+
const endAnchor = '<!-- curl-blocks:approve-plan-push:end -->';
|
|
1032
|
+
|
|
1033
|
+
const extractRegion = (source: string): string => {
|
|
1034
|
+
const startIdx = source.indexOf(startAnchor);
|
|
1035
|
+
const endIdx = source.indexOf(endAnchor);
|
|
1036
|
+
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
|
|
1037
|
+
throw new Error('curl-blocks anchors missing or out of order');
|
|
1038
|
+
}
|
|
1039
|
+
return source.slice(startIdx + startAnchor.length, endIdx);
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
it('plan.md declares the start anchor exactly once', () => {
|
|
1043
|
+
const matches = planContent.split(startAnchor).length - 1;
|
|
1044
|
+
expect(matches).toBe(1);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it('plan.md declares the end anchor exactly once', () => {
|
|
1048
|
+
const matches = planContent.split(endAnchor).length - 1;
|
|
1049
|
+
expect(matches).toBe(1);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it('plan.md start anchor precedes end anchor', () => {
|
|
1053
|
+
expect(planContent.indexOf(startAnchor)).toBeLessThan(
|
|
1054
|
+
planContent.indexOf(endAnchor),
|
|
1055
|
+
);
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it('approve-plan.md declares the start anchor exactly once', () => {
|
|
1059
|
+
const matches = approveContent.split(startAnchor).length - 1;
|
|
1060
|
+
expect(matches).toBe(1);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it('approve-plan.md declares the end anchor exactly once', () => {
|
|
1064
|
+
const matches = approveContent.split(endAnchor).length - 1;
|
|
1065
|
+
expect(matches).toBe(1);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it('approve-plan.md start anchor precedes end anchor', () => {
|
|
1069
|
+
expect(approveContent.indexOf(startAnchor)).toBeLessThan(
|
|
1070
|
+
approveContent.indexOf(endAnchor),
|
|
1071
|
+
);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it('the duplicated curl-blocks region is byte-equal between the two files', () => {
|
|
1075
|
+
const planRegion = extractRegion(planContent);
|
|
1076
|
+
const approveRegion = extractRegion(approveContent);
|
|
1077
|
+
expect(approveRegion).toBe(planRegion);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
it('the duplicated region is non-empty (slice 6 populated it)', () => {
|
|
1081
|
+
const planRegion = extractRegion(planContent);
|
|
1082
|
+
expect(planRegion.length).toBeGreaterThan(100);
|
|
1083
|
+
// Sanity: region must contain at least one of the six platform headings.
|
|
1084
|
+
expect(planRegion).toMatch(/### Jira/);
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
// PR 9 — Slice 1: Step 4c heading + run conditions. Step 4c is the optional
|
|
1089
|
+
// board push, gated on (a) Step 4a having written a marker AND (b) board
|
|
1090
|
+
// credentials being present in .clancy/.env. Either gate failing → skip
|
|
1091
|
+
// silently and continue to Step 7. Step 4b's tail must now route through 4c
|
|
1092
|
+
// instead of jumping straight to Step 7.
|
|
1093
|
+
describe('approve-plan Step 4c run conditions (PR 9 Slice 1)', () => {
|
|
1094
|
+
const content = readFileSync(
|
|
1095
|
+
new URL('approve-plan.md', import.meta.url),
|
|
1096
|
+
'utf8',
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
it('defines Step 4c — Optional board push', () => {
|
|
1100
|
+
expect(content).toContain('## Step 4c — Optional board push (best-effort)');
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it('Step 4c is positioned after Step 4b and before Step 5', () => {
|
|
1104
|
+
const stepFourB = content.indexOf('## Step 4b — Update brief marker');
|
|
1105
|
+
const stepFourC = content.indexOf('## Step 4c — Optional board push');
|
|
1106
|
+
const stepFive = content.indexOf('## Step 5 — Update ticket description');
|
|
1107
|
+
expect(stepFourB).toBeGreaterThan(-1);
|
|
1108
|
+
expect(stepFourC).toBeGreaterThan(stepFourB);
|
|
1109
|
+
expect(stepFive).toBeGreaterThan(stepFourC);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it('Step 4c gates on Step 4a having written the marker', () => {
|
|
1113
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1114
|
+
const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
|
|
1115
|
+
const fourCBody = content.slice(fourCStart, fourCEnd);
|
|
1116
|
+
// Run-condition prose must reference the marker write from 4a.
|
|
1117
|
+
expect(fourCBody).toMatch(/Step 4a/);
|
|
1118
|
+
expect(fourCBody).toMatch(/marker/i);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it('Step 4c gates on board credentials being available', () => {
|
|
1122
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1123
|
+
const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
|
|
1124
|
+
const fourCBody = content.slice(fourCStart, fourCEnd);
|
|
1125
|
+
expect(fourCBody).toContain('.clancy/.env');
|
|
1126
|
+
expect(fourCBody).toMatch(/board credentials/i);
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
it('Step 4c skips silently when either gate fails and continues to Step 7', () => {
|
|
1130
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1131
|
+
const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
|
|
1132
|
+
const fourCBody = content.slice(fourCStart, fourCEnd);
|
|
1133
|
+
// "skip silently" + onward routing to Step 7.
|
|
1134
|
+
expect(fourCBody).toMatch(/skip[^.]*silent/i);
|
|
1135
|
+
expect(fourCBody).toMatch(/Step 7/);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it('Step 4c is best-effort and never rolls back the local marker', () => {
|
|
1139
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1140
|
+
const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
|
|
1141
|
+
const fourCBody = content.slice(fourCStart, fourCEnd);
|
|
1142
|
+
expect(fourCBody).toMatch(/best-effort/i);
|
|
1143
|
+
// Marker is authoritative; push failure must not roll back.
|
|
1144
|
+
expect(fourCBody).toMatch(
|
|
1145
|
+
/never[^.]*rolls?[- ]?back|do(?:es)? not rolls?[- ]?back/i,
|
|
1146
|
+
);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it('Step 4b tail now routes through Step 4c instead of jumping to Step 7', () => {
|
|
1150
|
+
const fourBStart = content.indexOf('## Step 4b — Update brief marker');
|
|
1151
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1152
|
+
const fourBBody = content.slice(fourBStart, fourCStart);
|
|
1153
|
+
// 4b should hand off to 4c, not directly to Step 7.
|
|
1154
|
+
expect(fourBBody).toMatch(/Step 4c/);
|
|
1155
|
+
// The old "skip Steps 5, 5b, and 6 entirely" line must be gone — push
|
|
1156
|
+
// can still run via Step 4c when board credentials are present.
|
|
1157
|
+
expect(fourBBody).not.toMatch(/Skip Steps 5, 5b, and 6 entirely/);
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it('no stale "jump to Step 7" routing prose remains in Steps 4a/4b', () => {
|
|
1161
|
+
// DA review H2/L2: the stale routing prose ("jump to Step 7" / "Steps
|
|
1162
|
+
// 5, 5b, 6 are skipped entirely") existed in BOTH Step 4a's "After
|
|
1163
|
+
// writing the marker" subsection AND Step 4b's tail. Slice 1 only
|
|
1164
|
+
// rewrote 4b. This regression test scans the whole 4a→4c span and
|
|
1165
|
+
// asserts no leftover prose still implies a direct 4a/4b → 7 jump.
|
|
1166
|
+
const fourAStart = content.indexOf('## Step 4a — Write local marker');
|
|
1167
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1168
|
+
const span = content.slice(fourAStart, fourCStart);
|
|
1169
|
+
expect(span).not.toMatch(/jump to Step 7/i);
|
|
1170
|
+
expect(span).not.toMatch(/Skip Steps 5, 5b, and 6 entirely/);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
it('Step 4a "After writing the marker" continues to Step 4c', () => {
|
|
1174
|
+
// DA review H2: the Step 4a tail must explicitly hand off to 4c so
|
|
1175
|
+
// an LLM reading 4a in order doesn't internalise the old PR 7b flow.
|
|
1176
|
+
const afterMarkerIdx = content.indexOf('### After writing the marker');
|
|
1177
|
+
const nextHeadingIdx = content.indexOf('---', afterMarkerIdx);
|
|
1178
|
+
const afterMarkerBody = content.slice(afterMarkerIdx, nextHeadingIdx);
|
|
1179
|
+
expect(afterMarkerBody).toMatch(/Step 4c/);
|
|
1180
|
+
});
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
// PR 9 — Slice 2: Source field is read directly from the local plan file's
|
|
1184
|
+
// **Source:** header (NOT chased through the brief file the way Step 4b does).
|
|
1185
|
+
// The plan header is the single source of truth for Step 4c.
|
|
1186
|
+
describe('approve-plan Step 4c source-field read (PR 9 Slice 2)', () => {
|
|
1187
|
+
const content = readFileSync(
|
|
1188
|
+
new URL('approve-plan.md', import.meta.url),
|
|
1189
|
+
'utf8',
|
|
1190
|
+
);
|
|
1191
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1192
|
+
const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
|
|
1193
|
+
const fourCBody = content.slice(fourCStart, fourCEnd);
|
|
1194
|
+
|
|
1195
|
+
it('Step 4c reads the **Source:** header from the plan file', () => {
|
|
1196
|
+
expect(fourCBody).toContain('**Source:**');
|
|
1197
|
+
expect(fourCBody).toContain('.clancy/plans/{stem}.md');
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
it('Step 4c does NOT open the brief file to find Source', () => {
|
|
1201
|
+
// The brief-file path pattern (.clancy/briefs/) appears in Step 4b but
|
|
1202
|
+
// must not appear in Step 4c — slice 2 reads Source from the plan file
|
|
1203
|
+
// directly to avoid a second filesystem hop.
|
|
1204
|
+
expect(fourCBody).not.toContain('.clancy/briefs/');
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
it('Step 4c documents the read order (after marker, before Source parse)', () => {
|
|
1208
|
+
// The read happens inside Step 4c body, after the run-condition gates
|
|
1209
|
+
// and before the (slice 3) format detection. Test the prose calls out
|
|
1210
|
+
// "read" and "**Source:**" together so the order is unambiguous.
|
|
1211
|
+
expect(fourCBody).toMatch(/read[^.]*\*\*Source:\*\*/i);
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
it('Step 4c handles a missing **Source:** header gracefully', () => {
|
|
1215
|
+
// If the plan file has no **Source:** line, Step 4c must skip silently
|
|
1216
|
+
// (same semantics as the run-condition gates). No crash, no warning.
|
|
1217
|
+
expect(fourCBody).toMatch(
|
|
1218
|
+
/missing[^.]*\*\*Source:\*\*|no \*\*Source:\*\*/i,
|
|
1219
|
+
);
|
|
1220
|
+
expect(fourCBody).toMatch(/skip/i);
|
|
1221
|
+
});
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
// PR 9 — Slice 3: three-format Source parser. Brief writes Source in one of
|
|
1225
|
+
// three formats (per brief.md ~806-810): [KEY] Title (bracketed, pushable),
|
|
1226
|
+
// "text" (inline quoted, no ticket), or path/to/file.md (file path, no
|
|
1227
|
+
// ticket). Bracketed → continue to slice 4 validation. Other two →
|
|
1228
|
+
// BOARD_PUSH_SKIPPED_NO_TICKET log token + stdout note + continue to Step 7.
|
|
1229
|
+
describe('approve-plan Step 4c source-format parser (PR 9 Slice 3)', () => {
|
|
1230
|
+
const content = readFileSync(
|
|
1231
|
+
new URL('approve-plan.md', import.meta.url),
|
|
1232
|
+
'utf8',
|
|
1233
|
+
);
|
|
1234
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1235
|
+
const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
|
|
1236
|
+
const fourCBody = content.slice(fourCStart, fourCEnd);
|
|
1237
|
+
|
|
1238
|
+
it('Step 4c documents the three brief Source formats', () => {
|
|
1239
|
+
// Bracketed key — the only pushable format.
|
|
1240
|
+
expect(fourCBody).toMatch(/\[#?\d+\]|\[[A-Z]+-\d+\]|\[\{KEY\}\]/);
|
|
1241
|
+
// Inline-quoted text format.
|
|
1242
|
+
expect(fourCBody).toMatch(/inline[- ]quoted|"[^"]+"/i);
|
|
1243
|
+
// File-path format.
|
|
1244
|
+
expect(fourCBody).toMatch(/file[- ]path|\.md/);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it('Step 4c only pushes for the bracketed-key format', () => {
|
|
1248
|
+
// Prose must explicitly say bracketed is the only pushable format.
|
|
1249
|
+
expect(fourCBody).toMatch(
|
|
1250
|
+
/bracket[^.]*only[^.]*push|only[^.]*bracket[^.]*push/i,
|
|
1251
|
+
);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
it('Step 4c logs BOARD_PUSH_SKIPPED_NO_TICKET for non-bracketed Source', () => {
|
|
1255
|
+
expect(fourCBody).toContain('BOARD_PUSH_SKIPPED_NO_TICKET');
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it('Step 4c skip-token logs to .clancy/progress.txt', () => {
|
|
1259
|
+
// The skip token is a progress.txt row, not just stdout — same audit
|
|
1260
|
+
// surface as LOCAL_APPROVE_PLAN.
|
|
1261
|
+
expect(fourCBody).toMatch(
|
|
1262
|
+
/BOARD_PUSH_SKIPPED_NO_TICKET[\s\S]*progress\.txt|progress\.txt[\s\S]*BOARD_PUSH_SKIPPED_NO_TICKET/,
|
|
1263
|
+
);
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
it('Step 4c skip-token row uses the {stem} | TOKEN | {detail} convention', () => {
|
|
1267
|
+
// Matches plan.md Step 6 / Step 7 LOCAL_APPROVE_PLAN convention:
|
|
1268
|
+
// {stem} | BOARD_PUSH_SKIPPED_NO_TICKET | {source_format}
|
|
1269
|
+
expect(fourCBody).toMatch(
|
|
1270
|
+
/\{stem\}\s*\\?\|\s*BOARD_PUSH_SKIPPED_NO_TICKET\s*\\?\|\s*\{source_format\}/,
|
|
1271
|
+
);
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
it('Step 4c surfaces the skip in the local-mode success block', () => {
|
|
1275
|
+
// Stdout note so the user knows why no push happened. Not a warning —
|
|
1276
|
+
// an info line under the marker write success.
|
|
1277
|
+
expect(fourCBody).toMatch(/stdout|success block|local[- ]mode/i);
|
|
1278
|
+
expect(fourCBody).toMatch(/no pushable[^.]*ticket|no ticket[^.]*push/i);
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
it('Step 4c continues to Step 7 after a skip-no-ticket', () => {
|
|
1282
|
+
// After logging the skip token, flow continues normally — not an error.
|
|
1283
|
+
const skipRegion = fourCBody.slice(
|
|
1284
|
+
fourCBody.indexOf('BOARD_PUSH_SKIPPED_NO_TICKET'),
|
|
1285
|
+
);
|
|
1286
|
+
expect(skipRegion).toMatch(/Step 7|continue|proceed/i);
|
|
1287
|
+
});
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
// PR 9 — Slice 4: per-platform key validation. Once Source has been parsed
|
|
1291
|
+
// into a bracketed-key form, the extracted key must match the configured
|
|
1292
|
+
// board's regex BEFORE any push attempt. Six platforms, six inline regexes,
|
|
1293
|
+
// hard-error on mismatch — no second-chance fallback. Single-board env, no
|
|
1294
|
+
// cross-board disambiguation.
|
|
1295
|
+
describe('approve-plan Step 4c key validation (PR 9 Slice 4)', () => {
|
|
1296
|
+
const content = readFileSync(
|
|
1297
|
+
new URL('approve-plan.md', import.meta.url),
|
|
1298
|
+
'utf8',
|
|
1299
|
+
);
|
|
1300
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1301
|
+
const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
|
|
1302
|
+
const fourCBody = content.slice(fourCStart, fourCEnd);
|
|
1303
|
+
|
|
1304
|
+
it('Step 4c declares all six per-platform key regexes inline', () => {
|
|
1305
|
+
// Each assertion requires a literal `\d` (escaped backslash + d) so a
|
|
1306
|
+
// doc that accidentally drops the backslash from the regex table fails
|
|
1307
|
+
// the test instead of silently passing on a bare `d`. The Jira and
|
|
1308
|
+
// Linear assertions match on the [A-Za-z] character class (their first
|
|
1309
|
+
// distinguishing feature — lowercase allowed for parity with Step 2)
|
|
1310
|
+
// and tighten with \\d for the digits.
|
|
1311
|
+
// Jira: ^[A-Za-z][A-Za-z0-9]+-\d+$
|
|
1312
|
+
expect(fourCBody).toMatch(/Jira[\s\S]{0,200}\[A-Za-z\]\[A-Za-z0-9\]\+-\\d/);
|
|
1313
|
+
// GitHub: ^#?\d+$ — optional # so bare numbers are accepted (Step 2 parity)
|
|
1314
|
+
expect(fourCBody).toMatch(/GitHub[\s\S]{0,200}#\?\\d/);
|
|
1315
|
+
// Linear: ^[A-Za-z]{1,10}-\d+$
|
|
1316
|
+
expect(fourCBody).toMatch(/Linear[\s\S]{0,200}\[A-Za-z\]\{1,10\}-\\d/);
|
|
1317
|
+
// Azure DevOps: ^\d+$
|
|
1318
|
+
expect(fourCBody).toMatch(/Azure DevOps[\s\S]{0,200}\\d/);
|
|
1319
|
+
// Shortcut: ^(?:[A-Za-z]{1,5}-)?\d+$ — optional alpha prefix
|
|
1320
|
+
expect(fourCBody).toMatch(
|
|
1321
|
+
/Shortcut[\s\S]{0,200}\(\?:\[A-Za-z\]\{1,5\}-\)\?\\d/,
|
|
1322
|
+
);
|
|
1323
|
+
// Notion: ^(?:notion-[a-f0-9]{8}|[a-f0-9]{32}|[a-f0-9-]{36})$
|
|
1324
|
+
// Match the full alternation structure literally so a partially deleted
|
|
1325
|
+
// or malformed Notion regex (e.g. dropped one of the three branches,
|
|
1326
|
+
// dropped the non-capturing group, lost the anchors) fails the test
|
|
1327
|
+
// instead of passing on incidental notion- / [a-f0-9] tokens elsewhere.
|
|
1328
|
+
expect(fourCBody).toMatch(
|
|
1329
|
+
/Notion[\s\S]{0,400}\^\(\?:notion-\[a-f0-9\]\{8\}\|\[a-f0-9\]\{32\}\|\[a-f0-9-\]\{36\}\)\$/,
|
|
1330
|
+
);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
it('Step 4c selects the regex from the configured board (single-board env)', () => {
|
|
1334
|
+
// Detection mirrors Step 1's three-state preflight — same .env reads.
|
|
1335
|
+
expect(fourCBody).toMatch(/configured board|board.*configured/i);
|
|
1336
|
+
expect(fourCBody).toMatch(/single[- ]board/i);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
it('Step 4c validates the extracted key BEFORE any push attempt', () => {
|
|
1340
|
+
// Validation must happen before the curl region — not after.
|
|
1341
|
+
const validateIdx = fourCBody.search(/validat/i);
|
|
1342
|
+
const anchorIdx = fourCBody.indexOf(
|
|
1343
|
+
'<!-- curl-blocks:approve-plan-push:start -->',
|
|
1344
|
+
);
|
|
1345
|
+
expect(validateIdx).toBeGreaterThan(-1);
|
|
1346
|
+
expect(anchorIdx).toBeGreaterThan(-1);
|
|
1347
|
+
expect(validateIdx).toBeLessThan(anchorIdx);
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
it('Step 4c hard-errors on key/board mismatch (no fallback)', () => {
|
|
1351
|
+
// Mismatch is a hard error — not a silent skip, not a warning. The
|
|
1352
|
+
// user explicitly asked to push something the board can't accept.
|
|
1353
|
+
expect(fourCBody).toMatch(/hard[- ]error|hard error/i);
|
|
1354
|
+
// No second-chance — the test must reject prose suggesting a fallback
|
|
1355
|
+
// platform attempt or a "try the other regex" path.
|
|
1356
|
+
expect(fourCBody).not.toMatch(/try the other|fall ?back to[^.]*platform/i);
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it('Step 4c key-mismatch error names the key and the configured board', () => {
|
|
1360
|
+
// Error message must be actionable: which key, which board.
|
|
1361
|
+
expect(fourCBody).toMatch(
|
|
1362
|
+
/\{KEY\}[\s\S]{0,400}\{board\}|\{board\}[\s\S]{0,400}\{KEY\}/,
|
|
1363
|
+
);
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
it('Step 4c key-mismatch DOES NOT roll back the local marker', () => {
|
|
1367
|
+
// Same best-effort rule as push failure — the marker is authoritative.
|
|
1368
|
+
// A bad --ticket override should never undo a successful Step 4a.
|
|
1369
|
+
const mismatchRegion = fourCBody.slice(fourCBody.search(/hard[- ]error/i));
|
|
1370
|
+
expect(mismatchRegion).toMatch(
|
|
1371
|
+
/marker[^.]*(stays|preserved|kept|authoritative)|never[^.]*rolls?[- ]?back/i,
|
|
1372
|
+
);
|
|
1373
|
+
});
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
// PR 9 — Slice 5: decision matrix. Three orthogonal axes — --push, --afk,
|
|
1377
|
+
// --ticket — produce six meaningful cells. Default prompt is [y/N] (default
|
|
1378
|
+
// No, never surprise-write). Two interactive prompts in non-afk flow is
|
|
1379
|
+
// intentional (Step 4 confirms approval, Step 4c confirms push).
|
|
1380
|
+
describe('approve-plan Step 4c decision matrix (PR 9 Slice 5)', () => {
|
|
1381
|
+
const content = readFileSync(
|
|
1382
|
+
new URL('approve-plan.md', import.meta.url),
|
|
1383
|
+
'utf8',
|
|
1384
|
+
);
|
|
1385
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1386
|
+
const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
|
|
1387
|
+
const fourCBody = content.slice(fourCStart, fourCEnd);
|
|
1388
|
+
|
|
1389
|
+
it('Step 4c declares the new --push and --ticket flags', () => {
|
|
1390
|
+
expect(fourCBody).toContain('`--push`');
|
|
1391
|
+
expect(fourCBody).toContain('`--ticket');
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it('Step 4c interactive prompt defaults to [y/N] (default No)', () => {
|
|
1395
|
+
expect(fourCBody).toContain('[y/N]');
|
|
1396
|
+
expect(fourCBody).toMatch(/default[^.]*No|never surprise[- ]write/i);
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
it('Step 4c interactive prompt text names the resolved KEY', () => {
|
|
1400
|
+
// The prompt must show which ticket the push will hit.
|
|
1401
|
+
expect(fourCBody).toMatch(
|
|
1402
|
+
/Push approved plan to[^.]*\{KEY\}[^.]*comment\?[^.]*\[y\/N\]/,
|
|
1403
|
+
);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
it('Step 4c covers interactive without --push (prompt the user)', () => {
|
|
1407
|
+
expect(fourCBody).toMatch(
|
|
1408
|
+
/interactive[^.]*no `--push`|no `--push`[^.]*interactive/i,
|
|
1409
|
+
);
|
|
1410
|
+
// Interactive cell explicitly prompts.
|
|
1411
|
+
expect(fourCBody).toMatch(/prompt[\s\S]{0,300}\[y\/N\]/);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
it('Step 4c covers interactive + --push (skip prompt and push)', () => {
|
|
1415
|
+
expect(fourCBody).toMatch(
|
|
1416
|
+
/interactive[^.]*`--push`[^.]*skip[^.]*prompt|`--push`[^.]*skip[^.]*prompt[^.]*push/i,
|
|
1417
|
+
);
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
it('Step 4c covers --afk without --push (LOCAL_ONLY skip)', () => {
|
|
1421
|
+
expect(fourCBody).toContain('LOCAL_ONLY');
|
|
1422
|
+
expect(fourCBody).toMatch(
|
|
1423
|
+
/--afk[^.]*without[^.]*--push|--afk[^.]*no[^.]*--push/i,
|
|
1424
|
+
);
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
it('Step 4c LOCAL_ONLY token logs to .clancy/progress.txt with stem', () => {
|
|
1428
|
+
// {stem} | TOKEN | {detail} convention:
|
|
1429
|
+
// {stem} | LOCAL_ONLY | afk-without-push
|
|
1430
|
+
expect(fourCBody).toMatch(
|
|
1431
|
+
/\{stem\}\s*\\?\|\s*LOCAL_ONLY\s*\\?\|\s*afk-without-push/,
|
|
1432
|
+
);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
it('Step 4c covers --afk --push (push without prompting)', () => {
|
|
1436
|
+
expect(fourCBody).toMatch(
|
|
1437
|
+
/--afk\s*--push|--afk[^.]*--push[^.]*without[^.]*prompt/i,
|
|
1438
|
+
);
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
it('Step 4c covers --ticket KEY override (auto-detect bypassed)', () => {
|
|
1442
|
+
expect(fourCBody).toMatch(/--ticket[^.]*override|override[^.]*--ticket/i);
|
|
1443
|
+
// --ticket wins over Source auto-detect.
|
|
1444
|
+
expect(fourCBody).toMatch(
|
|
1445
|
+
/wins over[^.]*auto[- ]detect|override[^.]*auto[- ]detect/i,
|
|
1446
|
+
);
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
it('Step 4c documents --ticket is ignored under --afk-without-push', () => {
|
|
1450
|
+
// The LOCAL_ONLY skip cell ignores --ticket entirely (no push happens).
|
|
1451
|
+
const localOnlyRegion = fourCBody.slice(fourCBody.indexOf('LOCAL_ONLY'));
|
|
1452
|
+
expect(localOnlyRegion.slice(0, 800)).toMatch(
|
|
1453
|
+
/--ticket[^.]*ignored|ignored[^.]*--ticket/i,
|
|
1454
|
+
);
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
it('Step 4c calls out the two-prompt flow as intentional', () => {
|
|
1458
|
+
// Step 4 confirms approval, Step 4c confirms push — semantically distinct.
|
|
1459
|
+
expect(fourCBody).toMatch(/two[- ]prompt|two prompts|second prompt/i);
|
|
1460
|
+
expect(fourCBody).toMatch(/intentional|distinct|semantically/i);
|
|
1461
|
+
});
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
// PR 9 — Slice 7: failure semantics for actual push failures (HTTP non-2xx,
|
|
1465
|
+
// network/timeout/dns/auth). Marker stays, BOARD_PUSH_FAILED logged, exact
|
|
1466
|
+
// retry command printed. No rollback.
|
|
1467
|
+
describe('approve-plan Step 4c push failure semantics (PR 9 Slice 7)', () => {
|
|
1468
|
+
const content = readFileSync(
|
|
1469
|
+
new URL('approve-plan.md', import.meta.url),
|
|
1470
|
+
'utf8',
|
|
1471
|
+
);
|
|
1472
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1473
|
+
const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
|
|
1474
|
+
const fourCBody = content.slice(fourCStart, fourCEnd);
|
|
1475
|
+
|
|
1476
|
+
it('Step 4c handles push failure as best-effort (marker preserved)', () => {
|
|
1477
|
+
// Distinct from the slice 4 key-mismatch path — this is the
|
|
1478
|
+
// post-validation, post-curl HTTP failure path.
|
|
1479
|
+
expect(fourCBody).toMatch(/push fail/i);
|
|
1480
|
+
expect(fourCBody).toMatch(
|
|
1481
|
+
/marker[^.]*(stays|preserved|kept|authoritative)/i,
|
|
1482
|
+
);
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
it('Step 4c logs BOARD_PUSH_FAILED with stem and status class', () => {
|
|
1486
|
+
// {stem} | TOKEN | {detail} convention:
|
|
1487
|
+
// {stem} | BOARD_PUSH_FAILED | {http_status_or_error_class}
|
|
1488
|
+
expect(fourCBody).toContain('BOARD_PUSH_FAILED');
|
|
1489
|
+
expect(fourCBody).toMatch(
|
|
1490
|
+
/\{stem\}\s*\\?\|\s*BOARD_PUSH_FAILED\s*\\?\|\s*\{http_status_or_error_class\}/,
|
|
1491
|
+
);
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
it('Step 4c defines the error-class vocabulary', () => {
|
|
1495
|
+
// Locked spec: HTTP status code on non-2xx, OR lowercase error class
|
|
1496
|
+
// on transport failure: network, timeout, dns, auth.
|
|
1497
|
+
expect(fourCBody).toMatch(/network/);
|
|
1498
|
+
expect(fourCBody).toMatch(/timeout/);
|
|
1499
|
+
expect(fourCBody).toMatch(/dns/);
|
|
1500
|
+
expect(fourCBody).toMatch(/auth/);
|
|
1501
|
+
// HTTP status path also documented.
|
|
1502
|
+
expect(fourCBody).toMatch(/HTTP[^.]*status|status code|non-2xx/i);
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
it('Step 4c prints the exact retry command on push failure', () => {
|
|
1506
|
+
// Exact command pattern: /clancy:approve-plan {stem} --push --ticket {KEY}
|
|
1507
|
+
expect(fourCBody).toMatch(
|
|
1508
|
+
/\/clancy:approve-plan\s+\{stem\}\s+--push\s+--ticket\s+\{KEY\}/,
|
|
1509
|
+
);
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
it('Step 4c push-failure path continues to Step 7 (does not exit non-zero)', () => {
|
|
1513
|
+
// Push failure is best-effort — workflow still continues to render the
|
|
1514
|
+
// local-mode success block in Step 7.
|
|
1515
|
+
const failRegion = fourCBody.slice(fourCBody.search(/push fail/i));
|
|
1516
|
+
expect(failRegion).toMatch(/Step 7|continue|proceed/i);
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
it('Step 4c push-failure NEVER rolls back the marker', () => {
|
|
1520
|
+
expect(fourCBody).toMatch(
|
|
1521
|
+
/never[^.]*rolls?[- ]?back|do(?:es)? not rolls?[- ]?back/i,
|
|
1522
|
+
);
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
it('Step 4c logs LOCAL_APPROVE_PLAN_PUSH success row with stem and KEY', () => {
|
|
1526
|
+
// DA review H1: the success path needs a second progress.txt row
|
|
1527
|
+
// distinct from LOCAL_APPROVE_PLAN. Two-row audit is unambiguous.
|
|
1528
|
+
expect(fourCBody).toContain('LOCAL_APPROVE_PLAN_PUSH');
|
|
1529
|
+
expect(fourCBody).toMatch(
|
|
1530
|
+
/\{stem\}\s*\\?\|\s*LOCAL_APPROVE_PLAN_PUSH\s*\\?\|\s*\{KEY\}/,
|
|
1531
|
+
);
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
it('Step 4c documents the BOARD_PUSH_FAILED reason-field disambiguation', () => {
|
|
1535
|
+
// DA review L1: HTTP status, error-class, and key-mismatch:{KEY} share
|
|
1536
|
+
// the token. The contract that disambiguates them must be explicit.
|
|
1537
|
+
expect(fourCBody).toMatch(
|
|
1538
|
+
/key-mismatch:[^.]*reserved|reserved[^.]*key-mismatch/i,
|
|
1539
|
+
);
|
|
1540
|
+
});
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
// PR 9 — Slice 8: retry path. EEXIST + --push falls through to Step 4c
|
|
1544
|
+
// instead of stopping. Without --push, EEXIST stops as today (PR 7b).
|
|
1545
|
+
// This is the only mechanism to re-attempt a failed push — no new flag,
|
|
1546
|
+
// no marker deletion.
|
|
1547
|
+
describe('approve-plan Step 4a EEXIST retry path (PR 9 Slice 8)', () => {
|
|
1548
|
+
const content = readFileSync(
|
|
1549
|
+
new URL('approve-plan.md', import.meta.url),
|
|
1550
|
+
'utf8',
|
|
1551
|
+
);
|
|
1552
|
+
|
|
1553
|
+
// Step 4a's EEXIST handling section is the load-bearing region for slice 8.
|
|
1554
|
+
const eexistStart = content.indexOf('### Handle EEXIST');
|
|
1555
|
+
const eexistEnd = content.indexOf('### Marker is the gate');
|
|
1556
|
+
const eexistBody = content.slice(eexistStart, eexistEnd);
|
|
1557
|
+
|
|
1558
|
+
it('Step 4a EEXIST handling references the --push retry path', () => {
|
|
1559
|
+
expect(eexistBody).toContain('--push');
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
it('EEXIST + --push falls through to Step 4c (does not stop)', () => {
|
|
1563
|
+
expect(eexistBody).toMatch(
|
|
1564
|
+
/EEXIST[\s\S]*--push[\s\S]*(falls?[- ]through|continue)[\s\S]*Step 4c/i,
|
|
1565
|
+
);
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
it('EEXIST without --push still stops with the PR 7b message', () => {
|
|
1569
|
+
// PR 7b's existing behaviour preserved — bare EEXIST without --push
|
|
1570
|
+
// still hits the "Plan already approved" stop branch.
|
|
1571
|
+
expect(eexistBody).toMatch(
|
|
1572
|
+
/without[^.]*--push|no `--push`|`--push`\s+is\s+NOT\s+set|NOT\s+set/i,
|
|
1573
|
+
);
|
|
1574
|
+
expect(eexistBody).toContain('Plan already approved');
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
it('EEXIST + --push does NOT re-write the marker', () => {
|
|
1578
|
+
// The marker is preserved as-is. The retry only re-runs Step 4c.
|
|
1579
|
+
expect(eexistBody).toMatch(
|
|
1580
|
+
/(?:does not|never|do not|is not|not) re[- ]?written|marker[^a-z]*(stays|preserved|unchanged)/i,
|
|
1581
|
+
);
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
it('EEXIST + --push retry skips Step 4b (brief marker already updated)', () => {
|
|
1585
|
+
// Step 4b ran on the original approval; the brief marker is already
|
|
1586
|
+
// updated. The retry path goes EEXIST → Step 4c directly, not via 4b.
|
|
1587
|
+
expect(eexistBody).toMatch(/skip[^.]*Step 4b|Step 4b[^.]*skip/i);
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
it('Step 4c documents the retry entry path from EEXIST', () => {
|
|
1591
|
+
// Symmetry test: Step 4c should mention the retry entry path from
|
|
1592
|
+
// 4a's EEXIST branch so a reader of 4c knows where re-runs come from.
|
|
1593
|
+
const fourCStart = content.indexOf('## Step 4c — Optional board push');
|
|
1594
|
+
const fourCEnd = content.indexOf('## Step 5 — Update ticket description');
|
|
1595
|
+
const fourCBody = content.slice(fourCStart, fourCEnd);
|
|
1596
|
+
expect(fourCBody).toMatch(
|
|
1597
|
+
/EEXIST[^.]*--push|--push[^.]*EEXIST|retry[^.]*Step 4a/i,
|
|
1598
|
+
);
|
|
1599
|
+
});
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
// PR 9 — Slice 9: Notion flat-text note + commands/approve-plan.md flag docs.
|
|
1603
|
+
// Notion comments render as flat text — surface in the local-mode success
|
|
1604
|
+
// block so Notion users aren't surprised. Command file gets the new flag
|
|
1605
|
+
// surface (--push, --ticket).
|
|
1606
|
+
describe('approve-plan Step 7 Notion flat-text note (PR 9 Slice 9)', () => {
|
|
1607
|
+
const content = readFileSync(
|
|
1608
|
+
new URL('approve-plan.md', import.meta.url),
|
|
1609
|
+
'utf8',
|
|
1610
|
+
);
|
|
1611
|
+
const stepSevenStart = content.indexOf('## Step 7');
|
|
1612
|
+
const stepSevenBody = content.slice(stepSevenStart);
|
|
1613
|
+
|
|
1614
|
+
it('Step 7 local-mode block surfaces a Notion flat-text note', () => {
|
|
1615
|
+
expect(stepSevenBody).toMatch(
|
|
1616
|
+
/Notion[^.]*flat[- ]text|flat[- ]text[^.]*Notion/i,
|
|
1617
|
+
);
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
it('Notion note is conditional on the push target (only when Notion)', () => {
|
|
1621
|
+
expect(stepSevenBody).toMatch(
|
|
1622
|
+
/only[^.]*Notion|when[^.]*Notion|Notion[^.]*only/i,
|
|
1623
|
+
);
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
it('Notion note explicitly says structure will not be styled', () => {
|
|
1627
|
+
expect(stepSevenBody).toMatch(
|
|
1628
|
+
/won.?t be styled|no[^.]*styling|plain text/i,
|
|
1629
|
+
);
|
|
1630
|
+
});
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
describe('approve-plan command file flag surface (PR 9 Slice 9)', () => {
|
|
1634
|
+
const content = readFileSync(
|
|
1635
|
+
new URL('../commands/approve-plan.md', import.meta.url),
|
|
1636
|
+
'utf8',
|
|
1637
|
+
);
|
|
1638
|
+
|
|
1639
|
+
it('command file documents --push flag', () => {
|
|
1640
|
+
expect(content).toContain('`--push`');
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
it('command file documents --ticket flag', () => {
|
|
1644
|
+
expect(content).toContain('`--ticket');
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
it('command file --push doc references board push target', () => {
|
|
1648
|
+
const pushIdx = content.indexOf('`--push`');
|
|
1649
|
+
expect(pushIdx).toBeGreaterThan(-1);
|
|
1650
|
+
const pushParagraph = content.slice(pushIdx, pushIdx + 400);
|
|
1651
|
+
expect(pushParagraph).toMatch(/push|board/i);
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
it('command file --ticket doc references KEY override of Source', () => {
|
|
1655
|
+
const ticketIdx = content.indexOf('`--ticket');
|
|
1656
|
+
expect(ticketIdx).toBeGreaterThan(-1);
|
|
1657
|
+
const ticketParagraph = content.slice(ticketIdx, ticketIdx + 400);
|
|
1658
|
+
expect(ticketParagraph).toMatch(/override|Source|KEY/i);
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
it('command file preserves --afk from PR 7b', () => {
|
|
1662
|
+
expect(content).toContain('`--afk`');
|
|
1663
|
+
});
|
|
1664
|
+
});
|