@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.
@@ -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 jumps to Step 7 (log) and skips board flow', () => {
867
- expect(content).toContain('jump to Step 7');
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
+ });